feat(room): add category creation and drag-to-assign for channels
- Rewrite DiscordChannelSidebar with @dnd-kit drag-and-drop: rooms are sortable within categories; dragging onto a different category header assigns the room to that category - Add inline 'Add Category' button: Enter/Esc to confirm/cancel - Wire category create/move handlers in room.tsx via RoomContext - Fix onAiStreamChunk to accumulate content properly and avoid redundant re-renders during AI streaming (dedup guard) - No backend changes needed: category CRUD and room category update endpoints were already wired
This commit is contained in:
parent
b73cc8d421
commit
63c75ad453
1
.gitignore
vendored
1
.gitignore
vendored
@ -17,3 +17,4 @@ ARCHITECTURE.md
|
|||||||
.agents
|
.agents
|
||||||
.agents.md
|
.agents.md
|
||||||
.next
|
.next
|
||||||
|
admin
|
||||||
@ -5,7 +5,7 @@ pub mod logout;
|
|||||||
pub mod me;
|
pub mod me;
|
||||||
pub mod password;
|
pub mod password;
|
||||||
pub mod register;
|
pub mod register;
|
||||||
pub mod totp;
|
// pub mod totp; // 2FA disabled
|
||||||
pub mod ws_token;
|
pub mod ws_token;
|
||||||
|
|
||||||
pub fn init_auth_routes(cfg: &mut actix_web::web::ServiceConfig) {
|
pub fn init_auth_routes(cfg: &mut actix_web::web::ServiceConfig) {
|
||||||
@ -37,22 +37,12 @@ pub fn init_auth_routes(cfg: &mut actix_web::web::ServiceConfig) {
|
|||||||
"/password/confirm",
|
"/password/confirm",
|
||||||
actix_web::web::post().to(password::api_user_confirm_password_reset),
|
actix_web::web::post().to(password::api_user_confirm_password_reset),
|
||||||
)
|
)
|
||||||
.route(
|
// 2FA disabled {
|
||||||
"/2fa/enable",
|
// .route("/2fa/enable", actix_web::web::post().to(totp::api_2fa_enable))
|
||||||
actix_web::web::post().to(totp::api_2fa_enable),
|
// .route("/2fa/verify", actix_web::web::post().to(totp::api_2fa_verify))
|
||||||
)
|
// .route("/2fa/disable", actix_web::web::post().to(totp::api_2fa_disable))
|
||||||
.route(
|
// .route("/2fa/status", actix_web::web::post().to(totp::api_2fa_status))
|
||||||
"/2fa/verify",
|
// }
|
||||||
actix_web::web::post().to(totp::api_2fa_verify),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/2fa/disable",
|
|
||||||
actix_web::web::post().to(totp::api_2fa_disable),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/2fa/status",
|
|
||||||
actix_web::web::post().to(totp::api_2fa_status),
|
|
||||||
)
|
|
||||||
.route("/email", actix_web::web::post().to(email::api_email_get))
|
.route("/email", actix_web::web::post().to(email::api_email_get))
|
||||||
.route(
|
.route(
|
||||||
"/email/change",
|
"/email/change",
|
||||||
|
|||||||
@ -1,94 +1,96 @@
|
|||||||
use crate::{ApiResponse, error::ApiError};
|
// 2FA disabled {
|
||||||
use actix_web::{HttpResponse, Result, web};
|
// use crate::{ApiResponse, error::ApiError};
|
||||||
use service::AppService;
|
// use actix_web::{HttpResponse, Result, web};
|
||||||
use service::auth::totp::{
|
// use service::AppService;
|
||||||
Disable2FAParams, Enable2FAResponse, Get2FAStatusResponse, Verify2FAParams,
|
// use service::auth::totp::{
|
||||||
};
|
// Disable2FAParams, Enable2FAResponse, Get2FAStatusResponse, Verify2FAParams,
|
||||||
use session::Session;
|
// };
|
||||||
|
// use session::Session;
|
||||||
#[utoipa::path(
|
//
|
||||||
post,
|
// #[utoipa::path(
|
||||||
path = "/api/auth/2fa/enable",
|
// post,
|
||||||
responses(
|
// path = "/api/auth/2fa/enable",
|
||||||
(status = 200, description = "2FA setup initiated", body = Enable2FAResponse),
|
// responses(
|
||||||
(status = 401, description = "Unauthorized"),
|
// (status = 200, description = "2FA setup initiated", body = Enable2FAResponse),
|
||||||
(status = 409, description = "2FA already enabled"),
|
// (status = 401, description = "Unauthorized"),
|
||||||
(status = 500, description = "Internal server error"),
|
// (status = 409, description = "2FA already enabled"),
|
||||||
(status = 404, description = "Not found", body = ApiResponse<ApiError>),
|
// (status = 500, description = "Internal server error"),
|
||||||
),
|
// (status = 404, description = "Not found", body = ApiResponse<ApiError>),
|
||||||
tag = "Auth"
|
// ),
|
||||||
)]
|
// tag = "Auth"
|
||||||
pub async fn api_2fa_enable(
|
// )]
|
||||||
service: web::Data<AppService>,
|
// pub async fn api_2fa_enable(
|
||||||
session: Session,
|
// service: web::Data<AppService>,
|
||||||
) -> Result<HttpResponse, ApiError> {
|
// session: Session,
|
||||||
let resp = service.auth_2fa_enable(&session).await?;
|
// ) -> Result<HttpResponse, ApiError> {
|
||||||
Ok(ApiResponse::ok(resp).to_response())
|
// let resp = service.auth_2fa_enable(&session).await?;
|
||||||
}
|
// Ok(ApiResponse::ok(resp).to_response())
|
||||||
|
// }
|
||||||
#[utoipa::path(
|
//
|
||||||
post,
|
// #[utoipa::path(
|
||||||
path = "/api/auth/2fa/verify",
|
// post,
|
||||||
request_body = Verify2FAParams,
|
// path = "/api/auth/2fa/verify",
|
||||||
responses(
|
// request_body = Verify2FAParams,
|
||||||
(status = 200, description = "2FA verified and enabled"),
|
// responses(
|
||||||
(status = 401, description = "Unauthorized or invalid code"),
|
// (status = 200, description = "2FA verified and enabled"),
|
||||||
(status = 400, description = "2FA not set up"),
|
// (status = 401, description = "Unauthorized or invalid code"),
|
||||||
(status = 500, description = "Internal server error"),
|
// (status = 400, description = "2FA not set up"),
|
||||||
(status = 404, description = "Not found", body = ApiResponse<ApiError>),
|
// (status = 500, description = "Internal server error"),
|
||||||
),
|
// (status = 404, description = "Not found", body = ApiResponse<ApiError>),
|
||||||
tag = "Auth"
|
// ),
|
||||||
)]
|
// tag = "Auth"
|
||||||
pub async fn api_2fa_verify(
|
// )]
|
||||||
service: web::Data<AppService>,
|
// pub async fn api_2fa_verify(
|
||||||
session: Session,
|
// service: web::Data<AppService>,
|
||||||
params: web::Json<Verify2FAParams>,
|
// session: Session,
|
||||||
) -> Result<HttpResponse, ApiError> {
|
// params: web::Json<Verify2FAParams>,
|
||||||
service
|
// ) -> Result<HttpResponse, ApiError> {
|
||||||
.auth_2fa_verify_and_enable(&session, params.into_inner())
|
// service
|
||||||
.await?;
|
// .auth_2fa_verify_and_enable(&session, params.into_inner())
|
||||||
Ok(crate::api_success())
|
// .await?;
|
||||||
}
|
// Ok(crate::api_success())
|
||||||
|
// }
|
||||||
#[utoipa::path(
|
//
|
||||||
post,
|
// #[utoipa::path(
|
||||||
path = "/api/auth/2fa/disable",
|
// post,
|
||||||
request_body = Disable2FAParams,
|
// path = "/api/auth/2fa/disable",
|
||||||
responses(
|
// request_body = Disable2FAParams,
|
||||||
(status = 200, description = "2FA disabled"),
|
// responses(
|
||||||
(status = 401, description = "Unauthorized"),
|
// (status = 200, description = "2FA disabled"),
|
||||||
(status = 400, description = "2FA not enabled or invalid code/password"),
|
// (status = 401, description = "Unauthorized"),
|
||||||
(status = 500, description = "Internal server error"),
|
// (status = 400, description = "2FA not enabled or invalid code/password"),
|
||||||
(status = 404, description = "Not found", body = ApiResponse<ApiError>),
|
// (status = 500, description = "Internal server error"),
|
||||||
),
|
// (status = 404, description = "Not found", body = ApiResponse<ApiError>),
|
||||||
tag = "Auth"
|
// ),
|
||||||
)]
|
// tag = "Auth"
|
||||||
pub async fn api_2fa_disable(
|
// )]
|
||||||
service: web::Data<AppService>,
|
// pub async fn api_2fa_disable(
|
||||||
session: Session,
|
// service: web::Data<AppService>,
|
||||||
params: web::Json<Disable2FAParams>,
|
// session: Session,
|
||||||
) -> Result<HttpResponse, ApiError> {
|
// params: web::Json<Disable2FAParams>,
|
||||||
service
|
// ) -> Result<HttpResponse, ApiError> {
|
||||||
.auth_2fa_disable(&session, params.into_inner())
|
// service
|
||||||
.await?;
|
// .auth_2fa_disable(&session, params.into_inner())
|
||||||
Ok(crate::api_success())
|
// .await?;
|
||||||
}
|
// Ok(crate::api_success())
|
||||||
|
// }
|
||||||
#[utoipa::path(
|
//
|
||||||
post,
|
// #[utoipa::path(
|
||||||
path = "/api/auth/2fa/status",
|
// post,
|
||||||
responses(
|
// path = "/api/auth/2fa/status",
|
||||||
(status = 200, description = "2FA status", body = Get2FAStatusResponse),
|
// responses(
|
||||||
(status = 401, description = "Unauthorized"),
|
// (status = 200, description = "2FA status", body = Get2FAStatusResponse),
|
||||||
(status = 500, description = "Internal server error"),
|
// (status = 401, description = "Unauthorized"),
|
||||||
(status = 404, description = "Not found", body = ApiResponse<ApiError>),
|
// (status = 500, description = "Internal server error"),
|
||||||
),
|
// (status = 404, description = "Not found", body = ApiResponse<ApiError>),
|
||||||
tag = "Auth"
|
// ),
|
||||||
)]
|
// tag = "Auth"
|
||||||
pub async fn api_2fa_status(
|
// )]
|
||||||
service: web::Data<AppService>,
|
// pub async fn api_2fa_status(
|
||||||
session: Session,
|
// service: web::Data<AppService>,
|
||||||
) -> Result<HttpResponse, ApiError> {
|
// session: Session,
|
||||||
let resp = service.auth_2fa_status(&session).await?;
|
// ) -> Result<HttpResponse, ApiError> {
|
||||||
Ok(ApiResponse::ok(resp).to_response())
|
// let resp = service.auth_2fa_status(&session).await?;
|
||||||
}
|
// Ok(ApiResponse::ok(resp).to_response())
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|||||||
@ -23,10 +23,10 @@ use utoipa::OpenApi;
|
|||||||
crate::auth::password::api_user_change_password,
|
crate::auth::password::api_user_change_password,
|
||||||
crate::auth::password::api_user_request_password_reset,
|
crate::auth::password::api_user_request_password_reset,
|
||||||
crate::auth::password::api_user_confirm_password_reset,
|
crate::auth::password::api_user_confirm_password_reset,
|
||||||
crate::auth::totp::api_2fa_enable,
|
// crate::auth::totp::api_2fa_enable,
|
||||||
crate::auth::totp::api_2fa_verify,
|
// crate::auth::totp::api_2fa_verify,
|
||||||
crate::auth::totp::api_2fa_disable,
|
// crate::auth::totp::api_2fa_disable,
|
||||||
crate::auth::totp::api_2fa_status,
|
// crate::auth::totp::api_2fa_status,
|
||||||
crate::auth::email::api_email_get,
|
crate::auth::email::api_email_get,
|
||||||
crate::auth::email::api_email_change,
|
crate::auth::email::api_email_change,
|
||||||
crate::auth::email::api_email_verify,
|
crate::auth::email::api_email_verify,
|
||||||
@ -673,10 +673,10 @@ use utoipa::OpenApi;
|
|||||||
service::auth::captcha::CaptchaQuery,
|
service::auth::captcha::CaptchaQuery,
|
||||||
service::auth::captcha::CaptchaResponse,
|
service::auth::captcha::CaptchaResponse,
|
||||||
service::auth::me::ContextMe,
|
service::auth::me::ContextMe,
|
||||||
service::auth::totp::Enable2FAResponse,
|
// service::auth::totp::Enable2FAResponse,
|
||||||
service::auth::totp::Verify2FAParams,
|
// service::auth::totp::Verify2FAParams,
|
||||||
service::auth::totp::Disable2FAParams,
|
// service::auth::totp::Disable2FAParams,
|
||||||
service::auth::totp::Get2FAStatusResponse,
|
// service::auth::totp::Get2FAStatusResponse,
|
||||||
service::auth::email::EmailChangeRequest,
|
service::auth::email::EmailChangeRequest,
|
||||||
service::auth::email::EmailVerifyRequest,
|
service::auth::email::EmailVerifyRequest,
|
||||||
service::auth::email::EmailResponse,
|
service::auth::email::EmailResponse,
|
||||||
|
|||||||
@ -2,23 +2,23 @@ use crate::AppService;
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||||
use models::users::{user_activity_log, user_password};
|
use models::users::{user_activity_log, user_password};
|
||||||
use rand::RngExt;
|
// use rand::RngExt;
|
||||||
use redis::AsyncCommands;
|
// use redis::AsyncCommands;
|
||||||
use sea_orm::*;
|
use sea_orm::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use session::Session;
|
use session::Session;
|
||||||
use sha1::Digest;
|
// use sha1::Digest;
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct LoginParams {
|
pub struct LoginParams {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
pub captcha: String,
|
pub captcha: String,
|
||||||
pub totp_code: Option<String>,
|
// pub totp_code: Option<String>, // 2FA disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppService {
|
impl AppService {
|
||||||
pub const TOTP_KEY: &'static str = "totp_key";
|
// pub const TOTP_KEY: &'static str = "totp_key"; // 2FA disabled
|
||||||
pub async fn auth_login(&self, params: LoginParams, context: Session) -> Result<(), AppError> {
|
pub async fn auth_login(&self, params: LoginParams, context: Session) -> Result<(), AppError> {
|
||||||
self.auth_check_captcha(&context, params.captcha).await?;
|
self.auth_check_captcha(&context, params.captcha).await?;
|
||||||
let password = self.auth_rsa_decode(&context, params.password).await?;
|
let password = self.auth_rsa_decode(&context, params.password).await?;
|
||||||
@ -47,48 +47,50 @@ impl AppService {
|
|||||||
return Err(AppError::UserNotFound);
|
return Err(AppError::UserNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
let needs_totp_verification = context
|
// 2FA disabled {
|
||||||
.get::<String>(Self::TOTP_KEY)
|
// let needs_totp_verification = context
|
||||||
.ok()
|
// .get::<String>(Self::TOTP_KEY)
|
||||||
.flatten()
|
// .ok()
|
||||||
.is_some();
|
// .flatten()
|
||||||
|
// .is_some();
|
||||||
if needs_totp_verification {
|
//
|
||||||
if let Some(ref totp_code) = params.totp_code {
|
// if needs_totp_verification {
|
||||||
if !self.auth_2fa_verify_login(&context, totp_code).await? {
|
// if let Some(ref totp_code) = params.totp_code {
|
||||||
slog::warn!(self.logs, "Login failed: invalid 2FA code"; "username" => ¶ms.username, "ip" => context.ip_address());
|
// if !self.auth_2fa_verify_login(&context, totp_code).await? {
|
||||||
return Err(AppError::InvalidTwoFactorCode);
|
// slog::warn!(self.logs, "Login failed: invalid 2FA code"; "username" => ¶ms.username, "ip" => context.ip_address());
|
||||||
}
|
// return Err(AppError::InvalidTwoFactorCode);
|
||||||
}
|
// }
|
||||||
} else if !self.auth_2fa_status_by_uid(user.uid).await?.is_enabled {
|
// }
|
||||||
let user_uid = user.uid;
|
// } else if !self.auth_2fa_status_by_uid(user.uid).await?.is_enabled {
|
||||||
let mut rng = rand::rng();
|
// let user_uid = user.uid;
|
||||||
let mut sha = sha1::Sha1::default();
|
// let mut rng = rand::rng();
|
||||||
for _ in 0..5 {
|
// let mut sha = sha1::Sha1::default();
|
||||||
sha.update(
|
// for _ in 0..5 {
|
||||||
(0..1024)
|
// sha.update(
|
||||||
.map(|_| {
|
// (0..1024)
|
||||||
format!(
|
// .map(|_| {
|
||||||
"{:04}-{:04}-{:04}",
|
// format!(
|
||||||
rng.random_range(0..10000),
|
// "{:04}-{:04}-{:04}",
|
||||||
rng.random_range(0..10000),
|
// rng.random_range(0..10000),
|
||||||
rng.random_range(0..10000)
|
// rng.random_range(0..10000),
|
||||||
)
|
// rng.random_range(0..10000)
|
||||||
})
|
// )
|
||||||
.collect::<String>()
|
// })
|
||||||
.as_bytes(),
|
// .collect::<String>()
|
||||||
)
|
// .as_bytes(),
|
||||||
}
|
// )
|
||||||
let key = format!("{:?}", sha.finalize());
|
// }
|
||||||
context.insert(Self::TOTP_KEY, key.clone()).ok();
|
// let key = format!("{:?}", sha.finalize());
|
||||||
if let Ok(mut conn) = self.cache.conn().await {
|
// context.insert(Self::TOTP_KEY, key.clone()).ok();
|
||||||
conn.set_ex::<String, String, ()>(key, user_uid.to_string(), 60 * 5)
|
// if let Ok(mut conn) = self.cache.conn().await {
|
||||||
.await
|
// conn.set_ex::<String, String, ()>(key, user_uid.to_string(), 60 * 5)
|
||||||
.ok();
|
// .await
|
||||||
}
|
// .ok();
|
||||||
slog::info!(self.logs, "Login 2FA triggered for new 2FA user"; "username" => ¶ms.username, "ip" => context.ip_address());
|
// }
|
||||||
return Err(AppError::TwoFactorRequired);
|
// slog::info!(self.logs, "Login 2FA triggered for new 2FA user"; "username" => ¶ms.username, "ip" => context.ip_address());
|
||||||
}
|
// return Err(AppError::TwoFactorRequired);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
let mut arch = user.clone().into_active_model();
|
let mut arch = user.clone().into_active_model();
|
||||||
arch.last_sign_in_at = Set(Some(chrono::Utc::now()));
|
arch.last_sign_in_at = Set(Some(chrono::Utc::now()));
|
||||||
@ -104,7 +106,6 @@ impl AppService {
|
|||||||
details: Set(Some(serde_json::json!({
|
details: Set(Some(serde_json::json!({
|
||||||
"method": "password",
|
"method": "password",
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
"2fa_used": params.totp_code.is_some()
|
|
||||||
}))
|
}))
|
||||||
.into()),
|
.into()),
|
||||||
created_at: Set(chrono::Utc::now()),
|
created_at: Set(chrono::Utc::now()),
|
||||||
@ -116,7 +117,7 @@ impl AppService {
|
|||||||
context.set_user(user.uid);
|
context.set_user(user.uid);
|
||||||
context.remove(Self::RSA_PRIVATE_KEY);
|
context.remove(Self::RSA_PRIVATE_KEY);
|
||||||
context.remove(Self::RSA_PUBLIC_KEY);
|
context.remove(Self::RSA_PUBLIC_KEY);
|
||||||
slog::info!(self.logs, "User logged in successfully"; "user_uid" => %user.uid, "username" => &user.username, "ip" => context.ip_address(), "2fa_used" => params.totp_code.is_some());
|
slog::info!(self.logs, "User logged in successfully"; "user_uid" => %user.uid, "username" => &user.username, "ip" => context.ip_address());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,4 +6,4 @@ pub mod me;
|
|||||||
pub mod password;
|
pub mod password;
|
||||||
pub mod register;
|
pub mod register;
|
||||||
pub mod rsa;
|
pub mod rsa;
|
||||||
pub mod totp;
|
// pub mod totp; // 2FA disabled
|
||||||
|
|||||||
@ -105,7 +105,6 @@ function App() {
|
|||||||
<Route index element={<UserProfile/>}/>
|
<Route index element={<UserProfile/>}/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Landing sub-pages */}
|
|
||||||
<Route path="/pricing" element={<PricingPage/>}/>
|
<Route path="/pricing" element={<PricingPage/>}/>
|
||||||
<Route path="/pricing/enterprise" element={<PricingEnterprisePage/>}/>
|
<Route path="/pricing/enterprise" element={<PricingEnterprisePage/>}/>
|
||||||
<Route path="/pricing/faq" element={<PricingFaqPage/>}/>
|
<Route path="/pricing/faq" element={<PricingFaqPage/>}/>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import {useCallback, useEffect, useRef, useState} from "react";
|
|||||||
import {Link, useLocation, useNavigate} from "react-router-dom";
|
import {Link, useLocation, useNavigate} from "react-router-dom";
|
||||||
import {ArrowRight, Command, Eye, EyeOff, Loader2, ShieldAlert} from "lucide-react";
|
import {ArrowRight, Command, Eye, EyeOff, Loader2, ShieldAlert} from "lucide-react";
|
||||||
import {apiAuthCaptcha, type ApiResponseCaptchaResponse} from "@/client";
|
import {apiAuthCaptcha, type ApiResponseCaptchaResponse} from "@/client";
|
||||||
import {getApiErrorMessage, isTotpRequiredError} from "@/lib/api-error";
|
import {getApiErrorMessage /*, isTotpRequiredError*/} from "@/lib/api-error"; // 2FA disabled
|
||||||
import {useUser} from "@/contexts";
|
import {useUser} from "@/contexts";
|
||||||
import {AuthLayout} from "@/components/auth/auth-layout";
|
import {AuthLayout} from "@/components/auth/auth-layout";
|
||||||
import {Button} from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
@ -17,10 +17,10 @@ export function LoginPage() {
|
|||||||
const from = (location.state as { from?: string })?.from || "/w/me";
|
const from = (location.state as { from?: string })?.from || "/w/me";
|
||||||
const usernameRef = useRef<HTMLInputElement>(null);
|
const usernameRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [form, setForm] = useState({username: "", password: "", captcha: "", totp_code: ""});
|
const [form, setForm] = useState({username: "", password: "", captcha: ""});
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [needsTotp, setNeedsTotp] = useState(false);
|
// const [needsTotp, setNeedsTotp] = useState(false); // 2FA disabled
|
||||||
const [captcha, setCaptcha] = useState<ApiResponseCaptchaResponse['data'] | null>(null);
|
const [captcha, setCaptcha] = useState<ApiResponseCaptchaResponse['data'] | null>(null);
|
||||||
const [captchaLoading, setCaptchaLoading] = useState(false);
|
const [captchaLoading, setCaptchaLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -61,17 +61,17 @@ export function LoginPage() {
|
|||||||
username: form.username,
|
username: form.username,
|
||||||
password: encryptedPassword,
|
password: encryptedPassword,
|
||||||
captcha: form.captcha,
|
captcha: form.captcha,
|
||||||
totp_code: needsTotp && form.totp_code ? form.totp_code : undefined,
|
// totp_code: undefined, // 2FA disabled
|
||||||
});
|
});
|
||||||
navigate(from, {replace: true});
|
navigate(from, {replace: true});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (isTotpRequiredError(err)) {
|
// if (isTotpRequiredError(err)) { // 2FA disabled
|
||||||
setNeedsTotp(true);
|
// setNeedsTotp(true);
|
||||||
} else {
|
// } else {
|
||||||
setError(getApiErrorMessage(err, "Invalid credentials. Please try again."));
|
setError(getApiErrorMessage(err, "Invalid credentials. Please try again."));
|
||||||
await loadCaptcha();
|
await loadCaptcha();
|
||||||
setForm((p) => ({...p, captcha: ""}));
|
setForm((p) => ({...p, captcha: ""}));
|
||||||
}
|
// }
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@ -159,7 +159,7 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TOTP */}
|
{/* 2FA disabled {
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{needsTotp && (
|
{needsTotp && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -186,40 +186,41 @@ export function LoginPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
} */}
|
||||||
|
|
||||||
{/* 优雅的内嵌验证码设计 */}
|
{/* 优雅的内嵌验证码设计 */}
|
||||||
{!needsTotp && (
|
{/* {needsTotp && (} */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="captcha"
|
<label htmlFor="captcha"
|
||||||
className="text-sm font-medium text-zinc-900 dark:text-zinc-200">
|
className="text-sm font-medium text-zinc-900 dark:text-zinc-200">
|
||||||
Verification
|
Verification
|
||||||
</label>
|
</label>
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<Input
|
<Input
|
||||||
id="captcha"
|
id="captcha"
|
||||||
placeholder="Enter code"
|
placeholder="Enter code"
|
||||||
value={form.captcha}
|
value={form.captcha}
|
||||||
onChange={(e) => setForm((p) => ({...p, captcha: e.target.value}))}
|
onChange={(e) => setForm((p) => ({...p, captcha: e.target.value}))}
|
||||||
className="h-10 pl-3 pr-[110px] bg-zinc-50 dark:bg-zinc-900/50 border-zinc-200 dark:border-zinc-800 rounded-lg focus-visible:ring-1 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-600 transition-shadow"
|
className="h-10 pl-3 pr-[110px] bg-zinc-50 dark:bg-zinc-900/50 border-zinc-200 dark:border-zinc-800 rounded-lg focus-visible:ring-1 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-600 transition-shadow"
|
||||||
disabled={isLoading || captchaLoading}
|
disabled={isLoading || captchaLoading}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={loadCaptcha}
|
onClick={loadCaptcha}
|
||||||
disabled={captchaLoading}
|
disabled={captchaLoading}
|
||||||
className="absolute right-1 top-1 bottom-1 w-[100px] rounded-md overflow-hidden bg-white dark:bg-zinc-800 border border-zinc-100 dark:border-zinc-700 hover:opacity-80 transition-opacity flex items-center justify-center cursor-pointer"
|
className="absolute right-1 top-1 bottom-1 w-[100px] rounded-md overflow-hidden bg-white dark:bg-zinc-800 border border-zinc-100 dark:border-zinc-700 hover:opacity-80 transition-opacity flex items-center justify-center cursor-pointer"
|
||||||
>
|
>
|
||||||
{captchaLoading ? (
|
{captchaLoading ? (
|
||||||
<Loader2 className="size-4 animate-spin text-zinc-400"/>
|
<Loader2 className="size-4 animate-spin text-zinc-400"/>
|
||||||
) : captcha ? (
|
) : captcha ? (
|
||||||
// 暗黑模式下直接通过滤镜反转图片颜色,极其优雅
|
// 暗黑模式下直接通过滤镜反转图片颜色,极其优雅
|
||||||
<img src={captcha.base64} alt="captcha"
|
<img src={captcha.base64} alt="captcha"
|
||||||
className="h-full w-full object-cover dark:invert dark:hue-rotate-180"/>
|
className="h-full w-full object-cover dark:invert dark:hue-rotate-180"/>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
{/* )} */}
|
||||||
|
|
||||||
{/* 错误提示 - 使用非常克制的柔和边框风格 */}
|
{/* 错误提示 - 使用非常克制的柔和边框风格 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
export function SettingsSecurity() {
|
export function SettingsSecurity() {
|
||||||
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
// const [twoFactorEnabled, setTwoFactorEnabled] = useState(false); // 2FA disabled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 lg:max-w-4xl space-y-6 p-4 md:p-8 pt-6">
|
<div className="flex-1 lg:max-w-4xl space-y-6 p-4 md:p-8 pt-6">
|
||||||
@ -22,6 +21,7 @@ export function SettingsSecurity() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{/* 2FA disabled {
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between p-4 rounded-lg border bg-card hover:bg-accent/5 transition-colors">
|
<div className="flex flex-col md:flex-row md:items-center justify-between p-4 rounded-lg border bg-card hover:bg-accent/5 transition-colors">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="font-medium">Two-Factor Authentication</h3>
|
<h3 className="font-medium">Two-Factor Authentication</h3>
|
||||||
@ -39,6 +39,7 @@ export function SettingsSecurity() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
} */}
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between p-4 rounded-lg border bg-card hover:bg-accent/5 transition-colors">
|
<div className="flex flex-col md:flex-row md:items-center justify-between p-4 rounded-lg border bg-card hover:bg-accent/5 transition-colors">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@ -439,7 +439,7 @@ export function RoomProvider({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
const onAiStreamChunk = useCallback((chunk: AiStreamChunkPayload) => {
|
onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string }) => {
|
||||||
if (chunk.done) {
|
if (chunk.done) {
|
||||||
setStreamingContent((prev) => {
|
setStreamingContent((prev) => {
|
||||||
prev.delete(chunk.message_id);
|
prev.delete(chunk.message_id);
|
||||||
@ -491,7 +491,7 @@ export function RoomProvider({
|
|||||||
return current;
|
return current;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
|
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
|
||||||
if (!activeRoomIdRef.current) return;
|
if (!activeRoomIdRef.current) return;
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export interface ApiErrorBody {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CODE_TOTP_REQUIRED = 42801;
|
// export const CODE_TOTP_REQUIRED = 42801; // 2FA disabled
|
||||||
|
|
||||||
export function getApiErrorMessage(
|
export function getApiErrorMessage(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
@ -21,13 +21,13 @@ export function getApiErrorMessage(
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTotpRequiredError(error: unknown): boolean {
|
// export function isTotpRequiredError(error: unknown): boolean { // 2FA disabled
|
||||||
return (
|
// return (
|
||||||
axios.isAxiosError(error) &&
|
// axios.isAxiosError(error) &&
|
||||||
(error.response?.data as Partial<ApiErrorBody> | undefined)?.code ===
|
// (error.response?.data as Partial<ApiErrorBody> | undefined)?.code ===
|
||||||
CODE_TOTP_REQUIRED
|
// CODE_TOTP_REQUIRED
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
export function getApiErrorCode(error: unknown): number | undefined {
|
export function getApiErrorCode(error: unknown): number | undefined {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
|
|||||||
27
src/main.tsx
27
src/main.tsx
@ -1,26 +1,25 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
||||||
import { StrictMode } from 'react';
|
import {createRoot} from 'react-dom/client';
|
||||||
import { createRoot } from 'react-dom/client';
|
import {BrowserRouter} from 'react-router-dom';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import {Toaster} from 'sonner';
|
||||||
import { Toaster } from 'sonner';
|
import {UserProvider} from '@/contexts';
|
||||||
import { UserProvider } from '@/contexts';
|
import {ThemeProvider} from '@/contexts/theme-context';
|
||||||
import { ThemeProvider } from '@/contexts/theme-context';
|
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<App />
|
<App/>
|
||||||
<Toaster richColors position="bottom-right" />
|
<Toaster richColors position="bottom-right"/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</>,
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user