mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-06 18:26:56 +00:00
refactor(frontend): delete OAuthRegister and OAuthSsoRegister routes
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user