refactor(frontend): delete OAuthRegister and OAuthSsoRegister routes

This commit is contained in:
Lewis
2026-03-19 14:26:26 +00:00
committed by Tangled
parent 5b503c3f7f
commit b990c239e6
2 changed files with 0 additions and 1093 deletions
-585
View File
@@ -1,585 +0,0 @@
<script lang="ts">
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api } from '../lib/api'
import { _ } from '../lib/i18n'
import {
createRegistrationFlow,
restoreRegistrationFlow,
VerificationStep,
KeyChoiceStep,
DidDocStep,
AppPasswordStep,
} from '../lib/registration'
import {
prepareCreationOptions,
serializeAttestationResponse,
type WebAuthnCreationOptionsResponse,
} from '../lib/webauthn'
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
import HandleInput from '../components/HandleInput.svelte'
let serverInfo = $state<{
availableUserDomains: string[]
inviteCodeRequired: boolean
availableCommsChannels?: string[]
selfHostedDidWebEnabled?: boolean
} | 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 clientName = $state<string | null>(null)
let selectedDomain = $state('')
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get('request_uri')
}
$effect(() => {
if (!serverInfoLoaded) {
serverInfoLoaded = true
loadServerInfo()
fetchClientName()
checkSsoAvailable()
}
})
async function checkSsoAvailable() {
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
}
}
async function fetchClientName() {
const requestUri = getRequestUri()
if (!requestUri) return
try {
const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, {
headers: { 'Accept': 'application/json' }
})
if (response.ok) {
const data = await response.json()
clientName = data.client_name || null
}
} catch {
clientName = null
}
}
$effect(() => {
if (flow?.state.step === 'redirect-to-dashboard') {
completeOAuthRegistration()
}
})
let creatingStarted = false
$effect(() => {
if (flow?.state.step === 'creating' && !creatingStarted) {
creatingStarted = true
flow.createPasskeyAccount()
}
})
async function loadServerInfo() {
try {
const restored = restoreRegistrationFlow()
if (restored && restored.state.mode === 'passkey') {
flow = restored
serverInfo = await api.describeServer()
} else {
serverInfo = await api.describeServer()
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
flow = createRegistrationFlow('passkey', 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
}
}
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 (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
return $_('registerPasskey.errors.inviteRequired')
}
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) {
e.preventDefault()
if (!flow) return
const validationError = validateInfoStep()
if (validationError) {
flow.setError(validationError)
return
}
if (!window.PublicKeyCredential) {
flow.setError($_('registerPasskey.errors.passkeysNotSupported'))
return
}
flow.clearError()
flow.proceedFromInfo()
}
async function handlePasskeyRegistration() {
if (!flow || !flow.account) return
flow.setSubmitting(true)
flow.clearError()
try {
const { options } = await api.startPasskeyRegistrationForSetup(
flow.account.did,
flow.account.setupToken!,
passkeyName || undefined
)
const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse)
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions
})
if (!credential) {
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
flow.setSubmitting(false)
return
}
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
const result = await api.completePasskeySetup(
flow.account.did,
flow.account.setupToken!,
credentialResponse,
passkeyName || undefined
)
flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
} catch (err) {
if (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 = getRequestUri()
if (!requestUri || !flow?.account) {
navigate(routes.dashboard)
return
}
try {
const response = await fetch('/oauth/register/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
request_uri: requestUri,
did: flow.account.did,
app_password: flow.account.appPassword,
}),
})
const data = await response.json()
if (!response.ok) {
flow.setError(data.error_description || data.error || $_('common.error'))
return
}
if (data.redirect_uri) {
window.location.href = data.redirect_uri
return
}
navigate(routes.dashboard)
} catch {
flow.setError($_('common.error'))
}
}
function isChannelAvailable(ch: string): boolean {
const available = serverInfo?.availableCommsChannels ?? ['email']
return available.includes(ch)
}
function channelLabel(ch: string): string {
switch (ch) {
case 'email':
return $_('register.email')
case 'discord':
return $_('register.discord')
case 'telegram':
return $_('register.telegram')
case 'signal':
return $_('register.signal')
default:
return ch
}
}
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 = getRequestUri()
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 })
})
const data = await response.json()
if (data.redirect_uri) {
window.location.href = data.redirect_uri
}
} catch {
window.history.back()
}
}
function goToLogin() {
const requestUri = getRequestUri()
if (requestUri) {
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
} else {
navigate(routes.login)
}
}
</script>
<div class="oauth-register-container">
{#if loadingServerInfo}
<div class="loading"></div>
{:else if flow}
<header class="page-header">
<h1>{$_('oauth.register.title')}</h1>
<p class="subtitle">
{#if clientName}
{$_('oauth.register.subtitle')} <strong>{clientName}</strong>
{:else}
{$_('oauth.register.subtitleGeneric')}
{/if}
</p>
</header>
{#if flow.state.error}
<div class="error">{flow.state.error}</div>
{/if}
{#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>
<AccountTypeSwitcher active="passkey" {ssoAvailable} oauthRequestUri={getRequestUri()} />
<div class="split-layout">
<div class="form-section">
<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 fullHandle()}
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
{/if}
</div>
<fieldset>
<legend>{$_('register.contactMethod')}</legend>
<div class="contact-fields">
<div class="field">
<label for="verification-channel">{$_('register.verificationMethod')}</label>
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
<option value="email">{channelLabel('email')}</option>
{#if isChannelAvailable('discord')}
<option value="discord">{channelLabel('discord')}</option>
{/if}
{#if isChannelAvailable('telegram')}
<option value="telegram">{channelLabel('telegram')}</option>
{/if}
{#if isChannelAvailable('signal')}
<option value="signal">{channelLabel('signal')}</option>
{/if}
</select>
</div>
{#if flow.info.verificationChannel === 'email'}
<div class="field">
<label for="email">{$_('register.emailAddress')}</label>
<input
id="email"
type="email"
bind:value={flow.info.email}
placeholder={$_('register.emailPlaceholder')}
disabled={flow.state.submitting}
required
/>
</div>
{:else if flow.info.verificationChannel === 'discord'}
<div class="field">
<label for="discord-username">{$_('register.discordUsername')}</label>
<input
id="discord-username"
type="text"
bind:value={flow.info.discordUsername}
placeholder={$_('register.discordUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
</div>
{:else if flow.info.verificationChannel === 'telegram'}
<div class="field">
<label for="telegram-username">{$_('register.telegramUsername')}</label>
<input
id="telegram-username"
type="text"
bind:value={flow.info.telegramUsername}
placeholder={$_('register.telegramUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
</div>
{:else if flow.info.verificationChannel === 'signal'}
<div class="field">
<label for="signal-number">{$_('register.signalUsername')}</label>
<input
id="signal-number"
type="tel"
bind:value={flow.info.signalUsername}
placeholder={$_('register.signalUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.signalUsernameHint')}</p>
</div>
{/if}
</div>
</fieldset>
<fieldset>
<legend>{$_('registerPasskey.identityType')}</legend>
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
</span>
</label>
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWeb')}</strong>
{#if serverInfo?.selfHostedDidWebEnabled === false}
<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" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
</span>
</label>
</div>
{#if flow.info.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.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</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 flow.info.didType === 'web-external'}
<div class="field">
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required />
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? flow.extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
</div>
{/if}
</fieldset>
{#if serverInfo?.inviteCodeRequired}
<div>
<label for="invite-code">{$_('register.inviteCode')} <span class="required">*</span></label>
<input
id="invite-code"
type="text"
bind:value={flow.info.inviteCode}
placeholder={$_('register.inviteCodePlaceholder')}
disabled={flow.state.submitting}
required
/>
</div>
{/if}
<div class="actions">
<button type="submit" class="primary" disabled={flow.state.submitting}>
{flow.state.submitting ? $_('common.loading') : $_('common.continue')}
</button>
</div>
<div class="secondary-actions">
<button type="button" class="link" onclick={goToLogin}>
{$_('oauth.register.haveAccount')}
</button>
<button type="button" class="link" onclick={handleCancel}>
{$_('common.cancel')}
</button>
</div>
</form>
<div class="form-links">
<p class="link-text">
{$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a>
</p>
</div>
</div>
<aside class="info-panel">
<h3>{$_('registerPasskey.infoWhyPasskey')}</h3>
<p>{$_('registerPasskey.infoWhyPasskeyDesc')}</p>
<h3>{$_('registerPasskey.infoHowItWorks')}</h3>
<p>{$_('registerPasskey.infoHowItWorksDesc')}</p>
<h3>{$_('registerPasskey.infoAppAccess')}</h3>
<p>{$_('registerPasskey.infoAppAccessDesc')}</p>
</aside>
</div>
{:else if flow.state.step === 'key-choice'}
<KeyChoiceStep {flow} />
{:else if flow.state.step === 'initial-did-doc'}
<DidDocStep {flow} type="initial" onConfirm={() => flow?.createPasskeyAccount()} onBack={() => flow?.goBack()} />
{:else if flow.state.step === 'creating'}
<div class="creating">
<p>{$_('registerPasskey.creatingAccount')}</p>
</div>
{:else if flow.state.step === 'passkey'}
<div class="passkey-step">
<h2>{$_('registerPasskey.setupPasskey')}</h2>
<p>{$_('registerPasskey.passkeyDescription')}</p>
<div class="field">
<label for="passkey-name">{$_('registerPasskey.passkeyName')}</label>
<input
id="passkey-name"
type="text"
bind:value={passkeyName}
placeholder={$_('registerPasskey.passkeyNamePlaceholder')}
disabled={flow.state.submitting}
/>
<p class="hint">{$_('registerPasskey.passkeyNameHint')}</p>
</div>
<button
type="button"
class="primary"
onclick={handlePasskeyRegistration}
disabled={flow.state.submitting}
>
{flow.state.submitting ? $_('common.loading') : $_('registerPasskey.registerPasskey')}
</button>
</div>
{:else if flow.state.step === 'app-password'}
<AppPasswordStep {flow} />
{: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 flow.state.step === 'activating'}
<div class="creating">
<p>{$_('registerPasskey.activatingAccount')}</p>
</div>
{/if}
{/if}
</div>
-508
View File
@@ -1,508 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { _ } from '../lib/i18n'
import { toast } from '../lib/toast.svelte'
import SsoIcon from '../components/SsoIcon.svelte'
import HandleInput from '../components/HandleInput.svelte'
interface PendingRegistration {
request_uri: string
provider: string
provider_user_id: string
provider_username: string | null
provider_email: string | null
provider_email_verified: boolean
}
interface CommsChannelConfig {
email: boolean
discord: boolean
telegram: boolean
signal: boolean
}
let pending = $state<PendingRegistration | null>(null)
let loading = $state(true)
let submitting = $state(false)
let error = $state<string | null>(null)
let handle = $state('')
let email = $state('')
let providerEmailOriginal = $state<string | null>(null)
let inviteCode = $state('')
let verificationChannel = $state('email')
let discordUsername = $state('')
let telegramUsername = $state('')
let signalUsername = $state('')
let handleAvailable = $state<boolean | null>(null)
let checkingHandle = $state(false)
let handleError = $state<string | null>(null)
let selectedDomain = $state('')
let didType = $state<'plc' | 'web' | 'web-external'>('plc')
let externalDid = $state('')
let serverInfo = $state<{
availableUserDomains: string[]
inviteCodeRequired: boolean
selfHostedDidWebEnabled: boolean
} | null>(null)
let commsChannels = $state<CommsChannelConfig>({
email: true,
discord: false,
telegram: false,
signal: false,
})
function getToken(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get('token')
}
function getProviderDisplayName(provider: string): string {
const names: Record<string, string> = {
github: 'GitHub',
discord: 'Discord',
google: 'Google',
gitlab: 'GitLab',
oidc: 'SSO',
}
return names[provider] || provider
}
function isChannelAvailable(ch: string): boolean {
return commsChannels[ch as keyof CommsChannelConfig] ?? false
}
function extractDomain(did: string): string {
return did.replace('did:web:', '').replace(/%3A/g, ':')
}
let fullHandle = $derived(() => {
if (!handle.trim()) return ''
if (handle.includes('.')) return handle.trim()
return selectedDomain ? `${handle.trim()}.${selectedDomain}` : handle.trim()
})
onMount(() => {
loadPendingRegistration()
loadServerInfo()
})
async function loadServerInfo() {
try {
const response = await fetch('/xrpc/com.atproto.server.describeServer')
if (response.ok) {
const data = await response.json()
serverInfo = {
availableUserDomains: data.availableUserDomains || [],
inviteCodeRequired: data.inviteCodeRequired ?? false,
selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false,
}
const available: string[] = data.availableCommsChannels ?? ['email']
commsChannels = {
email: available.includes('email'),
discord: available.includes('discord'),
telegram: available.includes('telegram'),
signal: available.includes('signal'),
}
selectedDomain = data.availableUserDomains?.[0] || window.location.hostname
}
} catch {
serverInfo = null
}
}
async function loadPendingRegistration() {
const token = getToken()
if (!token) {
error = $_('sso_register.error_expired')
loading = false
return
}
try {
const response = await fetch(`/oauth/sso/pending-registration?token=${encodeURIComponent(token)}`)
if (!response.ok) {
const data = await response.json()
error = data.message || $_('sso_register.error_expired')
loading = false
return
}
pending = await response.json()
if (pending?.provider_email) {
email = pending.provider_email
providerEmailOriginal = pending.provider_email
}
if (pending?.provider_username) {
handle = pending.provider_username.toLowerCase().replace(/[^a-z0-9-]/g, '')
}
} catch {
error = $_('sso_register.error_expired')
} finally {
loading = false
}
}
let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null
$effect(() => {
void selectedDomain
if (checkHandleTimeout) {
clearTimeout(checkHandleTimeout)
}
handleAvailable = null
handleError = null
if (handle.length >= 3) {
checkHandleTimeout = setTimeout(() => checkHandleAvailability(), 400)
}
})
async function checkHandleAvailability() {
if (!handle || handle.length < 3) return
checkingHandle = true
handleError = null
try {
const params = new URLSearchParams({ handle })
if (selectedDomain) params.set('domain', selectedDomain)
const response = await fetch(`/oauth/sso/check-handle-available?${params}`)
const data = await response.json()
handleAvailable = data.available
if (!data.available && data.reason) {
handleError = data.reason
}
} catch {
handleAvailable = null
handleError = $_('common.error')
} finally {
checkingHandle = false
}
}
let usingVerifiedProviderEmail = $derived(
pending?.provider_email_verified &&
verificationChannel === 'email' &&
email.trim().toLowerCase() === providerEmailOriginal?.toLowerCase()
)
function isChannelValid(): boolean {
switch (verificationChannel) {
case 'email':
return !!email.trim()
case 'discord':
return !!discordUsername.trim()
case 'telegram':
return !!telegramUsername.trim()
case 'signal':
return !!signalUsername.trim()
default:
return false
}
}
async function handleSubmit(e: Event) {
e.preventDefault()
const token = getToken()
if (!token || !pending) return
if (!handle || handle.length < 3) {
handleError = $_('sso_register.error_handle_required')
return
}
if (handleAvailable === false) {
handleError = $_('sso_register.handle_taken')
return
}
if (!isChannelValid()) {
toast.error($_(`register.validation.${verificationChannel === 'email' ? 'emailRequired' : verificationChannel + 'Required'}`))
return
}
const fullHandle = !handle.includes('.') && selectedDomain
? `${handle.trim()}.${selectedDomain}`
: handle.trim()
submitting = true
try {
const response = await fetch('/oauth/sso/complete-registration', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
token,
handle: fullHandle,
email: email || null,
invite_code: inviteCode || null,
verification_channel: verificationChannel,
discord_username: discordUsername || null,
telegram_username: telegramUsername || null,
signal_username: signalUsername || null,
did_type: didType,
did: didType === 'web-external' ? externalDid.trim() : null,
}),
})
const data = await response.json()
if (!response.ok) {
toast.error(data.message || data.error_description || data.error || $_('common.error'))
submitting = false
return
}
if (data.accessJwt && data.refreshJwt) {
localStorage.setItem('accessJwt', data.accessJwt)
localStorage.setItem('refreshJwt', data.refreshJwt)
}
if (data.redirectUrl) {
if (data.redirectUrl.startsWith('/app/verify')) {
localStorage.setItem('tranquil_pds_pending_verification', JSON.stringify({
did: data.did,
handle: data.handle,
channel: verificationChannel,
}))
const url = new URL(data.redirectUrl, window.location.origin)
url.searchParams.set('handle', data.handle)
url.searchParams.set('channel', verificationChannel)
window.location.href = url.pathname + url.search
return
}
window.location.href = data.redirectUrl
return
}
toast.error($_('common.error'))
submitting = false
} catch {
toast.error($_('common.error'))
submitting = false
}
}
</script>
<div class="sso-register-container">
{#if loading}
<div class="loading"></div>
{:else if error && !pending}
<div class="error-container">
<div class="error-icon">!</div>
<h2>{$_('common.error')}</h2>
<p>{error}</p>
<a href="/app/register-sso" class="back-link">{$_('sso_register.tryAgain')}</a>
</div>
{:else if pending}
<header class="page-header">
<h1>{$_('sso_register.title')}</h1>
<p class="subtitle">{$_('sso_register.subtitle', { values: { provider: getProviderDisplayName(pending.provider) } })}</p>
</header>
<div class="provider-info">
<div class="provider-badge">
<SsoIcon provider={pending.provider} size={32} />
<div class="provider-details">
<span class="provider-name">{getProviderDisplayName(pending.provider)}</span>
{#if pending.provider_username}
<span class="provider-username">@{pending.provider_username}</span>
{/if}
</div>
</div>
</div>
<div class="split-layout sidebar-right">
<div class="form-section">
<form onsubmit={handleSubmit}>
<div>
<label for="handle">{$_('sso_register.handle_label')}</label>
<HandleInput
value={handle}
domains={serverInfo?.availableUserDomains ?? []}
{selectedDomain}
placeholder={$_('register.handlePlaceholder')}
disabled={submitting}
onInput={(v) => { handle = v }}
onDomainChange={(d) => { selectedDomain = d }}
/>
{#if checkingHandle}
<p class="hint">{$_('common.checking')}</p>
{:else if handleError}
<p class="hint error">{handleError}</p>
{:else if handleAvailable === false}
<p class="hint error">{$_('sso_register.handle_taken')}</p>
{:else if handleAvailable === true}
<p class="hint success">{$_('sso_register.handle_available')}</p>
{:else if fullHandle()}
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
{/if}
</div>
<fieldset>
<legend>{$_('register.contactMethod')}</legend>
<div class="contact-fields">
<div class="field">
<label for="verification-channel">{$_('register.verificationMethod')}</label>
<select id="verification-channel" bind:value={verificationChannel} disabled={submitting}>
<option value="email">{$_('register.email')}</option>
<option value="discord" disabled={!isChannelAvailable('discord')}>
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="signal" disabled={!isChannelAvailable('signal')}>
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
</option>
</select>
</div>
{#if verificationChannel === 'email'}
<div class="field">
<label for="email">{$_('register.emailAddress')}</label>
<input
id="email"
type="email"
bind:value={email}
placeholder={$_('register.emailPlaceholder')}
disabled={submitting}
required
/>
{#if pending?.provider_email && pending?.provider_email_verified}
{#if usingVerifiedProviderEmail}
<p class="hint success">{$_('sso_register.emailVerifiedByProvider', { values: { provider: getProviderDisplayName(pending.provider) } })}</p>
{:else}
<p class="hint">{$_('sso_register.emailChangedNeedsVerification')}</p>
{/if}
{/if}
</div>
{:else if verificationChannel === 'discord'}
<div class="field">
<label for="discord-username">{$_('register.discordUsername')}</label>
<input
id="discord-username"
type="text"
bind:value={discordUsername}
placeholder={$_('register.discordUsernamePlaceholder')}
disabled={submitting}
required
/>
</div>
{:else if verificationChannel === 'telegram'}
<div class="field">
<label for="telegram-username">{$_('register.telegramUsername')}</label>
<input
id="telegram-username"
type="text"
bind:value={telegramUsername}
placeholder={$_('register.telegramUsernamePlaceholder')}
disabled={submitting}
required
/>
</div>
{:else if verificationChannel === 'signal'}
<div class="field">
<label for="signal-number">{$_('register.signalUsername')}</label>
<input
id="signal-number"
type="tel"
bind:value={signalUsername}
placeholder={$_('register.signalUsernamePlaceholder')}
disabled={submitting}
required
/>
<p class="hint">{$_('register.signalUsernameHint')}</p>
</div>
{/if}
</div>
</fieldset>
<fieldset>
<legend>{$_('registerPasskey.identityType')}</legend>
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} />
<span class="radio-content">
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
</span>
</label>
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
<input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting || serverInfo?.selfHostedDidWebEnabled === false} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWeb')}</strong>
{#if serverInfo?.selfHostedDidWebEnabled === false}
<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" bind:group={didType} disabled={submitting} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
</span>
</label>
</div>
{#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.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</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 class="field">
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
<input id="external-did" type="text" bind:value={externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={submitting} required />
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{externalDid ? extractDomain(externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
</div>
{/if}
</fieldset>
{#if serverInfo?.inviteCodeRequired}
<div>
<label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
<input
id="invite-code"
type="text"
bind:value={inviteCode}
placeholder={$_('register.inviteCodePlaceholder')}
disabled={submitting}
required
/>
</div>
{/if}
<button type="submit" disabled={submitting || !handle || handle.length < 3 || handleAvailable === false || checkingHandle || !isChannelValid()}>
{submitting ? $_('common.creating') : $_('sso_register.submit')}
</button>
</form>
</div>
<aside class="info-panel">
<h3>{$_('sso_register.infoAfterTitle')}</h3>
<ul class="info-list">
<li>{$_('sso_register.infoAddPassword')}</li>
<li>{$_('sso_register.infoAddPasskey')}</li>
<li>{$_('sso_register.infoLinkProviders')}</li>
<li>{$_('sso_register.infoChangeHandle')}</li>
</ul>
</aside>
</div>
{/if}
</div>