mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 16:23:31 +00:00
Merge pull request #4920 from alexj11324/feat/passkey-auth
feat: add passkey authentication
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { get, post, patch, remove, credentialGet, credentialCreate } = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
credentialGet: vi.fn(),
|
||||
credentialCreate: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
get,
|
||||
post,
|
||||
patch,
|
||||
delete: remove
|
||||
}
|
||||
}))
|
||||
|
||||
import { passkeyAPI } from '@/api/passkey'
|
||||
|
||||
class FakePublicKeyCredential {
|
||||
id = 'credential-id'
|
||||
rawId = Uint8Array.from([1, 2, 3]).buffer
|
||||
type = 'public-key'
|
||||
authenticatorAttachment = 'platform'
|
||||
response: Record<string, unknown> = {
|
||||
authenticatorData: Uint8Array.from([4, 5]).buffer,
|
||||
clientDataJSON: Uint8Array.from([6, 7]).buffer,
|
||||
signature: Uint8Array.from([8, 9]).buffer,
|
||||
userHandle: Uint8Array.from([10, 11]).buffer
|
||||
}
|
||||
|
||||
getClientExtensionResults(): AuthenticationExtensionsClientOutputs {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRegistrationCredential extends FakePublicKeyCredential {
|
||||
constructor() {
|
||||
super()
|
||||
this.response = {
|
||||
attestationObject: Uint8Array.from([12, 13]).buffer,
|
||||
clientDataJSON: Uint8Array.from([6, 7]).buffer,
|
||||
getTransports: () => ['internal']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('passkey api', () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset()
|
||||
post.mockReset()
|
||||
patch.mockReset()
|
||||
remove.mockReset()
|
||||
credentialGet.mockReset()
|
||||
credentialCreate.mockReset()
|
||||
|
||||
vi.stubGlobal('PublicKeyCredential', FakePublicKeyCredential)
|
||||
Object.defineProperty(window, 'PublicKeyCredential', {
|
||||
configurable: true,
|
||||
value: FakePublicKeyCredential
|
||||
})
|
||||
Object.defineProperty(navigator, 'credentials', {
|
||||
configurable: true,
|
||||
value: { get: credentialGet, create: credentialCreate }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('converts assertion options and response bytes to WebAuthn JSON', async () => {
|
||||
post
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
session_token: 'one-time-session',
|
||||
options: {
|
||||
publicKey: {
|
||||
challenge: 'AQID',
|
||||
rpId: 'sub2api.example.com',
|
||||
userVerification: 'required'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
access_token: 'access',
|
||||
token_type: 'Bearer',
|
||||
user: { id: 1 }
|
||||
}
|
||||
})
|
||||
credentialGet.mockResolvedValue(new FakePublicKeyCredential())
|
||||
|
||||
await passkeyAPI.login()
|
||||
|
||||
const request = credentialGet.mock.calls[0][0] as CredentialRequestOptions
|
||||
expect(Array.from(new Uint8Array(request.publicKey!.challenge))).toEqual([1, 2, 3])
|
||||
expect(request.publicKey!.userVerification).toBe('required')
|
||||
|
||||
expect(post).toHaveBeenNthCalledWith(2, '/auth/passkey/login/finish', {
|
||||
session_token: 'one-time-session',
|
||||
credential: {
|
||||
id: 'credential-id',
|
||||
rawId: 'AQID',
|
||||
type: 'public-key',
|
||||
authenticatorAttachment: 'platform',
|
||||
clientExtensionResults: {},
|
||||
response: {
|
||||
authenticatorData: 'BAU',
|
||||
clientDataJSON: 'Bgc',
|
||||
signature: 'CAk',
|
||||
userHandle: 'Cgs'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the account password when beginning registration', async () => {
|
||||
post
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
session_token: 'register-session',
|
||||
options: {
|
||||
publicKey: {
|
||||
challenge: 'AQID',
|
||||
user: { id: 'BAU', name: 'user@example.com', displayName: 'user' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { id: 3, name: 'Laptop', created_at: '2026-07-28T00:00:00Z', backup: false }
|
||||
})
|
||||
credentialCreate.mockResolvedValue(new FakeRegistrationCredential())
|
||||
|
||||
await passkeyAPI.register('Laptop', 'hunter2')
|
||||
|
||||
expect(post).toHaveBeenNthCalledWith(1, '/user/passkeys/register/begin', {
|
||||
password: 'hunter2'
|
||||
})
|
||||
expect(post).toHaveBeenNthCalledWith(2, '/user/passkeys/register/finish', {
|
||||
session_token: 'register-session',
|
||||
name: 'Laptop',
|
||||
credential: expect.objectContaining({
|
||||
response: {
|
||||
attestationObject: 'DA0',
|
||||
clientDataJSON: 'Bgc',
|
||||
transports: ['internal']
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the account password when revoking a credential', async () => {
|
||||
remove.mockResolvedValue({ data: null })
|
||||
|
||||
await passkeyAPI.remove(12, 'hunter2')
|
||||
|
||||
expect(remove).toHaveBeenCalledWith('/user/passkeys/12', {
|
||||
data: { password: 'hunter2' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -366,6 +366,10 @@ export interface SystemSettings {
|
||||
invitation_code_enabled: boolean;
|
||||
totp_enabled: boolean; // TOTP 双因素认证
|
||||
totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置
|
||||
passkey_enabled: boolean;
|
||||
passkey_configured: boolean;
|
||||
passkey_rp_id: string;
|
||||
passkey_rp_origins: string[];
|
||||
session_binding_enabled: boolean; // 会话 IP/UA 绑定
|
||||
step_up_enabled: boolean; // 敏感操作 step-up 2FA
|
||||
audit_log_retention_days: number; // 审计日志保留天数
|
||||
@@ -674,6 +678,7 @@ export interface UpdateSettingsRequest {
|
||||
frontend_url?: string;
|
||||
invitation_code_enabled?: boolean;
|
||||
totp_enabled?: boolean; // TOTP 双因素认证
|
||||
passkey_enabled?: boolean;
|
||||
session_binding_enabled?: boolean; // 会话 IP/UA 绑定
|
||||
step_up_enabled?: boolean; // 敏感操作 step-up 2FA
|
||||
audit_log_retention_days?: number; // 审计日志保留天数
|
||||
|
||||
@@ -19,6 +19,7 @@ export { userGroupsAPI } from './groups'
|
||||
export { userChannelsAPI } from './channels'
|
||||
export * as batchImageAPI from './batchImage'
|
||||
export { totpAPI } from './totp'
|
||||
export { passkeyAPI, type PasskeyCredentialSummary } from './passkey'
|
||||
export { default as announcementsAPI } from './announcements'
|
||||
export { channelMonitorUserAPI } from './channelMonitor'
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { apiClient } from './client'
|
||||
import type { AuthResponse } from '@/types'
|
||||
|
||||
export interface PasskeyCredentialSummary {
|
||||
id: number
|
||||
name: string
|
||||
created_at: string
|
||||
last_used_at?: string
|
||||
backup: boolean
|
||||
}
|
||||
|
||||
interface CeremonyOptionsResponse {
|
||||
session_token: string
|
||||
options: {
|
||||
publicKey: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
function requirePasskeySupport(): void {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
throw new Error('Passkeys are not supported by this browser')
|
||||
}
|
||||
}
|
||||
|
||||
function base64URLToBuffer(value: string): ArrayBuffer {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
||||
const binary = atob(padded)
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
function bufferToBase64URL(value: ArrayBuffer | null): string | null {
|
||||
if (value === null) return null
|
||||
const bytes = new Uint8Array(value)
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function creationOptionsFromJSON(
|
||||
value: Record<string, unknown>
|
||||
): PublicKeyCredentialCreationOptions {
|
||||
const options = { ...value } as Record<string, unknown>
|
||||
options.challenge = base64URLToBuffer(String(options.challenge))
|
||||
|
||||
const user = { ...(options.user as Record<string, unknown>) }
|
||||
user.id = base64URLToBuffer(String(user.id))
|
||||
options.user = user
|
||||
|
||||
if (Array.isArray(options.excludeCredentials)) {
|
||||
options.excludeCredentials = options.excludeCredentials.map((descriptor) => ({
|
||||
...(descriptor as Record<string, unknown>),
|
||||
id: base64URLToBuffer(String((descriptor as Record<string, unknown>).id))
|
||||
}))
|
||||
}
|
||||
return options as unknown as PublicKeyCredentialCreationOptions
|
||||
}
|
||||
|
||||
function requestOptionsFromJSON(
|
||||
value: Record<string, unknown>
|
||||
): PublicKeyCredentialRequestOptions {
|
||||
const options = { ...value } as Record<string, unknown>
|
||||
options.challenge = base64URLToBuffer(String(options.challenge))
|
||||
if (Array.isArray(options.allowCredentials)) {
|
||||
options.allowCredentials = options.allowCredentials.map((descriptor) => ({
|
||||
...(descriptor as Record<string, unknown>),
|
||||
id: base64URLToBuffer(String((descriptor as Record<string, unknown>).id))
|
||||
}))
|
||||
}
|
||||
return options as unknown as PublicKeyCredentialRequestOptions
|
||||
}
|
||||
|
||||
function serializeRegistrationCredential(credential: PublicKeyCredential): Record<string, unknown> {
|
||||
const response = credential.response as AuthenticatorAttestationResponse
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64URL(credential.rawId),
|
||||
type: credential.type,
|
||||
authenticatorAttachment: credential.authenticatorAttachment,
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
response: {
|
||||
attestationObject: bufferToBase64URL(response.attestationObject),
|
||||
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
|
||||
transports: typeof response.getTransports === 'function' ? response.getTransports() : []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializeAssertionCredential(credential: PublicKeyCredential): Record<string, unknown> {
|
||||
const response = credential.response as AuthenticatorAssertionResponse
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64URL(credential.rawId),
|
||||
type: credential.type,
|
||||
authenticatorAttachment: credential.authenticatorAttachment,
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
response: {
|
||||
authenticatorData: bufferToBase64URL(response.authenticatorData),
|
||||
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
|
||||
signature: bufferToBase64URL(response.signature),
|
||||
userHandle: bufferToBase64URL(response.userHandle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function login(): Promise<AuthResponse> {
|
||||
requirePasskeySupport()
|
||||
const { data: begin } = await apiClient.post<CeremonyOptionsResponse>(
|
||||
'/auth/passkey/login/begin'
|
||||
)
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: requestOptionsFromJSON(begin.options.publicKey)
|
||||
})
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error('Passkey sign-in was cancelled')
|
||||
}
|
||||
const { data } = await apiClient.post<AuthResponse>('/auth/passkey/login/finish', {
|
||||
session_token: begin.session_token,
|
||||
credential: serializeAssertionCredential(credential)
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
async function register(name: string, password: string): Promise<PasskeyCredentialSummary> {
|
||||
requirePasskeySupport()
|
||||
const { data: begin } = await apiClient.post<CeremonyOptionsResponse>(
|
||||
'/user/passkeys/register/begin',
|
||||
{ password }
|
||||
)
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: creationOptionsFromJSON(begin.options.publicKey)
|
||||
})
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error('Passkey creation was cancelled')
|
||||
}
|
||||
const { data } = await apiClient.post<PasskeyCredentialSummary>(
|
||||
'/user/passkeys/register/finish',
|
||||
{
|
||||
session_token: begin.session_token,
|
||||
name,
|
||||
credential: serializeRegistrationCredential(credential)
|
||||
}
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
async function list(): Promise<PasskeyCredentialSummary[]> {
|
||||
const { data } = await apiClient.get<PasskeyCredentialSummary[]>('/user/passkeys')
|
||||
return data
|
||||
}
|
||||
|
||||
async function rename(id: number, name: string): Promise<void> {
|
||||
await apiClient.patch(`/user/passkeys/${id}`, { name })
|
||||
}
|
||||
|
||||
async function remove(id: number, password: string): Promise<void> {
|
||||
await apiClient.delete(`/user/passkeys/${id}`, { data: { password } })
|
||||
}
|
||||
|
||||
export const passkeyAPI = {
|
||||
isSupported: () => Boolean(window.PublicKeyCredential && navigator.credentials),
|
||||
login,
|
||||
register,
|
||||
list,
|
||||
rename,
|
||||
remove
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="flex items-start justify-between border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ t('profile.passkey.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.passkey.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="enabled && supported && !showAddForm"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="busy"
|
||||
@click="showAddForm = true"
|
||||
>
|
||||
{{ t('profile.passkey.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-6">
|
||||
<div v-if="!enabled" class="mb-5 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.passkey.featureDisabled') }}
|
||||
</div>
|
||||
<div v-if="enabled && !supported" class="mb-5 text-sm text-amber-600 dark:text-amber-400">
|
||||
{{ t('profile.passkey.unsupported') }}
|
||||
</div>
|
||||
<div>
|
||||
<form
|
||||
v-if="enabled && supported && showAddForm"
|
||||
class="mb-5 flex flex-col gap-3 rounded-lg border border-gray-200 p-4 dark:border-dark-700"
|
||||
@submit.prevent="addPasskey"
|
||||
>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="passkey-name" class="input-label">{{ t('profile.passkey.name') }}</label>
|
||||
<input
|
||||
id="passkey-name"
|
||||
v-model="newName"
|
||||
class="input"
|
||||
maxlength="100"
|
||||
:placeholder="t('profile.passkey.namePlaceholder')"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="passkey-add-password" class="input-label">{{
|
||||
t('profile.currentPassword')
|
||||
}}</label>
|
||||
<input
|
||||
id="passkey-add-password"
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
:placeholder="t('profile.passkey.passwordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-secondary" :disabled="busy" @click="cancelAdd">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="busy || newPassword.length === 0">
|
||||
{{ busy ? t('common.processing') : t('profile.passkey.continue') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-6">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="credentials.length === 0"
|
||||
class="rounded-lg border border-dashed border-gray-200 px-4 py-8 text-center text-sm text-gray-500 dark:border-dark-700 dark:text-gray-400"
|
||||
>
|
||||
{{ t('profile.passkey.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-gray-100 dark:divide-dark-700">
|
||||
<div
|
||||
v-for="credential in credentials"
|
||||
:key="credential.id"
|
||||
class="flex items-center justify-between gap-4 py-4 first:pt-0 last:pb-0"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="key" size="md" class="shrink-0 text-primary-500" />
|
||||
<p class="truncate font-medium text-gray-900 dark:text-white">
|
||||
{{ credential.name }}
|
||||
</p>
|
||||
<span
|
||||
v-if="credential.backup"
|
||||
class="rounded-full bg-green-50 px-2 py-0.5 text-xs text-green-700 dark:bg-green-900/30 dark:text-green-300"
|
||||
>
|
||||
{{ t('profile.passkey.synced') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.passkey.createdAt', { date: formatDate(credential.created_at) }) }}
|
||||
<template v-if="credential.last_used_at">
|
||||
· {{ t('profile.passkey.lastUsed', { date: formatDate(credential.last_used_at) }) }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="busy"
|
||||
@click="renamePasskey(credential)"
|
||||
>
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-950/30"
|
||||
:disabled="busy"
|
||||
@click="deletePasskey(credential)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认:吊销凭据需验证当前密码,防止被窃会话静默移除 Passkey -->
|
||||
<div v-if="deleteTarget" class="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50 transition-opacity" @click="closeDeleteDialog"></div>
|
||||
<div
|
||||
class="relative w-full max-w-md transform rounded-xl bg-white p-6 shadow-xl transition-all dark:bg-dark-800"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('profile.passkey.deleteTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.passkey.deleteConfirm', { name: deleteTarget.name }) }}
|
||||
</p>
|
||||
<form class="mt-4 space-y-4" @submit.prevent="confirmDelete">
|
||||
<div>
|
||||
<label for="passkey-delete-password" class="input-label">{{
|
||||
t('profile.currentPassword')
|
||||
}}</label>
|
||||
<input
|
||||
id="passkey-delete-password"
|
||||
v-model="deletePassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
:placeholder="t('profile.passkey.passwordPlaceholder')"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="btn btn-secondary" :disabled="busy" @click="closeDeleteDialog">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-danger"
|
||||
:disabled="busy || deletePassword.length === 0"
|
||||
>
|
||||
{{ busy ? t('common.processing') : t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { passkeyAPI, type PasskeyCredentialSummary } from '@/api'
|
||||
import { Icon } from '@/components/icons'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const props = defineProps<{ enabled: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const supported = passkeyAPI.isSupported()
|
||||
const loading = ref(false)
|
||||
const busy = ref(false)
|
||||
const showAddForm = ref(false)
|
||||
const newName = ref('')
|
||||
const newPassword = ref('')
|
||||
const deleteTarget = ref<PasskeyCredentialSummary | null>(null)
|
||||
const deletePassword = ref('')
|
||||
const credentials = ref<PasskeyCredentialSummary[]>([])
|
||||
|
||||
// apiClient 拦截器把错误规范化为 { code, reason, message };
|
||||
// 透出后端消息(如密码错误),否则回退到通用文案。
|
||||
function extractErrorMessage(error: unknown, fallback: string): string {
|
||||
const message = (error as { message?: string }).message
|
||||
return typeof message === 'string' && message.length > 0 ? message : fallback
|
||||
}
|
||||
|
||||
async function loadCredentials(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
credentials.value = await passkeyAPI.list()
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string }).code
|
||||
if (code !== 'PASSKEY_DISABLED') {
|
||||
appStore.showError(t('profile.passkey.loadFailed'))
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addPasskey(): Promise<void> {
|
||||
if (newPassword.value.length === 0) return
|
||||
busy.value = true
|
||||
try {
|
||||
await passkeyAPI.register(newName.value.trim(), newPassword.value)
|
||||
appStore.showSuccess(t('profile.passkey.added'))
|
||||
cancelAdd()
|
||||
await loadCredentials()
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException && error.name === 'NotAllowedError')) {
|
||||
appStore.showError(extractErrorMessage(error, t('profile.passkey.addFailed')))
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAdd(): void {
|
||||
showAddForm.value = false
|
||||
newName.value = ''
|
||||
newPassword.value = ''
|
||||
}
|
||||
|
||||
async function renamePasskey(credential: PasskeyCredentialSummary): Promise<void> {
|
||||
const name = window.prompt(t('profile.passkey.renamePrompt'), credential.name)?.trim()
|
||||
if (!name || name === credential.name) return
|
||||
busy.value = true
|
||||
try {
|
||||
await passkeyAPI.rename(credential.id, name)
|
||||
credential.name = name
|
||||
appStore.showSuccess(t('profile.passkey.renamed'))
|
||||
} catch {
|
||||
appStore.showError(t('profile.passkey.renameFailed'))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function deletePasskey(credential: PasskeyCredentialSummary): void {
|
||||
deleteTarget.value = credential
|
||||
deletePassword.value = ''
|
||||
}
|
||||
|
||||
function closeDeleteDialog(): void {
|
||||
deleteTarget.value = null
|
||||
deletePassword.value = ''
|
||||
}
|
||||
|
||||
async function confirmDelete(): Promise<void> {
|
||||
const credential = deleteTarget.value
|
||||
if (!credential || deletePassword.value.length === 0) return
|
||||
busy.value = true
|
||||
try {
|
||||
await passkeyAPI.remove(credential.id, deletePassword.value)
|
||||
credentials.value = credentials.value.filter((item) => item.id !== credential.id)
|
||||
appStore.showSuccess(t('profile.passkey.deleted'))
|
||||
closeDeleteDialog()
|
||||
} catch (error) {
|
||||
// 密码错误等失败保持对话框打开,允许重试
|
||||
appStore.showError(extractErrorMessage(error, t('profile.passkey.deleteFailed')))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.enabled,
|
||||
() => {
|
||||
void loadCredentials()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -127,6 +127,13 @@ export default {
|
||||
'Please configure TOTP_ENCRYPTION_KEY in environment variables first. Generate a key with: openssl rand -hex 32'
|
||||
},
|
||||
security: {
|
||||
passkey: 'Passkey Sign-in',
|
||||
passkeyHint: 'Allow passwordless sign-in and user-managed passkeys when the relying party configuration is valid.',
|
||||
passkeyConfigured: 'WebAuthn relying party configuration is valid.',
|
||||
passkeyNotConfigured: 'Configure a valid RP ID and allowed HTTPS origins before enabling passkey sign-in.',
|
||||
passkeyRPID: 'RP ID',
|
||||
passkeyOrigins: 'Allowed HTTPS origins',
|
||||
passkeyValueNotConfigured: 'Not configured',
|
||||
stepUp: 'Step-up 2FA for Sensitive Operations',
|
||||
stepUpHint: 'When enabled, sensitive operations (account/proxy export, backup creation and download, S3 config changes, promoting admins) require a recent TOTP verification (valid for 15 minutes). Your own account must have 2FA enabled before turning this on; turning it off also requires step-up verification.',
|
||||
stepUpEnableRequiresTotp: 'Enable 2FA (TOTP) for your own account in Profile before turning on step-up verification.',
|
||||
|
||||
@@ -205,6 +205,10 @@ export default {
|
||||
signInToAccount: 'Sign in to your account to continue',
|
||||
signIn: 'Sign In',
|
||||
signingIn: 'Signing in...',
|
||||
passkeySignIn: 'Sign in with a passkey',
|
||||
passkeySigningIn: 'Waiting for passkey...',
|
||||
passkeyCancelled: 'Passkey sign-in was cancelled.',
|
||||
passkeyFailed: 'Passkey sign-in failed. Please try again.',
|
||||
createAccount: 'Create Account',
|
||||
signUpToStart: 'Sign up to start using {siteName}',
|
||||
signUp: 'Sign up',
|
||||
|
||||
@@ -690,6 +690,31 @@ export default {
|
||||
codeSent: 'Verification code sent to your email',
|
||||
sendCodeFailed: 'Failed to send verification code'
|
||||
},
|
||||
passkey: {
|
||||
title: 'Passkeys',
|
||||
description: 'Use Face ID, Touch ID, Windows Hello, or a security key to sign in without a password.',
|
||||
add: 'Add passkey',
|
||||
continue: 'Create passkey',
|
||||
name: 'Passkey name',
|
||||
namePlaceholder: 'For example, MacBook Touch ID',
|
||||
passwordPlaceholder: 'Enter your current password to confirm',
|
||||
empty: 'No passkeys are registered yet.',
|
||||
synced: 'Synced',
|
||||
createdAt: 'Created {date}',
|
||||
lastUsed: 'Last used {date}',
|
||||
featureDisabled: 'Passkeys have not been configured by the administrator.',
|
||||
unsupported: 'This browser or device does not support passkeys.',
|
||||
loadFailed: 'Failed to load passkeys.',
|
||||
added: 'Passkey added.',
|
||||
addFailed: 'Failed to add passkey.',
|
||||
renamePrompt: 'Enter a new name for this passkey',
|
||||
renamed: 'Passkey renamed.',
|
||||
renameFailed: 'Failed to rename passkey.',
|
||||
deleteTitle: 'Delete passkey',
|
||||
deleteConfirm: 'Delete “{name}”? You will no longer be able to sign in with it.',
|
||||
deleted: 'Passkey deleted.',
|
||||
deleteFailed: 'Failed to delete passkey.'
|
||||
},
|
||||
balanceNotify: {
|
||||
title: 'Balance Low Notification',
|
||||
description: 'Send email alert when account balance falls below threshold',
|
||||
|
||||
@@ -127,6 +127,13 @@ export default {
|
||||
'请先在环境变量中配置 TOTP_ENCRYPTION_KEY。使用命令 openssl rand -hex 32 生成密钥。'
|
||||
},
|
||||
security: {
|
||||
passkey: 'Passkey 登录',
|
||||
passkeyHint: '当依赖方配置有效时,允许无密码登录及用户自行管理 Passkey。',
|
||||
passkeyConfigured: 'WebAuthn 依赖方配置有效。',
|
||||
passkeyNotConfigured: '请先配置有效的 RP ID 与允许的 HTTPS 来源,再启用 Passkey 登录。',
|
||||
passkeyRPID: 'RP ID',
|
||||
passkeyOrigins: '允许的 HTTPS 来源',
|
||||
passkeyValueNotConfigured: '未配置',
|
||||
stepUp: '敏感操作二次验证 (step-up 2FA)',
|
||||
stepUpHint: '开启后,账号/代理导出、备份创建与下载、S3 配置修改、提升管理员等敏感操作需要先完成 TOTP 二次验证(15 分钟内有效)。开启前需本人已启用 2FA;关闭该开关本身也需要二次验证。',
|
||||
stepUpEnableRequiresTotp: '开启敏感操作二次验证前,请先在个人资料中为当前账号启用 2FA (TOTP)。',
|
||||
|
||||
@@ -205,6 +205,10 @@ export default {
|
||||
signInToAccount: '登录您的账户以继续',
|
||||
signIn: '登录',
|
||||
signingIn: '登录中...',
|
||||
passkeySignIn: '使用 Passkey 登录',
|
||||
passkeySigningIn: '正在等待 Passkey...',
|
||||
passkeyCancelled: '已取消 Passkey 登录。',
|
||||
passkeyFailed: 'Passkey 登录失败,请重试。',
|
||||
createAccount: '创建账户',
|
||||
signUpToStart: '注册以开始使用 {siteName}',
|
||||
signUp: '注册',
|
||||
|
||||
@@ -695,6 +695,31 @@ export default {
|
||||
codeSent: '验证码已发送到您的邮箱',
|
||||
sendCodeFailed: '发送验证码失败'
|
||||
},
|
||||
passkey: {
|
||||
title: 'Passkey',
|
||||
description: '使用面容 ID、触控 ID、Windows Hello 或安全密钥免密码登录。',
|
||||
add: '添加 Passkey',
|
||||
continue: '创建 Passkey',
|
||||
name: 'Passkey 名称',
|
||||
namePlaceholder: '例如:MacBook 触控 ID',
|
||||
passwordPlaceholder: '输入当前登录密码以确认',
|
||||
empty: '尚未添加任何 Passkey。',
|
||||
synced: '已同步',
|
||||
createdAt: '创建于 {date}',
|
||||
lastUsed: '上次使用 {date}',
|
||||
featureDisabled: '管理员尚未配置 Passkey 功能。',
|
||||
unsupported: '当前浏览器或设备不支持 Passkey。',
|
||||
loadFailed: '加载 Passkey 失败。',
|
||||
added: 'Passkey 已添加。',
|
||||
addFailed: '添加 Passkey 失败。',
|
||||
renamePrompt: '请输入新的 Passkey 名称',
|
||||
renamed: 'Passkey 已重命名。',
|
||||
renameFailed: '重命名 Passkey 失败。',
|
||||
deleteTitle: '删除 Passkey',
|
||||
deleteConfirm: '删除“{name}”?删除后将无法再使用它登录。',
|
||||
deleted: 'Passkey 已删除。',
|
||||
deleteFailed: '删除 Passkey 失败。'
|
||||
},
|
||||
balanceNotify: {
|
||||
title: '余额不足提醒',
|
||||
description: '当账户余额低于阈值时发送邮件提醒',
|
||||
|
||||
@@ -358,6 +358,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
github_oauth_enabled: false,
|
||||
google_oauth_enabled: false,
|
||||
backend_mode_enabled: false,
|
||||
passkey_enabled: false,
|
||||
version: siteVersion.value,
|
||||
balance_low_notify_enabled: false,
|
||||
account_quota_notify_enabled: false,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import { authAPI, isTotp2FARequired, type LoginResponse } from '@/api'
|
||||
import { authAPI, isTotp2FARequired, passkeyAPI, type LoginResponse } from '@/api'
|
||||
import type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
|
||||
|
||||
const AUTH_TOKEN_KEY = 'auth_token'
|
||||
@@ -275,6 +275,17 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithPasskey(): Promise<User> {
|
||||
try {
|
||||
const response = await passkeyAPI.login()
|
||||
setAuthFromResponse(response)
|
||||
return user.value!
|
||||
} catch (error) {
|
||||
clearAuth({ preservePendingAuthSession: pendingAuthSession.value !== null })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set auth state from an AuthResponse
|
||||
* Internal helper function
|
||||
@@ -486,6 +497,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
// Actions
|
||||
login,
|
||||
loginWithPasskey,
|
||||
login2FA,
|
||||
register,
|
||||
setToken,
|
||||
|
||||
@@ -201,6 +201,7 @@ export interface PublicSettings {
|
||||
login_agreement_revision?: string
|
||||
login_agreement_documents?: LoginAgreementDocument[]
|
||||
turnstile_enabled: boolean
|
||||
passkey_enabled?: boolean
|
||||
turnstile_site_key: string
|
||||
site_name: string
|
||||
site_logo: string
|
||||
|
||||
@@ -1578,6 +1578,62 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Passkey sign-in -->
|
||||
<div
|
||||
class="border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
data-testid="passkey-settings"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t("admin.settings.security.passkey")
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.security.passkeyHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="form.passkey_enabled"
|
||||
data-testid="passkey-toggle"
|
||||
:disabled="!form.passkey_configured"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 rounded-lg border px-3 py-2 text-sm"
|
||||
:class="
|
||||
form.passkey_configured
|
||||
? 'border-green-200 bg-green-50 text-green-800 dark:border-green-900 dark:bg-green-950/40 dark:text-green-300'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300'
|
||||
"
|
||||
data-testid="passkey-config-status"
|
||||
>
|
||||
<p class="font-medium">
|
||||
{{
|
||||
form.passkey_configured
|
||||
? t("admin.settings.security.passkeyConfigured")
|
||||
: t("admin.settings.security.passkeyNotConfigured")
|
||||
}}
|
||||
</p>
|
||||
<p class="mt-1 break-all">
|
||||
{{ t("admin.settings.security.passkeyRPID") }}:
|
||||
{{
|
||||
form.passkey_rp_id ||
|
||||
t("admin.settings.security.passkeyValueNotConfigured")
|
||||
}}
|
||||
</p>
|
||||
<p class="mt-1 break-all">
|
||||
{{ t("admin.settings.security.passkeyOrigins") }}:
|
||||
{{
|
||||
form.passkey_rp_origins.length > 0
|
||||
? form.passkey_rp_origins.join(", ")
|
||||
: t(
|
||||
"admin.settings.security.passkeyValueNotConfigured",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 敏感操作 step-up 2FA -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
@@ -8699,6 +8755,10 @@ const form = reactive<SettingsForm>({
|
||||
password_reset_enabled: false,
|
||||
totp_enabled: false,
|
||||
totp_encryption_key_configured: false,
|
||||
passkey_enabled: false,
|
||||
passkey_configured: false,
|
||||
passkey_rp_id: "",
|
||||
passkey_rp_origins: [],
|
||||
session_binding_enabled: false,
|
||||
step_up_enabled: false,
|
||||
audit_log_retention_days: 180,
|
||||
@@ -10212,6 +10272,7 @@ async function saveSettings() {
|
||||
invitation_code_enabled: form.invitation_code_enabled,
|
||||
password_reset_enabled: form.password_reset_enabled,
|
||||
totp_enabled: form.totp_enabled,
|
||||
passkey_enabled: form.passkey_enabled,
|
||||
session_binding_enabled: form.session_binding_enabled,
|
||||
step_up_enabled: form.step_up_enabled,
|
||||
// 清空数字框时 v-model.number 会得到空串,后端 int 字段解析空串会 400 拒绝整次保存;
|
||||
|
||||
@@ -362,6 +362,10 @@ const baseSettingsResponse = {
|
||||
password_reset_enabled: false,
|
||||
totp_enabled: false,
|
||||
totp_encryption_key_configured: false,
|
||||
passkey_enabled: true,
|
||||
passkey_configured: true,
|
||||
passkey_rp_id: "sub3.nebula-spaces.com",
|
||||
passkey_rp_origins: ["https://sub3.nebula-spaces.com"],
|
||||
default_balance: 0,
|
||||
default_concurrency: 1,
|
||||
default_subscriptions: [],
|
||||
@@ -722,6 +726,47 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
expect(wrapper.text()).not.toContain("支付来源");
|
||||
});
|
||||
|
||||
it("shows valid passkey RP configuration and persists the sign-in toggle", async () => {
|
||||
const wrapper = mountView();
|
||||
|
||||
await flushPromises();
|
||||
await openSecurityTab(wrapper);
|
||||
|
||||
const settings = wrapper.get('[data-testid="passkey-settings"]');
|
||||
const toggle = settings.get('[data-testid="passkey-toggle"]');
|
||||
expect(toggle.attributes("disabled")).toBeUndefined();
|
||||
expect(settings.text()).toContain("sub3.nebula-spaces.com");
|
||||
expect(settings.text()).toContain("https://sub3.nebula-spaces.com");
|
||||
|
||||
await toggle.setValue(false);
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ passkey_enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables passkey sign-in when the RP configuration is unavailable", async () => {
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
passkey_enabled: false,
|
||||
passkey_configured: false,
|
||||
passkey_rp_id: "",
|
||||
passkey_rp_origins: [],
|
||||
});
|
||||
const wrapper = mountView();
|
||||
|
||||
await flushPromises();
|
||||
await openSecurityTab(wrapper);
|
||||
|
||||
const settings = wrapper.get('[data-testid="passkey-settings"]');
|
||||
expect(settings.get('[data-testid="passkey-toggle"]').attributes("disabled")).toBeDefined();
|
||||
expect(settings.get('[data-testid="passkey-config-status"]').text()).toContain(
|
||||
"admin.settings.security.passkeyNotConfigured",
|
||||
);
|
||||
});
|
||||
|
||||
it("loads, edits, validates, and saves forwarded client-IP headers", async () => {
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
@open="showAgreementModal = true"
|
||||
/>
|
||||
|
||||
<div v-if="showOAuthLogin" class="space-y-3 pt-1">
|
||||
<div v-if="showPasskeyLogin || showOAuthLogin" class="space-y-3 pt-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||
@@ -140,6 +140,17 @@
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="showPasskeyLogin"
|
||||
type="button"
|
||||
class="btn btn-secondary w-full"
|
||||
:disabled="authActionDisabled"
|
||||
@click="handlePasskeyLogin"
|
||||
>
|
||||
<Icon name="key" size="md" class="mr-2" />
|
||||
{{ passkeyLoading ? t('auth.passkeySigningIn') : t('auth.passkeySignIn') }}
|
||||
</button>
|
||||
|
||||
<EmailOAuthButtons
|
||||
:disabled="authActionDisabled"
|
||||
:github-enabled="githubOAuthEnabled"
|
||||
@@ -229,6 +240,7 @@ const appStore = useAppStore()
|
||||
// ==================== State ====================
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
const passkeyLoading = ref<boolean>(false)
|
||||
const errorMessage = ref<string>('')
|
||||
const showPassword = ref<boolean>(false)
|
||||
const publicSettingsLoaded = ref<boolean>(false)
|
||||
@@ -245,6 +257,7 @@ const oidcOAuthProviderName = ref<string>('OIDC')
|
||||
const githubOAuthEnabled = ref<boolean>(false)
|
||||
const googleOAuthEnabled = ref<boolean>(false)
|
||||
const passwordResetEnabled = ref<boolean>(false)
|
||||
const passkeyEnabled = ref<boolean>(false)
|
||||
const loginAgreementEnabled = ref<boolean>(false)
|
||||
const loginAgreementMode = ref<'modal' | 'checkbox' | string>('modal')
|
||||
const loginAgreementUpdatedAt = ref<string>('')
|
||||
@@ -283,7 +296,11 @@ const agreementGateActive = computed(
|
||||
)
|
||||
|
||||
const authActionDisabled = computed(
|
||||
() => isLoading.value || !publicSettingsLoaded.value || agreementGateActive.value
|
||||
() => isLoading.value || passkeyLoading.value || !publicSettingsLoaded.value || agreementGateActive.value
|
||||
)
|
||||
|
||||
const showPasskeyLogin = computed(
|
||||
() => passkeyEnabled.value && typeof window.PublicKeyCredential !== 'undefined'
|
||||
)
|
||||
|
||||
const showOAuthLogin = computed(
|
||||
@@ -328,6 +345,7 @@ onMounted(async () => {
|
||||
googleOAuthEnabled.value = settings.google_oauth_enabled
|
||||
backendModeEnabled.value = settings.backend_mode_enabled
|
||||
passwordResetEnabled.value = settings.password_reset_enabled
|
||||
passkeyEnabled.value = settings.passkey_enabled === true
|
||||
applyLoginAgreementSettings(settings)
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
@@ -516,6 +534,33 @@ async function handleLogin(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin(): Promise<void> {
|
||||
if (agreementGateActive.value) {
|
||||
appStore.showWarning(t('legal.loginAgreementPrompt.loginRequiredWarning'))
|
||||
if (loginAgreementMode.value !== 'checkbox') {
|
||||
showAgreementModal.value = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
passkeyLoading.value = true
|
||||
try {
|
||||
await authStore.loginWithPasskey()
|
||||
clearAllAffiliateReferralCodes()
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard'
|
||||
await router.push(redirectTo)
|
||||
} catch (error: unknown) {
|
||||
const fallback = error instanceof DOMException && error.name === 'NotAllowedError'
|
||||
? t('auth.passkeyCancelled')
|
||||
: t('auth.passkeyFailed')
|
||||
errorMessage.value = extractI18nErrorMessage(error, t, 'auth.errors', fallback)
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
passkeyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 2FA Handlers ====================
|
||||
|
||||
async function handle2FAVerify(code: string): Promise<void> {
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
/>
|
||||
|
||||
<ProfileTotpCard />
|
||||
<ProfilePasskeyCard :enabled="passkeyEnabled" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -57,6 +58,7 @@ import ProfileBalanceNotifyCard from '@/components/user/profile/ProfileBalanceNo
|
||||
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
|
||||
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
|
||||
import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue'
|
||||
import ProfilePasskeyCard from '@/components/user/profile/ProfilePasskeyCard.vue'
|
||||
import { isWeChatWebOAuthEnabled } from '@/api/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -76,6 +78,7 @@ const wechatOAuthOpenEnabled = ref<boolean | undefined>(undefined)
|
||||
const wechatOAuthMPEnabled = ref<boolean | undefined>(undefined)
|
||||
const oidcOAuthEnabled = ref(false)
|
||||
const oidcOAuthProviderName = ref('OIDC')
|
||||
const passkeyEnabled = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
const profileRefresh = authStore.refreshUser().catch((error) => {
|
||||
@@ -101,6 +104,7 @@ onMounted(async () => {
|
||||
: undefined
|
||||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled ?? false
|
||||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||
passkeyEnabled.value = settings.passkey_enabled === true
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load settings:', error)
|
||||
|
||||
Reference in New Issue
Block a user