diff --git a/apps/server/AGENTS.md b/apps/server/AGENTS.md index 77c2369..3a9e9e5 100644 --- a/apps/server/AGENTS.md +++ b/apps/server/AGENTS.md @@ -55,7 +55,7 @@ src/ │ ├── AdminSidebar.tsx # Admin sidebar (reads module registry) │ ├── Error.tsx # Error boundary fallback │ ├── NotFound.tsx # 404 fallback -│ └── ui/ # shadcn/ui components (DO NOT manually edit) +│ └── ui/ # shadcn/ui components (可自由修改,添加新组件用 bunx shadcn@latest add) ├── hooks/ │ └── use-mobile.ts ├── lib/ @@ -286,7 +286,7 @@ export const relations = defineRelations(schema, (r) => ({ - Use `useState` callback ref for virtualizer scroll elements inside Dialogs **DON'T:** -- Manually edit `src/components/ui/*.tsx` (use `bunx shadcn@latest add`) +- Add new `src/components/ui/*.tsx` without CLI (use `bunx shadcn@latest add` to scaffold, then freely customize) - Edit `src/routeTree.gen.ts` (auto-generated) - Use `asChild` prop (base-ui uses `render`, NOT Radix) - Import from `drizzle-zod` (use `drizzle-orm/zod`) diff --git a/apps/server/drizzle.config.ts b/apps/server/drizzle.config.ts index d7f0ba5..490ff0c 100644 --- a/apps/server/drizzle.config.ts +++ b/apps/server/drizzle.config.ts @@ -5,6 +5,6 @@ export default defineConfig({ schema: './src/server/db/schema/index.ts', dialect: 'postgresql', dbCredentials: { - url: process.env.DATABASE_URL!, + url: process.env.DATABASE_URL ?? '', }, }) diff --git a/apps/server/package.json b/apps/server/package.json index 772b331..4713407 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "build": "bunx --bun vite build", + "cli": "bun run src/cli/index.ts", "compile": "bun compile.ts", "compile:darwin": "bun run compile:darwin:arm64 && bun run compile:darwin:x64", "compile:darwin:arm64": "bun compile.ts --target bun-darwin-arm64", @@ -36,11 +37,12 @@ "@orpc/zod": "catalog:", "@t3-oss/env-core": "catalog:", "@tanstack/react-query": "catalog:", - "@tanstack/react-virtual": "catalog:", "@tanstack/react-router": "catalog:", "@tanstack/react-router-ssr-query": "catalog:", "@tanstack/react-start": "catalog:", + "@tanstack/react-virtual": "catalog:", "better-auth": "catalog:", + "citty": "catalog:", "class-variance-authority": "catalog:", "clsx": "catalog:", "drizzle-orm": "catalog:", diff --git a/apps/server/src/cli/commands/auth.ts b/apps/server/src/cli/commands/auth.ts new file mode 100644 index 0000000..5a0ab01 --- /dev/null +++ b/apps/server/src/cli/commands/auth.ts @@ -0,0 +1,68 @@ +import { hashPassword } from 'better-auth/crypto' +import { defineCommand } from 'citty' +import { eq } from 'drizzle-orm' +import { drizzle } from 'drizzle-orm/postgres-js' +import * as authSchema from '@/server/auth/schema' + +export const resetPassword = defineCommand({ + meta: { + name: 'reset-password', + description: '重置 owner 密码', + }, + args: { + password: { + type: 'string', + description: '新密码(至少 8 个字符)', + required: false, + }, + }, + run: async ({ args }) => { + const databaseUrl = process.env.DATABASE_URL + if (!databaseUrl) { + console.error('错误: 未设置 DATABASE_URL 环境变量') + process.exit(1) + } + + const db = drizzle({ connection: databaseUrl }) + + const owner = await db + .select({ id: authSchema.user.id, email: authSchema.user.email }) + .from(authSchema.user) + .limit(1) + if (owner.length === 0 || !owner[0]) { + console.error('错误: 系统尚未初始化,请先通过 Web 界面完成设置') + process.exit(1) + } + + let newPassword = args.password + if (!newPassword) { + process.stdout.write('请输入新密码: ') + const reader = Bun.stdin.stream().getReader() + const chunk = await reader.read() + newPassword = new TextDecoder().decode(chunk.value).trim() + } + + if (!newPassword || newPassword.length < 8) { + console.error('错误: 密码至少需要 8 个字符') + process.exit(1) + } + + const hash = await hashPassword(newPassword) + + const result = await db + .update(authSchema.account) + .set({ password: hash }) + .where(eq(authSchema.account.userId, owner[0].id)) + .returning({ id: authSchema.account.id }) + + if (result.length === 0) { + console.error('错误: 未找到凭据账户,请确认 owner 使用邮箱密码注册') + process.exit(1) + } + + await db.delete(authSchema.session).where(eq(authSchema.session.userId, owner[0].id)) + + console.log(`✓ 已重置 ${owner[0].email} 的密码,所有会话已失效`) + process.exit(0) + }, +}) diff --git a/apps/server/src/cli/index.ts b/apps/server/src/cli/index.ts new file mode 100644 index 0000000..b999996 --- /dev/null +++ b/apps/server/src/cli/index.ts @@ -0,0 +1,22 @@ +import { defineCommand, runMain } from 'citty' +import { resetPassword } from './commands/auth' + +const main = defineCommand({ + meta: { + name: 'kairos', + description: 'Kairos 服务端管理工具', + }, + subCommands: { + auth: defineCommand({ + meta: { + name: 'auth', + description: '认证管理', + }, + subCommands: { + 'reset-password': resetPassword, + }, + }), + }, +}) + +runMain(main) diff --git a/apps/server/src/components/ui/sidebar.tsx b/apps/server/src/components/ui/sidebar.tsx index 46af534..4983adb 100644 --- a/apps/server/src/components/ui/sidebar.tsx +++ b/apps/server/src/components/ui/sidebar.tsx @@ -70,7 +70,12 @@ function SidebarProvider({ } // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + cookieStore.set({ + name: SIDEBAR_COOKIE_NAME, + value: String(openState), + path: '/', + expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000, + }) }, [setOpenProp, open], ) diff --git a/apps/server/src/routeTree.gen.ts b/apps/server/src/routeTree.gen.ts index 48a0441..894fc28 100644 --- a/apps/server/src/routeTree.gen.ts +++ b/apps/server/src/routeTree.gen.ts @@ -9,7 +9,8 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as SignupRouteImport } from './routes/signup' +import { Route as SetupRouteImport } from './routes/setup' +import { Route as RecoverRouteImport } from './routes/recover' import { Route as LoginRouteImport } from './routes/login' import { Route as ProtectedRouteImport } from './routes/_protected' import { Route as ProtectedIndexRouteImport } from './routes/_protected/index' @@ -21,9 +22,14 @@ import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$' import { Route as ProtectedAdminBookmarksRouteImport } from './routes/_protected/admin/bookmarks' -const SignupRoute = SignupRouteImport.update({ - id: '/signup', - path: '/signup', +const SetupRoute = SetupRouteImport.update({ + id: '/setup', + path: '/setup', + getParentRoute: () => rootRouteImport, +} as any) +const RecoverRoute = RecoverRouteImport.update({ + id: '/recover', + path: '/recover', getParentRoute: () => rootRouteImport, } as any) const LoginRoute = LoginRouteImport.update({ @@ -79,7 +85,8 @@ const ProtectedAdminBookmarksRoute = ProtectedAdminBookmarksRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof ProtectedIndexRoute '/login': typeof LoginRoute - '/signup': typeof SignupRoute + '/recover': typeof RecoverRoute + '/setup': typeof SetupRoute '/admin': typeof ProtectedAdminRouteWithChildren '/api/$': typeof ApiSplatRoute '/api/health': typeof ApiHealthRoute @@ -90,7 +97,8 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/login': typeof LoginRoute - '/signup': typeof SignupRoute + '/recover': typeof RecoverRoute + '/setup': typeof SetupRoute '/api/$': typeof ApiSplatRoute '/api/health': typeof ApiHealthRoute '/': typeof ProtectedIndexRoute @@ -103,7 +111,8 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/_protected': typeof ProtectedRouteWithChildren '/login': typeof LoginRoute - '/signup': typeof SignupRoute + '/recover': typeof RecoverRoute + '/setup': typeof SetupRoute '/_protected/admin': typeof ProtectedAdminRouteWithChildren '/api/$': typeof ApiSplatRoute '/api/health': typeof ApiHealthRoute @@ -118,7 +127,8 @@ export interface FileRouteTypes { fullPaths: | '/' | '/login' - | '/signup' + | '/recover' + | '/setup' | '/admin' | '/api/$' | '/api/health' @@ -129,7 +139,8 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/login' - | '/signup' + | '/recover' + | '/setup' | '/api/$' | '/api/health' | '/' @@ -141,7 +152,8 @@ export interface FileRouteTypes { | '__root__' | '/_protected' | '/login' - | '/signup' + | '/recover' + | '/setup' | '/_protected/admin' | '/api/$' | '/api/health' @@ -155,7 +167,8 @@ export interface FileRouteTypes { export interface RootRouteChildren { ProtectedRoute: typeof ProtectedRouteWithChildren LoginRoute: typeof LoginRoute - SignupRoute: typeof SignupRoute + RecoverRoute: typeof RecoverRoute + SetupRoute: typeof SetupRoute ApiSplatRoute: typeof ApiSplatRoute ApiHealthRoute: typeof ApiHealthRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute @@ -164,11 +177,18 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/signup': { - id: '/signup' - path: '/signup' - fullPath: '/signup' - preLoaderRoute: typeof SignupRouteImport + '/setup': { + id: '/setup' + path: '/setup' + fullPath: '/setup' + preLoaderRoute: typeof SetupRouteImport + parentRoute: typeof rootRouteImport + } + '/recover': { + id: '/recover' + path: '/recover' + fullPath: '/recover' + preLoaderRoute: typeof RecoverRouteImport parentRoute: typeof rootRouteImport } '/login': { @@ -275,7 +295,8 @@ const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { ProtectedRoute: ProtectedRouteWithChildren, LoginRoute: LoginRoute, - SignupRoute: SignupRoute, + RecoverRoute: RecoverRoute, + SetupRoute: SetupRoute, ApiSplatRoute: ApiSplatRoute, ApiHealthRoute: ApiHealthRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, diff --git a/apps/server/src/routes/login.tsx b/apps/server/src/routes/login.tsx index 3772083..9d557bd 100644 --- a/apps/server/src/routes/login.tsx +++ b/apps/server/src/routes/login.tsx @@ -1,10 +1,14 @@ import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router' import { useState } from 'react' import { authClient } from '@/server/auth/client' -import { getSession } from '@/server/auth/functions' +import { checkInitialized, getSession } from '@/server/auth/functions' export const Route = createFileRoute('/login' as never)({ beforeLoad: async () => { + const initialized = await checkInitialized() + if (!initialized) { + throw redirect({ to: '/setup' as never }) + } const session = await getSession() if (session) { throw redirect({ to: '/' as never }) @@ -93,9 +97,8 @@ function LoginPage() {
- 还没有账号?{' '} - - 注册 + + 无法登录?使用恢复密钥
diff --git a/apps/server/src/routes/recover.tsx b/apps/server/src/routes/recover.tsx new file mode 100644 index 0000000..95074eb --- /dev/null +++ b/apps/server/src/routes/recover.tsx @@ -0,0 +1,157 @@ +import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router' +import { useState } from 'react' +import { checkInitialized, recoverAccount } from '@/server/auth/functions' + +export const Route = createFileRoute('/recover' as never)({ + beforeLoad: async () => { + const initialized = await checkInitialized() + if (!initialized) { + throw redirect({ to: '/setup' as never }) + } + }, + component: RecoverPage, +}) + +function RecoverPage() { + const router = useRouter() + const [recoveryKey, setRecoveryKey] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const [success, setSuccess] = useState(false) + + const handleSubmit = async (e: React.FormEvent密码已重置
+密码已成功重置,所有会话已失效。请使用新密码重新登录。
+账户恢复
++ + 返回登录 + +
+初始化完成
+请妥善保存你的恢复密钥(仅显示一次)
+如果无法登录且无法访问服务器终端,可通过此密钥重置账户。
+
+ {recoveryKey}
+
+
+ 创建你的账号
+初始化你的人生操作系统
- 已有账号?{' '} - - 登录 - -