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