From 830714c94f226d317f8a262d780bd55b1d2b2e85 Mon Sep 17 00:00:00 2001 From: imbytecat Date: Tue, 31 Mar 2026 18:33:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8D=95=20owner=20=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=20=E2=80=94=20=E6=9B=BF=E6=8D=A2=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E4=B8=BA=E4=B8=80=E6=AC=A1=E6=80=A7=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=90=91=E5=AF=BC=20+=20Recovery=20Key=20+=20CLI=20=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E9=87=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自托管 Life OS 不应有公开注册。改为: - /setup 一次性初始化向导(创建唯一 owner + 生成 Recovery Key) - /recover 通过 Recovery Key 重置密码 - /login 未初始化时重定向到 /setup,去掉注册链接 - Better Auth databaseHooks 阻止额外用户注册 - citty CLI: bun run cli auth reset-password - 删除 /signup 路由 - 新增 system_settings 表存储 recovery key hash - 修复 drizzle.config.ts 非空断言 + sidebar.tsx cookieStore API - 更新 AGENTS.md shadcn/ui 组件编辑规则 --- apps/server/AGENTS.md | 4 +- apps/server/drizzle.config.ts | 2 +- apps/server/package.json | 4 +- apps/server/src/cli/commands/auth.ts | 68 ++++++++ apps/server/src/cli/index.ts | 22 +++ apps/server/src/components/ui/sidebar.tsx | 7 +- apps/server/src/routeTree.gen.ts | 55 ++++-- apps/server/src/routes/login.tsx | 11 +- apps/server/src/routes/recover.tsx | 157 ++++++++++++++++++ .../src/routes/{signup.tsx => setup.tsx} | 87 ++++++++-- apps/server/src/server/auth/functions.ts | 79 +++++++++ apps/server/src/server/auth/index.ts | 13 ++ apps/server/src/server/db/schema/index.ts | 1 + apps/server/src/server/db/schema/system.ts | 11 ++ bun.lock | 4 + package.json | 3 +- 16 files changed, 483 insertions(+), 45 deletions(-) create mode 100644 apps/server/src/cli/commands/auth.ts create mode 100644 apps/server/src/cli/index.ts create mode 100644 apps/server/src/routes/recover.tsx rename apps/server/src/routes/{signup.tsx => setup.tsx} (60%) create mode 100644 apps/server/src/server/db/schema/system.ts 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) => { + e.preventDefault() + setError('') + + if (newPassword !== confirmPassword) { + setError('两次输入的密码不一致') + return + } + + if (newPassword.length < 8) { + setError('密码至少需要 8 个字符') + return + } + + setLoading(true) + + try { + await recoverAccount({ data: { recoveryKey: recoveryKey.trim(), newPassword } }) + setSuccess(true) + } catch (err) { + setError(err instanceof Error ? err.message : '恢复失败,请检查恢复密钥是否正确') + } finally { + setLoading(false) + } + } + + if (success) { + return ( +
+
+
+

Kairos

+

密码已重置

+
+ +
+
+
+

密码已成功重置,所有会话已失效。请使用新密码重新登录。

+
+ + +
+
+
+
+ ) + } + + return ( +
+
+
+

Kairos

+

账户恢复

+
+ +
+
+
+ + setRecoveryKey(e.target.value)} + className="w-full px-4 py-3 rounded-xl bg-slate-50 border-0 ring-1 ring-slate-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all text-slate-700 placeholder:text-slate-400 font-mono text-sm" + placeholder="kairos-recovery-..." + disabled={loading} + /> +
+ +
+ + setNewPassword(e.target.value)} + className="w-full px-4 py-3 rounded-xl bg-slate-50 border-0 ring-1 ring-slate-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all text-slate-700 placeholder:text-slate-400" + placeholder="至少 8 个字符" + disabled={loading} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full px-4 py-3 rounded-xl bg-slate-50 border-0 ring-1 ring-slate-200 focus:ring-2 focus:ring-indigo-500 outline-none transition-all text-slate-700 placeholder:text-slate-400" + placeholder="再次输入新密码" + disabled={loading} + /> +
+ + {error &&

{error}

} + + +
+ +

+ + 返回登录 + +

+
+
+
+ ) +} diff --git a/apps/server/src/routes/signup.tsx b/apps/server/src/routes/setup.tsx similarity index 60% rename from apps/server/src/routes/signup.tsx rename to apps/server/src/routes/setup.tsx index 0b0e708..ba1fc81 100644 --- a/apps/server/src/routes/signup.tsx +++ b/apps/server/src/routes/setup.tsx @@ -1,19 +1,19 @@ -import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router' +import { createFileRoute, redirect, useRouter } from '@tanstack/react-router' import { useState } from 'react' import { authClient } from '@/server/auth/client' -import { getSession } from '@/server/auth/functions' +import { checkInitialized, completeSetup } from '@/server/auth/functions' -export const Route = createFileRoute('/signup' as never)({ +export const Route = createFileRoute('/setup' as never)({ beforeLoad: async () => { - const session = await getSession() - if (session) { - throw redirect({ to: '/' as never }) + const initialized = await checkInitialized() + if (initialized) { + throw redirect({ to: '/login' as never }) } }, - component: SignupPage, + component: SetupPage, }) -function SignupPage() { +function SetupPage() { const router = useRouter() const [name, setName] = useState('') const [email, setEmail] = useState('') @@ -21,6 +21,8 @@ function SignupPage() { const [confirmPassword, setConfirmPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const [recoveryKey, setRecoveryKey] = useState(null) + const [copied, setCopied] = useState(false) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -45,20 +47,76 @@ function SignupPage() { }) if (signUpError) { - setError(signUpError.message ?? '注册失败,请重试') + setError(signUpError.message ?? '创建账号失败,请重试') setLoading(false) return } + const result = await completeSetup() + setRecoveryKey(result.recoveryKey) + setLoading(false) + } + + const handleCopy = async () => { + if (!recoveryKey) return + await navigator.clipboard.writeText(recoveryKey) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const handleContinue = () => { router.navigate({ to: '/' as never }) } + if (recoveryKey) { + return ( +
+
+
+

Kairos

+

初始化完成

+
+ +
+
+
+

请妥善保存你的恢复密钥(仅显示一次)

+

如果无法登录且无法访问服务器终端,可通过此密钥重置账户。

+
+ +
+ + {recoveryKey} + + +
+ + +
+
+
+
+ ) + } + return (

Kairos

-

创建你的账号

+

初始化你的人生操作系统

@@ -134,16 +192,9 @@ function SignupPage() { disabled={loading} className="w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-[0.98]" > - {loading ? '注册中...' : '注册'} + {loading ? '初始化中...' : '开始使用 Kairos'} - -

- 已有账号?{' '} - - 登录 - -

diff --git a/apps/server/src/server/auth/functions.ts b/apps/server/src/server/auth/functions.ts index 9e5445b..596b248 100644 --- a/apps/server/src/server/auth/functions.ts +++ b/apps/server/src/server/auth/functions.ts @@ -1,8 +1,87 @@ import { createServerFn } from '@tanstack/react-start' import { getRequestHeaders } from '@tanstack/react-start/server' +import { hashPassword, verifyPassword } from 'better-auth/crypto' +import { eq } from 'drizzle-orm' +import { z } from 'zod' import { auth } from '@/server/auth' +import * as authSchema from '@/server/auth/schema' +import { db } from '@/server/db' +import { systemSettings } from '@/server/db/schema/system' + +const RECOVERY_KEY_PREFIX = 'kairos-recovery-' +const RECOVERY_KEY_SETTING = 'recovery_key_hash' + +function generateRecoveryKey(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)) + const hex = Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + return `${RECOVERY_KEY_PREFIX}${hex}` +} export const getSession = createServerFn({ method: 'GET' }).handler(async () => { const headers = getRequestHeaders() return await auth.api.getSession({ headers }) }) + +export const checkInitialized = createServerFn({ method: 'GET' }).handler(async () => { + const users = await db.select({ id: authSchema.user.id }).from(authSchema.user).limit(1) + return users.length > 0 +}) + +export const completeSetup = createServerFn({ method: 'POST' }).handler(async () => { + const headers = getRequestHeaders() + const sessionData = await auth.api.getSession({ headers }) + if (!sessionData?.user) { + throw new Error('Not authenticated') + } + + const users = await db.select({ id: authSchema.user.id }).from(authSchema.user).limit(2) + if (users.length !== 1) { + throw new Error('Invalid system state') + } + + const existing = await db.select().from(systemSettings).where(eq(systemSettings.key, RECOVERY_KEY_SETTING)) + if (existing.length > 0) { + throw new Error('Setup already completed') + } + + const recoveryKey = generateRecoveryKey() + const hash = await hashPassword(recoveryKey) + + await db.insert(systemSettings).values({ key: RECOVERY_KEY_SETTING, value: hash }) + + return { recoveryKey } +}) + +const recoverAccountSchema = z.object({ + recoveryKey: z.string().min(1), + newPassword: z.string().min(8), +}) + +export const recoverAccount = createServerFn({ method: 'POST' }) + .inputValidator((data: z.input) => recoverAccountSchema.parse(data)) + .handler(async ({ data }) => { + const stored = await db.select().from(systemSettings).where(eq(systemSettings.key, RECOVERY_KEY_SETTING)) + if (stored.length === 0 || !stored[0]) { + throw new Error('Recovery not available') + } + + const isValid = await verifyPassword({ hash: stored[0].value, password: data.recoveryKey }) + if (!isValid) { + throw new Error('Invalid recovery key') + } + + const owner = await db.select().from(authSchema.user).limit(1) + if (owner.length === 0 || !owner[0]) { + throw new Error('No owner found') + } + + const newHash = await hashPassword(data.newPassword) + + await db.update(authSchema.account).set({ password: newHash }).where(eq(authSchema.account.userId, owner[0].id)) + + await db.delete(authSchema.session).where(eq(authSchema.session.userId, owner[0].id)) + + return { success: true } + }) diff --git a/apps/server/src/server/auth/index.ts b/apps/server/src/server/auth/index.ts index 2f31444..50c5bc0 100644 --- a/apps/server/src/server/auth/index.ts +++ b/apps/server/src/server/auth/index.ts @@ -1,5 +1,6 @@ import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { APIError } from 'better-auth/api' import { tanstackStartCookies } from 'better-auth/tanstack-start' import { env } from '@/env' import * as authSchema from '@/server/auth/schema' @@ -21,5 +22,17 @@ export const auth = betterAuth({ maxAge: 5 * 60, }, }, + databaseHooks: { + user: { + create: { + before: async () => { + const existingUsers = await db.select({ id: authSchema.user.id }).from(authSchema.user).limit(1) + if (existingUsers.length > 0) { + throw new APIError('FORBIDDEN', { message: 'System already has an owner. Registration is disabled.' }) + } + }, + }, + }, + }, plugins: [tanstackStartCookies()], }) diff --git a/apps/server/src/server/db/schema/index.ts b/apps/server/src/server/db/schema/index.ts index b56a0c0..6d357ab 100644 --- a/apps/server/src/server/db/schema/index.ts +++ b/apps/server/src/server/db/schema/index.ts @@ -1,2 +1,3 @@ export * from '../../../modules/bookmarks/schema' export * from '../../auth/schema' +export * from './system' diff --git a/apps/server/src/server/db/schema/system.ts b/apps/server/src/server/db/schema/system.ts new file mode 100644 index 0000000..65efb34 --- /dev/null +++ b/apps/server/src/server/db/schema/system.ts @@ -0,0 +1,11 @@ +import { pgTable, text, timestamp } from 'drizzle-orm/pg-core' + +export const systemSettings = pgTable('system_settings', { + key: text('key').primaryKey(), + value: text('value').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdateFn(() => new Date()), +}) diff --git a/bun.lock b/bun.lock index dfaf59d..f197f27 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,7 @@ "@tanstack/react-start": "catalog:", "@tanstack/react-virtual": "catalog:", "better-auth": "catalog:", + "citty": "catalog:", "class-variance-authority": "catalog:", "clsx": "catalog:", "drizzle-orm": "catalog:", @@ -96,6 +97,7 @@ "@vitejs/plugin-react": "^6.0.1", "babel-plugin-react-compiler": "^1.0.0", "better-auth": "^1.2.8", + "citty": "^0.2.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-kit": "1.0.0-beta.15-859cf75", @@ -619,6 +621,8 @@ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], diff --git a/package.json b/package.json index 6cfd60d..9aabd15 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", - "babel-plugin-react-compiler": "^1.0.0" + "babel-plugin-react-compiler": "^1.0.0", + "citty": "^0.2.1" } }