frontend: one simple sign-up form
Replace the Passkey / Password / SSO tabs, identity-type picker and verification-method picker with a single form: handle (with the .fae.mom suffix and live availability), email, password with a show toggle, an invite code only when describeServer requires one, and the Turnstile widget only when it advertises turnstileSiteKey. Accounts are always did:plc and verified by email. SSO providers render as "Sign up with <provider>" buttons above the form, and migrating is a small link. After the email code (or link) is confirmed the flow has a full-access session from confirmSignup / createSession, so it offers an optional passkey through com.atproto.server.startPasskeyRegistration and finishPasskeyRegistration before completing the OAuth request with the password via /oauth/register/complete, as before. Skipping, or a browser without WebAuthn, goes straight on. The handle check now hits this PDS (/oauth/sso/check-handle-available) instead of https://<first user domain>, which was the wrong host when user domains differ from the PDS hostname. /oauth/register-password and /oauth/register-sso render the same page. The SSO completion page loses its identity and channel pickers too. Saved registration state moves to version 2 (no did:web data); older entries are discarded. Copy lives under the new en.json "signup" keys; other locales fall back to English.
4 unmodified lines567899101112102 unmodified linesExpand all11511611711812011912212012112212312418 unmodified lines4 unmodified lines import { initI18n } from './lib/i18n' import { isLoading as i18nLoading } from 'svelte-i18n' import Toast from './components/Toast.svelte' import Login from './routes/Login.svelte' import RegisterSso from './routes/RegisterSso.svelte' import Verify from './routes/Verify.svelte' import ResetPassword from './routes/ResetPassword.svelte' import RecoverPasskey from './routes/RecoverPasskey.svelte' import RequestPasskeyRecovery from './routes/RequestPasskeyRecovery.svelte'102 unmodified linesExpand all return SsoRegisterComplete case '/register': case '/oauth/register': case '/oauth/register-password': return Register case '/oauth/register-sso': return RegisterSso return Register case '/act-as': return ActAs case '/migrate': return Migration18 unmodified lines12345678910111213141516171819202122232425262728293031323334353637383940<script lang="ts"> import { _ } from '../lib/i18n' import { getFullUrl } from '../lib/router.svelte' import { routes } from '../lib/types/routes'
interface Props { active: 'passkey' | 'password' | 'sso' ssoAvailable?: boolean oauthRequestUri?: string | null }
let { active, ssoAvailable = true, oauthRequestUri = null }: Props = $props()
function buildOauthUrl(route: string): string { const url = getFullUrl(route) return oauthRequestUri ? `${url}?request_uri=${encodeURIComponent(oauthRequestUri)}` : url }
const passkeyUrl = $derived(buildOauthUrl(routes.oauthRegister)) const passwordUrl = $derived(buildOauthUrl(routes.oauthRegisterPassword)) const ssoUrl = $derived(buildOauthUrl(routes.oauthRegisterSso))</script>
<div class="account-type-switcher"> <a href={passkeyUrl} class="switcher-option" class:active={active === 'passkey'}> {$_('register.passkeyAccount')} </a> <a href={passwordUrl} class="switcher-option" class:active={active === 'password'}> {$_('register.passwordAccount')} </a> {#if ssoAvailable || active === 'sso'} <a href={ssoUrl} class="switcher-option" class:active={active === 'sso'}> {$_('register.ssoAccount')} </a> {:else} <span class="switcher-option disabled"> {$_('register.ssoAccount')} </span> {/if}</div>123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778<script lang="ts"> import { _ } from '../lib/i18n'
interface Props { didType: 'plc' | 'web' | 'web-external' externalDid: string disabled: boolean selfHostedDidWebEnabled: boolean defaultDomain: string onDidTypeChange: (value: 'plc' | 'web' | 'web-external') => void onExternalDidChange: (value: string) => void }
let { didType, externalDid, disabled, selfHostedDidWebEnabled, defaultDomain, onDidTypeChange, onExternalDidChange, }: Props = $props()
function extractDomain(did: string): string { return did.replace(/^did:web:/, '').split(':')[0] || 'yourdomain.com' }</script>
<fieldset class="identity-section"> <legend>{$_('registerPasskey.identityType')}</legend> <div class="radio-group"> <label class="radio-label"> <input type="radio" name="didType" value="plc" checked={didType === 'plc'} onchange={() => onDidTypeChange('plc')} {disabled} /> <span class="radio-content"> <strong>{$_('registerPasskey.didPlcRecommended')}</strong> <span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span> </span> </label> <label class="radio-label" class:disabled={!selfHostedDidWebEnabled}> <input type="radio" name="didType" value="web" checked={didType === 'web'} onchange={() => onDidTypeChange('web')} disabled={disabled || !selfHostedDidWebEnabled} /> <span class="radio-content"> <strong>{$_('registerPasskey.didWeb')}</strong> {#if !selfHostedDidWebEnabled} <span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span> {:else} <span class="radio-hint">{$_('registerPasskey.didWebHint')}</span> {/if} </span> </label> <label class="radio-label"> <input type="radio" name="didType" value="web-external" checked={didType === 'web-external'} onchange={() => onDidTypeChange('web-external')} {disabled} /> <span class="radio-content"> <strong>{$_('registerPasskey.didWebBYOD')}</strong> <span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span> </span> </label> </div></fieldset>
{#if didType === 'web'} <div class="warning-box"> <strong>{$_('registerPasskey.didWebWarningTitle')}</strong> <ul> <li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${defaultDomain}</code>` } })}</li> <li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li> <li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li> <li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li> </ul> </div>{/if}
{#if didType === 'web-external'} <div> <label for="external-did">{$_('registerPasskey.externalDid')}</label> <input id="external-did" type="text" value={externalDid} oninput={(e) => onExternalDidChange(e.currentTarget.value)} placeholder={$_('registerPasskey.externalDidPlaceholder')} {disabled} required /> <p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{externalDid ? extractDomain(externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p> </div>{/if}1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980<script lang="ts"> import { _ } from '../lib/i18n' import SsoIcon from './SsoIcon.svelte' import { getOAuthRequestUri, getRequestUriFromUrl } from '../lib/oauth'
interface SsoProvider { provider: string name: string icon: string }
interface Props { disabled?: boolean onError: (message: string) => void }
let { disabled = false, onError }: Props = $props()
let providers = $state<SsoProvider[]>([]) let initiating = $state<string | null>(null)
$effect(() => { fetch('/oauth/sso/providers') .then((response) => (response.ok ? response.json() : { providers: [] })) .then((data: { providers?: SsoProvider[] }) => { providers = (data.providers ?? []).toSorted((a, b) => a.name.localeCompare(b.name)) }) .catch(() => { providers = [] }) })
async function restartWithFreshRequest() { const url = new URL(window.location.href) url.searchParams.set('request_uri', await getOAuthRequestUri('create')) window.location.href = url.toString() }
async function start(provider: string) { initiating = provider try { const response = await fetch('/oauth/sso/initiate', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ provider, action: 'register', request_uri: getRequestUriFromUrl() }), }) const data = await response.json() if (response.ok && data.redirect_url) { window.location.href = data.redirect_url return } if (!response.ok) { await restartWithFreshRequest() return } onError($_('signup.errors.generic')) } catch (err) { onError(err instanceof Error ? err.message : $_('signup.errors.generic')) } initiating = null }</script>
{#if providers.length > 0} <div class="sso-stack"> {#each providers as provider (provider.provider)} <button type="button" class="btn-pop alt" onclick={() => start(provider.provider)} disabled={disabled || initiating !== null} aria-busy={initiating === provider.provider} > <SsoIcon provider={provider.provider} size={20} /> {$_('signup.ssoButton', { values: { provider: provider.name } })} </button> {/each} </div> <p class="signup-or">{$_('signup.or')}</p>{/if}1265 unmodified linesExpand all126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481265 unmodified linesExpand all "success": { "desc": "Your account has been successfully restored to this PDS." } } }, "signup": { "kicker": "hello ✦", "title": "Make your account", "forClient": "then we'll send you back to {client}", "ssoButton": "Sign up with {provider}", "or": "or", "handle": "Your handle", "handlePlaceholder": "yourname", "handleHint": "this is your @name everywhere: wisp, Bluesky, Tangled", "handleChecking": "checking…", "handleAvailable": "@{handle} is free", "handleTaken": "@{handle} is taken, try another", "email": "Email", "emailPlaceholder": "you@example.com", "emailHint": "we'll send a code to make sure it's you", "password": "Password", "passwordHint": "8 or more characters. it also logs you into Bluesky", "showPassword": "show", "hidePassword": "hide", "showPasswordLabel": "Show password", "hidePasswordLabel": "Hide password", "inviteCode": "Invite code", "invitePlaceholder": "pds-wisp-place-xxxxx-xxxxx", "submit": "Make my account", "submitting": "Making your account…", "migrate": "Already have an account?", "migrateLink": "Migrate it", "cancel": "never mind, take me back", "redirecting": "Taking you back…", "errors": { "handleRequired": "Pick a handle first", "handleNoDots": "Just the first part, no dots. You can add your own domain later.", "handleTaken": "That handle is taken, try another", "handleInvalid": "Handles can use letters, numbers and hyphens", "emailRequired": "We need an email to send your code to", "emailInvalid": "That email doesn't look right", "passwordRequired": "Pick a password", "passwordLength": "Your password needs at least 8 characters", "inviteRequired": "This server needs an invite code", "inviteInvalid": "That invite code didn't work", "captchaRequired": "Please complete the human check", "captchaFailed": "The human check failed, please try it again", "generic": "Something went wrong, please try again" }, "verify": { "kicker": "almost there", "title": "Check your email", "body": "We sent a code to {email}. Paste it below, or click the link in the email and this page will move on by itself.", "bodyNoEmail": "We sent a code to your email. Paste it below, or click the link in the email and this page will move on by itself.", "code": "Verification code", "codeHint": "copy the whole code, dashes included", "submit": "Verify", "submitting": "Checking…", "resend": "Send a new code", "resending": "Sending…", "resent": "New code sent. Check your inbox.", "resendFailed": "Couldn't send a new code, please try again" }, "passkey": { "kicker": "one more thing", "title": "Add a passkey?", "body": "Next time, sign in with your fingerprint, face or device PIN. Your password keeps working everywhere.", "name": "Name it", "nameOptional": "(optional)", "namePlaceholder": "laptop, phone…", "add": "Add a passkey", "adding": "Waiting for your device…", "skip": "Not now", "cancelled": "Passkey setup was cancelled. Try again, or skip it for now." }, "sso": { "submit": "Create my account", "errorKicker": "oh no", "errorTitle": "That link ran out", "appPasswordKicker": "keep this safe" } }}1223456789101191012131213141516171819141516171819232420212223242526272829302627282930313233343536373839313233344142353637383944454647484950515253404142435657584445464761626348495051526653546955567172737475767757585960616263646566818283846768698770717273743 unmodified lines78798081991001018283848586878889901049110610710810911011111211311411592939495969711912012112212312412512612712812913013113213313413513613798991391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761001011781021031801811821041051061071081091101111121131141151861871881891901911921161171181191201951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262271211221232312322331241251261271281291302412422432441311321331342481351361372511382531392551401411422581431441451462631471481492671501511521532712722732742752761541551561571581592831601611622872882892902911631641652932942952962972982993001661671681691701711 unmodified line173174175176310311312313314315177178179180181182183184185186187188189190191192193194195196197198199200320321322201202203204205206207208209324325326327328329210211212213214215216331332333217218335336337338339340341342343344345219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262347263264265266267268269270271272273274275276277278279280281349350351352353354355356357358359360361362363364365366367368369370371372282283284285286287288289290291292293294295296297298299300301302303304305306307308309374375376377378379380381382383384385386310311312313314315316317318319320321322323324325326327328329330388389390391392393394395396397398399331332333334335336337338339340401402403404405406407408409410411412413414341342343344416417418419420421422423424345346347348349350351352353354426427428429430431432433434435436437438439440441442443444445446447448449450451452453355356357358455359360361362363364365366367368369370371372373374457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504375376377378379380381<script lang="ts"> import { tick } from 'svelte' import { navigate, routes, getFullUrl, getCurrentPath } from '../lib/router.svelte' import { navigate, routes, getFullUrl } from '../lib/router.svelte' import { api } from '../lib/api' import { _ } from '../lib/i18n' import { createRegistrationFlow, restoreRegistrationFlow, requirementsFrom, validateSignup, VerificationStep, KeyChoiceStep, DidDocStep, PasskeyOfferStep, } from '../lib/registration' import type { RegistrationMode } from '../lib/registration' import AppPasswordStep from '../components/migration/AppPasswordStep.svelte' import PasskeySetupStep from '../components/migration/PasskeySetupStep.svelte' import { performPasskeyRegistration, PasskeyCancelledError } from '../lib/flows/perform-passkey-registration' import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' import HandleInput from '../components/HandleInput.svelte' import IdentityTypeSection from '../components/IdentityTypeSection.svelte' import CommsChannelPicker from '../components/CommsChannelPicker.svelte' import type { RegistrationFlow, SignupError } from '../lib/registration' import type { ServerDescription } from '../lib/types/api' import SsoSignupButtons from '../components/SsoSignupButtons.svelte' import TurnstileWidget from '../components/TurnstileWidget.svelte' import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth'
const mode: RegistrationMode = getCurrentPath().includes('register-password') ? 'password' : 'passkey' const isPasskey = mode === 'passkey' const FIELD_FOR_ERROR: Record<SignupError, string> = { handleRequired: 'handle', handleNoDots: 'handle', handleTaken: 'handle', emailRequired: 'email', passwordRequired: 'password', passwordLength: 'password', inviteRequired: 'invite-code', captchaRequired: 'submit', }
let serverInfo = $state<{ availableUserDomains: string[] inviteCodeRequired: boolean availableCommsChannels?: import('../lib/types/api').VerificationChannel[] selfHostedDidWebEnabled?: boolean turnstileSiteKey?: string } | null>(null) let loadingServerInfo = $state(true) let serverInfoLoaded = false let ssoAvailable = $state(false)
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) let passkeyName = $state('') let confirmPassword = $state('') let serverInfo = $state<ServerDescription | null>(null) let loading = $state(true) let flow = $state<RegistrationFlow | null>(null) let clientName = $state<string | null>(null) let selectedDomain = $state('') let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null let showPassword = $state(false) let invalidField = $state<string | null>(null) let captchaResetKey = $state(0) let completing = false
$effect(() => { if (!flow) return const handle = flow.info.handle if (checkHandleTimeout) { clearTimeout(checkHandleTimeout) } if (handle.length >= 3 && !handle.includes('.')) { checkHandleTimeout = setTimeout(() => flow?.checkHandleAvailability(handle), 400) } }) const domains = $derived(serverInfo?.availableUserDomains ?? []) const requirements = $derived(requirementsFrom(serverInfo))
$effect(() => { if (!serverInfoLoaded) { serverInfoLoaded = true ensureRequestUri().then((requestUri) => { ensureRequestUri() .then((requestUri) => { if (!requestUri) return loadServerInfo() fetchClientName() checkSsoAvailable() }).catch((err) => { fetchClientName(requestUri) }) .catch((err) => { console.error('Failed to ensure OAuth request URI:', err) }) } })
async function checkSsoAvailable() { async function loadServerInfo() { try { const response = await fetch('/oauth/sso/providers') if (response.ok) { const data = await response.json() ssoAvailable = (data.providers?.length ?? 0) > 0 } } catch { ssoAvailable = false serverInfo = await api.describeServer() const domain = serverInfo.availableUserDomains?.[0] ?? '' flow = restoreRegistrationFlow(domain) ?? createRegistrationFlow(domain) } catch (e) { console.error('Failed to load server info:', e) } finally { loading = false } }
async function fetchClientName() { const requestUri = getRequestUriFromUrl() if (!requestUri) return
async function fetchClientName(requestUri: string) { try { const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, { headers: { 'Accept': 'application/json' } headers: { 'Accept': 'application/json' }, }) if (response.ok) { const data = await response.json() clientName = data.client_name || null3 unmodified lines } }
$effect(() => { if (flow?.state.step === 'redirect-to-dashboard') { completeOAuthRegistration() } if (!flow) return const handle = flow.info.handle void flow.state.selectedDomain flow.state.handleAvailable = null if (handle.trim().length < 3 || handle.includes('.')) return const timer = setTimeout(() => flow?.checkHandleAvailability(handle), 400) return () => clearTimeout(timer) })
let creatingStarted = false $effect(() => { if (flow?.state.step === 'info') creatingStarted = false }) $effect(() => { if (flow?.state.step === 'creating' && !creatingStarted) { creatingStarted = true if (isPasskey) { flow.createPasskeyAccount() } else { flow.createPasswordAccount() } if (flow?.state.step === 'redirect-to-dashboard' && !completing) { completing = true completeOAuthRegistration() } })
async function loadServerInfo() { try { const restored = restoreRegistrationFlow() if (restored && restored.state.mode === mode) { flow = restored serverInfo = await api.describeServer() } else { serverInfo = await api.describeServer() const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname flow = createRegistrationFlow(mode, hostname) } selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname if (flow) flow.setSelectedDomain(selectedDomain) } catch (e) { console.error('Failed to load server info:', e) } finally { loadingServerInfo = false } } const fullHandle = $derived(flow?.info.handle.trim() ? flow.getFullHandle() : '')
function validateInfoStep(): string | null { if (!flow) return 'Flow not initialized' const info = flow.info if (!info.handle.trim()) return $_('registerPasskey.errors.handleRequired') if (info.handle.includes('.')) return $_('registerPasskey.errors.handleNoDots') if (!isPasskey) { if (!info.password) return $_('register.validation.passwordRequired') if (info.password.length < 8) return $_('register.validation.passwordLength') if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch') } if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) { return $_('registerPasskey.errors.inviteRequired') } if (serverInfo?.turnstileSiteKey && !flow.state.captchaToken) { return $_('captcha.required') } if (info.didType === 'web-external') { if (!info.externalDid?.trim()) return $_('registerPasskey.errors.externalDidRequired') if (!info.externalDid.trim().startsWith('did:web:')) return $_('registerPasskey.errors.externalDidFormat') } switch (info.verificationChannel) { case 'email': if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired') break case 'discord': if (!info.discordUsername?.trim()) return $_('registerPasskey.errors.discordRequired') break case 'telegram': if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired') break case 'signal': if (!info.signalUsername?.trim()) return $_('registerPasskey.errors.signalRequired') break } return null }
async function handleInfoSubmit(e: Event) { async function handleSubmit(e: Event) { e.preventDefault() if (!flow) return if (!flow || flow.state.submitting) return
const validationError = validateInfoStep() if (validationError) { flow.setError(validationError) const problem = validateSignup(flow.info, requirements, { captchaToken: flow.state.captchaToken, handleAvailable: flow.state.handleAvailable, }) if (problem) { flow.setError($_(problem === 'captchaRequired' ? 'captcha.required' : `signup.errors.${problem}`)) invalidField = FIELD_FOR_ERROR[problem] await tick() document.getElementById(invalidField)?.focus() return }
if (isPasskey && !window.PublicKeyCredential) { flow.setError($_('registerPasskey.errors.passkeysNotSupported')) return }
flow.clearError() flow.proceedFromInfo() invalidField = null await flow.createAccount() if (flow.state.error && requirements.captchaRequired) captchaResetKey++ }
async function handlePasskeyRegistration() { if (!flow || !flow.account) return
const { did, setupToken } = flow.account if (!setupToken) return
flow.setSubmitting(true) flow.clearError()
try { const result = await performPasskeyRegistration({ startRegistration: () => api.startPasskeyRegistrationForSetup( did, setupToken, passkeyName || undefined, ), completeSetup: (credential, name) => api.completePasskeySetup( did, setupToken, credential, name, ), }, passkeyName || undefined)
flow.setPasskeyComplete(result.appPassword, result.appPasswordName) } catch (err) { if (err instanceof PasskeyCancelledError || (err instanceof DOMException && err.name === 'NotAllowedError')) { flow.setError($_('registerPasskey.errors.passkeyCancelled')) } else if (err instanceof Error) { flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed')) } else { flow.setError($_('registerPasskey.errors.passkeyFailed')) } } finally { flow.setSubmitting(false) } }
async function completeOAuthRegistration() { const requestUri = getRequestUriFromUrl() if (!requestUri || !flow?.account) { if (!isPasskey && flow) { await flow.finalizeSession() } navigate(routes.dashboard) return }
try { const response = await fetch('/oauth/register/complete', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ request_uri: requestUri, did: flow.account.did, app_password: flow.account.appPassword || (isPasskey ? undefined : flow.info.password), app_password: flow.info.password, }), })
const data = await response.json()
if (!response.ok) { flow.setError(data.error_description || data.error || $_('common.error')) flow.setError(data.error_description || data.error || $_('signup.errors.generic')) return }
if (data.redirect_uri) { window.location.href = data.redirect_uri return }
navigate(routes.dashboard) } catch (err) { console.error('OAuth registration completion failed:', err) flow.setError(err instanceof Error ? err.message : $_('common.error')) flow.setError(err instanceof Error ? err.message : $_('signup.errors.generic')) } }
let fullHandle = $derived(() => { if (!flow?.info.handle.trim()) return '' if (flow.info.handle.includes('.')) return flow.info.handle.trim() return selectedDomain ? `${flow.info.handle.trim()}.${selectedDomain}` : flow.info.handle.trim() })
async function handleCancel() { const requestUri = getRequestUriFromUrl() if (!requestUri) { window.history.back() return }
try { const response = await fetch('/oauth/authorize/deny', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ request_uri: requestUri }) headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ request_uri: requestUri }), })
if (!response.ok) { window.history.back() return }
const data = await response.json() if (data.redirect_uri) { const data = response.ok ? await response.json() : null if (data?.redirect_uri) { window.location.href = data.redirect_uri } else { window.history.back() }1 unmodified line window.history.back() } }
function goToLogin() { const requestUri = getRequestUriFromUrl() if (requestUri) { navigate(routes.oauthLogin, { params: { request_uri: requestUri } }) } else { navigate(routes.login) function touched(field: string) { if (invalidField !== field) return invalidField = null flow?.clearError() }
function handleState(f: RegistrationFlow): { text: string; tone: '' | 'good' | 'bad' } { if (f.info.handle.includes('.')) return { text: $_('signup.errors.handleNoDots'), tone: 'bad' } if (f.state.checkingHandle) return { text: $_('signup.handleChecking'), tone: '' } if (f.state.handleAvailable === false) { return { text: f.state.handleReason && f.state.handleReason !== 'Handle is already taken' ? f.state.handleReason : $_('signup.handleTaken', { values: { handle: fullHandle } }), tone: 'bad', } } if (f.state.handleAvailable === true) { return { text: $_('signup.handleAvailable', { values: { handle: fullHandle } }), tone: 'good' } } return { text: $_('signup.handleHint'), tone: '' } }</script>
<div class="page"> {#if loadingServerInfo} <div class="loading"></div><div class="signup-page"> {#if loading} <div class="loading" aria-busy="true"></div> {:else if flow} <div class="signup-card"> {#if flow.state.step === 'info'} {@const hint = handleState(flow)} <div class="panel"> <header class="signup-heading"> <header class="page-header"> <h1>{isPasskey ? $_('oauth.register.title') : $_('register.title')}</h1> {#if clientName} <p class="subtitle">{$_('oauth.register.subtitle')} <strong>{clientName}</strong></p> {/if} </header> <span class="kicker">{$_('signup.kicker')}</span> <h1 class="signup-title">{$_('signup.title')}</h1> {#if clientName} <p class="signup-sub">{$_('signup.forClient', { values: { client: clientName } })}</p> {/if} </header>
{#if flow.state.error} <div class="message error">{flow.state.error}</div> {/if} <SsoSignupButtons disabled={flow.state.submitting} onError={(m) => flow?.setError(m)} />
{#if flow.state.step === 'info'} <div class="migrate-callout"> <div class="migrate-icon">↗</div> <div class="migrate-content"> <strong>{$_('register.migrateTitle')}</strong> <p>{$_('register.migrateDescription')}</p> <a href={getFullUrl(routes.migrate)} class="migrate-link"> {$_('register.migrateLink')} → </a> </div> </div> <form class="signup-form" onsubmit={handleSubmit} novalidate> <div> <label class="signup-label" for="handle">{$_('signup.handle')}</label> <div class="wisp-field" class:bad={hint.tone === 'bad' || invalidField === 'handle'} class:good={hint.tone === 'good'} > <span class="affix at" aria-hidden="true">@</span> <input id="handle" type="text" inputmode="url" autocapitalize="none" autocorrect="off" spellcheck="false" autocomplete="off" placeholder={$_('signup.handlePlaceholder')} bind:value={flow.info.handle} oninput={() => touched('handle')} disabled={flow.state.submitting} required aria-describedby="handle-hint" aria-invalid={hint.tone === 'bad' || invalidField === 'handle'} /> {#if domains.length > 1} <select class="affix" aria-label={$_('signup.handle')} value={flow.state.selectedDomain} onchange={(e) => flow?.setSelectedDomain((e.target as HTMLSelectElement).value)} disabled={flow.state.submitting} > {#each domains as domain (domain)} <option value={domain}>.{domain}</option> {/each} </select> {:else if domains.length === 1} <span class="affix domain">.{domains[0]}</span> {/if} </div> <p class="signup-hint {hint.tone}" id="handle-hint" aria-live="polite">{hint.text}</p> </div>
<AccountTypeSwitcher active={mode} {ssoAvailable} oauthRequestUri={getRequestUriFromUrl()} /> <div> <label class="signup-label" for="email">{$_('signup.email')}</label> <div class="wisp-field" class:bad={invalidField === 'email'}> <input id="email" type="email" autocomplete="email" placeholder={$_('signup.emailPlaceholder')} bind:value={flow.info.email} oninput={() => touched('email')} disabled={flow.state.submitting} required aria-describedby="email-hint" aria-invalid={invalidField === 'email'} /> </div> <p class="signup-hint" id="email-hint">{$_('signup.emailHint')}</p> </div>
<form class="register-form" onsubmit={handleInfoSubmit}> <div> <label for="handle">{$_('register.handle')}</label> <HandleInput value={flow.info.handle} domains={serverInfo?.availableUserDomains ?? []} {selectedDomain} placeholder={$_('register.handlePlaceholder')} disabled={flow.state.submitting} onInput={(v) => { flow!.info.handle = v }} onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }} /> {#if flow.info.handle.includes('.')} <p class="hint warning">{$_('register.handleDotWarning')}</p> {:else if flow.state.checkingHandle} <p class="hint">{$_('common.checking')}</p> {:else if flow.state.handleAvailable === false} <p class="hint warning">{$_('register.handleTaken')}</p> {:else if flow.state.handleAvailable === true && fullHandle()} <p class="hint success">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> {:else if fullHandle()} <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> {/if} </div> <div> <label class="signup-label" for="password">{$_('signup.password')}</label> <div class="wisp-field" class:bad={invalidField === 'password'}> <input id="password" type={showPassword ? 'text' : 'password'} autocomplete="new-password" minlength="8" bind:value={flow.info.password} oninput={() => touched('password')} disabled={flow.state.submitting} required aria-describedby="password-hint" aria-invalid={invalidField === 'password'} /> <button type="button" class="reveal" aria-pressed={showPassword} aria-label={showPassword ? $_('signup.hidePasswordLabel') : $_('signup.showPasswordLabel')} onclick={() => (showPassword = !showPassword)} > {showPassword ? $_('signup.hidePassword') : $_('signup.showPassword')} </button> </div> <p class="signup-hint" id="password-hint">{$_('signup.passwordHint')}</p> </div>
{#if !isPasskey} <div> <label for="password">{$_('register.password')}</label> <input id="password" type="password" bind:value={flow.info.password} placeholder={$_('register.passwordPlaceholder')} disabled={flow.state.submitting} required minlength="8" /> </div> {#if requirements.inviteCodeRequired} <div> <label class="signup-label" for="invite-code">{$_('signup.inviteCode')}</label> <div class="wisp-field" class:bad={invalidField === 'invite-code'}> <input id="invite-code" type="text" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder={$_('signup.invitePlaceholder')} bind:value={flow.info.inviteCode} oninput={() => touched('invite-code')} disabled={flow.state.submitting} required aria-invalid={invalidField === 'invite-code'} /> </div> </div> {/if}
<div> <label for="confirm-password">{$_('register.confirmPassword')}</label> <input id="confirm-password" type="password" bind:value={confirmPassword} placeholder={$_('register.confirmPasswordPlaceholder')} disabled={flow.state.submitting} required /> </div> {/if} {#if serverInfo?.turnstileSiteKey} <div class="signup-turnstile"> <TurnstileWidget siteKey={serverInfo.turnstileSiteKey} resetKey={captchaResetKey} onToken={(token) => flow?.setCaptchaToken(token)} /> </div> {/if}
<CommsChannelPicker channel={flow.info.verificationChannel} email={flow.info.email} discordUsername={flow.info.discordUsername ?? ''} telegramUsername={flow.info.telegramUsername ?? ''} signalUsername={flow.info.signalUsername ?? ''} availableChannels={serverInfo?.availableCommsChannels ?? ['email']} disabled={flow.state.submitting} onChannelChange={(ch) => { if (flow) flow.info.verificationChannel = ch }} onEmailChange={(v) => { if (flow) flow.info.email = v }} onDiscordChange={(v) => { if (flow) flow.info.discordUsername = v }} onTelegramChange={(v) => { if (flow) flow.info.telegramUsername = v }} onSignalChange={(v) => { if (flow) flow.info.signalUsername = v }} /> {#if flow.state.error} <p class="wisp-error" role="alert">{flow.state.error}</p> {/if}
<IdentityTypeSection didType={flow.info.didType} externalDid={flow.info.externalDid ?? ''} disabled={flow.state.submitting} selfHostedDidWebEnabled={serverInfo?.selfHostedDidWebEnabled !== false} defaultDomain={serverInfo?.availableUserDomains?.[0] || 'this-pds.com'} onDidTypeChange={(v) => { if (flow) flow.info.didType = v }} onExternalDidChange={(v) => { if (flow) flow.info.externalDid = v }} /> <button id="submit" type="submit" class="btn-pop" disabled={flow.state.submitting || (requirements.captchaRequired && !flow.state.captchaToken)} > {flow.state.submitting ? $_('signup.submitting') : $_('signup.submit')} </button> </form>
{#if serverInfo?.inviteCodeRequired} <div> <label for="invite-code">{$_('register.inviteCode')}</label> <input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder={$_('register.inviteCodePlaceholder')} disabled={flow.state.submitting} required /> </div> {/if}
{#if serverInfo?.turnstileSiteKey} <TurnstileWidget siteKey={serverInfo.turnstileSiteKey} onToken={(token) => flow?.setCaptchaToken(token)} /> {/if}
<div class="form-actions"> <button type="button" class="secondary" onclick={handleCancel} disabled={flow.state.submitting}> {$_('common.cancel')} </button> <button type="submit" class="primary" disabled={flow.state.submitting || flow.state.handleAvailable === false || flow.state.checkingHandle || (!!serverInfo?.turnstileSiteKey && !flow.state.captchaToken)}> {flow.state.submitting ? $_('common.loading') : $_('common.continue')} </button> <p class="signup-fine"> {$_('signup.migrate')} <a href={getFullUrl(routes.migrate)}>{$_('signup.migrateLink')}</a> </p> </div> </form> {:else if flow.state.step === 'verify'} <VerificationStep {flow} /> {:else if flow.state.step === 'passkey'} <PasskeyOfferStep {flow} /> {:else if flow.state.step === 'redirect-to-dashboard'} <div class="panel signup-going" aria-live="polite"> {#if flow.state.error} <p class="wisp-error" role="alert">{flow.state.error}</p> {:else} <div class="dot-row" aria-hidden="true"><span></span><span></span><span></span></div> <p>{$_('signup.redirecting')}</p> {/if} </div> {/if} </div>
{:else if flow.state.step === 'key-choice'} <KeyChoiceStep {flow} />
{:else if flow.state.step === 'initial-did-doc'} <DidDocStep {flow} type="initial" onConfirm={() => isPasskey ? flow?.createPasskeyAccount() : flow?.createPasswordAccount()} onBack={() => flow?.goBack()} />
{:else if flow.state.step === 'creating'} <div class="loading"> <p>{isPasskey ? $_('registerPasskey.creatingAccount') : $_('common.creating')}</p> </div>
{:else if isPasskey && flow.state.step === 'passkey'} <PasskeySetupStep {passkeyName} loading={flow.state.submitting} error={flow.state.error} onPasskeyNameChange={(n) => passkeyName = n} onRegister={handlePasskeyRegistration} />
{:else if isPasskey && flow.state.step === 'app-password'} <AppPasswordStep appPassword={flow.account?.appPassword ?? ''} appPasswordName={flow.account?.appPasswordName ?? ''} loading={flow.state.submitting} onContinue={() => flow!.proceedFromAppPassword()} />
{:else if flow.state.step === 'verify'} <VerificationStep {flow} />
{:else if flow.state.step === 'updated-did-doc'} <DidDocStep {flow} type="updated" onConfirm={() => flow?.activateAccount()} />
{:else if isPasskey && flow.state.step === 'activating'} <div class="loading"> <p>{$_('registerPasskey.activatingAccount')}</p> </div>
{:else if !isPasskey && flow.state.step === 'redirect-to-dashboard'} <div class="loading"> <p>{$_('register.redirecting')}</p> </div> {#if flow.state.step === 'info'} <button type="button" class="text-link signup-leave" onclick={handleCancel} disabled={flow.state.submitting}> {$_('signup.cancel')} </button> {/if} {/if}</div>