diff --git a/controller/passkey.go b/controller/passkey.go index 6c73b006a777..f63731b84e57 100644 --- a/controller/passkey.go +++ b/controller/passkey.go @@ -186,14 +186,17 @@ func PasskeyStatus(c *gin.Context) { return } + data := gin.H{ + "enabled": false, + "system_enabled": system_setting.GetPasskeySettings().Enabled, + } + credential, err := model.GetPasskeyByUserID(user.Id) if errors.Is(err, model.ErrPasskeyNotFound) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", - "data": gin.H{ - "enabled": false, - }, + "data": data, }) return } @@ -202,10 +205,8 @@ func PasskeyStatus(c *gin.Context) { return } - data := gin.H{ - "enabled": true, - "last_used_at": credential.LastUsedAt, - } + data["enabled"] = true + data["last_used_at"] = credential.LastUsedAt c.JSON(http.StatusOK, gin.H{ "success": true, diff --git a/controller/passkey_test.go b/controller/passkey_test.go new file mode 100644 index 000000000000..5beaee7ec14e --- /dev/null +++ b/controller/passkey_test.go @@ -0,0 +1,102 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +package controller + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type passkeyStatusResponse struct { + Success bool `json:"success"` + Data struct { + Enabled bool `json:"enabled"` + SystemEnabled bool `json:"system_enabled"` + } `json:"data"` +} + +func TestPasskeyStatusSeparatesCredentialAndSystemState(t *testing.T) { + db := setupModelListControllerTestDB(t) + require.NoError(t, db.AutoMigrate(&model.PasskeyCredential{})) + + user := &model.User{ + Username: "passkey-status-user", + Password: "password", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + } + require.NoError(t, db.Create(user).Error) + + passkeySettings := system_setting.GetPasskeySettings() + originalEnabled := passkeySettings.Enabled + t.Cleanup(func() { + passkeySettings.Enabled = originalEnabled + }) + + requestStatus := func() passkeyStatusResponse { + recorder := httptest.NewRecorder() + router := gin.New() + router.Use(sessions.Sessions("session", cookie.NewStore([]byte("passkey-status-secret")))) + router.GET("/", func(c *gin.Context) { + session := sessions.Default(c) + session.Set("id", user.Id) + PasskeyStatus(c) + }) + + request := httptest.NewRequest(http.MethodGet, "/", nil) + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + var response passkeyStatusResponse + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.True(t, response.Success) + return response + } + + passkeySettings.Enabled = false + response := requestStatus() + assert.False(t, response.Data.Enabled) + assert.False(t, response.Data.SystemEnabled) + + passkeySettings.Enabled = true + response = requestStatus() + assert.False(t, response.Data.Enabled) + assert.True(t, response.Data.SystemEnabled) + + require.NoError(t, db.Create(&model.PasskeyCredential{ + UserID: user.Id, + CredentialID: "credential-id", + PublicKey: "public-key", + }).Error) + passkeySettings.Enabled = false + response = requestStatus() + assert.True(t, response.Data.Enabled) + assert.False(t, response.Data.SystemEnabled) +} diff --git a/web/default/src/features/auth/passkey/hooks/use-passkey-management.ts b/web/default/src/features/auth/passkey/hooks/use-passkey-management.ts index 670c9d664fcb..765062d9ae7c 100644 --- a/web/default/src/features/auth/passkey/hooks/use-passkey-management.ts +++ b/web/default/src/features/auth/passkey/hooks/use-passkey-management.ts @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import i18next from 'i18next' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' import { @@ -171,8 +171,9 @@ export function usePasskeyManagement( } }, [fetchStatus]) - const enabled = useMemo(() => Boolean(status?.enabled), [status]) - const lastUsed = useMemo(() => status?.last_used_at ?? null, [status]) + const enabled = Boolean(status?.enabled) + const systemEnabled = status?.system_enabled ?? null + const lastUsed = status?.last_used_at ?? null return { status, @@ -181,6 +182,7 @@ export function usePasskeyManagement( removing, supported, enabled, + systemEnabled, lastUsed, fetchStatus, register, diff --git a/web/default/src/features/auth/passkey/types.ts b/web/default/src/features/auth/passkey/types.ts index a331a549c146..a3c4563db15d 100644 --- a/web/default/src/features/auth/passkey/types.ts +++ b/web/default/src/features/auth/passkey/types.ts @@ -24,6 +24,7 @@ export interface ApiResponse { export interface PasskeyStatus { enabled: boolean + system_enabled: boolean last_used_at?: string | null backup_eligible?: boolean backup_state?: boolean diff --git a/web/default/src/features/auth/secure-verification/api.ts b/web/default/src/features/auth/secure-verification/api.ts index d5cada898061..a7da0eb545ef 100644 --- a/web/default/src/features/auth/secure-verification/api.ts +++ b/web/default/src/features/auth/secure-verification/api.ts @@ -46,7 +46,8 @@ export async function checkVerificationMethods(): Promise { Boolean(twoFAResponse?.success) && Boolean(twoFAResponse?.data?.enabled) const hasPasskey = Boolean(passkeyResponse?.success) && - Boolean(passkeyResponse?.data?.enabled) + Boolean(passkeyResponse?.data?.enabled) && + Boolean(passkeyResponse?.data?.system_enabled) return { has2FA, diff --git a/web/default/src/features/auth/secure-verification/components/secure-verification-dialog.tsx b/web/default/src/features/auth/secure-verification/components/secure-verification-dialog.tsx index dcced4cf4bf9..ce2397e17010 100644 --- a/web/default/src/features/auth/secure-verification/components/secure-verification-dialog.tsx +++ b/web/default/src/features/auth/secure-verification/components/secure-verification-dialog.tsx @@ -61,7 +61,9 @@ export function SecureVerificationDialog({ }, [methods]) const activeMethod = - state.method ?? (availableTabs.length > 0 ? availableTabs[0] : null) + state.method && availableTabs.includes(state.method) + ? state.method + : (availableTabs[0] ?? null) const title = state.title ?? diff --git a/web/default/src/features/auth/secure-verification/hooks/use-secure-verification.ts b/web/default/src/features/auth/secure-verification/hooks/use-secure-verification.ts index a9d95cccc3fe..a6b2cb8d35a0 100644 --- a/web/default/src/features/auth/secure-verification/hooks/use-secure-verification.ts +++ b/web/default/src/features/auth/secure-verification/hooks/use-secure-verification.ts @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import i18next from 'i18next' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' import { @@ -26,6 +26,7 @@ import { } from '@/lib/secure-verification' import { checkVerificationMethods, verify } from '../api' +import { selectVerificationMethod } from '../lib/select-verification-method' import type { SecureVerificationState, StartVerificationOptions, @@ -87,7 +88,12 @@ export function useSecureVerification( const { preferredMethod, title, description } = config const availableMethods = await fetchVerificationMethods() - if (!availableMethods.has2FA && !availableMethods.hasPasskey) { + const defaultMethod = selectVerificationMethod( + availableMethods, + preferredMethod + ) + + if (!defaultMethod) { toast.error( i18next.t( 'Please enable Two-factor Authentication or Passkey before proceeding' @@ -101,15 +107,6 @@ export function useSecureVerification( return false } - let defaultMethod: VerificationMethod | null = preferredMethod ?? null - if (!defaultMethod) { - if (availableMethods.hasPasskey && availableMethods.passkeySupported) { - defaultMethod = 'passkey' - } else if (availableMethods.has2FA) { - defaultMethod = '2fa' - } - } - setState((prev) => ({ ...prev, apiCall, @@ -211,11 +208,7 @@ export function useSecureVerification( [methods] ) - const recommendedMethod = useMemo(() => { - if (methods.hasPasskey && methods.passkeySupported) return 'passkey' - if (methods.has2FA) return '2fa' - return null - }, [methods]) + const recommendedMethod = selectVerificationMethod(methods) return { open, diff --git a/web/default/src/features/auth/secure-verification/lib/select-verification-method.test.ts b/web/default/src/features/auth/secure-verification/lib/select-verification-method.test.ts new file mode 100644 index 000000000000..ec55fe90e650 --- /dev/null +++ b/web/default/src/features/auth/secure-verification/lib/select-verification-method.test.ts @@ -0,0 +1,87 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import type { VerificationMethod, VerificationMethods } from '../types' +import { selectVerificationMethod } from './select-verification-method' + +describe('selectVerificationMethod', () => { + const cases: Array<{ + name: string + methods: VerificationMethods + preferred?: VerificationMethod + expected: VerificationMethod | null + }> = [ + { + name: 'falls back to 2FA when the preferred Passkey is unavailable', + methods: { has2FA: true, hasPasskey: false, passkeySupported: true }, + preferred: 'passkey', + expected: '2fa', + }, + { + name: 'falls back to 2FA when a bound Passkey is unsupported by the device', + methods: { has2FA: true, hasPasskey: true, passkeySupported: false }, + preferred: 'passkey', + expected: '2fa', + }, + { + name: 'uses an available preferred 2FA method', + methods: { has2FA: true, hasPasskey: true, passkeySupported: true }, + preferred: '2fa', + expected: '2fa', + }, + { + name: 'uses an available preferred Passkey method', + methods: { has2FA: true, hasPasskey: true, passkeySupported: true }, + preferred: 'passkey', + expected: 'passkey', + }, + { + name: 'defaults to Passkey when no preference is provided', + methods: { has2FA: true, hasPasskey: true, passkeySupported: true }, + expected: 'passkey', + }, + { + name: 'defaults to 2FA when it is the only available method', + methods: { has2FA: true, hasPasskey: false, passkeySupported: true }, + expected: '2fa', + }, + { + name: 'falls back to Passkey when preferred 2FA is unavailable', + methods: { has2FA: false, hasPasskey: true, passkeySupported: true }, + preferred: '2fa', + expected: 'passkey', + }, + { + name: 'returns null when no usable method is available', + methods: { has2FA: false, hasPasskey: true, passkeySupported: false }, + expected: null, + }, + ] + + for (const testCase of cases) { + test(testCase.name, () => { + assert.equal( + selectVerificationMethod(testCase.methods, testCase.preferred), + testCase.expected + ) + }) + } +}) diff --git a/web/default/src/features/auth/secure-verification/lib/select-verification-method.ts b/web/default/src/features/auth/secure-verification/lib/select-verification-method.ts new file mode 100644 index 000000000000..7eca0cbf0b0b --- /dev/null +++ b/web/default/src/features/auth/secure-verification/lib/select-verification-method.ts @@ -0,0 +1,32 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { VerificationMethod, VerificationMethods } from '../types' + +export function selectVerificationMethod( + methods: VerificationMethods, + preferredMethod?: VerificationMethod +): VerificationMethod | null { + const passkeyAvailable = methods.hasPasskey && methods.passkeySupported + + if (preferredMethod === '2fa' && methods.has2FA) return '2fa' + if (preferredMethod === 'passkey' && passkeyAvailable) return 'passkey' + if (passkeyAvailable) return 'passkey' + if (methods.has2FA) return '2fa' + return null +} diff --git a/web/default/src/features/profile/components/passkey-card.tsx b/web/default/src/features/profile/components/passkey-card.tsx index 6ef09e6778fa..90bd3b51fc83 100644 --- a/web/default/src/features/profile/components/passkey-card.tsx +++ b/web/default/src/features/profile/components/passkey-card.tsx @@ -16,12 +16,14 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { Link } from '@tanstack/react-router' import { AlertTriangle, KeyRound, Loader2, ShieldAlert } from 'lucide-react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { StatusBadge } from '@/components/status-badge' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { AlertDialog, AlertDialogAction, @@ -51,6 +53,8 @@ import { type VerificationMethods, } from '@/features/auth/secure-verification' import dayjs from '@/lib/dayjs' +import { ROLE } from '@/lib/roles' +import { useAuthStore } from '@/stores/auth-store' interface PasskeyCardProps { loading: boolean @@ -61,6 +65,8 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { const [confirmOpen, setConfirmOpen] = useState(false) const [restrictedMethod, setRestrictedMethod] = useState(null) + const isSuperAdmin = + useAuthStore((state) => state.auth.user?.role) === ROLE.SUPER_ADMIN const { status, @@ -69,6 +75,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { removing, supported, enabled, + systemEnabled, lastUsed, register, remove, @@ -102,6 +109,11 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { }, [restrictedMethod, verificationMethods]) const handleRegister = useCallback(async () => { + if (systemEnabled !== true) { + toast.info(t('Passkey login is disabled by the administrator.')) + return + } + if (!supported) { toast.info(t('This device does not support Passkey')) return @@ -123,7 +135,14 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { 'Confirm your identity with Two-factor Authentication before registering a Passkey.' ), }) - }, [fetchVerificationMethods, register, startVerification, supported, t]) + }, [ + fetchVerificationMethods, + register, + startVerification, + supported, + systemEnabled, + t, + ]) const handleRemove = useCallback(async () => { const methods = await fetchVerificationMethods() @@ -250,7 +269,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {

{t('Passkey Authentication')}

- {!enabled && ( + {!enabled && systemEnabled === true && (