feat(room): add category creation and drag-to-assign for channels
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions

- 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:
ZhenYi 2026-04-19 16:44:31 +08:00
parent b73cc8d421
commit 63c75ad453
12 changed files with 235 additions and 241 deletions

1
.gitignore vendored
View File

@ -17,3 +17,4 @@ ARCHITECTURE.md
.agents
.agents.md
.next
admin

View File

@ -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",

View File

@ -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())
// }
// }

View File

@ -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,

View File

@ -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" => &params.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" => &params.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" => &params.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" => &params.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(())
}
}

View File

@ -6,4 +6,4 @@ pub mod me;
pub mod password;
pub mod register;
pub mod rsa;
pub mod totp;
// pub mod totp; // 2FA disabled

View File

@ -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/>}/>

View File

@ -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 {
// 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,9 +186,10 @@ export function LoginPage() {
</motion.div>
)}
</AnimatePresence>
} */}
{/* 优雅的内嵌验证码设计 */}
{!needsTotp && (
{/* {needsTotp && (} */}
<div className="space-y-2">
<label htmlFor="captcha"
className="text-sm font-medium text-zinc-900 dark:text-zinc-200">
@ -219,7 +220,7 @@ export function LoginPage() {
</button>
</div>
</div>
)}
{/* )} */}
{/* 错误提示 - 使用非常克制的柔和边框风格 */}
<AnimatePresence>

View File

@ -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">

View File

@ -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) => {

View File

@ -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)) {

View File

@ -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" />
<App/>
<Toaster richColors position="bottom-right"/>
</ThemeProvider>
</UserProvider>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
</>,
);