feat: 单 owner 认证模型 — 替换注册为一次性设置向导 + Recovery Key + CLI 密码重置

自托管 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 组件编辑规则
This commit is contained in:
2026-03-31 18:33:16 +08:00
parent d67aaa723e
commit 830714c94f
16 changed files with 483 additions and 45 deletions
+2 -2
View File
@@ -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`)
+1 -1
View File
@@ -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 ?? '',
},
})
+3 -1
View File
@@ -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:",
+68
View File
@@ -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)
},
})
+22
View File
@@ -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)
+6 -1
View File
@@ -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],
)
+38 -17
View File
@@ -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,
+7 -4
View File
@@ -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() {
</form>
<p className="text-center text-sm text-slate-500 mt-6">
{' '}
<Link to={'/signup' as never} className="text-indigo-600 hover:text-indigo-700 font-medium">
<Link to={'/recover' as never} className="text-indigo-600 hover:text-indigo-700 font-medium">
使
</Link>
</p>
</div>
+157
View File
@@ -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<HTMLFormElement>) => {
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 (
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">Kairos</h1>
<p className="text-slate-500 mt-2"></p>
</div>
<div className="bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] ring-1 ring-slate-100 p-8">
<div className="space-y-5">
<div className="bg-emerald-50 ring-1 ring-emerald-200 rounded-xl p-4">
<p className="text-sm text-emerald-800">使</p>
</div>
<button
type="button"
onClick={() => router.navigate({ to: '/login' as never })}
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 hover:shadow-lg hover:shadow-indigo-300 active:scale-[0.98]"
>
</button>
</div>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">Kairos</h1>
<p className="text-slate-500 mt-2"></p>
</div>
<div className="bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] ring-1 ring-slate-100 p-8">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="recoveryKey" className="block text-sm font-medium text-slate-700 mb-1.5">
</label>
<input
id="recoveryKey"
type="text"
required
value={recoveryKey}
onChange={(e) => 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}
/>
</div>
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-slate-700 mb-1.5">
</label>
<input
id="newPassword"
type="password"
required
value={newPassword}
onChange={(e) => 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}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-700 mb-1.5">
</label>
<input
id="confirmPassword"
type="password"
required
value={confirmPassword}
onChange={(e) => 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}
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
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 ? '恢复中...' : '重置密码'}
</button>
</form>
<p className="text-center text-sm text-slate-500 mt-6">
<Link to={'/login' as never} className="text-indigo-600 hover:text-indigo-700 font-medium">
</Link>
</p>
</div>
</div>
</div>
)
}
@@ -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<string | null>(null)
const [copied, setCopied] = useState(false)
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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 (
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">Kairos</h1>
<p className="text-slate-500 mt-2"></p>
</div>
<div className="bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] ring-1 ring-slate-100 p-8">
<div className="space-y-5">
<div className="bg-amber-50 ring-1 ring-amber-200 rounded-xl p-4">
<p className="text-sm font-medium text-amber-800 mb-2"></p>
<p className="text-xs text-amber-600">访</p>
</div>
<div className="relative">
<code className="block w-full px-4 py-3 rounded-xl bg-slate-50 ring-1 ring-slate-200 text-slate-700 text-sm font-mono break-all select-all">
{recoveryKey}
</code>
<button
type="button"
onClick={handleCopy}
className="absolute top-2 right-2 px-2.5 py-1 text-xs bg-white ring-1 ring-slate-200 rounded-lg text-slate-600 hover:bg-slate-50 transition-all"
>
{copied ? '已复制' : '复制'}
</button>
</div>
<button
type="button"
onClick={handleContinue}
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 hover:shadow-lg hover:shadow-indigo-300 active:scale-[0.98]"
>
使 Kairos
</button>
</div>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">Kairos</h1>
<p className="text-slate-500 mt-2"></p>
<p className="text-slate-500 mt-2"></p>
</div>
<div className="bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] ring-1 ring-slate-100 p-8">
@@ -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'}
</button>
</form>
<p className="text-center text-sm text-slate-500 mt-6">
{' '}
<Link to={'/login' as never} className="text-indigo-600 hover:text-indigo-700 font-medium">
</Link>
</p>
</div>
</div>
</div>
+79
View File
@@ -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<typeof recoverAccountSchema>) => 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 }
})
+13
View File
@@ -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()],
})
@@ -1,2 +1,3 @@
export * from '../../../modules/bookmarks/schema'
export * from '../../auth/schema'
export * from './system'
@@ -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()),
})
+4
View File
@@ -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=="],
+2 -1
View File
@@ -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"
}
}