forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
1<script lang="ts">
2 import { onMount } from 'svelte'
3 import { _ } from '../lib/i18n'
4 import { toast } from '../lib/toast.svelte'
5 import SsoIcon from '../components/SsoIcon.svelte'
6
7 interface PendingRegistration {
8 request_uri: string
9 provider: string
10 provider_user_id: string
11 provider_username: string | null
12 provider_email: string | null
13 provider_email_verified: boolean
14 }
15
16 interface CommsChannelConfig {
17 email: boolean
18 discord: boolean
19 telegram: boolean
20 signal: boolean
21 }
22
23 let pending = $state<PendingRegistration | null>(null)
24 let loading = $state(true)
25 let submitting = $state(false)
26 let error = $state<string | null>(null)
27
28 let handle = $state('')
29 let email = $state('')
30 let providerEmailOriginal = $state<string | null>(null)
31 let inviteCode = $state('')
32 let verificationChannel = $state('email')
33 let discordId = $state('')
34 let telegramUsername = $state('')
35 let signalNumber = $state('')
36
37 let handleAvailable = $state<boolean | null>(null)
38 let checkingHandle = $state(false)
39 let handleError = $state<string | null>(null)
40
41 let didType = $state<'plc' | 'web' | 'web-external'>('plc')
42 let externalDid = $state('')
43
44 let serverInfo = $state<{
45 availableUserDomains: string[]
46 inviteCodeRequired: boolean
47 selfHostedDidWebEnabled: boolean
48 } | null>(null)
49
50 let commsChannels = $state<CommsChannelConfig>({
51 email: true,
52 discord: false,
53 telegram: false,
54 signal: false,
55 })
56
57 function getToken(): string | null {
58 const params = new URLSearchParams(window.location.search)
59 return params.get('token')
60 }
61
62 function getProviderDisplayName(provider: string): string {
63 const names: Record<string, string> = {
64 github: 'GitHub',
65 discord: 'Discord',
66 google: 'Google',
67 gitlab: 'GitLab',
68 oidc: 'SSO',
69 }
70 return names[provider] || provider
71 }
72
73 function isChannelAvailable(ch: string): boolean {
74 return commsChannels[ch as keyof CommsChannelConfig] ?? false
75 }
76
77 function extractDomain(did: string): string {
78 return did.replace('did:web:', '').replace(/%3A/g, ':')
79 }
80
81 let fullHandle = $derived(() => {
82 if (!handle.trim()) return ''
83 const domain = serverInfo?.availableUserDomains?.[0]
84 return domain ? `${handle.trim()}.${domain}` : handle.trim()
85 })
86
87 onMount(() => {
88 loadPendingRegistration()
89 loadServerInfo()
90 })
91
92 async function loadServerInfo() {
93 try {
94 const response = await fetch('/xrpc/com.atproto.server.describeServer')
95 if (response.ok) {
96 const data = await response.json()
97 serverInfo = {
98 availableUserDomains: data.availableUserDomains || [],
99 inviteCodeRequired: data.inviteCodeRequired ?? false,
100 selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false,
101 }
102 if (data.commsChannels) {
103 commsChannels = {
104 email: data.commsChannels.email ?? true,
105 discord: data.commsChannels.discord ?? false,
106 telegram: data.commsChannels.telegram ?? false,
107 signal: data.commsChannels.signal ?? false,
108 }
109 }
110 }
111 } catch {
112 serverInfo = null
113 }
114 }
115
116 async function loadPendingRegistration() {
117 const token = getToken()
118 if (!token) {
119 error = $_('sso_register.error_expired')
120 loading = false
121 return
122 }
123
124 try {
125 const response = await fetch(`/oauth/sso/pending-registration?token=${encodeURIComponent(token)}`)
126 if (!response.ok) {
127 const data = await response.json()
128 error = data.message || $_('sso_register.error_expired')
129 loading = false
130 return
131 }
132
133 pending = await response.json()
134 if (pending?.provider_email) {
135 email = pending.provider_email
136 providerEmailOriginal = pending.provider_email
137 }
138 if (pending?.provider_username) {
139 handle = pending.provider_username.toLowerCase().replace(/[^a-z0-9-]/g, '')
140 }
141 } catch {
142 error = $_('sso_register.error_expired')
143 } finally {
144 loading = false
145 }
146 }
147
148 let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null
149
150 $effect(() => {
151 if (checkHandleTimeout) {
152 clearTimeout(checkHandleTimeout)
153 }
154 handleAvailable = null
155 handleError = null
156 if (handle.length >= 3) {
157 checkHandleTimeout = setTimeout(() => checkHandleAvailability(), 400)
158 }
159 })
160
161 async function checkHandleAvailability() {
162 if (!handle || handle.length < 3) return
163
164 checkingHandle = true
165 handleError = null
166
167 try {
168 const response = await fetch(`/oauth/sso/check-handle-available?handle=${encodeURIComponent(handle)}`)
169 const data = await response.json()
170 handleAvailable = data.available
171 if (!data.available && data.reason) {
172 handleError = data.reason
173 }
174 } catch {
175 handleAvailable = null
176 handleError = $_('common.error')
177 } finally {
178 checkingHandle = false
179 }
180 }
181
182 let usingVerifiedProviderEmail = $derived(
183 pending?.provider_email_verified &&
184 verificationChannel === 'email' &&
185 email.trim().toLowerCase() === providerEmailOriginal?.toLowerCase()
186 )
187
188 function isChannelValid(): boolean {
189 switch (verificationChannel) {
190 case 'email':
191 return !!email.trim()
192 case 'discord':
193 return !!discordId.trim()
194 case 'telegram':
195 return !!telegramUsername.trim()
196 case 'signal':
197 return !!signalNumber.trim()
198 default:
199 return false
200 }
201 }
202
203 async function handleSubmit(e: Event) {
204 e.preventDefault()
205 const token = getToken()
206 if (!token || !pending) return
207
208 if (!handle || handle.length < 3) {
209 handleError = $_('sso_register.error_handle_required')
210 return
211 }
212
213 if (handleAvailable === false) {
214 handleError = $_('sso_register.handle_taken')
215 return
216 }
217
218 if (!isChannelValid()) {
219 toast.error($_(`register.validation.${verificationChannel === 'email' ? 'emailRequired' : verificationChannel + 'Required'}`))
220 return
221 }
222
223 submitting = true
224
225 try {
226 const response = await fetch('/oauth/sso/complete-registration', {
227 method: 'POST',
228 headers: {
229 'Content-Type': 'application/json',
230 'Accept': 'application/json',
231 },
232 body: JSON.stringify({
233 token,
234 handle,
235 email: email || null,
236 invite_code: inviteCode || null,
237 verification_channel: verificationChannel,
238 discord_id: discordId || null,
239 telegram_username: telegramUsername || null,
240 signal_number: signalNumber || null,
241 did_type: didType,
242 did: didType === 'web-external' ? externalDid.trim() : null,
243 }),
244 })
245
246 const data = await response.json()
247
248 if (!response.ok) {
249 toast.error(data.message || data.error_description || data.error || $_('common.error'))
250 submitting = false
251 return
252 }
253
254 if (data.accessJwt && data.refreshJwt) {
255 localStorage.setItem('accessJwt', data.accessJwt)
256 localStorage.setItem('refreshJwt', data.refreshJwt)
257 }
258
259 if (data.redirectUrl) {
260 if (data.redirectUrl.startsWith('/app/verify')) {
261 localStorage.setItem('tranquil_pds_pending_verification', JSON.stringify({
262 did: data.did,
263 handle: data.handle,
264 channel: verificationChannel,
265 }))
266 const url = new URL(data.redirectUrl, window.location.origin)
267 url.searchParams.set('handle', data.handle)
268 url.searchParams.set('channel', verificationChannel)
269 window.location.href = url.pathname + url.search
270 return
271 }
272 window.location.href = data.redirectUrl
273 return
274 }
275
276 toast.error($_('common.error'))
277 submitting = false
278 } catch {
279 toast.error($_('common.error'))
280 submitting = false
281 }
282 }
283</script>
284
285<div class="sso-register-container">
286 {#if loading}
287 <div class="loading">
288 <div class="spinner"></div>
289 <p>{$_('common.loading')}</p>
290 </div>
291 {:else if error && !pending}
292 <div class="error-container">
293 <div class="error-icon">!</div>
294 <h2>{$_('common.error')}</h2>
295 <p>{error}</p>
296 <a href="/app/register-sso" class="back-link">{$_('sso_register.tryAgain')}</a>
297 </div>
298 {:else if pending}
299 <header class="page-header">
300 <h1>{$_('sso_register.title')}</h1>
301 <p class="subtitle">{$_('sso_register.subtitle', { values: { provider: getProviderDisplayName(pending.provider) } })}</p>
302 </header>
303
304 <div class="provider-info">
305 <div class="provider-badge">
306 <SsoIcon provider={pending.provider} size={32} />
307 <div class="provider-details">
308 <span class="provider-name">{getProviderDisplayName(pending.provider)}</span>
309 {#if pending.provider_username}
310 <span class="provider-username">@{pending.provider_username}</span>
311 {/if}
312 </div>
313 </div>
314 </div>
315
316 <div class="split-layout sidebar-right">
317 <div class="form-section">
318 <form onsubmit={handleSubmit}>
319 <div class="field">
320 <label for="handle">{$_('sso_register.handle_label')}</label>
321 <input
322 id="handle"
323 type="text"
324 bind:value={handle}
325 placeholder={$_('register.handlePlaceholder')}
326 disabled={submitting}
327 required
328 autocomplete="off"
329 />
330 {#if checkingHandle}
331 <p class="hint">{$_('common.checking')}</p>
332 {:else if handleError}
333 <p class="hint error">{handleError}</p>
334 {:else if handleAvailable === false}
335 <p class="hint error">{$_('sso_register.handle_taken')}</p>
336 {:else if handleAvailable === true}
337 <p class="hint success">{$_('sso_register.handle_available')}</p>
338 {:else if fullHandle()}
339 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
340 {/if}
341 </div>
342
343 <fieldset>
344 <legend>{$_('register.contactMethod')}</legend>
345 <div class="contact-fields">
346 <div class="field">
347 <label for="verification-channel">{$_('register.verificationMethod')}</label>
348 <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}>
349 <option value="email">{$_('register.email')}</option>
350 <option value="discord" disabled={!isChannelAvailable('discord')}>
351 {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
352 </option>
353 <option value="telegram" disabled={!isChannelAvailable('telegram')}>
354 {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
355 </option>
356 <option value="signal" disabled={!isChannelAvailable('signal')}>
357 {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
358 </option>
359 </select>
360 </div>
361
362 {#if verificationChannel === 'email'}
363 <div class="field">
364 <label for="email">{$_('register.emailAddress')}</label>
365 <input
366 id="email"
367 type="email"
368 bind:value={email}
369 placeholder={$_('register.emailPlaceholder')}
370 disabled={submitting}
371 required
372 />
373 {#if pending?.provider_email && pending?.provider_email_verified}
374 {#if usingVerifiedProviderEmail}
375 <p class="hint success">{$_('sso_register.emailVerifiedByProvider', { values: { provider: getProviderDisplayName(pending.provider) } })}</p>
376 {:else}
377 <p class="hint">{$_('sso_register.emailChangedNeedsVerification')}</p>
378 {/if}
379 {/if}
380 </div>
381 {:else if verificationChannel === 'discord'}
382 <div class="field">
383 <label for="discord-id">{$_('register.discordId')}</label>
384 <input
385 id="discord-id"
386 type="text"
387 bind:value={discordId}
388 placeholder={$_('register.discordIdPlaceholder')}
389 disabled={submitting}
390 required
391 />
392 <p class="hint">{$_('register.discordIdHint')}</p>
393 </div>
394 {:else if verificationChannel === 'telegram'}
395 <div class="field">
396 <label for="telegram-username">{$_('register.telegramUsername')}</label>
397 <input
398 id="telegram-username"
399 type="text"
400 bind:value={telegramUsername}
401 placeholder={$_('register.telegramUsernamePlaceholder')}
402 disabled={submitting}
403 required
404 />
405 </div>
406 {:else if verificationChannel === 'signal'}
407 <div class="field">
408 <label for="signal-number">{$_('register.signalNumber')}</label>
409 <input
410 id="signal-number"
411 type="tel"
412 bind:value={signalNumber}
413 placeholder={$_('register.signalNumberPlaceholder')}
414 disabled={submitting}
415 required
416 />
417 <p class="hint">{$_('register.signalNumberHint')}</p>
418 </div>
419 {/if}
420 </div>
421 </fieldset>
422
423 <fieldset>
424 <legend>{$_('registerPasskey.identityType')}</legend>
425 <p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
426 <div class="radio-group">
427 <label class="radio-label">
428 <input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} />
429 <span class="radio-content">
430 <strong>{$_('registerPasskey.didPlcRecommended')}</strong>
431 <span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
432 </span>
433 </label>
434 <label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
435 <input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting || serverInfo?.selfHostedDidWebEnabled === false} />
436 <span class="radio-content">
437 <strong>{$_('registerPasskey.didWeb')}</strong>
438 {#if serverInfo?.selfHostedDidWebEnabled === false}
439 <span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
440 {:else}
441 <span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
442 {/if}
443 </span>
444 </label>
445 <label class="radio-label">
446 <input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} />
447 <span class="radio-content">
448 <strong>{$_('registerPasskey.didWebBYOD')}</strong>
449 <span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
450 </span>
451 </label>
452 </div>
453 {#if didType === 'web'}
454 <div class="warning-box">
455 <strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
456 <ul>
457 <li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
458 <li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
459 <li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
460 <li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
461 </ul>
462 </div>
463 {/if}
464 {#if didType === 'web-external'}
465 <div class="field">
466 <label for="external-did">{$_('registerPasskey.externalDid')}</label>
467 <input id="external-did" type="text" bind:value={externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={submitting} required />
468 <p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{externalDid ? extractDomain(externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
469 </div>
470 {/if}
471 </fieldset>
472
473 {#if serverInfo?.inviteCodeRequired}
474 <div class="field">
475 <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
476 <input
477 id="invite-code"
478 type="text"
479 bind:value={inviteCode}
480 placeholder={$_('register.inviteCodePlaceholder')}
481 disabled={submitting}
482 required
483 />
484 </div>
485 {/if}
486
487 <button type="submit" disabled={submitting || !handle || handle.length < 3 || handleAvailable === false || checkingHandle || !isChannelValid()}>
488 {submitting ? $_('common.creating') : $_('sso_register.submit')}
489 </button>
490 </form>
491 </div>
492
493 <aside class="info-panel">
494 <h3>{$_('sso_register.infoAfterTitle')}</h3>
495 <ul class="info-list">
496 <li>{$_('sso_register.infoAddPassword')}</li>
497 <li>{$_('sso_register.infoAddPasskey')}</li>
498 <li>{$_('sso_register.infoLinkProviders')}</li>
499 <li>{$_('sso_register.infoChangeHandle')}</li>
500 </ul>
501 </aside>
502 </div>
503 {/if}
504</div>
505
506<style>
507 .sso-register-container {
508 max-width: var(--width-lg);
509 margin: var(--space-9) auto;
510 padding: var(--space-7);
511 }
512
513 .loading {
514 display: flex;
515 flex-direction: column;
516 align-items: center;
517 gap: var(--space-4);
518 padding: var(--space-8);
519 }
520
521 .loading p {
522 color: var(--text-secondary);
523 }
524
525 .error-container {
526 text-align: center;
527 padding: var(--space-8);
528 }
529
530 .error-icon {
531 width: 48px;
532 height: 48px;
533 border-radius: 50%;
534 background: var(--error-text);
535 color: var(--text-inverse);
536 display: flex;
537 align-items: center;
538 justify-content: center;
539 font-size: 24px;
540 font-weight: bold;
541 margin: 0 auto var(--space-4);
542 }
543
544 .error-container h2 {
545 margin-bottom: var(--space-2);
546 }
547
548 .error-container p {
549 color: var(--text-secondary);
550 margin-bottom: var(--space-6);
551 }
552
553 .back-link {
554 color: var(--accent);
555 text-decoration: none;
556 }
557
558 .back-link:hover {
559 text-decoration: underline;
560 }
561
562 .page-header {
563 margin-bottom: var(--space-6);
564 }
565
566 .page-header h1 {
567 margin: 0 0 var(--space-3) 0;
568 }
569
570 .subtitle {
571 color: var(--text-secondary);
572 margin: 0;
573 }
574
575 .form-section {
576 min-width: 0;
577 }
578
579 form {
580 display: flex;
581 flex-direction: column;
582 gap: var(--space-5);
583 }
584
585 .contact-fields {
586 display: flex;
587 flex-direction: column;
588 gap: var(--space-4);
589 }
590
591 .contact-fields .field {
592 margin-bottom: 0;
593 }
594
595 .hint.success {
596 color: var(--success-text);
597 }
598
599 .hint.error {
600 color: var(--error-text);
601 }
602
603 .info-panel {
604 background: var(--bg-secondary);
605 border-radius: var(--radius-xl);
606 padding: var(--space-6);
607 }
608
609 .info-panel h3 {
610 margin: 0 0 var(--space-4) 0;
611 font-size: var(--text-base);
612 font-weight: var(--font-semibold);
613 }
614
615 .info-list {
616 margin: 0;
617 padding-left: var(--space-5);
618 }
619
620 .info-list li {
621 margin-bottom: var(--space-2);
622 font-size: var(--text-sm);
623 color: var(--text-secondary);
624 line-height: var(--leading-relaxed);
625 }
626
627 .info-list li:last-child {
628 margin-bottom: 0;
629 }
630
631 .provider-info {
632 margin-bottom: var(--space-6);
633 }
634
635 .provider-badge {
636 display: flex;
637 align-items: center;
638 gap: var(--space-3);
639 padding: var(--space-4);
640 background: var(--bg-secondary);
641 border-radius: var(--radius-md);
642 }
643
644 .provider-details {
645 display: flex;
646 flex-direction: column;
647 }
648
649 .provider-name {
650 font-weight: var(--font-semibold);
651 }
652
653 .provider-username {
654 font-size: var(--text-sm);
655 color: var(--text-secondary);
656 }
657
658 .required {
659 color: var(--error-text);
660 }
661
662 button[type="submit"] {
663 margin-top: var(--space-3);
664 }
665
666 .spinner {
667 width: 32px;
668 height: 32px;
669 border: 3px solid var(--border-color);
670 border-top-color: var(--accent);
671 border-radius: 50%;
672 animation: spin 1s linear infinite;
673 }
674
675 @keyframes spin {
676 to {
677 transform: rotate(360deg);
678 }
679 }
680</style>