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