forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
20 kB
560 lines
1<script lang="ts">
2 import { navigate, routes, getFullUrl } from '../lib/router.svelte'
3 import { api, ApiError } from '../lib/api'
4 import { _ } from '../lib/i18n'
5 import {
6 createRegistrationFlow,
7 restoreRegistrationFlow,
8 VerificationStep,
9 KeyChoiceStep,
10 DidDocStep,
11 } from '../lib/registration'
12 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
13 import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth'
14
15 let serverInfo = $state<{
16 availableUserDomains: string[]
17 inviteCodeRequired: boolean
18 availableCommsChannels?: string[]
19 selfHostedDidWebEnabled?: boolean
20 } | null>(null)
21 let loadingServerInfo = $state(true)
22 let serverInfoLoaded = false
23 let ssoAvailable = $state(false)
24
25 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
26 let confirmPassword = $state('')
27 let clientName = $state<string | null>(null)
28
29 $effect(() => {
30 if (!serverInfoLoaded) {
31 serverInfoLoaded = true
32 ensureRequestUri().then((requestUri) => {
33 if (!requestUri) return
34 loadServerInfo()
35 checkSsoAvailable()
36 fetchClientName()
37 }).catch((err) => {
38 console.error('Failed to ensure OAuth request URI:', err)
39 })
40 }
41 })
42
43 async function fetchClientName() {
44 const requestUri = getRequestUriFromUrl()
45 if (!requestUri) return
46
47 try {
48 const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, {
49 headers: { 'Accept': 'application/json' }
50 })
51 if (response.ok) {
52 const data = await response.json()
53 clientName = data.client_name || null
54 }
55 } catch {
56 clientName = null
57 }
58 }
59
60 async function checkSsoAvailable() {
61 try {
62 const response = await fetch('/oauth/sso/providers')
63 if (response.ok) {
64 const data = await response.json()
65 ssoAvailable = (data.providers?.length ?? 0) > 0
66 }
67 } catch {
68 ssoAvailable = false
69 }
70 }
71
72 $effect(() => {
73 if (flow?.state.step === 'redirect-to-dashboard') {
74 completeOAuthRegistration()
75 }
76 })
77
78 let creatingStarted = false
79 $effect(() => {
80 if (flow?.state.step === 'creating' && !creatingStarted) {
81 creatingStarted = true
82 flow.createPasswordAccount()
83 }
84 })
85
86 async function loadServerInfo() {
87 try {
88 const restored = restoreRegistrationFlow()
89 if (restored && restored.state.mode === 'password') {
90 flow = restored
91 serverInfo = await api.describeServer()
92 } else {
93 serverInfo = await api.describeServer()
94 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
95 flow = createRegistrationFlow('password', hostname)
96 }
97 } catch (e) {
98 console.error('Failed to load server info:', e)
99 } finally {
100 loadingServerInfo = false
101 }
102 }
103
104 function validateInfoStep(): string | null {
105 if (!flow) return 'Flow not initialized'
106 const info = flow.info
107 if (!info.handle.trim()) return $_('register.validation.handleRequired')
108 if (info.handle.includes('.')) return $_('register.validation.handleNoDots')
109 if (!info.password) return $_('register.validation.passwordRequired')
110 if (info.password.length < 8) return $_('register.validation.passwordLength')
111 if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch')
112 if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
113 return $_('register.validation.inviteCodeRequired')
114 }
115 if (info.didType === 'web-external') {
116 if (!info.externalDid?.trim()) return $_('register.validation.externalDidRequired')
117 if (!info.externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat')
118 }
119 switch (info.verificationChannel) {
120 case 'email':
121 if (!info.email.trim()) return $_('register.validation.emailRequired')
122 break
123 case 'discord':
124 if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired')
125 break
126 case 'telegram':
127 if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired')
128 break
129 case 'signal':
130 if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired')
131 break
132 }
133 return null
134 }
135
136 async function handleInfoSubmit(e: Event) {
137 e.preventDefault()
138 if (!flow) return
139
140 const validationError = validateInfoStep()
141 if (validationError) {
142 flow.setError(validationError)
143 return
144 }
145
146 flow.clearError()
147 flow.proceedFromInfo()
148 }
149
150 async function handleCreateAccount() {
151 if (!flow) return
152 await flow.createPasswordAccount()
153 }
154
155 async function handleComplete() {
156 if (flow) {
157 await flow.finalizeSession()
158 }
159 navigate(routes.dashboard)
160 }
161
162 async function completeOAuthRegistration() {
163 const requestUri = getRequestUriFromUrl()
164 if (!requestUri || !flow?.account) {
165 navigate(routes.dashboard)
166 return
167 }
168
169 try {
170 const response = await fetch('/oauth/register/complete', {
171 method: 'POST',
172 headers: {
173 'Content-Type': 'application/json',
174 'Accept': 'application/json',
175 },
176 body: JSON.stringify({
177 request_uri: requestUri,
178 did: flow.account.did,
179 app_password: flow.account.appPassword,
180 }),
181 })
182
183 const data = await response.json()
184
185 if (!response.ok) {
186 flow.setError(data.error_description || data.error || $_('common.error'))
187 return
188 }
189
190 if (data.redirect_uri) {
191 window.location.href = data.redirect_uri
192 return
193 }
194
195 navigate(routes.dashboard)
196 } catch (err) {
197 console.error('OAuth registration completion failed:', err)
198 flow.setError(err instanceof Error ? err.message : $_('common.error'))
199 }
200 }
201
202 function isChannelAvailable(ch: string): boolean {
203 const available = serverInfo?.availableCommsChannels ?? ['email']
204 return available.includes(ch)
205 }
206
207 function channelLabel(ch: string): string {
208 switch (ch) {
209 case 'email': return $_('register.email')
210 case 'discord': return $_('register.discord')
211 case 'telegram': return $_('register.telegram')
212 case 'signal': return $_('register.signal')
213 default: return ch
214 }
215 }
216
217 let fullHandle = $derived(() => {
218 if (!flow?.info.handle.trim()) return ''
219 if (flow.info.handle.includes('.')) return flow.info.handle.trim()
220 const domain = serverInfo?.availableUserDomains?.[0]
221 if (domain) return `${flow.info.handle.trim()}.${domain}`
222 return flow.info.handle.trim()
223 })
224
225 function extractDomain(did: string): string {
226 return did.replace('did:web:', '').replace(/%3A/g, ':')
227 }
228
229 function getSubtitle(): string {
230 if (!flow) return ''
231 switch (flow.state.step) {
232 case 'info': return $_('register.subtitle')
233 case 'key-choice': return $_('register.subtitleKeyChoice')
234 case 'initial-did-doc': return $_('register.subtitleInitialDidDoc')
235 case 'creating': return $_('common.creating')
236 case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
237 case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc')
238 case 'activating': return $_('register.subtitleActivating')
239 case 'redirect-to-dashboard': return $_('register.subtitleComplete')
240 default: return ''
241 }
242 }
243</script>
244
245<div class="page">
246 <header class="page-header">
247 <h1>{$_('register.title')}</h1>
248 <p class="subtitle">{getSubtitle()}</p>
249 {#if clientName}
250 <p class="client-name">{$_('oauth.login.subtitle')} <strong>{clientName}</strong></p>
251 {/if}
252 </header>
253
254 {#if flow?.state.error}
255 <div class="message error">{flow.state.error}</div>
256 {/if}
257
258 {#if loadingServerInfo || !flow}
259 <div class="loading">
260 <div class="spinner md"></div>
261 </div>
262 {:else if flow.state.step === 'info'}
263 <div class="migrate-callout">
264 <div class="migrate-icon">↗</div>
265 <div class="migrate-content">
266 <strong>{$_('register.migrateTitle')}</strong>
267 <p>{$_('register.migrateDescription')}</p>
268 <a href={getFullUrl(routes.migrate)} class="migrate-link">
269 {$_('register.migrateLink')} →
270 </a>
271 </div>
272 </div>
273
274 <AccountTypeSwitcher active="password" {ssoAvailable} oauthRequestUri={getRequestUriFromUrl()} />
275
276 <div class="split-layout sidebar-right">
277 <div class="form-section">
278 <form onsubmit={handleInfoSubmit}>
279 <div class="field">
280 <label for="handle">{$_('register.handle')}</label>
281 <input
282 id="handle"
283 type="text"
284 bind:value={flow.info.handle}
285 placeholder={$_('register.handlePlaceholder')}
286 disabled={flow.state.submitting}
287 required
288 />
289 {#if flow.info.handle.includes('.')}
290 <p class="hint warning">{$_('register.handleDotWarning')}</p>
291 {:else if fullHandle()}
292 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
293 {/if}
294 </div>
295
296 <div class="form-row">
297 <div class="field">
298 <label for="password">{$_('register.password')}</label>
299 <input
300 id="password"
301 type="password"
302 bind:value={flow.info.password}
303 placeholder={$_('register.passwordPlaceholder')}
304 disabled={flow.state.submitting}
305 required
306 minlength="8"
307 />
308 </div>
309
310 <div class="field">
311 <label for="confirm-password">{$_('register.confirmPassword')}</label>
312 <input
313 id="confirm-password"
314 type="password"
315 bind:value={confirmPassword}
316 placeholder={$_('register.confirmPasswordPlaceholder')}
317 disabled={flow.state.submitting}
318 required
319 />
320 </div>
321 </div>
322
323 <fieldset class="section-fieldset">
324 <legend>{$_('register.identityType')}</legend>
325 <div class="radio-group">
326 <label class="radio-label">
327 <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
328 <span class="radio-content">
329 <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')}
330 <span class="radio-hint">{$_('register.didPlcHint')}</span>
331 </span>
332 </label>
333
334 <label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
335 <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
336 <span class="radio-content">
337 <strong>{$_('register.didWeb')}</strong>
338 {#if serverInfo?.selfHostedDidWebEnabled === false}
339 <span class="radio-hint disabled-hint">{$_('register.didWebDisabledHint')}</span>
340 {:else}
341 <span class="radio-hint">{$_('register.didWebHint')}</span>
342 {/if}
343 </span>
344 </label>
345
346 <label class="radio-label">
347 <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
348 <span class="radio-content">
349 <strong>{$_('register.didWebBYOD')}</strong>
350 <span class="radio-hint">{$_('register.didWebBYODHint')}</span>
351 </span>
352 </label>
353 </div>
354
355 {#if flow.info.didType === 'web'}
356 <div class="warning-box">
357 <strong>{$_('register.didWebWarningTitle')}</strong>
358 <ul>
359 <li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li>
360 <li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li>
361 <li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li>
362 <li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li>
363 </ul>
364 </div>
365 {/if}
366
367 {#if flow.info.didType === 'web-external'}
368 <div class="field">
369 <label for="external-did">{$_('register.externalDid')}</label>
370 <input
371 id="external-did"
372 type="text"
373 bind:value={flow.info.externalDid}
374 placeholder={$_('register.externalDidPlaceholder')}
375 disabled={flow.state.submitting}
376 required
377 />
378 <p class="hint">{$_('register.externalDidHint')}</p>
379 </div>
380 {/if}
381 </fieldset>
382
383 <fieldset class="section-fieldset">
384 <legend>{$_('register.contactMethod')}</legend>
385 <div class="contact-fields">
386 <div class="field">
387 <label for="verification-channel">{$_('register.verificationMethod')}</label>
388 <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
389 <option value="email">{$_('register.email')}</option>
390 <option value="discord" disabled={!isChannelAvailable('discord')}>
391 {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
392 </option>
393 <option value="telegram" disabled={!isChannelAvailable('telegram')}>
394 {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
395 </option>
396 <option value="signal" disabled={!isChannelAvailable('signal')}>
397 {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
398 </option>
399 </select>
400 </div>
401
402 {#if flow.info.verificationChannel === 'email'}
403 <div class="field">
404 <label for="email">{$_('register.emailAddress')}</label>
405 <input
406 id="email"
407 type="email"
408 bind:value={flow.info.email}
409 onblur={() => flow?.checkEmailInUse(flow.info.email)}
410 placeholder={$_('register.emailPlaceholder')}
411 disabled={flow.state.submitting}
412 required
413 />
414 {#if flow.state.emailInUse}
415 <p class="hint warning">{$_('register.emailInUseWarning')}</p>
416 {/if}
417 </div>
418 {:else if flow.info.verificationChannel === 'discord'}
419 <div class="field">
420 <label for="discord-id">{$_('register.discordId')}</label>
421 <input
422 id="discord-id"
423 type="text"
424 bind:value={flow.info.discordId}
425 onblur={() => flow?.checkCommsChannelInUse('discord', flow.info.discordId ?? '')}
426 placeholder={$_('register.discordIdPlaceholder')}
427 disabled={flow.state.submitting}
428 required
429 />
430 <p class="hint">{$_('register.discordIdHint')}</p>
431 {#if flow.state.discordInUse}
432 <p class="hint warning">{$_('register.discordInUseWarning')}</p>
433 {/if}
434 </div>
435 {:else if flow.info.verificationChannel === 'telegram'}
436 <div class="field">
437 <label for="telegram-username">{$_('register.telegramUsername')}</label>
438 <input
439 id="telegram-username"
440 type="text"
441 bind:value={flow.info.telegramUsername}
442 onblur={() => flow?.checkCommsChannelInUse('telegram', flow.info.telegramUsername ?? '')}
443 placeholder={$_('register.telegramUsernamePlaceholder')}
444 disabled={flow.state.submitting}
445 required
446 />
447 {#if flow.state.telegramInUse}
448 <p class="hint warning">{$_('register.telegramInUseWarning')}</p>
449 {/if}
450 </div>
451 {:else if flow.info.verificationChannel === 'signal'}
452 <div class="field">
453 <label for="signal-number">{$_('register.signalNumber')}</label>
454 <input
455 id="signal-number"
456 type="tel"
457 bind:value={flow.info.signalNumber}
458 onblur={() => flow?.checkCommsChannelInUse('signal', flow.info.signalNumber ?? '')}
459 placeholder={$_('register.signalNumberPlaceholder')}
460 disabled={flow.state.submitting}
461 required
462 />
463 <p class="hint">{$_('register.signalNumberHint')}</p>
464 {#if flow.state.signalInUse}
465 <p class="hint warning">{$_('register.signalInUseWarning')}</p>
466 {/if}
467 </div>
468 {/if}
469 </div>
470 </fieldset>
471
472 {#if serverInfo?.inviteCodeRequired}
473 <div class="field">
474 <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
475 <input
476 id="invite-code"
477 type="text"
478 bind:value={flow.info.inviteCode}
479 placeholder={$_('register.inviteCodePlaceholder')}
480 disabled={flow.state.submitting}
481 required
482 />
483 </div>
484 {/if}
485
486 <button type="submit" disabled={flow.state.submitting}>
487 {flow.state.submitting ? $_('common.creating') : $_('register.createButton')}
488 </button>
489 </form>
490
491 <div class="form-links">
492 <p class="link-text">
493 {$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</a>
494 </p>
495 </div>
496 </div>
497
498 <aside class="info-panel">
499 <h3>{$_('register.identityHint')}</h3>
500 <p>{$_('register.infoIdentityDesc')}</p>
501
502 <h3>{$_('register.contactMethodHint')}</h3>
503 <p>{$_('register.infoContactDesc')}</p>
504
505 <h3>{$_('register.infoNextTitle')}</h3>
506 <p>{$_('register.infoNextDesc')}</p>
507 </aside>
508 </div>
509
510 {:else if flow.state.step === 'key-choice'}
511 <KeyChoiceStep {flow} />
512
513 {:else if flow.state.step === 'initial-did-doc'}
514 <DidDocStep
515 {flow}
516 type="initial"
517 onConfirm={handleCreateAccount}
518 onBack={() => flow?.goBack()}
519 />
520
521 {:else if flow.state.step === 'creating'}
522 <div class="loading">
523 <div class="spinner md"></div>
524 <p>{$_('common.creating')}</p>
525 </div>
526
527 {:else if flow.state.step === 'verify'}
528 <VerificationStep {flow} />
529
530 {:else if flow.state.step === 'updated-did-doc'}
531 <DidDocStep
532 {flow}
533 type="updated"
534 onConfirm={() => flow?.activateAccount()}
535 />
536
537 {:else if flow.state.step === 'redirect-to-dashboard'}
538 <div class="loading">
539 <div class="spinner md"></div>
540 <p>{$_('register.redirecting')}</p>
541 </div>
542 {/if}
543</div>
544
545<style>
546 .client-name {
547 color: var(--text-secondary);
548 margin-top: var(--space-2);
549 }
550
551 form {
552 display: flex;
553 flex-direction: column;
554 gap: var(--space-5);
555 }
556
557 button[type="submit"] {
558 margin-top: var(--space-3);
559 }
560</style>