this repo has no description
1<script lang="ts"> 2 import { register, getAuthState } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api' 5 import { _ } from '../lib/i18n' 6 7 const STORAGE_KEY = 'tranquil_pds_pending_verification' 8 9 let handle = $state('') 10 let email = $state('') 11 let password = $state('') 12 let confirmPassword = $state('') 13 let inviteCode = $state('') 14 let verificationChannel = $state<VerificationChannel>('email') 15 let discordId = $state('') 16 let telegramUsername = $state('') 17 let signalNumber = $state('') 18 let didType = $state<DidType>('plc') 19 let externalDid = $state('') 20 let submitting = $state(false) 21 let error = $state<string | null>(null) 22 let serverInfo = $state<{ 23 availableUserDomains: string[] 24 inviteCodeRequired: boolean 25 availableCommsChannels?: string[] 26 } | null>(null) 27 let loadingServerInfo = $state(true) 28 let serverInfoLoaded = false 29 30 const auth = getAuthState() 31 32 $effect(() => { 33 if (!serverInfoLoaded) { 34 serverInfoLoaded = true 35 loadServerInfo() 36 } 37 }) 38 39 async function loadServerInfo() { 40 try { 41 serverInfo = await api.describeServer() 42 } catch (e) { 43 console.error('Failed to load server info:', e) 44 } finally { 45 loadingServerInfo = false 46 } 47 } 48 49 let handleHasDot = $derived(handle.includes('.')) 50 51 function isChannelAvailable(channel: string): boolean { 52 const available = serverInfo?.availableCommsChannels ?? ['email'] 53 return available.includes(channel) 54 } 55 56 function validateForm(): string | null { 57 if (!handle.trim()) return $_('register.validation.handleRequired') 58 if (handle.includes('.')) return $_('register.validation.handleNoDots') 59 if (!password) return $_('register.validation.passwordRequired') 60 if (password.length < 8) return $_('register.validation.passwordLength') 61 if (password !== confirmPassword) return $_('register.validation.passwordsMismatch') 62 if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) { 63 return $_('register.validation.inviteCodeRequired') 64 } 65 if (didType === 'web-external') { 66 if (!externalDid.trim()) return $_('register.validation.externalDidRequired') 67 if (!externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat') 68 } 69 switch (verificationChannel) { 70 case 'email': 71 if (!email.trim()) return $_('register.validation.emailRequired') 72 break 73 case 'discord': 74 if (!discordId.trim()) return $_('register.validation.discordIdRequired') 75 break 76 case 'telegram': 77 if (!telegramUsername.trim()) return $_('register.validation.telegramRequired') 78 break 79 case 'signal': 80 if (!signalNumber.trim()) return $_('register.validation.signalRequired') 81 break 82 } 83 return null 84 } 85 86 async function handleSubmit(e: Event) { 87 e.preventDefault() 88 const validationError = validateForm() 89 if (validationError) { 90 error = validationError 91 return 92 } 93 submitting = true 94 error = null 95 try { 96 const result = await register({ 97 handle: handle.trim(), 98 email: email.trim(), 99 password, 100 inviteCode: inviteCode.trim() || undefined, 101 didType, 102 did: didType === 'web-external' ? externalDid.trim() : undefined, 103 verificationChannel, 104 discordId: discordId.trim() || undefined, 105 telegramUsername: telegramUsername.trim() || undefined, 106 signalNumber: signalNumber.trim() || undefined, 107 }) 108 if (result.verificationRequired) { 109 localStorage.setItem(STORAGE_KEY, JSON.stringify({ 110 did: result.did, 111 handle: result.handle, 112 channel: result.verificationChannel, 113 })) 114 navigate('/verify') 115 } else { 116 navigate('/dashboard') 117 } 118 } catch (err: any) { 119 if (err instanceof ApiError) { 120 error = err.message || 'Registration failed' 121 } else if (err instanceof Error) { 122 error = err.message || 'Registration failed' 123 } else { 124 error = 'Registration failed' 125 } 126 } finally { 127 submitting = false 128 } 129 } 130 131 let fullHandle = $derived(() => { 132 if (!handle.trim()) return '' 133 if (handle.includes('.')) return handle.trim() 134 const domain = serverInfo?.availableUserDomains?.[0] 135 if (domain) return `${handle.trim()}.${domain}` 136 return handle.trim() 137 }) 138</script> 139 140<div class="register-page"> 141 <div class="migrate-callout"> 142 <div class="migrate-icon"></div> 143 <div class="migrate-content"> 144 <strong>{$_('register.migrateTitle')}</strong> 145 <p>{$_('register.migrateDescription')}</p> 146 <a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link"> 147 {$_('register.migrateLink')}148 </a> 149 </div> 150 </div> 151 152 {#if error} 153 <div class="message error">{error}</div> 154 {/if} 155 156 <h1>{$_('register.title')}</h1> 157 <p class="subtitle">{$_('register.subtitle')}</p> 158 159 {#if loadingServerInfo} 160 <p class="loading">{$_('common.loading')}</p> 161 {:else} 162 <form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}> 163 <div class="field"> 164 <label for="handle">{$_('register.handle')}</label> 165 <input 166 id="handle" 167 type="text" 168 bind:value={handle} 169 placeholder={$_('register.handlePlaceholder')} 170 disabled={submitting} 171 required 172 /> 173 {#if handleHasDot} 174 <p class="hint warning">{$_('register.handleDotWarning')}</p> 175 {:else if fullHandle()} 176 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> 177 {/if} 178 </div> 179 180 <div class="field"> 181 <label for="password">{$_('register.password')}</label> 182 <input 183 id="password" 184 type="password" 185 bind:value={password} 186 placeholder={$_('register.passwordPlaceholder')} 187 disabled={submitting} 188 required 189 minlength="8" 190 /> 191 </div> 192 193 <div class="field"> 194 <label for="confirm-password">{$_('register.confirmPassword')}</label> 195 <input 196 id="confirm-password" 197 type="password" 198 bind:value={confirmPassword} 199 placeholder={$_('register.confirmPasswordPlaceholder')} 200 disabled={submitting} 201 required 202 /> 203 </div> 204 205 <fieldset class="section-fieldset"> 206 <legend>{$_('register.identityType')}</legend> 207 <p class="section-hint">{$_('register.identityHint')}</p> 208 209 <div class="radio-group"> 210 <label class="radio-label"> 211 <input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} /> 212 <span class="radio-content"> 213 <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')} 214 <span class="radio-hint">{$_('register.didPlcHint')}</span> 215 </span> 216 </label> 217 218 <label class="radio-label"> 219 <input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} /> 220 <span class="radio-content"> 221 <strong>{$_('register.didWeb')}</strong> 222 <span class="radio-hint">{$_('register.didWebHint')}</span> 223 </span> 224 </label> 225 226 <label class="radio-label"> 227 <input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} /> 228 <span class="radio-content"> 229 <strong>{$_('register.didWebBYOD')}</strong> 230 <span class="radio-hint">{$_('register.didWebBYODHint')}</span> 231 </span> 232 </label> 233 </div> 234 235 {#if didType === 'web'} 236 <div class="warning-box"> 237 <strong>{$_('register.didWebWarningTitle')}</strong> 238 <ul> 239 <li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li> 240 <li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li> 241 <li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li> 242 <li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li> 243 </ul> 244 </div> 245 {/if} 246 247 {#if didType === 'web-external'} 248 <div class="field"> 249 <label for="external-did">{$_('register.externalDid')}</label> 250 <input 251 id="external-did" 252 type="text" 253 bind:value={externalDid} 254 placeholder={$_('register.externalDidPlaceholder')} 255 disabled={submitting} 256 required 257 /> 258 <p class="hint">{$_('register.externalDidHint')}</p> 259 </div> 260 {/if} 261 </fieldset> 262 263 <fieldset class="section-fieldset"> 264 <legend>{$_('register.contactMethod')}</legend> 265 <p class="section-hint">{$_('register.contactMethodHint')}</p> 266 267 <div class="field"> 268 <label for="verification-channel">{$_('register.verificationMethod')}</label> 269 <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 270 <option value="email">{$_('register.email')}</option> 271 <option value="discord" disabled={!isChannelAvailable('discord')}> 272 {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 273 </option> 274 <option value="telegram" disabled={!isChannelAvailable('telegram')}> 275 {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 276 </option> 277 <option value="signal" disabled={!isChannelAvailable('signal')}> 278 {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 279 </option> 280 </select> 281 </div> 282 283 {#if verificationChannel === 'email'} 284 <div class="field"> 285 <label for="email">{$_('register.emailAddress')}</label> 286 <input 287 id="email" 288 type="email" 289 bind:value={email} 290 placeholder={$_('register.emailPlaceholder')} 291 disabled={submitting} 292 required 293 /> 294 </div> 295 {:else if verificationChannel === 'discord'} 296 <div class="field"> 297 <label for="discord-id">{$_('register.discordId')}</label> 298 <input 299 id="discord-id" 300 type="text" 301 bind:value={discordId} 302 placeholder={$_('register.discordIdPlaceholder')} 303 disabled={submitting} 304 required 305 /> 306 <p class="hint">{$_('register.discordIdHint')}</p> 307 </div> 308 {:else if verificationChannel === 'telegram'} 309 <div class="field"> 310 <label for="telegram-username">{$_('register.telegramUsername')}</label> 311 <input 312 id="telegram-username" 313 type="text" 314 bind:value={telegramUsername} 315 placeholder={$_('register.telegramUsernamePlaceholder')} 316 disabled={submitting} 317 required 318 /> 319 </div> 320 {:else if verificationChannel === 'signal'} 321 <div class="field"> 322 <label for="signal-number">{$_('register.signalNumber')}</label> 323 <input 324 id="signal-number" 325 type="tel" 326 bind:value={signalNumber} 327 placeholder={$_('register.signalNumberPlaceholder')} 328 disabled={submitting} 329 required 330 /> 331 <p class="hint">{$_('register.signalNumberHint')}</p> 332 </div> 333 {/if} 334 </fieldset> 335 336 {#if serverInfo?.inviteCodeRequired} 337 <div class="field"> 338 <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label> 339 <input 340 id="invite-code" 341 type="text" 342 bind:value={inviteCode} 343 placeholder={$_('register.inviteCodePlaceholder')} 344 disabled={submitting} 345 required 346 /> 347 </div> 348 {/if} 349 350 <button type="submit" disabled={submitting}> 351 {submitting ? $_('register.creating') : $_('register.createButton')} 352 </button> 353 </form> 354 355 <p class="link-text"> 356 {$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a> 357 </p> 358 <p class="link-text"> 359 {$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a> 360 </p> 361 {/if} 362</div> 363 364<style> 365 .register-page { 366 max-width: var(--width-sm); 367 margin: var(--space-9) auto; 368 padding: var(--space-7); 369 } 370 371 .migrate-callout { 372 display: flex; 373 gap: var(--space-4); 374 padding: var(--space-5); 375 background: var(--accent-muted); 376 border: 1px solid var(--accent); 377 border-radius: var(--radius-xl); 378 margin-bottom: var(--space-6); 379 } 380 381 .migrate-icon { 382 font-size: var(--text-2xl); 383 line-height: 1; 384 color: var(--accent); 385 } 386 387 .migrate-content { 388 flex: 1; 389 } 390 391 .migrate-content strong { 392 display: block; 393 color: var(--text-primary); 394 margin-bottom: var(--space-2); 395 } 396 397 .migrate-content p { 398 margin: 0 0 var(--space-3) 0; 399 font-size: var(--text-sm); 400 color: var(--text-secondary); 401 line-height: var(--leading-relaxed); 402 } 403 404 .migrate-link { 405 font-size: var(--text-sm); 406 font-weight: var(--font-medium); 407 color: var(--accent); 408 text-decoration: none; 409 } 410 411 .migrate-link:hover { 412 text-decoration: underline; 413 } 414 415 h1 { 416 margin: 0 0 var(--space-3) 0; 417 } 418 419 .subtitle { 420 color: var(--text-secondary); 421 margin: 0 0 var(--space-7) 0; 422 } 423 424 .loading { 425 text-align: center; 426 color: var(--text-secondary); 427 } 428 429 form { 430 display: flex; 431 flex-direction: column; 432 gap: var(--space-5); 433 } 434 435 .required { 436 color: var(--error-text); 437 } 438 439 .section-fieldset { 440 border: 1px solid var(--border-color); 441 border-radius: var(--radius-lg); 442 padding: var(--space-5); 443 } 444 445 .section-fieldset legend { 446 font-weight: var(--font-semibold); 447 padding: 0 var(--space-3); 448 } 449 450 .section-hint { 451 font-size: var(--text-sm); 452 color: var(--text-secondary); 453 margin: 0 0 var(--space-5) 0; 454 } 455 456 .radio-group { 457 display: flex; 458 flex-direction: column; 459 gap: var(--space-4); 460 } 461 462 .radio-label { 463 display: flex; 464 align-items: flex-start; 465 gap: var(--space-3); 466 cursor: pointer; 467 font-size: var(--text-base); 468 font-weight: var(--font-normal); 469 margin-bottom: 0; 470 } 471 472 .radio-label input[type="radio"] { 473 margin-top: var(--space-1); 474 width: auto; 475 } 476 477 .radio-content { 478 display: flex; 479 flex-direction: column; 480 gap: var(--space-1); 481 } 482 483 .radio-hint { 484 font-size: var(--text-xs); 485 color: var(--text-secondary); 486 } 487 488 .warning-box { 489 margin-top: var(--space-5); 490 padding: var(--space-5); 491 background: var(--warning-bg); 492 border: 1px solid var(--warning-border); 493 border-radius: var(--radius-lg); 494 font-size: var(--text-sm); 495 } 496 497 .warning-box strong { 498 color: var(--warning-text); 499 } 500 501 .warning-box ul { 502 margin: var(--space-4) 0 0 0; 503 padding-left: var(--space-5); 504 } 505 506 .warning-box li { 507 margin-bottom: var(--space-3); 508 line-height: var(--leading-normal); 509 } 510 511 .warning-box li:last-child { 512 margin-bottom: 0; 513 } 514 515 button[type="submit"] { 516 margin-top: var(--space-3); 517 } 518 519 .link-text { 520 text-align: center; 521 margin-top: var(--space-6); 522 color: var(--text-secondary); 523 } 524 525 .link-text a { 526 color: var(--accent); 527 } 528</style>