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
6 const STORAGE_KEY = 'tranquil_pds_pending_verification'
7
8 let handle = $state('')
9 let email = $state('')
10 let password = $state('')
11 let confirmPassword = $state('')
12 let inviteCode = $state('')
13 let verificationChannel = $state<VerificationChannel>('email')
14 let discordId = $state('')
15 let telegramUsername = $state('')
16 let signalNumber = $state('')
17 let didType = $state<DidType>('plc')
18 let externalDid = $state('')
19 let submitting = $state(false)
20 let error = $state<string | null>(null)
21 let serverInfo = $state<{
22 availableUserDomains: string[]
23 inviteCodeRequired: boolean
24 } | null>(null)
25 let loadingServerInfo = $state(true)
26 let serverInfoLoaded = false
27
28 const auth = getAuthState()
29
30 $effect(() => {
31 if (auth.session) {
32 navigate('/dashboard')
33 }
34 })
35
36 $effect(() => {
37 if (!serverInfoLoaded) {
38 serverInfoLoaded = true
39 loadServerInfo()
40 }
41 })
42
43 async function loadServerInfo() {
44 try {
45 serverInfo = await api.describeServer()
46 } catch (e) {
47 console.error('Failed to load server info:', e)
48 } finally {
49 loadingServerInfo = false
50 }
51 }
52
53 let handleHasDot = $derived(handle.includes('.'))
54
55 function validateForm(): string | null {
56 if (!handle.trim()) return 'Handle is required'
57 if (handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.'
58 if (!password) return 'Password is required'
59 if (password.length < 8) return 'Password must be at least 8 characters'
60 if (password !== confirmPassword) return 'Passwords do not match'
61 if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) {
62 return 'Invite code is required'
63 }
64 if (didType === 'web-external') {
65 if (!externalDid.trim()) return 'External did:web is required'
66 if (!externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:'
67 }
68 switch (verificationChannel) {
69 case 'email':
70 if (!email.trim()) return 'Email is required for email verification'
71 break
72 case 'discord':
73 if (!discordId.trim()) return 'Discord ID is required for Discord verification'
74 break
75 case 'telegram':
76 if (!telegramUsername.trim()) return 'Telegram username is required for Telegram verification'
77 break
78 case 'signal':
79 if (!signalNumber.trim()) return 'Phone number is required for Signal verification'
80 break
81 }
82 return null
83 }
84
85 async function handleSubmit(e: Event) {
86 e.preventDefault()
87 const validationError = validateForm()
88 if (validationError) {
89 error = validationError
90 return
91 }
92 submitting = true
93 error = null
94 try {
95 const result = await register({
96 handle: handle.trim(),
97 email: email.trim(),
98 password,
99 inviteCode: inviteCode.trim() || undefined,
100 didType,
101 did: didType === 'web-external' ? externalDid.trim() : undefined,
102 verificationChannel,
103 discordId: discordId.trim() || undefined,
104 telegramUsername: telegramUsername.trim() || undefined,
105 signalNumber: signalNumber.trim() || undefined,
106 })
107 if (result.verificationRequired) {
108 localStorage.setItem(STORAGE_KEY, JSON.stringify({
109 did: result.did,
110 handle: result.handle,
111 channel: result.verificationChannel,
112 }))
113 navigate('/verify')
114 } else {
115 navigate('/dashboard')
116 }
117 } catch (err: any) {
118 if (err instanceof ApiError) {
119 error = err.message || 'Registration failed'
120 } else if (err instanceof Error) {
121 error = err.message || 'Registration failed'
122 } else {
123 error = 'Registration failed'
124 }
125 } finally {
126 submitting = false
127 }
128 }
129
130 let fullHandle = $derived(() => {
131 if (!handle.trim()) return ''
132 if (handle.includes('.')) return handle.trim()
133 const domain = serverInfo?.availableUserDomains?.[0]
134 if (domain) return `${handle.trim()}.${domain}`
135 return handle.trim()
136 })
137</script>
138<div class="register-container">
139 {#if error}
140 <div class="error">{error}</div>
141 {/if}
142 <h1>Create Account</h1>
143 <p class="subtitle">Create a new account on this PDS</p>
144 {#if loadingServerInfo}
145 <p class="loading">Loading...</p>
146 {:else}
147 <form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
148 <div class="field">
149 <label for="handle">Handle</label>
150 <input
151 id="handle"
152 type="text"
153 bind:value={handle}
154 placeholder="yourname"
155 disabled={submitting}
156 required
157 />
158 {#if handleHasDot}
159 <p class="hint warning">Custom domain handles can be set up after account creation in Settings.</p>
160 {:else if fullHandle()}
161 <p class="hint">Your full handle will be: @{fullHandle()}</p>
162 {/if}
163 </div>
164 <div class="field">
165 <label for="password">Password</label>
166 <input
167 id="password"
168 type="password"
169 bind:value={password}
170 placeholder="At least 8 characters"
171 disabled={submitting}
172 required
173 minlength="8"
174 />
175 </div>
176 <div class="field">
177 <label for="confirm-password">Confirm Password</label>
178 <input
179 id="confirm-password"
180 type="password"
181 bind:value={confirmPassword}
182 placeholder="Confirm your password"
183 disabled={submitting}
184 required
185 />
186 </div>
187 <fieldset class="identity-section">
188 <legend>Identity Type</legend>
189 <p class="section-hint">Choose how your decentralized identity will be managed.</p>
190 <div class="radio-group">
191 <label class="radio-label">
192 <input
193 type="radio"
194 name="didType"
195 value="plc"
196 bind:group={didType}
197 disabled={submitting}
198 />
199 <span class="radio-content">
200 <strong>did:plc</strong> (Recommended)
201 <span class="radio-hint">Portable identity managed by PLC Directory</span>
202 </span>
203 </label>
204 <label class="radio-label">
205 <input
206 type="radio"
207 name="didType"
208 value="web"
209 bind:group={didType}
210 disabled={submitting}
211 />
212 <span class="radio-content">
213 <strong>did:web</strong>
214 <span class="radio-hint">Identity hosted on this PDS (read warning below)</span>
215 </span>
216 </label>
217 <label class="radio-label">
218 <input
219 type="radio"
220 name="didType"
221 value="web-external"
222 bind:group={didType}
223 disabled={submitting}
224 />
225 <span class="radio-content">
226 <strong>did:web (BYOD)</strong>
227 <span class="radio-hint">Bring your own domain</span>
228 </span>
229 </label>
230 </div>
231 {#if didType === 'web'}
232 <div class="did-web-warning">
233 <strong>Important: Understand the trade-offs</strong>
234 <ul>
235 <li><strong>Permanent tie to this PDS:</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>. Even if you migrate to another PDS later, this server must continue hosting your DID document.</li>
236 <li><strong>No recovery mechanism:</strong> Unlike did:plc, did:web has no rotation keys. If this PDS goes offline permanently, your identity cannot be recovered.</li>
237 <li><strong>We commit to you:</strong> If you migrate away, we will continue serving a minimal DID document pointing to your new PDS. Your identity will remain functional.</li>
238 <li><strong>Recommendation:</strong> Choose did:plc unless you have a specific reason to prefer did:web.</li>
239 </ul>
240 </div>
241 {/if}
242 {#if didType === 'web-external'}
243 <div class="field">
244 <label for="external-did">Your did:web</label>
245 <input
246 id="external-did"
247 type="text"
248 bind:value={externalDid}
249 placeholder="did:web:yourdomain.com"
250 disabled={submitting}
251 required
252 />
253 <p class="hint">Your domain must serve a valid DID document at /.well-known/did.json pointing to this PDS</p>
254 </div>
255 {/if}
256 </fieldset>
257 <fieldset class="verification-section">
258 <legend>Contact Method</legend>
259 <p class="section-hint">Choose how you'd like to verify your account and receive notifications. You only need one.</p>
260 <div class="field">
261 <label for="verification-channel">Verification Method</label>
262 <select
263 id="verification-channel"
264 bind:value={verificationChannel}
265 disabled={submitting}
266 >
267 <option value="email">Email</option>
268 <option value="discord">Discord</option>
269 <option value="telegram">Telegram</option>
270 <option value="signal">Signal</option>
271 </select>
272 </div>
273 {#if verificationChannel === 'email'}
274 <div class="field">
275 <label for="email">Email Address</label>
276 <input
277 id="email"
278 type="email"
279 bind:value={email}
280 placeholder="you@example.com"
281 disabled={submitting}
282 required
283 />
284 </div>
285 {:else if verificationChannel === 'discord'}
286 <div class="field">
287 <label for="discord-id">Discord User ID</label>
288 <input
289 id="discord-id"
290 type="text"
291 bind:value={discordId}
292 placeholder="Your Discord user ID"
293 disabled={submitting}
294 required
295 />
296 <p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p>
297 </div>
298 {:else if verificationChannel === 'telegram'}
299 <div class="field">
300 <label for="telegram-username">Telegram Username</label>
301 <input
302 id="telegram-username"
303 type="text"
304 bind:value={telegramUsername}
305 placeholder="@yourusername"
306 disabled={submitting}
307 required
308 />
309 </div>
310 {:else if verificationChannel === 'signal'}
311 <div class="field">
312 <label for="signal-number">Signal Phone Number</label>
313 <input
314 id="signal-number"
315 type="tel"
316 bind:value={signalNumber}
317 placeholder="+1234567890"
318 disabled={submitting}
319 required
320 />
321 <p class="hint">Include country code (e.g., +1 for US)</p>
322 </div>
323 {/if}
324 </fieldset>
325 {#if serverInfo?.inviteCodeRequired}
326 <div class="field">
327 <label for="invite-code">Invite Code <span class="required">*</span></label>
328 <input
329 id="invite-code"
330 type="text"
331 bind:value={inviteCode}
332 placeholder="Enter your invite code"
333 disabled={submitting}
334 required
335 />
336 </div>
337 {/if}
338 <button type="submit" disabled={submitting}>
339 {submitting ? 'Creating account...' : 'Create Account'}
340 </button>
341 </form>
342 <p class="login-link">
343 Already have an account? <a href="#/login">Sign in</a>
344 </p>
345 <p class="login-link">
346 Want passwordless security? <a href="#/register-passkey">Create a passkey account</a>
347 </p>
348 {/if}
349</div>
350<style>
351 .register-container {
352 max-width: 400px;
353 margin: 4rem auto;
354 padding: 2rem;
355 }
356 h1 {
357 margin: 0 0 0.5rem 0;
358 }
359 .subtitle {
360 color: var(--text-secondary);
361 margin: 0 0 2rem 0;
362 }
363 .loading {
364 text-align: center;
365 color: var(--text-secondary);
366 }
367 form {
368 display: flex;
369 flex-direction: column;
370 gap: 1rem;
371 }
372 .field {
373 display: flex;
374 flex-direction: column;
375 gap: 0.25rem;
376 }
377 label {
378 font-size: 0.875rem;
379 font-weight: 500;
380 }
381 .required {
382 color: var(--error-text);
383 }
384 input, select {
385 padding: 0.75rem;
386 border: 1px solid var(--border-color-light);
387 border-radius: 4px;
388 font-size: 1rem;
389 background: var(--bg-input);
390 color: var(--text-primary);
391 }
392 input:focus, select:focus {
393 outline: none;
394 border-color: var(--accent);
395 }
396 .hint {
397 font-size: 0.75rem;
398 color: var(--text-secondary);
399 margin: 0.25rem 0 0 0;
400 }
401 .hint.warning {
402 color: var(--warning-text, #856404);
403 }
404 .verification-section {
405 border: 1px solid var(--border-color-light);
406 border-radius: 6px;
407 padding: 1rem;
408 margin: 0.5rem 0;
409 }
410 .verification-section legend {
411 font-weight: 600;
412 padding: 0 0.5rem;
413 color: var(--text-primary);
414 }
415 .identity-section {
416 border: 1px solid var(--border-color-light);
417 border-radius: 6px;
418 padding: 1rem;
419 margin: 0.5rem 0;
420 }
421 .identity-section legend {
422 font-weight: 600;
423 padding: 0 0.5rem;
424 color: var(--text-primary);
425 }
426 .radio-group {
427 display: flex;
428 flex-direction: column;
429 gap: 0.75rem;
430 }
431 .radio-label {
432 display: flex;
433 align-items: flex-start;
434 gap: 0.5rem;
435 cursor: pointer;
436 }
437 .radio-label input[type="radio"] {
438 margin-top: 0.25rem;
439 }
440 .radio-content {
441 display: flex;
442 flex-direction: column;
443 gap: 0.125rem;
444 }
445 .radio-hint {
446 font-size: 0.75rem;
447 color: var(--text-secondary);
448 }
449 .section-hint {
450 font-size: 0.8rem;
451 color: var(--text-secondary);
452 margin: 0 0 1rem 0;
453 }
454 .did-web-warning {
455 margin-top: 1rem;
456 padding: 1rem;
457 background: var(--warning-bg, #fff3cd);
458 border: 1px solid var(--warning-border, #ffc107);
459 border-radius: 6px;
460 font-size: 0.875rem;
461 }
462 .did-web-warning strong {
463 color: var(--warning-text, #856404);
464 }
465 .did-web-warning ul {
466 margin: 0.75rem 0 0 0;
467 padding-left: 1.25rem;
468 }
469 .did-web-warning li {
470 margin-bottom: 0.5rem;
471 line-height: 1.4;
472 }
473 .did-web-warning li:last-child {
474 margin-bottom: 0;
475 }
476 .did-web-warning code {
477 background: rgba(0, 0, 0, 0.1);
478 padding: 0.125rem 0.25rem;
479 border-radius: 3px;
480 font-size: 0.8rem;
481 }
482 button {
483 padding: 0.75rem;
484 background: var(--accent);
485 color: white;
486 border: none;
487 border-radius: 4px;
488 font-size: 1rem;
489 cursor: pointer;
490 margin-top: 0.5rem;
491 }
492 button:hover:not(:disabled) {
493 background: var(--accent-hover);
494 }
495 button:disabled {
496 opacity: 0.6;
497 cursor: not-allowed;
498 }
499 .error {
500 padding: 0.75rem;
501 background: var(--error-bg);
502 border: 1px solid var(--error-border);
503 border-radius: 4px;
504 color: var(--error-text);
505 }
506 .login-link {
507 text-align: center;
508 margin-top: 1.5rem;
509 color: var(--text-secondary);
510 }
511 .login-link a {
512 color: var(--accent);
513 }
514</style>