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:
@@ -55,7 +55,7 @@ src/
|
|||||||
│ ├── AdminSidebar.tsx # Admin sidebar (reads module registry)
|
│ ├── AdminSidebar.tsx # Admin sidebar (reads module registry)
|
||||||
│ ├── Error.tsx # Error boundary fallback
|
│ ├── Error.tsx # Error boundary fallback
|
||||||
│ ├── NotFound.tsx # 404 fallback
|
│ ├── NotFound.tsx # 404 fallback
|
||||||
│ └── ui/ # shadcn/ui components (DO NOT manually edit)
|
│ └── ui/ # shadcn/ui components (可自由修改,添加新组件用 bunx shadcn@latest add)
|
||||||
├── hooks/
|
├── hooks/
|
||||||
│ └── use-mobile.ts
|
│ └── use-mobile.ts
|
||||||
├── lib/
|
├── lib/
|
||||||
@@ -286,7 +286,7 @@ export const relations = defineRelations(schema, (r) => ({
|
|||||||
- Use `useState` callback ref for virtualizer scroll elements inside Dialogs
|
- Use `useState` callback ref for virtualizer scroll elements inside Dialogs
|
||||||
|
|
||||||
**DON'T:**
|
**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)
|
- Edit `src/routeTree.gen.ts` (auto-generated)
|
||||||
- Use `asChild` prop (base-ui uses `render`, NOT Radix)
|
- Use `asChild` prop (base-ui uses `render`, NOT Radix)
|
||||||
- Import from `drizzle-zod` (use `drizzle-orm/zod`)
|
- Import from `drizzle-zod` (use `drizzle-orm/zod`)
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ export default defineConfig({
|
|||||||
schema: './src/server/db/schema/index.ts',
|
schema: './src/server/db/schema/index.ts',
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL!,
|
url: process.env.DATABASE_URL ?? '',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bunx --bun vite build",
|
"build": "bunx --bun vite build",
|
||||||
|
"cli": "bun run src/cli/index.ts",
|
||||||
"compile": "bun compile.ts",
|
"compile": "bun compile.ts",
|
||||||
"compile:darwin": "bun run compile:darwin:arm64 && bun run compile:darwin:x64",
|
"compile:darwin": "bun run compile:darwin:arm64 && bun run compile:darwin:x64",
|
||||||
"compile:darwin:arm64": "bun compile.ts --target bun-darwin-arm64",
|
"compile:darwin:arm64": "bun compile.ts --target bun-darwin-arm64",
|
||||||
@@ -36,11 +37,12 @@
|
|||||||
"@orpc/zod": "catalog:",
|
"@orpc/zod": "catalog:",
|
||||||
"@t3-oss/env-core": "catalog:",
|
"@t3-oss/env-core": "catalog:",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-virtual": "catalog:",
|
|
||||||
"@tanstack/react-router": "catalog:",
|
"@tanstack/react-router": "catalog:",
|
||||||
"@tanstack/react-router-ssr-query": "catalog:",
|
"@tanstack/react-router-ssr-query": "catalog:",
|
||||||
"@tanstack/react-start": "catalog:",
|
"@tanstack/react-start": "catalog:",
|
||||||
|
"@tanstack/react-virtual": "catalog:",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
|
"citty": "catalog:",
|
||||||
"class-variance-authority": "catalog:",
|
"class-variance-authority": "catalog:",
|
||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
@@ -70,7 +70,12 @@ function SidebarProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// This sets the cookie to keep the sidebar state.
|
// 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],
|
[setOpenProp, open],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
// 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 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 LoginRouteImport } from './routes/login'
|
||||||
import { Route as ProtectedRouteImport } from './routes/_protected'
|
import { Route as ProtectedRouteImport } from './routes/_protected'
|
||||||
import { Route as ProtectedIndexRouteImport } from './routes/_protected/index'
|
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 ApiAuthSplatRouteImport } from './routes/api/auth.$'
|
||||||
import { Route as ProtectedAdminBookmarksRouteImport } from './routes/_protected/admin/bookmarks'
|
import { Route as ProtectedAdminBookmarksRouteImport } from './routes/_protected/admin/bookmarks'
|
||||||
|
|
||||||
const SignupRoute = SignupRouteImport.update({
|
const SetupRoute = SetupRouteImport.update({
|
||||||
id: '/signup',
|
id: '/setup',
|
||||||
path: '/signup',
|
path: '/setup',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const RecoverRoute = RecoverRouteImport.update({
|
||||||
|
id: '/recover',
|
||||||
|
path: '/recover',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
@@ -79,7 +85,8 @@ const ProtectedAdminBookmarksRoute = ProtectedAdminBookmarksRouteImport.update({
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof ProtectedIndexRoute
|
'/': typeof ProtectedIndexRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/signup': typeof SignupRoute
|
'/recover': typeof RecoverRoute
|
||||||
|
'/setup': typeof SetupRoute
|
||||||
'/admin': typeof ProtectedAdminRouteWithChildren
|
'/admin': typeof ProtectedAdminRouteWithChildren
|
||||||
'/api/$': typeof ApiSplatRoute
|
'/api/$': typeof ApiSplatRoute
|
||||||
'/api/health': typeof ApiHealthRoute
|
'/api/health': typeof ApiHealthRoute
|
||||||
@@ -90,7 +97,8 @@ export interface FileRoutesByFullPath {
|
|||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/signup': typeof SignupRoute
|
'/recover': typeof RecoverRoute
|
||||||
|
'/setup': typeof SetupRoute
|
||||||
'/api/$': typeof ApiSplatRoute
|
'/api/$': typeof ApiSplatRoute
|
||||||
'/api/health': typeof ApiHealthRoute
|
'/api/health': typeof ApiHealthRoute
|
||||||
'/': typeof ProtectedIndexRoute
|
'/': typeof ProtectedIndexRoute
|
||||||
@@ -103,7 +111,8 @@ export interface FileRoutesById {
|
|||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/_protected': typeof ProtectedRouteWithChildren
|
'/_protected': typeof ProtectedRouteWithChildren
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/signup': typeof SignupRoute
|
'/recover': typeof RecoverRoute
|
||||||
|
'/setup': typeof SetupRoute
|
||||||
'/_protected/admin': typeof ProtectedAdminRouteWithChildren
|
'/_protected/admin': typeof ProtectedAdminRouteWithChildren
|
||||||
'/api/$': typeof ApiSplatRoute
|
'/api/$': typeof ApiSplatRoute
|
||||||
'/api/health': typeof ApiHealthRoute
|
'/api/health': typeof ApiHealthRoute
|
||||||
@@ -118,7 +127,8 @@ export interface FileRouteTypes {
|
|||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/signup'
|
| '/recover'
|
||||||
|
| '/setup'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/api/$'
|
| '/api/$'
|
||||||
| '/api/health'
|
| '/api/health'
|
||||||
@@ -129,7 +139,8 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/signup'
|
| '/recover'
|
||||||
|
| '/setup'
|
||||||
| '/api/$'
|
| '/api/$'
|
||||||
| '/api/health'
|
| '/api/health'
|
||||||
| '/'
|
| '/'
|
||||||
@@ -141,7 +152,8 @@ export interface FileRouteTypes {
|
|||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_protected'
|
| '/_protected'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/signup'
|
| '/recover'
|
||||||
|
| '/setup'
|
||||||
| '/_protected/admin'
|
| '/_protected/admin'
|
||||||
| '/api/$'
|
| '/api/$'
|
||||||
| '/api/health'
|
| '/api/health'
|
||||||
@@ -155,7 +167,8 @@ export interface FileRouteTypes {
|
|||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
ProtectedRoute: typeof ProtectedRouteWithChildren
|
ProtectedRoute: typeof ProtectedRouteWithChildren
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
SignupRoute: typeof SignupRoute
|
RecoverRoute: typeof RecoverRoute
|
||||||
|
SetupRoute: typeof SetupRoute
|
||||||
ApiSplatRoute: typeof ApiSplatRoute
|
ApiSplatRoute: typeof ApiSplatRoute
|
||||||
ApiHealthRoute: typeof ApiHealthRoute
|
ApiHealthRoute: typeof ApiHealthRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
@@ -164,11 +177,18 @@ export interface RootRouteChildren {
|
|||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/signup': {
|
'/setup': {
|
||||||
id: '/signup'
|
id: '/setup'
|
||||||
path: '/signup'
|
path: '/setup'
|
||||||
fullPath: '/signup'
|
fullPath: '/setup'
|
||||||
preLoaderRoute: typeof SignupRouteImport
|
preLoaderRoute: typeof SetupRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/recover': {
|
||||||
|
id: '/recover'
|
||||||
|
path: '/recover'
|
||||||
|
fullPath: '/recover'
|
||||||
|
preLoaderRoute: typeof RecoverRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/login': {
|
'/login': {
|
||||||
@@ -275,7 +295,8 @@ const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren(
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
ProtectedRoute: ProtectedRouteWithChildren,
|
ProtectedRoute: ProtectedRouteWithChildren,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
SignupRoute: SignupRoute,
|
RecoverRoute: RecoverRoute,
|
||||||
|
SetupRoute: SetupRoute,
|
||||||
ApiSplatRoute: ApiSplatRoute,
|
ApiSplatRoute: ApiSplatRoute,
|
||||||
ApiHealthRoute: ApiHealthRoute,
|
ApiHealthRoute: ApiHealthRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { authClient } from '@/server/auth/client'
|
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)({
|
export const Route = createFileRoute('/login' as never)({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
|
const initialized = await checkInitialized()
|
||||||
|
if (!initialized) {
|
||||||
|
throw redirect({ to: '/setup' as never })
|
||||||
|
}
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (session) {
|
if (session) {
|
||||||
throw redirect({ to: '/' as never })
|
throw redirect({ to: '/' as never })
|
||||||
@@ -93,9 +97,8 @@ function LoginPage() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-sm text-slate-500 mt-6">
|
<p className="text-center text-sm text-slate-500 mt-6">
|
||||||
还没有账号?{' '}
|
<Link to={'/recover' as never} className="text-indigo-600 hover:text-indigo-700 font-medium">
|
||||||
<Link to={'/signup' as never} className="text-indigo-600 hover:text-indigo-700 font-medium">
|
无法登录?使用恢复密钥
|
||||||
注册
|
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { useState } from 'react'
|
||||||
import { authClient } from '@/server/auth/client'
|
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 () => {
|
beforeLoad: async () => {
|
||||||
const session = await getSession()
|
const initialized = await checkInitialized()
|
||||||
if (session) {
|
if (initialized) {
|
||||||
throw redirect({ to: '/' as never })
|
throw redirect({ to: '/login' as never })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: SignupPage,
|
component: SetupPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function SignupPage() {
|
function SetupPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
@@ -21,6 +21,8 @@ function SignupPage() {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [recoveryKey, setRecoveryKey] = useState<string | null>(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -45,20 +47,76 @@ function SignupPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (signUpError) {
|
if (signUpError) {
|
||||||
setError(signUpError.message ?? '注册失败,请重试')
|
setError(signUpError.message ?? '创建账号失败,请重试')
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
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 })
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-4">
|
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">Kairos</h1>
|
<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>
|
||||||
|
|
||||||
<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="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}
|
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]"
|
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>
|
</button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,8 +1,87 @@
|
|||||||
import { createServerFn } from '@tanstack/react-start'
|
import { createServerFn } from '@tanstack/react-start'
|
||||||
import { getRequestHeaders } from '@tanstack/react-start/server'
|
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 { 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 () => {
|
export const getSession = createServerFn({ method: 'GET' }).handler(async () => {
|
||||||
const headers = getRequestHeaders()
|
const headers = getRequestHeaders()
|
||||||
return await auth.api.getSession({ headers })
|
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 }
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { betterAuth } from 'better-auth'
|
import { betterAuth } from 'better-auth'
|
||||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||||
|
import { APIError } from 'better-auth/api'
|
||||||
import { tanstackStartCookies } from 'better-auth/tanstack-start'
|
import { tanstackStartCookies } from 'better-auth/tanstack-start'
|
||||||
import { env } from '@/env'
|
import { env } from '@/env'
|
||||||
import * as authSchema from '@/server/auth/schema'
|
import * as authSchema from '@/server/auth/schema'
|
||||||
@@ -21,5 +22,17 @@ export const auth = betterAuth({
|
|||||||
maxAge: 5 * 60,
|
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()],
|
plugins: [tanstackStartCookies()],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from '../../../modules/bookmarks/schema'
|
export * from '../../../modules/bookmarks/schema'
|
||||||
export * from '../../auth/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()),
|
||||||
|
})
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"@tanstack/react-start": "catalog:",
|
"@tanstack/react-start": "catalog:",
|
||||||
"@tanstack/react-virtual": "catalog:",
|
"@tanstack/react-virtual": "catalog:",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
|
"citty": "catalog:",
|
||||||
"class-variance-authority": "catalog:",
|
"class-variance-authority": "catalog:",
|
||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
@@ -96,6 +97,7 @@
|
|||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"better-auth": "^1.2.8",
|
"better-auth": "^1.2.8",
|
||||||
|
"citty": "^0.2.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-kit": "1.0.0-beta.15-859cf75",
|
"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=="],
|
"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=="],
|
"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=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|||||||
+2
-1
@@ -66,6 +66,7 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user