-1093
Diff
round #2
-585
frontend/src/routes/OAuthRegister.svelte
-585
frontend/src/routes/OAuthRegister.svelte
···
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
-
import HandleInput from '../components/HandleInput.svelte'
20
-
21
-
let serverInfo = $state<{
22
-
availableUserDomains: string[]
23
-
inviteCodeRequired: boolean
24
-
availableCommsChannels?: string[]
25
-
selfHostedDidWebEnabled?: boolean
26
-
} | null>(null)
27
-
let loadingServerInfo = $state(true)
28
-
let serverInfoLoaded = false
29
-
let ssoAvailable = $state(false)
30
-
31
-
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
32
-
let passkeyName = $state('')
33
-
let clientName = $state<string | null>(null)
34
-
let selectedDomain = $state('')
35
-
36
-
function getRequestUri(): string | null {
37
-
const params = new URLSearchParams(window.location.search)
38
-
return params.get('request_uri')
39
-
}
40
-
41
-
$effect(() => {
42
-
if (!serverInfoLoaded) {
43
-
serverInfoLoaded = true
44
-
loadServerInfo()
45
-
fetchClientName()
46
-
checkSsoAvailable()
47
-
}
48
-
})
49
-
50
-
async function checkSsoAvailable() {
51
-
try {
52
-
const response = await fetch('/oauth/sso/providers')
53
-
if (response.ok) {
54
-
const data = await response.json()
55
-
ssoAvailable = (data.providers?.length ?? 0) > 0
56
-
}
57
-
} catch {
58
-
ssoAvailable = false
59
-
}
60
-
}
61
-
62
-
async function fetchClientName() {
63
-
const requestUri = getRequestUri()
64
-
if (!requestUri) return
65
-
66
-
try {
67
-
const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, {
68
-
headers: { 'Accept': 'application/json' }
69
-
})
70
-
if (response.ok) {
71
-
const data = await response.json()
72
-
clientName = data.client_name || null
73
-
}
74
-
} catch {
75
-
clientName = null
76
-
}
77
-
}
78
-
79
-
$effect(() => {
80
-
if (flow?.state.step === 'redirect-to-dashboard') {
81
-
completeOAuthRegistration()
82
-
}
83
-
})
84
-
85
-
let creatingStarted = false
86
-
$effect(() => {
87
-
if (flow?.state.step === 'creating' && !creatingStarted) {
88
-
creatingStarted = true
89
-
flow.createPasskeyAccount()
90
-
}
91
-
})
92
-
93
-
async function loadServerInfo() {
94
-
try {
95
-
const restored = restoreRegistrationFlow()
96
-
if (restored && restored.state.mode === 'passkey') {
97
-
flow = restored
98
-
serverInfo = await api.describeServer()
99
-
} else {
100
-
serverInfo = await api.describeServer()
101
-
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
102
-
flow = createRegistrationFlow('passkey', hostname)
103
-
}
104
-
selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname
105
-
if (flow) flow.setSelectedDomain(selectedDomain)
106
-
} catch (e) {
107
-
console.error('Failed to load server info:', e)
108
-
} finally {
109
-
loadingServerInfo = false
110
-
}
111
-
}
112
-
113
-
function validateInfoStep(): string | null {
114
-
if (!flow) return 'Flow not initialized'
115
-
const info = flow.info
116
-
if (!info.handle.trim()) return $_('registerPasskey.errors.handleRequired')
117
-
if (info.handle.includes('.')) return $_('registerPasskey.errors.handleNoDots')
118
-
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
119
-
return $_('registerPasskey.errors.inviteRequired')
120
-
}
121
-
if (info.didType === 'web-external') {
122
-
if (!info.externalDid?.trim()) return $_('registerPasskey.errors.externalDidRequired')
123
-
if (!info.externalDid.trim().startsWith('did:web:')) return $_('registerPasskey.errors.externalDidFormat')
124
-
}
125
-
switch (info.verificationChannel) {
126
-
case 'email':
127
-
if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired')
128
-
break
129
-
case 'discord':
130
-
if (!info.discordUsername?.trim()) return $_('registerPasskey.errors.discordRequired')
131
-
break
132
-
case 'telegram':
133
-
if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired')
134
-
break
135
-
case 'signal':
136
-
if (!info.signalUsername?.trim()) return $_('registerPasskey.errors.signalRequired')
137
-
break
138
-
}
139
-
return null
140
-
}
141
-
142
-
async function handleInfoSubmit(e: Event) {
143
-
e.preventDefault()
144
-
if (!flow) return
145
-
146
-
const validationError = validateInfoStep()
147
-
if (validationError) {
148
-
flow.setError(validationError)
149
-
return
150
-
}
151
-
152
-
if (!window.PublicKeyCredential) {
153
-
flow.setError($_('registerPasskey.errors.passkeysNotSupported'))
154
-
return
155
-
}
156
-
157
-
flow.clearError()
158
-
flow.proceedFromInfo()
159
-
}
160
-
161
-
async function handlePasskeyRegistration() {
162
-
if (!flow || !flow.account) return
163
-
164
-
flow.setSubmitting(true)
165
-
flow.clearError()
166
-
167
-
try {
168
-
const { options } = await api.startPasskeyRegistrationForSetup(
169
-
flow.account.did,
170
-
flow.account.setupToken!,
171
-
passkeyName || undefined
172
-
)
173
-
174
-
const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse)
175
-
const credential = await navigator.credentials.create({
176
-
publicKey: publicKeyOptions
177
-
})
178
-
179
-
if (!credential) {
180
-
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
181
-
flow.setSubmitting(false)
182
-
return
183
-
}
184
-
185
-
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
186
-
187
-
const result = await api.completePasskeySetup(
188
-
flow.account.did,
189
-
flow.account.setupToken!,
190
-
credentialResponse,
191
-
passkeyName || undefined
192
-
)
193
-
194
-
flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
195
-
} catch (err) {
196
-
if (err instanceof DOMException && err.name === 'NotAllowedError') {
197
-
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
198
-
} else if (err instanceof Error) {
199
-
flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
200
-
} else {
201
-
flow.setError($_('registerPasskey.errors.passkeyFailed'))
202
-
}
203
-
} finally {
204
-
flow.setSubmitting(false)
205
-
}
206
-
}
207
-
208
-
async function completeOAuthRegistration() {
209
-
const requestUri = getRequestUri()
210
-
if (!requestUri || !flow?.account) {
211
-
navigate(routes.dashboard)
212
-
return
213
-
}
214
-
215
-
try {
216
-
const response = await fetch('/oauth/register/complete', {
217
-
method: 'POST',
218
-
headers: {
219
-
'Content-Type': 'application/json',
220
-
'Accept': 'application/json',
221
-
},
222
-
body: JSON.stringify({
223
-
request_uri: requestUri,
224
-
did: flow.account.did,
225
-
app_password: flow.account.appPassword,
226
-
}),
227
-
})
228
-
229
-
const data = await response.json()
230
-
231
-
if (!response.ok) {
232
-
flow.setError(data.error_description || data.error || $_('common.error'))
233
-
return
234
-
}
235
-
236
-
if (data.redirect_uri) {
237
-
window.location.href = data.redirect_uri
238
-
return
239
-
}
240
-
241
-
navigate(routes.dashboard)
242
-
} catch {
243
-
flow.setError($_('common.error'))
244
-
}
245
-
}
246
-
247
-
function isChannelAvailable(ch: string): boolean {
248
-
const available = serverInfo?.availableCommsChannels ?? ['email']
249
-
return available.includes(ch)
250
-
}
251
-
252
-
function channelLabel(ch: string): string {
253
-
switch (ch) {
254
-
case 'email':
255
-
return $_('register.email')
256
-
case 'discord':
257
-
return $_('register.discord')
258
-
case 'telegram':
259
-
return $_('register.telegram')
260
-
case 'signal':
261
-
return $_('register.signal')
262
-
default:
263
-
return ch
264
-
}
265
-
}
266
-
267
-
let fullHandle = $derived(() => {
268
-
if (!flow?.info.handle.trim()) return ''
269
-
if (flow.info.handle.includes('.')) return flow.info.handle.trim()
270
-
return selectedDomain ? `${flow.info.handle.trim()}.${selectedDomain}` : flow.info.handle.trim()
271
-
})
272
-
273
-
async function handleCancel() {
274
-
const requestUri = getRequestUri()
275
-
if (!requestUri) {
276
-
window.history.back()
277
-
return
278
-
}
279
-
280
-
try {
281
-
const response = await fetch('/oauth/authorize/deny', {
282
-
method: 'POST',
283
-
headers: {
284
-
'Content-Type': 'application/json',
285
-
'Accept': 'application/json'
286
-
},
287
-
body: JSON.stringify({ request_uri: requestUri })
288
-
})
289
-
290
-
const data = await response.json()
291
-
if (data.redirect_uri) {
292
-
window.location.href = data.redirect_uri
293
-
}
294
-
} catch {
295
-
window.history.back()
296
-
}
297
-
}
298
-
299
-
function goToLogin() {
300
-
const requestUri = getRequestUri()
301
-
if (requestUri) {
302
-
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
303
-
} else {
304
-
navigate(routes.login)
305
-
}
306
-
}
307
-
</script>
308
-
309
-
<div class="oauth-register-container">
310
-
{#if loadingServerInfo}
311
-
<div class="loading"></div>
312
-
{:else if flow}
313
-
<header class="page-header">
314
-
<h1>{$_('oauth.register.title')}</h1>
315
-
<p class="subtitle">
316
-
{#if clientName}
317
-
{$_('oauth.register.subtitle')} <strong>{clientName}</strong>
318
-
{:else}
319
-
{$_('oauth.register.subtitleGeneric')}
320
-
{/if}
321
-
</p>
322
-
</header>
323
-
324
-
{#if flow.state.error}
325
-
<div class="error">{flow.state.error}</div>
326
-
{/if}
327
-
328
-
{#if flow.state.step === 'info'}
329
-
<div class="migrate-callout">
330
-
<div class="migrate-icon">↗</div>
331
-
<div class="migrate-content">
332
-
<strong>{$_('register.migrateTitle')}</strong>
333
-
<p>{$_('register.migrateDescription')}</p>
334
-
<a href={getFullUrl(routes.migrate)} class="migrate-link">
335
-
{$_('register.migrateLink')} →
336
-
</a>
337
-
</div>
338
-
</div>
339
-
340
-
<AccountTypeSwitcher active="passkey" {ssoAvailable} oauthRequestUri={getRequestUri()} />
341
-
342
-
<div class="split-layout">
343
-
<div class="form-section">
344
-
<form onsubmit={handleInfoSubmit}>
345
-
<div>
346
-
<label for="handle">{$_('register.handle')}</label>
347
-
<HandleInput
348
-
value={flow.info.handle}
349
-
domains={serverInfo?.availableUserDomains ?? []}
350
-
{selectedDomain}
351
-
placeholder={$_('register.handlePlaceholder')}
352
-
disabled={flow.state.submitting}
353
-
onInput={(v) => { flow!.info.handle = v }}
354
-
onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }}
355
-
/>
356
-
{#if fullHandle()}
357
-
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
358
-
{/if}
359
-
</div>
360
-
361
-
<fieldset>
362
-
<legend>{$_('register.contactMethod')}</legend>
363
-
<div class="contact-fields">
364
-
<div class="field">
365
-
<label for="verification-channel">{$_('register.verificationMethod')}</label>
366
-
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
367
-
<option value="email">{channelLabel('email')}</option>
368
-
{#if isChannelAvailable('discord')}
369
-
<option value="discord">{channelLabel('discord')}</option>
370
-
{/if}
371
-
{#if isChannelAvailable('telegram')}
372
-
<option value="telegram">{channelLabel('telegram')}</option>
373
-
{/if}
374
-
{#if isChannelAvailable('signal')}
375
-
<option value="signal">{channelLabel('signal')}</option>
376
-
{/if}
377
-
</select>
378
-
</div>
379
-
380
-
{#if flow.info.verificationChannel === 'email'}
381
-
<div class="field">
382
-
<label for="email">{$_('register.emailAddress')}</label>
383
-
<input
384
-
id="email"
385
-
type="email"
386
-
bind:value={flow.info.email}
387
-
placeholder={$_('register.emailPlaceholder')}
388
-
disabled={flow.state.submitting}
389
-
required
390
-
/>
391
-
</div>
392
-
{:else if flow.info.verificationChannel === 'discord'}
393
-
<div class="field">
394
-
<label for="discord-username">{$_('register.discordUsername')}</label>
395
-
<input
396
-
id="discord-username"
397
-
type="text"
398
-
bind:value={flow.info.discordUsername}
399
-
placeholder={$_('register.discordUsernamePlaceholder')}
400
-
disabled={flow.state.submitting}
401
-
required
402
-
/>
403
-
</div>
404
-
{:else if flow.info.verificationChannel === 'telegram'}
405
-
<div class="field">
406
-
<label for="telegram-username">{$_('register.telegramUsername')}</label>
407
-
<input
408
-
id="telegram-username"
409
-
type="text"
410
-
bind:value={flow.info.telegramUsername}
411
-
placeholder={$_('register.telegramUsernamePlaceholder')}
412
-
disabled={flow.state.submitting}
413
-
required
414
-
/>
415
-
</div>
416
-
{:else if flow.info.verificationChannel === 'signal'}
417
-
<div class="field">
418
-
<label for="signal-number">{$_('register.signalUsername')}</label>
419
-
<input
420
-
id="signal-number"
421
-
type="tel"
422
-
bind:value={flow.info.signalUsername}
423
-
placeholder={$_('register.signalUsernamePlaceholder')}
424
-
disabled={flow.state.submitting}
425
-
required
426
-
/>
427
-
<p class="hint">{$_('register.signalUsernameHint')}</p>
428
-
</div>
429
-
{/if}
430
-
</div>
431
-
</fieldset>
432
-
433
-
<fieldset>
434
-
<legend>{$_('registerPasskey.identityType')}</legend>
435
-
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
436
-
<div class="radio-group">
437
-
<label class="radio-label">
438
-
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
439
-
<span class="radio-content">
440
-
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
441
-
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
442
-
</span>
443
-
</label>
444
-
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
445
-
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
446
-
<span class="radio-content">
447
-
<strong>{$_('registerPasskey.didWeb')}</strong>
448
-
{#if serverInfo?.selfHostedDidWebEnabled === false}
449
-
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
450
-
{:else}
451
-
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
452
-
{/if}
453
-
</span>
454
-
</label>
455
-
<label class="radio-label">
456
-
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
457
-
<span class="radio-content">
458
-
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
459
-
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
460
-
</span>
461
-
</label>
462
-
</div>
463
-
{#if flow.info.didType === 'web'}
464
-
<div class="warning-box">
465
-
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
466
-
<ul>
467
-
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
468
-
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
469
-
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
470
-
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
471
-
</ul>
472
-
</div>
473
-
{/if}
474
-
{#if flow.info.didType === 'web-external'}
475
-
<div class="field">
476
-
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
477
-
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required />
478
-
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? flow.extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
479
-
</div>
480
-
{/if}
481
-
</fieldset>
482
-
483
-
{#if serverInfo?.inviteCodeRequired}
484
-
<div>
485
-
<label for="invite-code">{$_('register.inviteCode')} <span class="required">*</span></label>
486
-
<input
487
-
id="invite-code"
488
-
type="text"
489
-
bind:value={flow.info.inviteCode}
490
-
placeholder={$_('register.inviteCodePlaceholder')}
491
-
disabled={flow.state.submitting}
492
-
required
493
-
/>
494
-
</div>
495
-
{/if}
496
-
497
-
<div class="actions">
498
-
<button type="submit" class="primary" disabled={flow.state.submitting}>
499
-
{flow.state.submitting ? $_('common.loading') : $_('common.continue')}
500
-
</button>
501
-
</div>
502
-
503
-
<div class="secondary-actions">
504
-
<button type="button" class="link" onclick={goToLogin}>
505
-
{$_('oauth.register.haveAccount')}
506
-
</button>
507
-
<button type="button" class="link" onclick={handleCancel}>
508
-
{$_('common.cancel')}
509
-
</button>
510
-
</div>
511
-
</form>
512
-
513
-
<div class="form-links">
514
-
<p class="link-text">
515
-
{$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a>
516
-
</p>
517
-
</div>
518
-
</div>
519
-
520
-
<aside class="info-panel">
521
-
<h3>{$_('registerPasskey.infoWhyPasskey')}</h3>
522
-
<p>{$_('registerPasskey.infoWhyPasskeyDesc')}</p>
523
-
524
-
<h3>{$_('registerPasskey.infoHowItWorks')}</h3>
525
-
<p>{$_('registerPasskey.infoHowItWorksDesc')}</p>
526
-
527
-
<h3>{$_('registerPasskey.infoAppAccess')}</h3>
528
-
<p>{$_('registerPasskey.infoAppAccessDesc')}</p>
529
-
</aside>
530
-
</div>
531
-
532
-
{:else if flow.state.step === 'key-choice'}
533
-
<KeyChoiceStep {flow} />
534
-
535
-
{:else if flow.state.step === 'initial-did-doc'}
536
-
<DidDocStep {flow} type="initial" onConfirm={() => flow?.createPasskeyAccount()} onBack={() => flow?.goBack()} />
537
-
538
-
{:else if flow.state.step === 'creating'}
539
-
<div class="creating">
540
-
<p>{$_('registerPasskey.creatingAccount')}</p>
541
-
</div>
542
-
543
-
{:else if flow.state.step === 'passkey'}
544
-
<div class="passkey-step">
545
-
<h2>{$_('registerPasskey.setupPasskey')}</h2>
546
-
<p>{$_('registerPasskey.passkeyDescription')}</p>
547
-
548
-
<div class="field">
549
-
<label for="passkey-name">{$_('registerPasskey.passkeyName')}</label>
550
-
<input
551
-
id="passkey-name"
552
-
type="text"
553
-
bind:value={passkeyName}
554
-
placeholder={$_('registerPasskey.passkeyNamePlaceholder')}
555
-
disabled={flow.state.submitting}
556
-
/>
557
-
<p class="hint">{$_('registerPasskey.passkeyNameHint')}</p>
558
-
</div>
559
-
560
-
<button
561
-
type="button"
562
-
class="primary"
563
-
onclick={handlePasskeyRegistration}
564
-
disabled={flow.state.submitting}
565
-
>
566
-
{flow.state.submitting ? $_('common.loading') : $_('registerPasskey.registerPasskey')}
567
-
</button>
568
-
</div>
569
-
570
-
{:else if flow.state.step === 'app-password'}
571
-
<AppPasswordStep {flow} />
572
-
573
-
{:else if flow.state.step === 'verify'}
574
-
<VerificationStep {flow} />
575
-
576
-
{:else if flow.state.step === 'updated-did-doc'}
577
-
<DidDocStep {flow} type="updated" onConfirm={() => flow?.activateAccount()} />
578
-
579
-
{:else if flow.state.step === 'activating'}
580
-
<div class="creating">
581
-
<p>{$_('registerPasskey.activatingAccount')}</p>
582
-
</div>
583
-
{/if}
584
-
{/if}
585
-
</div>
-508
frontend/src/routes/OAuthSsoRegister.svelte
-508
frontend/src/routes/OAuthSsoRegister.svelte
···
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
-
import HandleInput from '../components/HandleInput.svelte'
7
-
8
-
interface PendingRegistration {
9
-
request_uri: string
10
-
provider: string
11
-
provider_user_id: string
12
-
provider_username: string | null
13
-
provider_email: string | null
14
-
provider_email_verified: boolean
15
-
}
16
-
17
-
interface CommsChannelConfig {
18
-
email: boolean
19
-
discord: boolean
20
-
telegram: boolean
21
-
signal: boolean
22
-
}
23
-
24
-
let pending = $state<PendingRegistration | null>(null)
25
-
let loading = $state(true)
26
-
let submitting = $state(false)
27
-
let error = $state<string | null>(null)
28
-
29
-
let handle = $state('')
30
-
let email = $state('')
31
-
let providerEmailOriginal = $state<string | null>(null)
32
-
let inviteCode = $state('')
33
-
let verificationChannel = $state('email')
34
-
let discordUsername = $state('')
35
-
let telegramUsername = $state('')
36
-
let signalUsername = $state('')
37
-
38
-
let handleAvailable = $state<boolean | null>(null)
39
-
let checkingHandle = $state(false)
40
-
let handleError = $state<string | null>(null)
41
-
let selectedDomain = $state('')
42
-
43
-
let didType = $state<'plc' | 'web' | 'web-external'>('plc')
44
-
let externalDid = $state('')
45
-
46
-
let serverInfo = $state<{
47
-
availableUserDomains: string[]
48
-
inviteCodeRequired: boolean
49
-
selfHostedDidWebEnabled: boolean
50
-
} | null>(null)
51
-
52
-
let commsChannels = $state<CommsChannelConfig>({
53
-
email: true,
54
-
discord: false,
55
-
telegram: false,
56
-
signal: false,
57
-
})
58
-
59
-
function getToken(): string | null {
60
-
const params = new URLSearchParams(window.location.search)
61
-
return params.get('token')
62
-
}
63
-
64
-
function getProviderDisplayName(provider: string): string {
65
-
const names: Record<string, string> = {
66
-
github: 'GitHub',
67
-
discord: 'Discord',
68
-
google: 'Google',
69
-
gitlab: 'GitLab',
70
-
oidc: 'SSO',
71
-
}
72
-
return names[provider] || provider
73
-
}
74
-
75
-
function isChannelAvailable(ch: string): boolean {
76
-
return commsChannels[ch as keyof CommsChannelConfig] ?? false
77
-
}
78
-
79
-
function extractDomain(did: string): string {
80
-
return did.replace('did:web:', '').replace(/%3A/g, ':')
81
-
}
82
-
83
-
let fullHandle = $derived(() => {
84
-
if (!handle.trim()) return ''
85
-
if (handle.includes('.')) return handle.trim()
86
-
return selectedDomain ? `${handle.trim()}.${selectedDomain}` : handle.trim()
87
-
})
88
-
89
-
onMount(() => {
90
-
loadPendingRegistration()
91
-
loadServerInfo()
92
-
})
93
-
94
-
async function loadServerInfo() {
95
-
try {
96
-
const response = await fetch('/xrpc/com.atproto.server.describeServer')
97
-
if (response.ok) {
98
-
const data = await response.json()
99
-
serverInfo = {
100
-
availableUserDomains: data.availableUserDomains || [],
101
-
inviteCodeRequired: data.inviteCodeRequired ?? false,
102
-
selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false,
103
-
}
104
-
const available: string[] = data.availableCommsChannels ?? ['email']
105
-
commsChannels = {
106
-
email: available.includes('email'),
107
-
discord: available.includes('discord'),
108
-
telegram: available.includes('telegram'),
109
-
signal: available.includes('signal'),
110
-
}
111
-
selectedDomain = data.availableUserDomains?.[0] || window.location.hostname
112
-
}
113
-
} catch {
114
-
serverInfo = null
115
-
}
116
-
}
117
-
118
-
async function loadPendingRegistration() {
119
-
const token = getToken()
120
-
if (!token) {
121
-
error = $_('sso_register.error_expired')
122
-
loading = false
123
-
return
124
-
}
125
-
126
-
try {
127
-
const response = await fetch(`/oauth/sso/pending-registration?token=${encodeURIComponent(token)}`)
128
-
if (!response.ok) {
129
-
const data = await response.json()
130
-
error = data.message || $_('sso_register.error_expired')
131
-
loading = false
132
-
return
133
-
}
134
-
135
-
pending = await response.json()
136
-
if (pending?.provider_email) {
137
-
email = pending.provider_email
138
-
providerEmailOriginal = pending.provider_email
139
-
}
140
-
if (pending?.provider_username) {
141
-
handle = pending.provider_username.toLowerCase().replace(/[^a-z0-9-]/g, '')
142
-
}
143
-
} catch {
144
-
error = $_('sso_register.error_expired')
145
-
} finally {
146
-
loading = false
147
-
}
148
-
}
149
-
150
-
let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null
151
-
152
-
$effect(() => {
153
-
void selectedDomain
154
-
if (checkHandleTimeout) {
155
-
clearTimeout(checkHandleTimeout)
156
-
}
157
-
handleAvailable = null
158
-
handleError = null
159
-
if (handle.length >= 3) {
160
-
checkHandleTimeout = setTimeout(() => checkHandleAvailability(), 400)
161
-
}
162
-
})
163
-
164
-
async function checkHandleAvailability() {
165
-
if (!handle || handle.length < 3) return
166
-
167
-
checkingHandle = true
168
-
handleError = null
169
-
170
-
try {
171
-
const params = new URLSearchParams({ handle })
172
-
if (selectedDomain) params.set('domain', selectedDomain)
173
-
const response = await fetch(`/oauth/sso/check-handle-available?${params}`)
174
-
const data = await response.json()
175
-
handleAvailable = data.available
176
-
if (!data.available && data.reason) {
177
-
handleError = data.reason
178
-
}
179
-
} catch {
180
-
handleAvailable = null
181
-
handleError = $_('common.error')
182
-
} finally {
183
-
checkingHandle = false
184
-
}
185
-
}
186
-
187
-
let usingVerifiedProviderEmail = $derived(
188
-
pending?.provider_email_verified &&
189
-
verificationChannel === 'email' &&
190
-
email.trim().toLowerCase() === providerEmailOriginal?.toLowerCase()
191
-
)
192
-
193
-
function isChannelValid(): boolean {
194
-
switch (verificationChannel) {
195
-
case 'email':
196
-
return !!email.trim()
197
-
case 'discord':
198
-
return !!discordUsername.trim()
199
-
case 'telegram':
200
-
return !!telegramUsername.trim()
201
-
case 'signal':
202
-
return !!signalUsername.trim()
203
-
default:
204
-
return false
205
-
}
206
-
}
207
-
208
-
async function handleSubmit(e: Event) {
209
-
e.preventDefault()
210
-
const token = getToken()
211
-
if (!token || !pending) return
212
-
213
-
if (!handle || handle.length < 3) {
214
-
handleError = $_('sso_register.error_handle_required')
215
-
return
216
-
}
217
-
218
-
if (handleAvailable === false) {
219
-
handleError = $_('sso_register.handle_taken')
220
-
return
221
-
}
222
-
223
-
if (!isChannelValid()) {
224
-
toast.error($_(`register.validation.${verificationChannel === 'email' ? 'emailRequired' : verificationChannel + 'Required'}`))
225
-
return
226
-
}
227
-
228
-
const fullHandle = !handle.includes('.') && selectedDomain
229
-
? `${handle.trim()}.${selectedDomain}`
230
-
: handle.trim()
231
-
submitting = true
232
-
233
-
try {
234
-
const response = await fetch('/oauth/sso/complete-registration', {
235
-
method: 'POST',
236
-
headers: {
237
-
'Content-Type': 'application/json',
238
-
'Accept': 'application/json',
239
-
},
240
-
body: JSON.stringify({
241
-
token,
242
-
handle: fullHandle,
243
-
email: email || null,
244
-
invite_code: inviteCode || null,
245
-
verification_channel: verificationChannel,
246
-
discord_username: discordUsername || null,
247
-
telegram_username: telegramUsername || null,
248
-
signal_username: signalUsername || null,
249
-
did_type: didType,
250
-
did: didType === 'web-external' ? externalDid.trim() : null,
251
-
}),
252
-
})
253
-
254
-
const data = await response.json()
255
-
256
-
if (!response.ok) {
257
-
toast.error(data.message || data.error_description || data.error || $_('common.error'))
258
-
submitting = false
259
-
return
260
-
}
261
-
262
-
if (data.accessJwt && data.refreshJwt) {
263
-
localStorage.setItem('accessJwt', data.accessJwt)
264
-
localStorage.setItem('refreshJwt', data.refreshJwt)
265
-
}
266
-
267
-
if (data.redirectUrl) {
268
-
if (data.redirectUrl.startsWith('/app/verify')) {
269
-
localStorage.setItem('tranquil_pds_pending_verification', JSON.stringify({
270
-
did: data.did,
271
-
handle: data.handle,
272
-
channel: verificationChannel,
273
-
}))
274
-
const url = new URL(data.redirectUrl, window.location.origin)
275
-
url.searchParams.set('handle', data.handle)
276
-
url.searchParams.set('channel', verificationChannel)
277
-
window.location.href = url.pathname + url.search
278
-
return
279
-
}
280
-
window.location.href = data.redirectUrl
281
-
return
282
-
}
283
-
284
-
toast.error($_('common.error'))
285
-
submitting = false
286
-
} catch {
287
-
toast.error($_('common.error'))
288
-
submitting = false
289
-
}
290
-
}
291
-
</script>
292
-
293
-
<div class="sso-register-container">
294
-
{#if loading}
295
-
<div class="loading"></div>
296
-
{:else if error && !pending}
297
-
<div class="error-container">
298
-
<div class="error-icon">!</div>
299
-
<h2>{$_('common.error')}</h2>
300
-
<p>{error}</p>
301
-
<a href="/app/register-sso" class="back-link">{$_('sso_register.tryAgain')}</a>
302
-
</div>
303
-
{:else if pending}
304
-
<header class="page-header">
305
-
<h1>{$_('sso_register.title')}</h1>
306
-
<p class="subtitle">{$_('sso_register.subtitle', { values: { provider: getProviderDisplayName(pending.provider) } })}</p>
307
-
</header>
308
-
309
-
<div class="provider-info">
310
-
<div class="provider-badge">
311
-
<SsoIcon provider={pending.provider} size={32} />
312
-
<div class="provider-details">
313
-
<span class="provider-name">{getProviderDisplayName(pending.provider)}</span>
314
-
{#if pending.provider_username}
315
-
<span class="provider-username">@{pending.provider_username}</span>
316
-
{/if}
317
-
</div>
318
-
</div>
319
-
</div>
320
-
321
-
<div class="split-layout sidebar-right">
322
-
<div class="form-section">
323
-
<form onsubmit={handleSubmit}>
324
-
<div>
325
-
<label for="handle">{$_('sso_register.handle_label')}</label>
326
-
<HandleInput
327
-
value={handle}
328
-
domains={serverInfo?.availableUserDomains ?? []}
329
-
{selectedDomain}
330
-
placeholder={$_('register.handlePlaceholder')}
331
-
disabled={submitting}
332
-
onInput={(v) => { handle = v }}
333
-
onDomainChange={(d) => { selectedDomain = d }}
334
-
/>
335
-
{#if checkingHandle}
336
-
<p class="hint">{$_('common.checking')}</p>
337
-
{:else if handleError}
338
-
<p class="hint error">{handleError}</p>
339
-
{:else if handleAvailable === false}
340
-
<p class="hint error">{$_('sso_register.handle_taken')}</p>
341
-
{:else if handleAvailable === true}
342
-
<p class="hint success">{$_('sso_register.handle_available')}</p>
343
-
{:else if fullHandle()}
344
-
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
345
-
{/if}
346
-
</div>
347
-
348
-
<fieldset>
349
-
<legend>{$_('register.contactMethod')}</legend>
350
-
<div class="contact-fields">
351
-
<div class="field">
352
-
<label for="verification-channel">{$_('register.verificationMethod')}</label>
353
-
<select id="verification-channel" bind:value={verificationChannel} disabled={submitting}>
354
-
<option value="email">{$_('register.email')}</option>
355
-
<option value="discord" disabled={!isChannelAvailable('discord')}>
356
-
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
357
-
</option>
358
-
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
359
-
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
360
-
</option>
361
-
<option value="signal" disabled={!isChannelAvailable('signal')}>
362
-
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
363
-
</option>
364
-
</select>
365
-
</div>
366
-
367
-
{#if verificationChannel === 'email'}
368
-
<div class="field">
369
-
<label for="email">{$_('register.emailAddress')}</label>
370
-
<input
371
-
id="email"
372
-
type="email"
373
-
bind:value={email}
374
-
placeholder={$_('register.emailPlaceholder')}
375
-
disabled={submitting}
376
-
required
377
-
/>
378
-
{#if pending?.provider_email && pending?.provider_email_verified}
379
-
{#if usingVerifiedProviderEmail}
380
-
<p class="hint success">{$_('sso_register.emailVerifiedByProvider', { values: { provider: getProviderDisplayName(pending.provider) } })}</p>
381
-
{:else}
382
-
<p class="hint">{$_('sso_register.emailChangedNeedsVerification')}</p>
383
-
{/if}
384
-
{/if}
385
-
</div>
386
-
{:else if verificationChannel === 'discord'}
387
-
<div class="field">
388
-
<label for="discord-username">{$_('register.discordUsername')}</label>
389
-
<input
390
-
id="discord-username"
391
-
type="text"
392
-
bind:value={discordUsername}
393
-
placeholder={$_('register.discordUsernamePlaceholder')}
394
-
disabled={submitting}
395
-
required
396
-
/>
397
-
</div>
398
-
{:else if verificationChannel === 'telegram'}
399
-
<div class="field">
400
-
<label for="telegram-username">{$_('register.telegramUsername')}</label>
401
-
<input
402
-
id="telegram-username"
403
-
type="text"
404
-
bind:value={telegramUsername}
405
-
placeholder={$_('register.telegramUsernamePlaceholder')}
406
-
disabled={submitting}
407
-
required
408
-
/>
409
-
</div>
410
-
{:else if verificationChannel === 'signal'}
411
-
<div class="field">
412
-
<label for="signal-number">{$_('register.signalUsername')}</label>
413
-
<input
414
-
id="signal-number"
415
-
type="tel"
416
-
bind:value={signalUsername}
417
-
placeholder={$_('register.signalUsernamePlaceholder')}
418
-
disabled={submitting}
419
-
required
420
-
/>
421
-
<p class="hint">{$_('register.signalUsernameHint')}</p>
422
-
</div>
423
-
{/if}
424
-
</div>
425
-
</fieldset>
426
-
427
-
<fieldset>
428
-
<legend>{$_('registerPasskey.identityType')}</legend>
429
-
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
430
-
<div class="radio-group">
431
-
<label class="radio-label">
432
-
<input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} />
433
-
<span class="radio-content">
434
-
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
435
-
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
436
-
</span>
437
-
</label>
438
-
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
439
-
<input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting || serverInfo?.selfHostedDidWebEnabled === false} />
440
-
<span class="radio-content">
441
-
<strong>{$_('registerPasskey.didWeb')}</strong>
442
-
{#if serverInfo?.selfHostedDidWebEnabled === false}
443
-
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
444
-
{:else}
445
-
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
446
-
{/if}
447
-
</span>
448
-
</label>
449
-
<label class="radio-label">
450
-
<input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} />
451
-
<span class="radio-content">
452
-
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
453
-
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
454
-
</span>
455
-
</label>
456
-
</div>
457
-
{#if didType === 'web'}
458
-
<div class="warning-box">
459
-
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
460
-
<ul>
461
-
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
462
-
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
463
-
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
464
-
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
465
-
</ul>
466
-
</div>
467
-
{/if}
468
-
{#if didType === 'web-external'}
469
-
<div class="field">
470
-
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
471
-
<input id="external-did" type="text" bind:value={externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={submitting} required />
472
-
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{externalDid ? extractDomain(externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
473
-
</div>
474
-
{/if}
475
-
</fieldset>
476
-
477
-
{#if serverInfo?.inviteCodeRequired}
478
-
<div>
479
-
<label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
480
-
<input
481
-
id="invite-code"
482
-
type="text"
483
-
bind:value={inviteCode}
484
-
placeholder={$_('register.inviteCodePlaceholder')}
485
-
disabled={submitting}
486
-
required
487
-
/>
488
-
</div>
489
-
{/if}
490
-
491
-
<button type="submit" disabled={submitting || !handle || handle.length < 3 || handleAvailable === false || checkingHandle || !isChannelValid()}>
492
-
{submitting ? $_('common.creating') : $_('sso_register.submit')}
493
-
</button>
494
-
</form>
495
-
</div>
496
-
497
-
<aside class="info-panel">
498
-
<h3>{$_('sso_register.infoAfterTitle')}</h3>
499
-
<ul class="info-list">
500
-
<li>{$_('sso_register.infoAddPassword')}</li>
501
-
<li>{$_('sso_register.infoAddPasskey')}</li>
502
-
<li>{$_('sso_register.infoLinkProviders')}</li>
503
-
<li>{$_('sso_register.infoChangeHandle')}</li>
504
-
</ul>
505
-
</aside>
506
-
</div>
507
-
{/if}
508
-
</div>
History
3 rounds
0 comments
oyster.cafe
submitted
#2
1 commit
expand
collapse
refactor(frontend): delete OAuthRegister and OAuthSsoRegister routes
expand 0 comments
pull request successfully merged
oyster.cafe
submitted
#1
1 commit
expand
collapse
refactor(frontend): delete OAuthRegister and OAuthSsoRegister routes
expand 0 comments
oyster.cafe
submitted
#0
1 commit
expand
collapse
refactor(frontend): delete OAuthRegister and OAuthSsoRegister routes