From d3d25d56d8650fcf668d2f464fe2339ec71eb406 Mon Sep 17 00:00:00 2001 From: Timothy Yin Date: Tue, 10 Mar 2026 22:46:51 +0800 Subject: [PATCH] feaeet: add passkeys support --- apps/csms/src/db/auth-schema.ts | 160 ++++++++++++++-------- apps/csms/src/lib/auth.ts | 23 ++-- apps/web/app/dashboard/settings/page.tsx | 162 +++++++++++++++++++++++ apps/web/app/login/page.tsx | 42 +++++- apps/web/components/sidebar.tsx | 32 ++++- apps/web/lib/auth-client.ts | 3 +- package.json | 3 + 7 files changed, 358 insertions(+), 67 deletions(-) create mode 100644 apps/web/app/dashboard/settings/page.tsx diff --git a/apps/csms/src/db/auth-schema.ts b/apps/csms/src/db/auth-schema.ts index 29e27f6..486467b 100644 --- a/apps/csms/src/db/auth-schema.ts +++ b/apps/csms/src/db/auth-schema.ts @@ -1,4 +1,5 @@ -import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { pgTable, text, timestamp, boolean, integer, index } from "drizzle-orm/pg-core"; export const user = pgTable("user", { id: text("id").primaryKey(), @@ -19,57 +20,112 @@ export const user = pgTable("user", { displayUsername: text("display_username"), }); -export const session = pgTable("session", { - id: text("id").primaryKey(), - expiresAt: timestamp("expires_at").notNull(), - token: text("token").notNull().unique(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), - ipAddress: text("ip_address"), - userAgent: text("user_agent"), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - impersonatedBy: text("impersonated_by"), -}); +export const session = pgTable( + "session", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + impersonatedBy: text("impersonated_by"), + }, + (table) => [index("session_userId_idx").on(table.userId)], +); -export const account = pgTable("account", { - id: text("id").primaryKey(), - accountId: text("account_id").notNull(), - providerId: text("provider_id").notNull(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - accessToken: text("access_token"), - refreshToken: text("refresh_token"), - idToken: text("id_token"), - accessTokenExpiresAt: timestamp("access_token_expires_at"), - refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), - scope: text("scope"), - password: text("password"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), -}); +export const account = pgTable( + "account", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("account_userId_idx").on(table.userId)], +); -export const verification = pgTable("verification", { - id: text("id").primaryKey(), - identifier: text("identifier").notNull(), - value: text("value").notNull(), - expiresAt: timestamp("expires_at").notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") - .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) - .notNull(), -}); +export const verification = pgTable( + "verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("verification_identifier_idx").on(table.identifier)], +); -export const jwks = pgTable("jwks", { - id: text("id").primaryKey(), - publicKey: text("public_key").notNull(), - privateKey: text("private_key").notNull(), - createdAt: timestamp("created_at").notNull(), -}); +export const passkey = pgTable( + "passkey", + { + id: text("id").primaryKey(), + name: text("name"), + publicKey: text("public_key").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + credentialID: text("credential_id").notNull(), + counter: integer("counter").notNull(), + deviceType: text("device_type").notNull(), + backedUp: boolean("backed_up").notNull(), + transports: text("transports"), + createdAt: timestamp("created_at"), + aaguid: text("aaguid"), + }, + (table) => [ + index("passkey_userId_idx").on(table.userId), + index("passkey_credentialID_idx").on(table.credentialID), + ], +); + +export const userRelations = relations(user, ({ many }) => ({ + sessions: many(session), + accounts: many(account), + passkeys: many(passkey), +})); + +export const sessionRelations = relations(session, ({ one }) => ({ + user: one(user, { + fields: [session.userId], + references: [user.id], + }), +})); + +export const accountRelations = relations(account, ({ one }) => ({ + user: one(user, { + fields: [account.userId], + references: [user.id], + }), +})); + +export const passkeyRelations = relations(passkey, ({ one }) => ({ + user: one(user, { + fields: [passkey.userId], + references: [user.id], + }), +})); diff --git a/apps/csms/src/lib/auth.ts b/apps/csms/src/lib/auth.ts index 941da73..342fffc 100644 --- a/apps/csms/src/lib/auth.ts +++ b/apps/csms/src/lib/auth.ts @@ -1,27 +1,26 @@ -import { betterAuth } from 'better-auth' -import { drizzleAdapter } from 'better-auth/adapters/drizzle' -import { useDrizzle } from './db.js' -import * as schema from '@/db/schema.ts' -import { admin, bearer, username } from 'better-auth/plugins' +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { useDrizzle } from "./db.js"; +import * as schema from "@/db/schema.ts"; +import { admin, bearer, username } from "better-auth/plugins"; +import { passkey } from "@better-auth/passkey"; export const auth = betterAuth({ database: drizzleAdapter(useDrizzle(), { - provider: 'pg', + provider: "pg", schema: { ...schema, }, }), - trustedOrigins: [ - process.env.WEB_ORIGIN ?? 'http://localhost:3000', - ], + trustedOrigins: [process.env.WEB_ORIGIN ?? "http://localhost:3000"], user: { additionalFields: {}, }, emailAndPassword: { enabled: true, }, - plugins: [admin(), username(), bearer()], + plugins: [admin(), username(), bearer(), passkey()], advanced: { - cookiePrefix: 'helios_auth', + cookiePrefix: "helios_auth", }, -}) +}); diff --git a/apps/web/app/dashboard/settings/page.tsx b/apps/web/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..847dd47 --- /dev/null +++ b/apps/web/app/dashboard/settings/page.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Alert, Button, CloseButton, Spinner } from "@heroui/react"; +import { Fingerprint, TrashBin } from "@gravity-ui/icons"; +import { authClient } from "@/lib/auth-client"; + +type Passkey = { + id: string; + name?: string | null; + createdAt: Date | string; + deviceType?: string | null; +}; + +export default function SettingsPage() { + const [passkeys, setPasskeys] = useState([]); + const [loading, setLoading] = useState(true); + const [adding, setAdding] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + const loadPasskeys = useCallback(async () => { + setLoading(true); + try { + const res = await authClient.passkey.listUserPasskeys(); + setPasskeys((res.data as Passkey[] | null) ?? []); + } catch { + setError("获取 Passkey 列表失败"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadPasskeys(); + }, [loadPasskeys]); + + const handleAdd = async () => { + setError(""); + setSuccess(""); + setAdding(true); + try { + const res = await authClient.passkey.addPasskey(); + if (res?.error) { + setError(res.error.message ?? "注册 Passkey 失败"); + } else { + setSuccess("Passkey 注册成功"); + await loadPasskeys(); + } + } catch { + setError("注册 Passkey 失败,请重试"); + } finally { + setAdding(false); + } + }; + + const handleDelete = async (id: string) => { + setError(""); + setSuccess(""); + setDeletingId(id); + try { + const res = await authClient.passkey.deletePasskey({ id }); + if (res?.error) { + setError(res.error.message ?? "删除 Passkey 失败"); + } else { + setPasskeys((prev) => prev.filter((p) => p.id !== id)); + setSuccess("Passkey 已删除"); + } + } catch { + setError("删除 Passkey 失败,请重试"); + } finally { + setDeletingId(null); + } + }; + + return ( +
+
+

账号设置

+

管理您的安全凭据

+
+ + {error && ( + + + + {error} + + setError("")} /> + + )} + {success && ( + + + + {success} + + setSuccess("")} /> + + )} + + {/* Passkey section */} +
+
+
+
+ +
+
+

Passkey

+

使用设备生物识别或 PIN 免密登录

+
+
+ +
+ + {loading ? ( +
+ +
+ ) : passkeys.length === 0 ? ( +
尚未添加任何 Passkey
+ ) : ( +
    + {passkeys.map((pk) => ( +
  • +
    + +
    +

    + {pk.name ?? pk.deviceType ?? "Passkey"} +

    +

    + 添加于{" "} + {new Date(pk.createdAt).toLocaleString("zh-CN", { + year: "numeric", + month: "short", + day: "numeric", + })} +

    +
    +
    + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index ab463be..81e6e0e 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Alert, Button, Card, CloseButton, Input, Label, TextField } from "@heroui/react"; -import { Thunderbolt } from "@gravity-ui/icons"; +import { Fingerprint, Thunderbolt } from "@gravity-ui/icons"; import { authClient } from "@/lib/auth-client"; export default function LoginPage() { @@ -14,6 +14,7 @@ export default function LoginPage() { const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [passkeyLoading, setPasskeyLoading] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -38,6 +39,24 @@ export default function LoginPage() { } }; + const handlePasskey = async () => { + setError(""); + setPasskeyLoading(true); + try { + const res = await authClient.signIn.passkey(); + if (res?.error) { + setError(res.error.message ?? "Passkey 登录失败"); + } else { + router.push("/dashboard"); + router.refresh(); + } + } catch { + setError("Passkey 登录失败,请重试"); + } finally { + setPasskeyLoading(false); + } + }; + return (
{/* Brand */} @@ -97,6 +116,27 @@ export default function LoginPage() { +
+
+ +
+
+ diff --git a/apps/web/components/sidebar.tsx b/apps/web/components/sidebar.tsx index bb2c220..5ee75c7 100644 --- a/apps/web/components/sidebar.tsx +++ b/apps/web/components/sidebar.tsx @@ -3,7 +3,7 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' import { useState } from 'react' -import { CreditCard, ListCheck, Person, PlugConnection, Thunderbolt, Xmark, Bars } from '@gravity-ui/icons' +import { CreditCard, Gear, ListCheck, Person, PlugConnection, Thunderbolt, Xmark, Bars } from '@gravity-ui/icons' import SidebarFooter from '@/components/sidebar-footer' import { useSession } from '@/lib/auth-client' @@ -15,6 +15,10 @@ const navItems = [ { href: '/dashboard/users', label: '用户管理', icon: Person, adminOnly: true }, ] +const settingsItems = [ + { href: '/dashboard/settings', label: '账号设置', icon: Gear }, +] + function NavContent({ pathname, isAdmin, onNavigate }: { pathname: string; isAdmin: boolean; onNavigate?: () => void }) { return ( <> @@ -58,6 +62,32 @@ function NavContent({ pathname, isAdmin, onNavigate }: { pathname: string; isAdm ) })} +

+ 设置 +

+ {settingsItems.map((item) => { + const isActive = pathname === item.href || pathname.startsWith(item.href + '/') + const Icon = item.icon + return ( + + + {item.label} + {isActive && ( + + )} + + ) + })} {/* Footer */} diff --git a/apps/web/lib/auth-client.ts b/apps/web/lib/auth-client.ts index 10f0c81..e515dd1 100644 --- a/apps/web/lib/auth-client.ts +++ b/apps/web/lib/auth-client.ts @@ -1,9 +1,10 @@ import { createAuthClient } from "better-auth/react"; import { adminClient, usernameClient } from "better-auth/client/plugins"; +import { passkeyClient } from "@better-auth/passkey/client"; export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001", - plugins: [usernameClient(), adminClient()], + plugins: [usernameClient(), adminClient(), passkeyClient()], }); export const { signIn, signOut, signUp, useSession } = authClient; diff --git a/package.json b/package.json index e441960..74ecc57 100644 --- a/package.json +++ b/package.json @@ -32,5 +32,8 @@ "npm-run-all": "^4.1.5", "oxfmt": "^0.36.0", "oxlint": "^1.52.0" + }, + "dependencies": { + "@better-auth/passkey": "^1.5.4" } } \ No newline at end of file