Something went wrong. Try again.
A downstream fork of Tranquil PDS for use in margin.cafe
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539<script lang="ts"> import { onMount } from 'svelte' import { api, ApiError } from '../../lib/api' import { _ } from '../../lib/i18n' import { formatDate } from '../../lib/date' import { getOAuthClientDisplayName } from '../../lib/oauth' import type { RememberedAppChoice, Session } from '../../lib/types/api' import { toast } from '../../lib/toast.svelte' import ReauthModal from '../ReauthModal.svelte' import SsoIcon from '../SsoIcon.svelte' import PasskeySection from './PasskeySection.svelte' import TotpSection from './TotpSection.svelte' import PasswordSection from './PasswordSection.svelte'
interface Props { session: Session }
let { session }: Props = $props()
let hasPassword = $state(true) let passkeyCount = $state(0) let totpEnabled = $state(false)
interface SsoProvider { provider: string name: string icon: string }
interface SsoLinkedAccount { id: string provider: string provider_name: string provider_username: string provider_email?: string created_at: string last_login_at?: string }
let ssoProviders = $state<SsoProvider[]>([]) let linkedAccounts = $state<SsoLinkedAccount[]>([]) let linkedAccountsLoading = $state(true) let linkingProvider = $state<string | null>(null) let unlinkingId = $state<string | null>(null)
let allowLegacyLogin = $state(true) let hasMfa = $state(false) let legacyLoginLoading = $state(true) let legacyLoginUpdating = $state(false)
interface TrustedDevice { id: string userAgent: string | null friendlyName: string | null trustedAt: string | null trustedUntil: string | null lastSeenAt: string } let trustedDevices = $state<TrustedDevice[]>([]) let trustedDevicesLoading = $state(true) let editingDeviceId = $state<string | null>(null) let editDeviceName = $state('') let rememberedAppChoices = $state<RememberedAppChoice[]>([]) let rememberedAppChoicesLoading = $state(true)
let showReauthModal = $state(false) let reauthMethods = $state<string[]>(['password']) let pendingAction = $state<(() => Promise<void>) | null>(null)
function handleReauthRequired(methods: string[], retryAction: () => Promise<void>) { reauthMethods = methods pendingAction = retryAction showReauthModal = true }
function handleReauthSuccess() { if (pendingAction) { pendingAction() pendingAction = null } }
function handleReauthCancel() { pendingAction = null }
onMount(async () => { const params = new URLSearchParams(globalThis.location.search) const error = params.get('error') const ssoLinked = params.get('sso_linked') if (error) { toast.error(error) globalThis.history.replaceState({}, '', globalThis.location.pathname) } else if (ssoLinked === 'true') { toast.success($_('oauth.sso.linkSuccess')) globalThis.history.replaceState({}, '', globalThis.location.pathname) }
await Promise.all([ loadSsoProviders(), loadLinkedAccounts(), loadLegacyLoginPreference(), loadTrustedDevices(), loadRememberedAppChoices() ]) })
async function loadSsoProviders() { try { const response = await fetch('/oauth/sso/providers') if (response.ok) { const data = await response.json() ssoProviders = (data.providers || []).toSorted((a: SsoProvider, b: SsoProvider) => a.name.localeCompare(b.name)) } } catch { ssoProviders = [] } }
async function loadLinkedAccounts() { linkedAccountsLoading = true try { const result = await api.getSsoLinkedAccounts(session.accessJwt) linkedAccounts = result.accounts || [] } catch { linkedAccounts = [] } finally { linkedAccountsLoading = false } }
async function loadTrustedDevices() { trustedDevicesLoading = true try { const result = await api.listTrustedDevices(session.accessJwt) trustedDevices = result.devices } catch { trustedDevices = [] } finally { trustedDevicesLoading = false } }
async function handleRevokeDevice(deviceId: string) { if (!confirm($_('trustedDevices.revokeConfirm'))) return try { await api.revokeTrustedDevice(session.accessJwt, deviceId) await loadTrustedDevices() toast.success($_('trustedDevices.deviceRevoked')) } catch (e) { toast.error(e instanceof ApiError ? e.message : $_('common.error')) } }
async function loadRememberedAppChoices() { rememberedAppChoicesLoading = true try { const result = await api.listRememberedAppChoices(session.accessJwt) rememberedAppChoices = result.choices } catch { rememberedAppChoices = [] } finally { rememberedAppChoicesLoading = false } }
async function handleForgetAppChoice(clientId: string) { if (!confirm($_('rememberedApps.forgetConfirm'))) return try { await api.forgetRememberedAppChoice(session.accessJwt, clientId) rememberedAppChoices = rememberedAppChoices.filter(choice => choice.clientId !== clientId) toast.success($_('rememberedApps.forgotten')) } catch (e) { toast.error(e instanceof ApiError ? e.message : $_('common.error')) } }
function startEditDevice(device: TrustedDevice) { editingDeviceId = device.id editDeviceName = device.friendlyName || '' }
function cancelEditDevice() { editingDeviceId = null editDeviceName = '' }
async function handleSaveDeviceName() { if (!editingDeviceId || !editDeviceName.trim()) return try { await api.updateTrustedDevice(session.accessJwt, editingDeviceId, editDeviceName.trim()) await loadTrustedDevices() editingDeviceId = null editDeviceName = '' toast.success($_('trustedDevices.deviceRenamed')) } catch (e) { toast.error(e instanceof ApiError ? e.message : $_('common.error')) } }
function parseUserAgent(ua: string | null): string { if (!ua) return $_('trustedDevices.unknownDevice') if (ua.includes('Firefox')) return 'Firefox' if (ua.includes('Chrome')) return 'Chrome' if (ua.includes('Safari')) return 'Safari' if (ua.includes('Edge')) return 'Edge' return 'Browser' }
function getDaysRemaining(trustedUntil: string | null): number { if (!trustedUntil) return 0 const now = new Date() const until = new Date(trustedUntil) const diff = until.getTime() - now.getTime() return Math.ceil(diff / (1000 * 60 * 60 * 24)) }
async function handleLinkAccount(provider: string) { linkingProvider = provider const linkRequestUri = `urn:tranquil:sso:link:${Date.now()}`
try { const result = await api.initiateSsoLink(session.accessJwt, provider, linkRequestUri) if (result.redirect_url) { window.location.href = result.redirect_url return } toast.error($_('common.error')) linkingProvider = null } catch (e) { if (e instanceof ApiError) { if (e.error === 'ReauthRequired') { handleReauthRequired(e.reauthMethods || ['password'], () => handleLinkAccount(provider)) } else { toast.error(e.message || $_('oauth.sso.linkFailed')) } } else { toast.error($_('common.error')) } linkingProvider = null } }
async function handleUnlinkAccount(id: string) { const account = linkedAccounts.find(a => a.id === id) if (!confirm($_('oauth.sso.unlinkConfirm'))) return
unlinkingId = id try { await api.unlinkSsoAccount(session.accessJwt, id) await loadLinkedAccounts() toast.success($_('oauth.sso.unlinked', { values: { provider: account?.provider_name || 'account' } })) } catch (e) { if (e instanceof ApiError) { if (e.error === 'ReauthRequired') { handleReauthRequired(e.reauthMethods || ['password'], () => handleUnlinkAccount(id)) } else { toast.error(e.message || $_('oauth.sso.unlinkFailed')) } } else { toast.error($_('common.error')) } } finally { unlinkingId = null } }
async function loadLegacyLoginPreference() { legacyLoginLoading = true try { const pref = await api.getLegacyLoginPreference(session.accessJwt) allowLegacyLogin = pref.allowLegacyLogin hasMfa = pref.hasMfa } catch { allowLegacyLogin = true hasMfa = false } finally { legacyLoginLoading = false } }
async function handleToggleLegacyLogin() { legacyLoginUpdating = true try { const result = await api.updateLegacyLoginPreference(session.accessJwt, !allowLegacyLogin) allowLegacyLogin = result.allowLegacyLogin toast.success(allowLegacyLogin ? $_('security.legacyLoginEnabled') : $_('security.legacyLoginDisabled')) } catch (e) { if (e instanceof ApiError) { if (e.error === 'ReauthRequired' || e.error === 'MfaVerificationRequired') { handleReauthRequired(e.reauthMethods || ['password'], handleToggleLegacyLogin) } else { toast.error(e.message) } } else { toast.error($_('security.failedToUpdatePreference')) } } finally { legacyLoginUpdating = false } }</script>
<div class="security"> <PasskeySection {session} {hasPassword} onPasskeysChanged={(count) => passkeyCount = count} onReauthRequired={handleReauthRequired} />
<TotpSection {session} onStatusChanged={(enabled, _hasBackup) => totpEnabled = enabled} />
<PasswordSection {session} {passkeyCount} onPasswordChanged={(has) => hasPassword = has} onReauthRequired={handleReauthRequired} />
{#if ssoProviders.length > 0} <section> <h3>{$_('oauth.sso.linkedAccounts')}</h3>
{#if linkedAccountsLoading} <div class="loading">{$_('common.loading')}</div> {:else} {#if linkedAccounts.length > 0} <ul class="sso-list"> {#each linkedAccounts as account} <li class="sso-item"> <div class="sso-info"> <span class="sso-provider">{account.provider_name}</span> <span class="sso-id">{account.provider_username}</span> <span class="sso-meta">{$_('oauth.sso.linkedAt')} {formatDate(account.created_at)}</span> </div> <button type="button" class="sm danger-outline" onclick={() => handleUnlinkAccount(account.id)} disabled={unlinkingId === account.id} > {unlinkingId === account.id ? $_('common.loading') : $_('oauth.sso.unlink')} </button> </li> {/each} </ul> {:else} <p class="empty">{$_('oauth.sso.noLinkedAccounts')}</p> {/if}
<div class="sso-providers"> <h4>{$_('oauth.sso.linkNewAccount')}</h4> <div class="provider-buttons"> {#each ssoProviders as provider} {@const isLinked = linkedAccounts.some(a => a.provider === provider.provider)} <button type="button" class="provider-btn" onclick={() => handleLinkAccount(provider.provider)} disabled={linkingProvider === provider.provider || isLinked} > <SsoIcon provider={provider.provider} /> <span class="provider-name">{linkingProvider === provider.provider ? $_('common.loading') : provider.name}</span> {#if isLinked} <span class="linked-badge">{$_('oauth.sso.linked')}</span> {/if} </button> {/each} </div> </div> {/if} </section> {/if}
{#if hasMfa} <section> <h3>{$_('security.appCompatibility')}</h3> <p class="section-description">{$_('security.legacyLoginDescription')}</p>
{#if !legacyLoginLoading} <div class="toggle-row"> <div class="toggle-info"> <span class="toggle-label">{$_('security.legacyLogin')}</span> <span class="toggle-description"> {#if allowLegacyLogin} {$_('security.legacyLoginOn')} {:else} {$_('security.legacyLoginOff')} {/if} </span> </div> <button type="button" class="toggle-button {allowLegacyLogin ? 'on' : 'off'}" onclick={handleToggleLegacyLogin} disabled={legacyLoginUpdating} aria-label={allowLegacyLogin ? $_('security.disableLegacyLogin') : $_('security.enableLegacyLogin')} role="switch" aria-checked={allowLegacyLogin} > <span class="toggle-slider" aria-hidden="true"></span> </button> </div>
{#if totpEnabled && allowLegacyLogin} <div class="warning-box"> <strong>{$_('security.legacyLoginWarning')}</strong> </div> {/if}
<div class="info-box"> <strong>{$_('security.legacyAppsTitle')}</strong> <p>{$_('security.legacyAppsDescription')}</p> </div> {/if} </section> {/if}
<section> <h3>{$_('trustedDevices.title')}</h3> <p class="section-description">{$_('trustedDevices.description')}</p>
{#if trustedDevicesLoading} <div class="loading">{$_('common.loading')}</div> {:else if trustedDevices.length === 0} <p class="empty-hint">{$_('trustedDevices.noDevices')}</p> <p class="hint-text">{$_('trustedDevices.noDevicesHint')}</p> {:else} <div class="device-list"> {#each trustedDevices as device} <div class="device-card"> <div class="device-header"> {#if editingDeviceId === device.id} <input type="text" class="edit-name-input" bind:value={editDeviceName} placeholder={$_('trustedDevices.deviceNamePlaceholder')} /> <div class="edit-actions"> <button type="button" class="sm" onclick={handleSaveDeviceName}>{$_('common.save')}</button> <button type="button" class="sm ghost" onclick={cancelEditDevice}>{$_('common.cancel')}</button> </div> {:else} <span class="device-name">{device.friendlyName || parseUserAgent(device.userAgent)}</span> <button type="button" class="icon" onclick={() => startEditDevice(device)} title={$_('security.rename')}> ✎ </button> {/if} </div>
<div class="device-details"> {#if device.userAgent} <span class="detail">{parseUserAgent(device.userAgent)}</span> {/if} {#if device.trustedAt} <span class="detail">{$_('trustedDevices.trustedSince')} {formatDate(device.trustedAt)}</span> {/if} <span class="detail">{$_('trustedDevices.lastSeen')} {formatDate(device.lastSeenAt)}</span> {#if device.trustedUntil} {@const daysRemaining = getDaysRemaining(device.trustedUntil)} <span class="detail" class:expiring-soon={daysRemaining <= 7}> {#if daysRemaining <= 0} {$_('trustedDevices.expired')} {:else if daysRemaining === 1} {$_('trustedDevices.tomorrow')} {:else} {$_('trustedDevices.inDays', { values: { days: daysRemaining } })} {/if} </span> {/if} </div>
<button type="button" class="sm danger-outline" onclick={() => handleRevokeDevice(device.id)}> {$_('trustedDevices.forget')} </button> </div> {/each} </div> {/if} </section>
<section> <h3>{$_('rememberedApps.title')}</h3> <p class="section-description">{$_('rememberedApps.description')}</p>
{#if rememberedAppChoicesLoading} <div class="loading">{$_('common.loading')}</div> {:else if rememberedAppChoices.length === 0} <p class="empty-hint">{$_('rememberedApps.none')}</p> {:else} <ul class="app-choice-list"> {#each rememberedAppChoices as choice} {@const name = getOAuthClientDisplayName(choice.clientId)} <li class="app-choice-card"> <div class="app-choice-header"> <div class="app-choice-identity"> <span class="app-choice-name">{name}</span> {#if name !== choice.clientId} <span class="app-choice-client-id">{choice.clientId}</span> {/if} </div> <button type="button" class="sm danger-outline" onclick={() => handleForgetAppChoice(choice.clientId)}> {$_('rememberedApps.forget')} </button> </div>
<details class="app-choice-permissions"> <summary> {$_('appPasswords.permissions')} <span class="badge">{choice.scopes.length}</span> </summary> <ul class="app-choice-scopes"> {#each choice.scopes as scope} <li><code>{scope}</code></li> {/each} </ul> </details> </li> {/each} </ul> {/if} </section></div>
<ReauthModal bind:show={showReauthModal} availableMethods={reauthMethods} onSuccess={handleReauthSuccess} onCancel={handleReauthCancel}/>