Our Personal Data Server from scratch!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at fix/small-bugs 680 lines 21 kB view raw
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>