能登录管理端的账号。
@@ -576,6 +632,7 @@ function Users() {
) : null}
+ >
)
}
diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts
index 6c0246b..980e4df 100644
--- a/web/shared/api/schema.ts
+++ b/web/shared/api/schema.ts
@@ -327,6 +327,40 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/admin/auth/passkey/options": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** A challenge for signing in with a passkey (discoverable credential, no username needed) */
+ post: operations["auth_passkey_options"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/admin/auth/passkey/login": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Sign in with a passkey assertion; same response as the password sign-in */
+ post: operations["auth_passkey_login"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/admin/releases": {
parameters: {
query?: never;
@@ -362,6 +396,58 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/admin/auth/passkeys": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** The signed-in user's passkeys */
+ get: operations["auth_passkeys_list"];
+ put?: never;
+ /** Register a passkey from a WebAuthn attestation (ES256, user verification required) */
+ post: operations["auth_passkeys_register"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/admin/auth/passkeys/options": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** A challenge for registering a new passkey (the user must be signed in) */
+ post: operations["auth_passkeys_options"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/admin/auth/passkeys/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /** Remove one of your own passkeys */
+ delete: operations["auth_passkeys_delete"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/admin/auth/me": {
parameters: {
query?: never;
@@ -1574,6 +1660,10 @@ export interface components {
items: components["schemas"]["NodeInfoDto"][];
next_cursor: null | string;
};
+ PageDtoOfPasskeyDto: {
+ items: components["schemas"]["PasskeyDto"][];
+ next_cursor: null | string;
+ };
PageDtoOfPlanDto: {
items: components["schemas"]["PlanDto"][];
next_cursor: null | string;
@@ -1622,6 +1712,38 @@ export interface components {
values: null | string[];
description: string;
};
+ PasskeyCreationOptionsDto: {
+ challenge: string;
+ rp_id: string;
+ rp_name: string;
+ user_id: string;
+ user_name: string;
+ exclude_credentials: string[];
+ /** Format: int32 */
+ timeout_ms: number | string;
+ };
+ PasskeyDto: {
+ id: string;
+ name: string;
+ /** Format: date-time */
+ created_at: unknown;
+ /** Format: date-time */
+ last_used_at: unknown;
+ transports: string[];
+ };
+ PasskeyLoginRequest: {
+ credential_id: string;
+ authenticator_data: string;
+ client_data_json: string;
+ signature: string;
+ return_session: null | boolean;
+ };
+ PasskeyRequestOptionsDto: {
+ challenge: string;
+ rp_id: string;
+ /** Format: int32 */
+ timeout_ms: number | string;
+ };
PatchProbeRequest: {
name: null | string;
};
@@ -1718,6 +1840,13 @@ export interface components {
/** Format: int32 */
queue_length: number | string;
};
+ RegisterPasskeyRequest: {
+ name: string;
+ credential_id: string;
+ attestation_object: string;
+ client_data_json: string;
+ transports: null | string[];
+ };
RejectedDto: {
id: string;
reason: string;
@@ -2744,6 +2873,86 @@ export interface operations {
};
};
};
+ auth_passkey_options: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PasskeyRequestOptionsDto"];
+ };
+ };
+ /** @description Too Many Requests */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ };
+ };
+ auth_passkey_login: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["PasskeyLoginRequest"];
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["LoginResponse"];
+ };
+ };
+ /** @description Bad Request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ /** @description Unauthorized */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ /** @description Too Many Requests */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ };
+ };
releases_list: {
parameters: {
query?: never;
@@ -2871,6 +3080,182 @@ export interface operations {
};
};
};
+ auth_passkeys_list: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PageDtoOfPasskeyDto"];
+ };
+ };
+ /** @description Unauthorized */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ };
+ };
+ auth_passkeys_register: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["RegisterPasskeyRequest"];
+ };
+ };
+ responses: {
+ /** @description Created */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PasskeyDto"];
+ };
+ };
+ /** @description Bad Request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ /** @description Unauthorized */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ /** @description Forbidden */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ /** @description Conflict */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ };
+ };
+ auth_passkeys_options: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PasskeyCreationOptionsDto"];
+ };
+ };
+ /** @description Bad Request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ /** @description Unauthorized */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ /** @description Forbidden */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ };
+ };
+ auth_passkeys_delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description No Content */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Unauthorized */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ /** @description Not Found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ };
+ };
auth_me: {
parameters: {
query?: never;
diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts
index 483df39..0833115 100644
--- a/web/shared/api/types.ts
+++ b/web/shared/api/types.ts
@@ -40,6 +40,9 @@ export type CreatedJoinTokenDto = Narrow
export type ApiTokenDto = Narrow
export type CreatedApiTokenDto = Narrow
export type CapabilityCatalogDto = Narrow
+export type PasskeyDto = Narrow
+export type PasskeyCreationOptionsDto = Narrow
+export type PasskeyRequestOptionsDto = Narrow
export type MeDto = Narrow
export type SettingDto = Narrow
export type UserDto = Narrow
diff --git a/web/shared/api/webauthn.ts b/web/shared/api/webauthn.ts
new file mode 100644
index 0000000..2abf28e
--- /dev/null
+++ b/web/shared/api/webauthn.ts
@@ -0,0 +1,87 @@
+import { api } from './client'
+import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto } from './types'
+
+/** 浏览器是否支持在输入框的自动填充里列出 passkey(conditional UI)。 */
+export async function conditionalSupported(): Promise {
+ try {
+ return passkeySupported() && (await PublicKeyCredential.isConditionalMediationAvailable?.()) === true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * passkey 保存在哪里:浏览器和系统决定具体用谁的密码管理器,网站只能给提示。
+ * `device`:此设备或浏览器账户(Chrome 里是 Google 密码管理工具,也可以是 Windows Hello、iCloud 钥匙串);
+ * `external`:安全密钥,或者用手机扫码。
+ */
+export type PasskeyPlace = 'device' | 'external'
+
+/** 浏览器是否支持 WebAuthn(passkey)。 */
+export function passkeySupported(): boolean {
+ return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials
+}
+
+function toBytes(text: string): Uint8Array {
+ const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=')
+ const binary = atob(padded)
+ const out = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
+ return out
+}
+
+function toText(buffer: ArrayBuffer): string {
+ let binary = ''
+ for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte)
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
+}
+
+/**
+ * 用 passkey 登录(可发现凭证:不需要用户名)。服务端设了会话 Cookie;返回登录响应。
+ * passkey 绑定在后端的域名上:只有和后端同站的页面能用,所以其他后端(跨域)不显示这个入口。
+ */
+export async function signInWithPasskey(opts: { conditional?: boolean; signal?: AbortSignal } = {}): Promise {
+ const options = await api.post('/api/admin/auth/passkey/options')
+ const credential = (await navigator.credentials.get({
+ // conditional:不弹窗,浏览器在用户名输入框的自动填充里列出已保存的 passkey(Chrome 里就是 Google 密码管理工具里的)
+ mediation: opts.conditional ? 'conditional' : undefined,
+ signal: opts.signal,
+ publicKey: { challenge: toBytes(options.challenge), rpId: options.rp_id, timeout: opts.conditional ? undefined : options.timeout_ms, userVerification: 'required' },
+ })) as PublicKeyCredential | null
+ if (!credential) throw new Error('没有选择 passkey')
+ const response = credential.response as AuthenticatorAssertionResponse
+ return api.post('/api/admin/auth/passkey/login', {
+ credential_id: credential.id,
+ authenticator_data: toText(response.authenticatorData),
+ client_data_json: toText(response.clientDataJSON),
+ signature: toText(response.signature),
+ })
+}
+
+/** 给当前登录的用户注册一个新 passkey(要求用户验证,ES256)。 */
+export async function registerPasskey(name: string, place: PasskeyPlace = 'device'): Promise {
+ const options = await api.post('/api/admin/auth/passkeys/options')
+ const credential = (await navigator.credentials.create({
+ publicKey: {
+ challenge: toBytes(options.challenge),
+ rp: { id: options.rp_id, name: options.rp_name },
+ user: { id: toBytes(options.user_id), name: options.user_name, displayName: options.user_name },
+ pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
+ excludeCredentials: options.exclude_credentials.map((id) => ({ type: 'public-key' as const, id: toBytes(id) })),
+ authenticatorSelection: { authenticatorAttachment: place === 'device' ? 'platform' : 'cross-platform', residentKey: 'required', requireResidentKey: true, userVerification: 'required' },
+ attestation: 'none',
+ timeout: options.timeout_ms,
+ // 给浏览器的提示(Chrome 128+):优先用设备自带的(含 Google 密码管理工具),或者安全密钥和手机
+ hints: place === 'device' ? ['client-device'] : ['security-key', 'hybrid'],
+ } as PublicKeyCredentialCreationOptions,
+ })) as PublicKeyCredential | null
+ if (!credential) throw new Error('没有创建 passkey')
+ const response = credential.response as AuthenticatorAttestationResponse
+ return api.post('/api/admin/auth/passkeys', {
+ name,
+ credential_id: credential.id,
+ attestation_object: toText(response.attestationObject),
+ client_data_json: toText(response.clientDataJSON),
+ transports: response.getTransports?.() ?? [],
+ })
+}
diff --git a/web/shared/theme/base.css b/web/shared/theme/base.css
index 45e7146..1fd3f3c 100644
--- a/web/shared/theme/base.css
+++ b/web/shared/theme/base.css
@@ -504,6 +504,15 @@ textarea {
box-shadow: 0 4px 16px color-mix(in srgb, var(--app-shadow), transparent 75%);
}
+.toast-title {
+ font-weight: 700;
+ margin-bottom: 2px;
+}
+
+.toast {
+ cursor: pointer;
+}
+
.toast.bad {
background: var(--app-bad);
color: var(--app-card);
diff --git a/web/shared/ui/index.tsx b/web/shared/ui/index.tsx
index c6d453e..d28f984 100644
--- a/web/shared/ui/index.tsx
+++ b/web/shared/ui/index.tsx
@@ -104,24 +104,33 @@ interface ToastItem {
id: number
text: string
bad: boolean
+ title?: string
}
-const ToastContext = createContext<(text: string, bad?: boolean) => void>(() => undefined)
+/** 提示的可选项:`title` 是加粗的标题行,`ms` 是停留的毫秒数(缺省成功 3 秒、失败 6 秒)。点一下提示可以提前关掉。 */
+export interface ToastOptions {
+ title?: string
+ ms?: number
+}
+
+const ToastContext = createContext<(text: string, bad?: boolean, options?: ToastOptions) => void>(() => undefined)
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState([])
const nextId = useRef(1)
- const push = useCallback((text: string, bad = false) => {
+ const push = useCallback((text: string, bad = false, options?: ToastOptions) => {
const id = nextId.current++
- setItems((list) => [...list, { id, text, bad }])
- window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), bad ? 6000 : 3000)
+ setItems((list) => [...list, { id, text, bad, title: options?.title }])
+ window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), options?.ms ?? (bad ? 6000 : 3000))
}, [])
+ const dismiss = (id: number) => setItems((list) => list.filter((t) => t.id !== id))
return (
{children}
{items.map((t) => (
-
+
dismiss(t.id)} title="点一下关闭">
+ {t.title ?
{t.title}
: null}
{t.text}
))}