diff --git a/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx b/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx index 1839815ce..57084c62c 100644 --- a/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx +++ b/web/src/app/home/components/account-settings-dialog/AccountSettingsPanel.tsx @@ -67,6 +67,9 @@ export default function AccountSettingsPanel({ const [spaceBindLoading, setSpaceBindLoading] = useState(false); const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); const [passkeys, setPasskeys] = useState([]); + // Passkeys are OSS-only. The core reports explicit false on cloud deployments, + // so an absent flag must not be read as "enabled". + const [passkeySupported, setPasskeySupported] = useState(false); const [passkeyLoading, setPasskeyLoading] = useState(false); const [registeringPasskey, setRegisteringPasskey] = useState(false); const [totpDialogOpen, setTotpDialogOpen] = useState(false); @@ -90,7 +93,7 @@ export default function AccountSettingsPanel({ useEffect(() => { if (active) { loadUserInfo(); - loadPasskeys(); + loadPasskeyCapability(); } }, [active]); @@ -165,6 +168,22 @@ export default function AccountSettingsPanel({ } } + async function loadPasskeyCapability() { + try { + const res = await httpClient.getAccountInfo(); + const supported = + res.passkey_login_enabled === true || res.passkey_supported === true; + setPasskeySupported(supported); + if (supported) { + await loadPasskeys(); + } + } catch { + // Treat an unreadable capability response as "not supported": the passkey + // routes refuse on cloud anyway, and this avoids a spurious error toast. + setPasskeySupported(false); + } + } + async function loadPasskeys() { setPasskeyLoading(true); try { @@ -181,7 +200,7 @@ export default function AccountSettingsPanel({ setRegisteringPasskey(true); try { const { options, challenge_token } = - await httpClient.getPasskeyRegisterOptions(window.location.origin); + await httpClient.getPasskeyRegisterOptions(); const regResp = await startRegistration({ optionsJSON: options }); const defaultName = prompt(t('account.passkeyNamePlaceholder')) || undefined; @@ -320,104 +339,106 @@ export default function AccountSettingsPanel({ )} - {/* Passkey Section */} -
-
-
-

- {t('account.passkeySectionTitle')} -

-

- {t('account.passkeySectionDesc')} -

+ {/* Passkey Section — OSS-only, so it is not rendered on cloud. */} + {passkeySupported && ( +
+
+
+

+ {t('account.passkeySectionTitle')} +

+

+ {t('account.passkeySectionDesc')} +

+
+
- -
- {passkeyLoading ? ( -
- -
- ) : passkeys.length === 0 ? ( -
- {t('account.noPasskeys')} -
- ) : ( -
- {passkeys.map((pk) => ( - - - - - - {pk.name} - - {pk.created_at && ( - - {t('account.passkeyCreated', { - date: new Date( - pk.created_at, - ).toLocaleDateString(), - })} - - )} - {pk.last_used_at && ( - - ·{' '} - {t('account.passkeyLastUsed', { - date: new Date( - pk.last_used_at, - ).toLocaleDateString(), - })} - - )} - - - - - - - - ))} -
- )} -
+ {passkeyLoading ? ( +
+ +
+ ) : passkeys.length === 0 ? ( +
+ {t('account.noPasskeys')} +
+ ) : ( +
+ {passkeys.map((pk) => ( + + + + + + {pk.name} + + {pk.created_at && ( + + {t('account.passkeyCreated', { + date: new Date( + pk.created_at, + ).toLocaleDateString(), + })} + + )} + {pk.last_used_at && ( + + ·{' '} + {t('account.passkeyLastUsed', { + date: new Date( + pk.last_used_at, + ).toLocaleDateString(), + })} + + )} + + + + + + + + ))} +
+ )} +
+ )} {/* TOTP second factor. The card is informational: changing the factor happens from the action on the Account's own row. */} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 517b294d3..35ddf99df 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -1760,11 +1760,13 @@ export class BackendClient extends BaseHttpClient { // ============ Passkey (WebAuthn) API ============ public getPasskeyAuthOptions( email?: string, - origin?: string, ): Promise<{ options: any; challenge_token: string }> { + // No origin is sent: the WebAuthn RP ID and expected origin are resolved + // server-side from api.webui_url / api.webhook_prefix. Sending a client + // origin would only invite a future handler to trust it again. return this.post( '/api/v1/user/passkey/auth/options', - { email, origin }, + { email }, { skipWorkspace: true }, ); } @@ -1780,12 +1782,14 @@ export class BackendClient extends BaseHttpClient { ); } - public getPasskeyRegisterOptions( - origin?: string, - ): Promise<{ options: any; challenge_token: string }> { + public getPasskeyRegisterOptions(): Promise<{ + options: any; + challenge_token: string; + }> { + // See getPasskeyAuthOptions: the origin is server-configured, not supplied. return this.post( '/api/v1/user/passkey/register/options', - { origin }, + {}, { skipWorkspace: true }, ); } diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index d342909ed..00ca2319a 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -105,8 +105,10 @@ export default function Login() { } setShowLocalLogin(res.password_login_enabled !== false); setShowSpaceLogin(res.space_login_enabled !== false); + // Passkeys are OSS-only: the core reports explicit false on cloud, so an + // absent flag must not be read as "enabled". setShowPasskeyLogin( - res.passkey_login_enabled !== false || Boolean(res.passkey_supported), + res.passkey_login_enabled === true || res.passkey_supported === true, ); setLoading(false); @@ -206,10 +208,7 @@ export default function Login() { setPasskeyLoading(true); try { const { options, challenge_token } = - await httpClient.getPasskeyAuthOptions( - undefined, - window.location.origin, - ); + await httpClient.getPasskeyAuthOptions(undefined); const authResp = await startAuthentication({ optionsJSON: options }); const res = await httpClient.verifyPasskeyAuth(challenge_token, authResp); // A rejected assertion answers with an error payload and no session, so a