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
3
.gitignore
vendored
3
.gitignore
vendored
@ -16,4 +16,5 @@ AGENT.md
|
||||
ARCHITECTURE.md
|
||||
.agents
|
||||
.agents.md
|
||||
.next
|
||||
.next
|
||||
admin
|
||||
@ -5,7 +5,7 @@ pub mod logout;
|
||||
pub mod me;
|
||||
pub mod password;
|
||||
pub mod register;
|
||||
pub mod totp;
|
||||
// pub mod totp; // 2FA disabled
|
||||
pub mod ws_token;
|
||||
|
||||
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",
|
||||
actix_web::web::post().to(password::api_user_confirm_password_reset),
|
||||
)
|
||||
.route(
|
||||
"/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(
|
||||
"/2fa/status",
|
||||
actix_web::web::post().to(totp::api_2fa_status),
|
||||
)
|
||||
// 2FA disabled {
|
||||
// .route("/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("/2fa/status", actix_web::web::post().to(totp::api_2fa_status))
|
||||
// }
|
||||
.route("/email", actix_web::web::post().to(email::api_email_get))
|
||||
.route(
|
||||
"/email/change",
|
||||
|
||||
@ -1,94 +1,96 @@
|
||||
use crate::{ApiResponse, error::ApiError};
|
||||
use actix_web::{HttpResponse, Result, web};
|
||||
use service::AppService;
|
||||
use service::auth::totp::{
|
||||
Disable2FAParams, Enable2FAResponse, Get2FAStatusResponse, Verify2FAParams,
|
||||
};
|
||||
use session::Session;
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/2fa/enable",
|
||||
responses(
|
||||
(status = 200, description = "2FA setup initiated", body = Enable2FAResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 409, description = "2FA already enabled"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
(status = 404, description = "Not found", body = ApiResponse<ApiError>),
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn api_2fa_enable(
|
||||
service: web::Data<AppService>,
|
||||
session: Session,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let resp = service.auth_2fa_enable(&session).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/2fa/verify",
|
||||
request_body = Verify2FAParams,
|
||||
responses(
|
||||
(status = 200, description = "2FA verified and enabled"),
|
||||
(status = 401, description = "Unauthorized or invalid code"),
|
||||
(status = 400, description = "2FA not set up"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
(status = 404, description = "Not found", body = ApiResponse<ApiError>),
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn api_2fa_verify(
|
||||
service: web::Data<AppService>,
|
||||
session: Session,
|
||||
params: web::Json<Verify2FAParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
service
|
||||
.auth_2fa_verify_and_enable(&session, params.into_inner())
|
||||
.await?;
|
||||
Ok(crate::api_success())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/2fa/disable",
|
||||
request_body = Disable2FAParams,
|
||||
responses(
|
||||
(status = 200, description = "2FA disabled"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 400, description = "2FA not enabled or invalid code/password"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
(status = 404, description = "Not found", body = ApiResponse<ApiError>),
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn api_2fa_disable(
|
||||
service: web::Data<AppService>,
|
||||
session: Session,
|
||||
params: web::Json<Disable2FAParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
service
|
||||
.auth_2fa_disable(&session, params.into_inner())
|
||||
.await?;
|
||||
Ok(crate::api_success())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/2fa/status",
|
||||
responses(
|
||||
(status = 200, description = "2FA status", body = Get2FAStatusResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
(status = 404, description = "Not found", body = ApiResponse<ApiError>),
|
||||
),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn api_2fa_status(
|
||||
service: web::Data<AppService>,
|
||||
session: Session,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let resp = service.auth_2fa_status(&session).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
// 2FA disabled {
|
||||
// use crate::{ApiResponse, error::ApiError};
|
||||
// use actix_web::{HttpResponse, Result, web};
|
||||
// use service::AppService;
|
||||
// use service::auth::totp::{
|
||||
// Disable2FAParams, Enable2FAResponse, Get2FAStatusResponse, Verify2FAParams,
|
||||
// };
|
||||
// use session::Session;
|
||||
//
|
||||
// #[utoipa::path(
|
||||
// post,
|
||||
// path = "/api/auth/2fa/enable",
|
||||
// responses(
|
||||
// (status = 200, description = "2FA setup initiated", body = Enable2FAResponse),
|
||||
// (status = 401, description = "Unauthorized"),
|
||||
// (status = 409, description = "2FA already enabled"),
|
||||
// (status = 500, description = "Internal server error"),
|
||||
// (status = 404, description = "Not found", body = ApiResponse<ApiError>),
|
||||
// ),
|
||||
// tag = "Auth"
|
||||
// )]
|
||||
// pub async fn api_2fa_enable(
|
||||
// service: web::Data<AppService>,
|
||||
// session: Session,
|
||||
// ) -> Result<HttpResponse, ApiError> {
|
||||
// let resp = service.auth_2fa_enable(&session).await?;
|
||||
// Ok(ApiResponse::ok(resp).to_response())
|
||||
// }
|
||||
//
|
||||
// #[utoipa::path(
|
||||
// post,
|
||||
// path = "/api/auth/2fa/verify",
|
||||
// request_body = Verify2FAParams,
|
||||
// responses(
|
||||
// (status = 200, description = "2FA verified and enabled"),
|
||||
// (status = 401, description = "Unauthorized or invalid code"),
|
||||
// (status = 400, description = "2FA not set up"),
|
||||
// (status = 500, description = "Internal server error"),
|
||||
// (status = 404, description = "Not found", body = ApiResponse<ApiError>),
|
||||
// ),
|
||||
// tag = "Auth"
|
||||
// )]
|
||||
// pub async fn api_2fa_verify(
|
||||
// service: web::Data<AppService>,
|
||||
// session: Session,
|
||||
// params: web::Json<Verify2FAParams>,
|
||||
// ) -> Result<HttpResponse, ApiError> {
|
||||
// service
|
||||
// .auth_2fa_verify_and_enable(&session, params.into_inner())
|
||||
// .await?;
|
||||
// Ok(crate::api_success())
|
||||
// }
|
||||
//
|
||||
// #[utoipa::path(
|
||||
// post,
|
||||
// path = "/api/auth/2fa/disable",
|
||||
// request_body = Disable2FAParams,
|
||||
// responses(
|
||||
// (status = 200, description = "2FA disabled"),
|
||||
// (status = 401, description = "Unauthorized"),
|
||||
// (status = 400, description = "2FA not enabled or invalid code/password"),
|
||||
// (status = 500, description = "Internal server error"),
|
||||
// (status = 404, description = "Not found", body = ApiResponse<ApiError>),
|
||||
// ),
|
||||
// tag = "Auth"
|
||||
// )]
|
||||
// pub async fn api_2fa_disable(
|
||||
// service: web::Data<AppService>,
|
||||
// session: Session,
|
||||
// params: web::Json<Disable2FAParams>,
|
||||
// ) -> Result<HttpResponse, ApiError> {
|
||||
// service
|
||||
// .auth_2fa_disable(&session, params.into_inner())
|
||||
// .await?;
|
||||
// Ok(crate::api_success())
|
||||
// }
|
||||
//
|
||||
// #[utoipa::path(
|
||||
// post,
|
||||
// path = "/api/auth/2fa/status",
|
||||
// responses(
|
||||
// (status = 200, description = "2FA status", body = Get2FAStatusResponse),
|
||||
// (status = 401, description = "Unauthorized"),
|
||||
// (status = 500, description = "Internal server error"),
|
||||
// (status = 404, description = "Not found", body = ApiResponse<ApiError>),
|
||||
// ),
|
||||
// tag = "Auth"
|
||||
// )]
|
||||
// pub async fn api_2fa_status(
|
||||
// service: web::Data<AppService>,
|
||||
// session: Session,
|
||||
// ) -> Result<HttpResponse, ApiError> {
|
||||
// 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_request_password_reset,
|
||||
crate::auth::password::api_user_confirm_password_reset,
|
||||
crate::auth::totp::api_2fa_enable,
|
||||
crate::auth::totp::api_2fa_verify,
|
||||
crate::auth::totp::api_2fa_disable,
|
||||
crate::auth::totp::api_2fa_status,
|
||||
// crate::auth::totp::api_2fa_enable,
|
||||
// crate::auth::totp::api_2fa_verify,
|
||||
// crate::auth::totp::api_2fa_disable,
|
||||
// crate::auth::totp::api_2fa_status,
|
||||
crate::auth::email::api_email_get,
|
||||
crate::auth::email::api_email_change,
|
||||
crate::auth::email::api_email_verify,
|
||||
@ -673,10 +673,10 @@ use utoipa::OpenApi;
|
||||
service::auth::captcha::CaptchaQuery,
|
||||
service::auth::captcha::CaptchaResponse,
|
||||
service::auth::me::ContextMe,
|
||||
service::auth::totp::Enable2FAResponse,
|
||||
service::auth::totp::Verify2FAParams,
|
||||
service::auth::totp::Disable2FAParams,
|
||||
service::auth::totp::Get2FAStatusResponse,
|
||||
// service::auth::totp::Enable2FAResponse,
|
||||
// service::auth::totp::Verify2FAParams,
|
||||
// service::auth::totp::Disable2FAParams,
|
||||
// service::auth::totp::Get2FAStatusResponse,
|
||||
service::auth::email::EmailChangeRequest,
|
||||
service::auth::email::EmailVerifyRequest,
|
||||
service::auth::email::EmailResponse,
|
||||
|
||||
@ -2,23 +2,23 @@ use crate::AppService;
|
||||
use crate::error::AppError;
|
||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||
use models::users::{user_activity_log, user_password};
|
||||
use rand::RngExt;
|
||||
use redis::AsyncCommands;
|
||||
// use rand::RngExt;
|
||||
// use redis::AsyncCommands;
|
||||
use sea_orm::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session::Session;
|
||||
use sha1::Digest;
|
||||
// use sha1::Digest;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct LoginParams {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub captcha: String,
|
||||
pub totp_code: Option<String>,
|
||||
// pub totp_code: Option<String>, // 2FA disabled
|
||||
}
|
||||
|
||||
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> {
|
||||
self.auth_check_captcha(&context, params.captcha).await?;
|
||||
let password = self.auth_rsa_decode(&context, params.password).await?;
|
||||
@ -47,48 +47,50 @@ impl AppService {
|
||||
return Err(AppError::UserNotFound);
|
||||
}
|
||||
|
||||
let needs_totp_verification = context
|
||||
.get::<String>(Self::TOTP_KEY)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
|
||||
if needs_totp_verification {
|
||||
if let Some(ref totp_code) = params.totp_code {
|
||||
if !self.auth_2fa_verify_login(&context, totp_code).await? {
|
||||
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;
|
||||
let mut rng = rand::rng();
|
||||
let mut sha = sha1::Sha1::default();
|
||||
for _ in 0..5 {
|
||||
sha.update(
|
||||
(0..1024)
|
||||
.map(|_| {
|
||||
format!(
|
||||
"{:04}-{:04}-{:04}",
|
||||
rng.random_range(0..10000),
|
||||
rng.random_range(0..10000),
|
||||
rng.random_range(0..10000)
|
||||
)
|
||||
})
|
||||
.collect::<String>()
|
||||
.as_bytes(),
|
||||
)
|
||||
}
|
||||
let key = format!("{:?}", sha.finalize());
|
||||
context.insert(Self::TOTP_KEY, key.clone()).ok();
|
||||
if let Ok(mut conn) = self.cache.conn().await {
|
||||
conn.set_ex::<String, String, ()>(key, user_uid.to_string(), 60 * 5)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
slog::info!(self.logs, "Login 2FA triggered for new 2FA user"; "username" => ¶ms.username, "ip" => context.ip_address());
|
||||
return Err(AppError::TwoFactorRequired);
|
||||
}
|
||||
// 2FA disabled {
|
||||
// let needs_totp_verification = context
|
||||
// .get::<String>(Self::TOTP_KEY)
|
||||
// .ok()
|
||||
// .flatten()
|
||||
// .is_some();
|
||||
//
|
||||
// if needs_totp_verification {
|
||||
// if let Some(ref totp_code) = params.totp_code {
|
||||
// if !self.auth_2fa_verify_login(&context, totp_code).await? {
|
||||
// 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;
|
||||
// let mut rng = rand::rng();
|
||||
// let mut sha = sha1::Sha1::default();
|
||||
// for _ in 0..5 {
|
||||
// sha.update(
|
||||
// (0..1024)
|
||||
// .map(|_| {
|
||||
// format!(
|
||||
// "{:04}-{:04}-{:04}",
|
||||
// rng.random_range(0..10000),
|
||||
// rng.random_range(0..10000),
|
||||
// rng.random_range(0..10000)
|
||||
// )
|
||||
// })
|
||||
// .collect::<String>()
|
||||
// .as_bytes(),
|
||||
// )
|
||||
// }
|
||||
// let key = format!("{:?}", sha.finalize());
|
||||
// context.insert(Self::TOTP_KEY, key.clone()).ok();
|
||||
// if let Ok(mut conn) = self.cache.conn().await {
|
||||
// conn.set_ex::<String, String, ()>(key, user_uid.to_string(), 60 * 5)
|
||||
// .await
|
||||
// .ok();
|
||||
// }
|
||||
// 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();
|
||||
arch.last_sign_in_at = Set(Some(chrono::Utc::now()));
|
||||
@ -104,7 +106,6 @@ impl AppService {
|
||||
details: Set(Some(serde_json::json!({
|
||||
"method": "password",
|
||||
"username": user.username,
|
||||
"2fa_used": params.totp_code.is_some()
|
||||
}))
|
||||
.into()),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
@ -116,7 +117,7 @@ impl AppService {
|
||||
context.set_user(user.uid);
|
||||
context.remove(Self::RSA_PRIVATE_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(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,4 +6,4 @@ pub mod me;
|
||||
pub mod password;
|
||||
pub mod register;
|
||||
pub mod rsa;
|
||||
pub mod totp;
|
||||
// pub mod totp; // 2FA disabled
|
||||
|
||||
@ -105,7 +105,6 @@ function App() {
|
||||
<Route index element={<UserProfile/>}/>
|
||||
</Route>
|
||||
|
||||
{/* Landing sub-pages */}
|
||||
<Route path="/pricing" element={<PricingPage/>}/>
|
||||
<Route path="/pricing/enterprise" element={<PricingEnterprisePage/>}/>
|
||||
<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 {ArrowRight, Command, Eye, EyeOff, Loader2, ShieldAlert} from "lucide-react";
|
||||
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 {AuthLayout} from "@/components/auth/auth-layout";
|
||||
import {Button} from "@/components/ui/button";
|
||||
@ -17,10 +17,10 @@ export function LoginPage() {
|
||||
const from = (location.state as { from?: string })?.from || "/w/me";
|
||||
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 [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 [captchaLoading, setCaptchaLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -61,17 +61,17 @@ export function LoginPage() {
|
||||
username: form.username,
|
||||
password: encryptedPassword,
|
||||
captcha: form.captcha,
|
||||
totp_code: needsTotp && form.totp_code ? form.totp_code : undefined,
|
||||
// totp_code: undefined, // 2FA disabled
|
||||
});
|
||||
navigate(from, {replace: true});
|
||||
} catch (err: unknown) {
|
||||
if (isTotpRequiredError(err)) {
|
||||
setNeedsTotp(true);
|
||||
} else {
|
||||
setError(getApiErrorMessage(err, "Invalid credentials. Please try again."));
|
||||
await loadCaptcha();
|
||||
setForm((p) => ({...p, captcha: ""}));
|
||||
}
|
||||
// if (isTotpRequiredError(err)) { // 2FA disabled
|
||||
// setNeedsTotp(true);
|
||||
// } else {
|
||||
setError(getApiErrorMessage(err, "Invalid credentials. Please try again."));
|
||||
await loadCaptcha();
|
||||
setForm((p) => ({...p, captcha: ""}));
|
||||
// }
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -159,7 +159,7 @@ export function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TOTP */}
|
||||
{/* 2FA disabled {
|
||||
<AnimatePresence>
|
||||
{needsTotp && (
|
||||
<motion.div
|
||||
@ -186,40 +186,41 @@ export function LoginPage() {
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
} */}
|
||||
|
||||
{/* 优雅的内嵌验证码设计 */}
|
||||
{!needsTotp && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="captcha"
|
||||
className="text-sm font-medium text-zinc-900 dark:text-zinc-200">
|
||||
Verification
|
||||
</label>
|
||||
<div className="relative flex items-center">
|
||||
<Input
|
||||
id="captcha"
|
||||
placeholder="Enter code"
|
||||
value={form.captcha}
|
||||
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"
|
||||
disabled={isLoading || captchaLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadCaptcha}
|
||||
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"
|
||||
>
|
||||
{captchaLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-zinc-400"/>
|
||||
) : captcha ? (
|
||||
// 暗黑模式下直接通过滤镜反转图片颜色,极其优雅
|
||||
<img src={captcha.base64} alt="captcha"
|
||||
className="h-full w-full object-cover dark:invert dark:hue-rotate-180"/>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
{/* {needsTotp && (} */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="captcha"
|
||||
className="text-sm font-medium text-zinc-900 dark:text-zinc-200">
|
||||
Verification
|
||||
</label>
|
||||
<div className="relative flex items-center">
|
||||
<Input
|
||||
id="captcha"
|
||||
placeholder="Enter code"
|
||||
value={form.captcha}
|
||||
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"
|
||||
disabled={isLoading || captchaLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadCaptcha}
|
||||
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"
|
||||
>
|
||||
{captchaLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-zinc-400"/>
|
||||
) : captcha ? (
|
||||
// 暗黑模式下直接通过滤镜反转图片颜色,极其优雅
|
||||
<img src={captcha.base64} alt="captcha"
|
||||
className="h-full w-full object-cover dark:invert dark:hue-rotate-180"/>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* )} */}
|
||||
|
||||
{/* 错误提示 - 使用非常克制的柔和边框风格 */}
|
||||
<AnimatePresence>
|
||||
@ -265,4 +266,4 @@ export function LoginPage() {
|
||||
</motion.div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
export function SettingsSecurity() {
|
||||
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
||||
// const [twoFactorEnabled, setTwoFactorEnabled] = useState(false); // 2FA disabled
|
||||
|
||||
return (
|
||||
<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>
|
||||
</CardHeader>
|
||||
<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="space-y-1">
|
||||
<h3 className="font-medium">Two-Factor Authentication</h3>
|
||||
@ -39,6 +39,7 @@ export function SettingsSecurity() {
|
||||
</Button>
|
||||
</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="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) {
|
||||
setStreamingContent((prev) => {
|
||||
prev.delete(chunk.message_id);
|
||||
@ -491,7 +491,7 @@ export function RoomProvider({
|
||||
return current;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
|
||||
if (!activeRoomIdRef.current) return;
|
||||
setMessages((prev) => {
|
||||
|
||||
@ -6,7 +6,7 @@ export interface ApiErrorBody {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const CODE_TOTP_REQUIRED = 42801;
|
||||
// export const CODE_TOTP_REQUIRED = 42801; // 2FA disabled
|
||||
|
||||
export function getApiErrorMessage(
|
||||
error: unknown,
|
||||
@ -21,13 +21,13 @@ export function getApiErrorMessage(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function isTotpRequiredError(error: unknown): boolean {
|
||||
return (
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.data as Partial<ApiErrorBody> | undefined)?.code ===
|
||||
CODE_TOTP_REQUIRED
|
||||
);
|
||||
}
|
||||
// export function isTotpRequiredError(error: unknown): boolean { // 2FA disabled
|
||||
// return (
|
||||
// axios.isAxiosError(error) &&
|
||||
// (error.response?.data as Partial<ApiErrorBody> | undefined)?.code ===
|
||||
// CODE_TOTP_REQUIRED
|
||||
// );
|
||||
// }
|
||||
|
||||
export function getApiErrorCode(error: unknown): number | undefined {
|
||||
if (axios.isAxiosError(error)) {
|
||||
|
||||
27
src/main.tsx
27
src/main.tsx
@ -1,26 +1,25 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Toaster } from 'sonner';
|
||||
import { UserProvider } from '@/contexts';
|
||||
import { ThemeProvider } from '@/contexts/theme-context';
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {BrowserRouter} from 'react-router-dom';
|
||||
import {Toaster} from 'sonner';
|
||||
import {UserProvider} from '@/contexts';
|
||||
import {ThemeProvider} from '@/contexts/theme-context';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<UserProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</ThemeProvider>
|
||||
</UserProvider>
|
||||
<ThemeProvider>
|
||||
<App/>
|
||||
<Toaster richColors position="bottom-right"/>
|
||||
</ThemeProvider>
|
||||
</UserProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
</>,
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user