Our Personal Data Server from scratch! tranquil.farm
atproto pds rust postgresql fun oauth

refactor(frontend): delete OAuthRegister and OAuthSsoRegister routes #70

merged opened by oyster.cafe targeting main from refactor/frontend-deletions
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mhg53l5uwt22
-1093
Diff #2
-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
··· 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
sign up or login to add to the discussion
1 commit
expand
refactor(frontend): delete OAuthRegister and OAuthSsoRegister routes
expand 0 comments
pull request successfully merged
1 commit
expand
refactor(frontend): delete OAuthRegister and OAuthSsoRegister routes
expand 0 comments
1 commit
expand
refactor(frontend): delete OAuthRegister and OAuthSsoRegister routes
expand 0 comments