feat: add passkey authentication

This commit is contained in:
Zhixuan Jiang
2026-07-26 09:50:28 -04:00
parent 2730c1c43b
commit cc62979aa7
37 changed files with 1797 additions and 8 deletions
+108
View File
@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { get, post, patch, remove, credentialGet } = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
remove: vi.fn(),
credentialGet: 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 = {
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 {}
}
}
describe('passkey api', () => {
beforeEach(() => {
get.mockReset()
post.mockReset()
patch.mockReset()
remove.mockReset()
credentialGet.mockReset()
vi.stubGlobal('PublicKeyCredential', FakePublicKeyCredential)
Object.defineProperty(window, 'PublicKeyCredential', {
configurable: true,
value: FakePublicKeyCredential
})
Object.defineProperty(navigator, 'credentials', {
configurable: true,
value: { get: credentialGet }
})
})
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'
}
}
})
})
})
+1
View File
@@ -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'
+167
View File
@@ -0,0 +1,167 @@
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): Promise<PasskeyCredentialSummary> {
requirePasskeySupport()
const { data: begin } = await apiClient.post<CeremonyOptionsResponse>(
'/user/passkeys/register/begin'
)
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): Promise<void> {
await apiClient.delete(`/user/passkeys/${id}`)
}
export const passkeyAPI = {
isSupported: () => Boolean(window.PublicKeyCredential && navigator.credentials),
login,
register,
list,
rename,
remove
}
@@ -0,0 +1,217 @@
<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="text-sm text-gray-500 dark:text-gray-400">
{{ t('profile.passkey.featureDisabled') }}
</div>
<div v-else-if="!supported" class="text-sm text-amber-600 dark:text-amber-400">
{{ t('profile.passkey.unsupported') }}
</div>
<div v-else>
<form
v-if="showAddForm"
class="mb-5 flex flex-col gap-3 rounded-lg border border-gray-200 p-4 dark:border-dark-700 sm:flex-row sm:items-end"
@submit.prevent="addPasskey"
>
<div class="flex-1">
<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 class="flex 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">
{{ 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>
</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 credentials = ref<PasskeyCredentialSummary[]>([])
async function loadCredentials(): Promise<void> {
if (!supported) return
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> {
busy.value = true
try {
await passkeyAPI.register(newName.value.trim())
appStore.showSuccess(t('profile.passkey.added'))
cancelAdd()
await loadCredentials()
} catch (error) {
if (!(error instanceof DOMException && error.name === 'NotAllowedError')) {
appStore.showError(t('profile.passkey.addFailed'))
}
} finally {
busy.value = false
}
}
function cancelAdd(): void {
showAddForm.value = false
newName.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
}
}
async function deletePasskey(credential: PasskeyCredentialSummary): Promise<void> {
if (!window.confirm(t('profile.passkey.deleteConfirm', { name: credential.name }))) return
busy.value = true
try {
await passkeyAPI.remove(credential.id)
credentials.value = credentials.value.filter((item) => item.id !== credential.id)
appStore.showSuccess(t('profile.passkey.deleted'))
} catch {
appStore.showError(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,
(enabled) => {
if (enabled) void loadCredentials()
},
{ immediate: true }
)
</script>
+4
View File
@@ -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',
+23
View File
@@ -690,6 +690,29 @@ 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',
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.',
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',
+4
View File
@@ -205,6 +205,10 @@ export default {
signInToAccount: '登录您的账户以继续',
signIn: '登录',
signingIn: '登录中...',
passkeySignIn: '使用 Passkey 登录',
passkeySigningIn: '正在等待 Passkey...',
passkeyCancelled: '已取消 Passkey 登录。',
passkeyFailed: 'Passkey 登录失败,请重试。',
createAccount: '创建账户',
signUpToStart: '注册以开始使用 {siteName}',
signUp: '注册',
+23
View File
@@ -695,6 +695,29 @@ export default {
codeSent: '验证码已发送到您的邮箱',
sendCodeFailed: '发送验证码失败'
},
passkey: {
title: 'Passkey',
description: '使用面容 ID、触控 ID、Windows Hello 或安全密钥免密码登录。',
add: '添加 Passkey',
continue: '创建 Passkey',
name: 'Passkey 名称',
namePlaceholder: '例如:MacBook 触控 ID',
empty: '尚未添加任何 Passkey。',
synced: '已同步',
createdAt: '创建于 {date}',
lastUsed: '上次使用 {date}',
featureDisabled: '管理员尚未配置 Passkey 功能。',
unsupported: '当前浏览器或设备不支持 Passkey。',
loadFailed: '加载 Passkey 失败。',
added: 'Passkey 已添加。',
addFailed: '添加 Passkey 失败。',
renamePrompt: '请输入新的 Passkey 名称',
renamed: 'Passkey 已重命名。',
renameFailed: '重命名 Passkey 失败。',
deleteConfirm: '删除“{name}”?删除后将无法再使用它登录。',
deleted: 'Passkey 已删除。',
deleteFailed: '删除 Passkey 失败。'
},
balanceNotify: {
title: '余额不足提醒',
description: '当账户余额低于阈值时发送邮件提醒',
+1
View File
@@ -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,
+13 -1
View File
@@ -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,
+1
View File
@@ -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
+47 -2
View File
@@ -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> {
+4
View File
@@ -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)