forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
24 kB
779 lines
1<script lang="ts">
2 import { navigate, routes, getFullUrl } from '../lib/router.svelte'
3 import { api } from '../lib/api'
4 import { _ } from '../lib/i18n'
5 import {
6 createRegistrationFlow,
7 restoreRegistrationFlow,
8 VerificationStep,
9 KeyChoiceStep,
10 DidDocStep,
11 AppPasswordStep,
12 } from '../lib/registration'
13 import {
14 prepareCreationOptions,
15 serializeAttestationResponse,
16 type WebAuthnCreationOptionsResponse,
17 } from '../lib/webauthn'
18 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
19
20 let serverInfo = $state<{
21 availableUserDomains: string[]
22 inviteCodeRequired: boolean
23 availableCommsChannels?: string[]
24 selfHostedDidWebEnabled?: boolean
25 } | null>(null)
26 let loadingServerInfo = $state(true)
27 let serverInfoLoaded = false
28 let ssoAvailable = $state(false)
29
30 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
31 let passkeyName = $state('')
32 let clientName = $state<string | null>(null)
33
34 function getRequestUri(): string | null {
35 const params = new URLSearchParams(window.location.search)
36 return params.get('request_uri')
37 }
38
39 $effect(() => {
40 if (!serverInfoLoaded) {
41 serverInfoLoaded = true
42 loadServerInfo()
43 fetchClientName()
44 checkSsoAvailable()
45 }
46 })
47
48 async function checkSsoAvailable() {
49 try {
50 const response = await fetch('/oauth/sso/providers')
51 if (response.ok) {
52 const data = await response.json()
53 ssoAvailable = (data.providers?.length ?? 0) > 0
54 }
55 } catch {
56 ssoAvailable = false
57 }
58 }
59
60 async function fetchClientName() {
61 const requestUri = getRequestUri()
62 if (!requestUri) return
63
64 try {
65 const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, {
66 headers: { 'Accept': 'application/json' }
67 })
68 if (response.ok) {
69 const data = await response.json()
70 clientName = data.client_name || null
71 }
72 } catch {
73 clientName = null
74 }
75 }
76
77 $effect(() => {
78 if (flow?.state.step === 'redirect-to-dashboard') {
79 completeOAuthRegistration()
80 }
81 })
82
83 let creatingStarted = false
84 $effect(() => {
85 if (flow?.state.step === 'creating' && !creatingStarted) {
86 creatingStarted = true
87 flow.createPasskeyAccount()
88 }
89 })
90
91 async function loadServerInfo() {
92 try {
93 const restored = restoreRegistrationFlow()
94 if (restored && restored.state.mode === 'passkey') {
95 flow = restored
96 serverInfo = await api.describeServer()
97 } else {
98 serverInfo = await api.describeServer()
99 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
100 flow = createRegistrationFlow('passkey', hostname)
101 }
102 } catch (e) {
103 console.error('Failed to load server info:', e)
104 } finally {
105 loadingServerInfo = false
106 }
107 }
108
109 function validateInfoStep(): string | null {
110 if (!flow) return 'Flow not initialized'
111 const info = flow.info
112 if (!info.handle.trim()) return $_('registerPasskey.errors.handleRequired')
113 if (info.handle.includes('.')) return $_('registerPasskey.errors.handleNoDots')
114 if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
115 return $_('registerPasskey.errors.inviteRequired')
116 }
117 if (info.didType === 'web-external') {
118 if (!info.externalDid?.trim()) return $_('registerPasskey.errors.externalDidRequired')
119 if (!info.externalDid.trim().startsWith('did:web:')) return $_('registerPasskey.errors.externalDidFormat')
120 }
121 switch (info.verificationChannel) {
122 case 'email':
123 if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired')
124 break
125 case 'discord':
126 if (!info.discordUsername?.trim()) return $_('registerPasskey.errors.discordRequired')
127 break
128 case 'telegram':
129 if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired')
130 break
131 case 'signal':
132 if (!info.signalUsername?.trim()) return $_('registerPasskey.errors.signalRequired')
133 break
134 }
135 return null
136 }
137
138 async function handleInfoSubmit(e: Event) {
139 e.preventDefault()
140 if (!flow) return
141
142 const validationError = validateInfoStep()
143 if (validationError) {
144 flow.setError(validationError)
145 return
146 }
147
148 if (!window.PublicKeyCredential) {
149 flow.setError($_('registerPasskey.errors.passkeysNotSupported'))
150 return
151 }
152
153 flow.clearError()
154 flow.proceedFromInfo()
155 }
156
157 async function handlePasskeyRegistration() {
158 if (!flow || !flow.account) return
159
160 flow.setSubmitting(true)
161 flow.clearError()
162
163 try {
164 const { options } = await api.startPasskeyRegistrationForSetup(
165 flow.account.did,
166 flow.account.setupToken!,
167 passkeyName || undefined
168 )
169
170 const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse)
171 const credential = await navigator.credentials.create({
172 publicKey: publicKeyOptions
173 })
174
175 if (!credential) {
176 flow.setError($_('registerPasskey.errors.passkeyCancelled'))
177 flow.setSubmitting(false)
178 return
179 }
180
181 const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
182
183 const result = await api.completePasskeySetup(
184 flow.account.did,
185 flow.account.setupToken!,
186 credentialResponse,
187 passkeyName || undefined
188 )
189
190 flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
191 } catch (err) {
192 if (err instanceof DOMException && err.name === 'NotAllowedError') {
193 flow.setError($_('registerPasskey.errors.passkeyCancelled'))
194 } else if (err instanceof Error) {
195 flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
196 } else {
197 flow.setError($_('registerPasskey.errors.passkeyFailed'))
198 }
199 } finally {
200 flow.setSubmitting(false)
201 }
202 }
203
204 async function completeOAuthRegistration() {
205 const requestUri = getRequestUri()
206 if (!requestUri || !flow?.account) {
207 navigate(routes.dashboard)
208 return
209 }
210
211 try {
212 const response = await fetch('/oauth/register/complete', {
213 method: 'POST',
214 headers: {
215 'Content-Type': 'application/json',
216 'Accept': 'application/json',
217 },
218 body: JSON.stringify({
219 request_uri: requestUri,
220 did: flow.account.did,
221 app_password: flow.account.appPassword,
222 }),
223 })
224
225 const data = await response.json()
226
227 if (!response.ok) {
228 flow.setError(data.error_description || data.error || $_('common.error'))
229 return
230 }
231
232 if (data.redirect_uri) {
233 window.location.href = data.redirect_uri
234 return
235 }
236
237 navigate(routes.dashboard)
238 } catch {
239 flow.setError($_('common.error'))
240 }
241 }
242
243 function isChannelAvailable(ch: string): boolean {
244 const available = serverInfo?.availableCommsChannels ?? ['email']
245 return available.includes(ch)
246 }
247
248 function channelLabel(ch: string): string {
249 switch (ch) {
250 case 'email':
251 return $_('register.email')
252 case 'discord':
253 return $_('register.discord')
254 case 'telegram':
255 return $_('register.telegram')
256 case 'signal':
257 return $_('register.signal')
258 default:
259 return ch
260 }
261 }
262
263 let fullHandle = $derived(() => {
264 if (!flow?.info.handle.trim()) return ''
265 return `${flow.info.handle.trim()}.${flow.state.pdsHostname}`
266 })
267
268 async function handleCancel() {
269 const requestUri = getRequestUri()
270 if (!requestUri) {
271 window.history.back()
272 return
273 }
274
275 try {
276 const response = await fetch('/oauth/authorize/deny', {
277 method: 'POST',
278 headers: {
279 'Content-Type': 'application/json',
280 'Accept': 'application/json'
281 },
282 body: JSON.stringify({ request_uri: requestUri })
283 })
284
285 const data = await response.json()
286 if (data.redirect_uri) {
287 window.location.href = data.redirect_uri
288 }
289 } catch {
290 window.history.back()
291 }
292 }
293
294 function goToLogin() {
295 const requestUri = getRequestUri()
296 if (requestUri) {
297 navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
298 } else {
299 navigate(routes.login)
300 }
301 }
302</script>
303
304<div class="oauth-register-container">
305 {#if loadingServerInfo}
306 <div class="loading">
307 <div class="spinner"></div>
308 <p>{$_('common.loading')}</p>
309 </div>
310 {:else if flow}
311 <header class="page-header">
312 <h1>{$_('oauth.register.title')}</h1>
313 <p class="subtitle">
314 {#if clientName}
315 {$_('oauth.register.subtitle')} <strong>{clientName}</strong>
316 {:else}
317 {$_('oauth.register.subtitleGeneric')}
318 {/if}
319 </p>
320 </header>
321
322 {#if flow.state.error}
323 <div class="error">{flow.state.error}</div>
324 {/if}
325
326 {#if flow.state.step === 'info'}
327 <div class="migrate-callout">
328 <div class="migrate-icon">↗</div>
329 <div class="migrate-content">
330 <strong>{$_('register.migrateTitle')}</strong>
331 <p>{$_('register.migrateDescription')}</p>
332 <a href={getFullUrl(routes.migrate)} class="migrate-link">
333 {$_('register.migrateLink')} →
334 </a>
335 </div>
336 </div>
337
338 <AccountTypeSwitcher active="passkey" {ssoAvailable} oauthRequestUri={getRequestUri()} />
339
340 <div class="split-layout">
341 <div class="form-section">
342 <form onsubmit={handleInfoSubmit}>
343 <div class="field">
344 <label for="handle">{$_('register.handle')}</label>
345 <input
346 id="handle"
347 type="text"
348 bind:value={flow.info.handle}
349 placeholder={$_('register.handlePlaceholder')}
350 disabled={flow.state.submitting}
351 required
352 autocomplete="off"
353 />
354 {#if fullHandle()}
355 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
356 {/if}
357 </div>
358
359 <fieldset>
360 <legend>{$_('register.contactMethod')}</legend>
361 <div class="contact-fields">
362 <div class="field">
363 <label for="verification-channel">{$_('register.verificationMethod')}</label>
364 <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
365 <option value="email">{channelLabel('email')}</option>
366 {#if isChannelAvailable('discord')}
367 <option value="discord">{channelLabel('discord')}</option>
368 {/if}
369 {#if isChannelAvailable('telegram')}
370 <option value="telegram">{channelLabel('telegram')}</option>
371 {/if}
372 {#if isChannelAvailable('signal')}
373 <option value="signal">{channelLabel('signal')}</option>
374 {/if}
375 </select>
376 </div>
377
378 {#if flow.info.verificationChannel === 'email'}
379 <div class="field">
380 <label for="email">{$_('register.emailAddress')}</label>
381 <input
382 id="email"
383 type="email"
384 bind:value={flow.info.email}
385 placeholder={$_('register.emailPlaceholder')}
386 disabled={flow.state.submitting}
387 required
388 />
389 </div>
390 {:else if flow.info.verificationChannel === 'discord'}
391 <div class="field">
392 <label for="discord-username">{$_('register.discordUsername')}</label>
393 <input
394 id="discord-username"
395 type="text"
396 bind:value={flow.info.discordUsername}
397 placeholder={$_('register.discordUsernamePlaceholder')}
398 disabled={flow.state.submitting}
399 required
400 />
401 </div>
402 {:else if flow.info.verificationChannel === 'telegram'}
403 <div class="field">
404 <label for="telegram-username">{$_('register.telegramUsername')}</label>
405 <input
406 id="telegram-username"
407 type="text"
408 bind:value={flow.info.telegramUsername}
409 placeholder={$_('register.telegramUsernamePlaceholder')}
410 disabled={flow.state.submitting}
411 required
412 />
413 </div>
414 {:else if flow.info.verificationChannel === 'signal'}
415 <div class="field">
416 <label for="signal-number">{$_('register.signalUsername')}</label>
417 <input
418 id="signal-number"
419 type="tel"
420 bind:value={flow.info.signalUsername}
421 placeholder={$_('register.signalUsernamePlaceholder')}
422 disabled={flow.state.submitting}
423 required
424 />
425 <p class="hint">{$_('register.signalUsernameHint')}</p>
426 </div>
427 {/if}
428 </div>
429 </fieldset>
430
431 <fieldset>
432 <legend>{$_('registerPasskey.identityType')}</legend>
433 <p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
434 <div class="radio-group">
435 <label class="radio-label">
436 <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
437 <span class="radio-content">
438 <strong>{$_('registerPasskey.didPlcRecommended')}</strong>
439 <span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
440 </span>
441 </label>
442 <label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
443 <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
444 <span class="radio-content">
445 <strong>{$_('registerPasskey.didWeb')}</strong>
446 {#if serverInfo?.selfHostedDidWebEnabled === false}
447 <span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
448 {:else}
449 <span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
450 {/if}
451 </span>
452 </label>
453 <label class="radio-label">
454 <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
455 <span class="radio-content">
456 <strong>{$_('registerPasskey.didWebBYOD')}</strong>
457 <span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
458 </span>
459 </label>
460 </div>
461 {#if flow.info.didType === 'web'}
462 <div class="warning-box">
463 <strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
464 <ul>
465 <li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
466 <li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
467 <li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
468 <li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
469 </ul>
470 </div>
471 {/if}
472 {#if flow.info.didType === 'web-external'}
473 <div class="field">
474 <label for="external-did">{$_('registerPasskey.externalDid')}</label>
475 <input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required />
476 <p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? flow.extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
477 </div>
478 {/if}
479 </fieldset>
480
481 {#if serverInfo?.inviteCodeRequired}
482 <div class="field">
483 <label for="invite-code">{$_('register.inviteCode')} <span class="required">*</span></label>
484 <input
485 id="invite-code"
486 type="text"
487 bind:value={flow.info.inviteCode}
488 placeholder={$_('register.inviteCodePlaceholder')}
489 disabled={flow.state.submitting}
490 required
491 />
492 </div>
493 {/if}
494
495 <div class="actions">
496 <button type="submit" class="primary" disabled={flow.state.submitting}>
497 {flow.state.submitting ? $_('common.loading') : $_('common.continue')}
498 </button>
499 </div>
500
501 <div class="secondary-actions">
502 <button type="button" class="link-btn" onclick={goToLogin}>
503 {$_('oauth.register.haveAccount')}
504 </button>
505 <button type="button" class="link-btn" onclick={handleCancel}>
506 {$_('common.cancel')}
507 </button>
508 </div>
509 </form>
510
511 <div class="form-links">
512 <p class="link-text">
513 {$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a>
514 </p>
515 </div>
516 </div>
517
518 <aside class="info-panel">
519 <h3>{$_('registerPasskey.infoWhyPasskey')}</h3>
520 <p>{$_('registerPasskey.infoWhyPasskeyDesc')}</p>
521
522 <h3>{$_('registerPasskey.infoHowItWorks')}</h3>
523 <p>{$_('registerPasskey.infoHowItWorksDesc')}</p>
524
525 <h3>{$_('registerPasskey.infoAppAccess')}</h3>
526 <p>{$_('registerPasskey.infoAppAccessDesc')}</p>
527 </aside>
528 </div>
529
530 {:else if flow.state.step === 'key-choice'}
531 <KeyChoiceStep {flow} />
532
533 {:else if flow.state.step === 'initial-did-doc'}
534 <DidDocStep {flow} type="initial" onConfirm={() => flow?.createPasskeyAccount()} onBack={() => flow?.goBack()} />
535
536 {:else if flow.state.step === 'creating'}
537 <div class="creating">
538 <div class="spinner"></div>
539 <p>{$_('registerPasskey.creatingAccount')}</p>
540 </div>
541
542 {:else if flow.state.step === 'passkey'}
543 <div class="passkey-step">
544 <h2>{$_('registerPasskey.setupPasskey')}</h2>
545 <p>{$_('registerPasskey.passkeyDescription')}</p>
546
547 <div class="field">
548 <label for="passkey-name">{$_('registerPasskey.passkeyName')}</label>
549 <input
550 id="passkey-name"
551 type="text"
552 bind:value={passkeyName}
553 placeholder={$_('registerPasskey.passkeyNamePlaceholder')}
554 disabled={flow.state.submitting}
555 />
556 <p class="hint">{$_('registerPasskey.passkeyNameHint')}</p>
557 </div>
558
559 <button
560 type="button"
561 class="primary"
562 onclick={handlePasskeyRegistration}
563 disabled={flow.state.submitting}
564 >
565 {flow.state.submitting ? $_('common.loading') : $_('registerPasskey.registerPasskey')}
566 </button>
567 </div>
568
569 {:else if flow.state.step === 'app-password'}
570 <AppPasswordStep {flow} />
571
572 {:else if flow.state.step === 'verify'}
573 <VerificationStep {flow} />
574
575 {:else if flow.state.step === 'updated-did-doc'}
576 <DidDocStep {flow} type="updated" onConfirm={() => flow?.activateAccount()} />
577
578 {:else if flow.state.step === 'activating'}
579 <div class="creating">
580 <div class="spinner"></div>
581 <p>{$_('registerPasskey.activatingAccount')}</p>
582 </div>
583 {/if}
584 {/if}
585</div>
586
587<style>
588 .oauth-register-container {
589 max-width: var(--width-lg);
590 margin: var(--space-9) auto;
591 padding: var(--space-7);
592 }
593
594 .loading, .creating {
595 display: flex;
596 flex-direction: column;
597 align-items: center;
598 gap: var(--space-4);
599 padding: var(--space-8);
600 }
601
602 .loading p, .creating p {
603 color: var(--text-secondary);
604 }
605
606 .page-header {
607 margin-bottom: var(--space-6);
608 }
609
610 .page-header h1 {
611 margin: 0 0 var(--space-2) 0;
612 }
613
614 .subtitle {
615 color: var(--text-secondary);
616 margin: 0;
617 }
618
619 .form-section {
620 min-width: 0;
621 }
622
623 .form-links {
624 margin-top: var(--space-6);
625 }
626
627 .link-text {
628 text-align: center;
629 color: var(--text-secondary);
630 }
631
632 .link-text a {
633 color: var(--accent);
634 }
635
636 form {
637 display: flex;
638 flex-direction: column;
639 gap: var(--space-5);
640 }
641
642 .field {
643 display: flex;
644 flex-direction: column;
645 gap: var(--space-1);
646 }
647
648 label {
649 font-size: var(--text-sm);
650 font-weight: var(--font-medium);
651 }
652
653 input, select {
654 padding: var(--space-3);
655 border: 1px solid var(--border-color);
656 border-radius: var(--radius-md);
657 font-size: var(--text-base);
658 background: var(--bg-input);
659 color: var(--text-primary);
660 }
661
662 input:focus, select:focus {
663 outline: none;
664 border-color: var(--accent);
665 }
666
667 .hint {
668 font-size: var(--text-xs);
669 color: var(--text-muted);
670 margin: var(--space-1) 0 0 0;
671 }
672
673 .error {
674 padding: var(--space-3);
675 background: var(--error-bg);
676 border: 1px solid var(--error-border);
677 border-radius: var(--radius-md);
678 color: var(--error-text);
679 margin-bottom: var(--space-4);
680 }
681
682 .actions {
683 display: flex;
684 gap: var(--space-4);
685 margin-top: var(--space-2);
686 }
687
688 button.primary {
689 flex: 1;
690 padding: var(--space-3);
691 background: var(--accent);
692 color: var(--text-inverse);
693 border: none;
694 border-radius: var(--radius-md);
695 font-size: var(--text-base);
696 cursor: pointer;
697 transition: background-color var(--transition-fast);
698 }
699
700 button.primary:hover:not(:disabled) {
701 background: var(--accent-hover);
702 }
703
704 button.primary:disabled {
705 opacity: 0.6;
706 cursor: not-allowed;
707 }
708
709 .secondary-actions {
710 display: flex;
711 justify-content: center;
712 gap: var(--space-4);
713 margin-top: var(--space-4);
714 }
715
716 .link-btn {
717 background: none;
718 border: none;
719 color: var(--accent);
720 cursor: pointer;
721 font-size: var(--text-sm);
722 padding: var(--space-2);
723 }
724
725 .link-btn:hover {
726 text-decoration: underline;
727 }
728
729 .contact-fields {
730 display: flex;
731 flex-direction: column;
732 gap: var(--space-4);
733 }
734
735 .required {
736 color: var(--error-text);
737 }
738
739 .passkey-step {
740 display: flex;
741 flex-direction: column;
742 gap: var(--space-4);
743 }
744
745 .passkey-step h2 {
746 margin: 0;
747 }
748
749 .passkey-step p {
750 color: var(--text-secondary);
751 margin: 0;
752 }
753
754 fieldset {
755 border: 1px solid var(--border-color);
756 border-radius: var(--radius-md);
757 padding: var(--space-4);
758 }
759
760 legend {
761 padding: 0 var(--space-2);
762 font-weight: var(--font-medium);
763 }
764
765 .spinner {
766 width: 32px;
767 height: 32px;
768 border: 3px solid var(--border-color);
769 border-top-color: var(--accent);
770 border-radius: 50%;
771 animation: spin 1s linear infinite;
772 }
773
774 @keyframes spin {
775 to {
776 transform: rotate(360deg);
777 }
778 }
779</style>