forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
1<script lang="ts">
2 import {
3 loginWithOAuth,
4 confirmSignup,
5 resendVerification,
6 getAuthState,
7 switchAccount,
8 forgetAccount,
9 clearError,
10 matchAuthState,
11 type SavedAccount,
12 type AuthError,
13 } from '../lib/auth.svelte'
14 import { navigate, routes } from '../lib/router.svelte'
15 import { _ } from '../lib/i18n'
16 import { isOk, isErr } from '../lib/types/result'
17 import { unsafeAsDid, type Did } from '../lib/types/branded'
18 import { toast } from '../lib/toast.svelte'
19
20 type PageState =
21 | { kind: 'login' }
22 | { kind: 'verification'; did: Did }
23
24 let pageState = $state<PageState>({ kind: 'login' })
25 let submitting = $state(false)
26 let verificationCode = $state('')
27 let resendingCode = $state(false)
28 let resendMessage = $state<string | null>(null)
29 let autoRedirectAttempted = $state(false)
30
31 const auth = $derived(getAuthState())
32
33 function getSavedAccounts(): readonly SavedAccount[] {
34 return auth.savedAccounts
35 }
36
37 function isLoading(): boolean {
38 return auth.kind === 'loading'
39 }
40
41 $effect(() => {
42 if (auth.kind === 'error') {
43 toast.error(auth.error.message)
44 clearError()
45 }
46 })
47
48 $effect(() => {
49 const accounts = getSavedAccounts()
50 const loading = isLoading()
51 const hasError = auth.kind === 'error'
52
53 if (!loading && !hasError && accounts.length === 0 && pageState.kind === 'login' && !autoRedirectAttempted) {
54 autoRedirectAttempted = true
55 loginWithOAuth()
56 }
57 })
58
59 async function handleSwitchAccount(did: Did) {
60 submitting = true
61 const result = await switchAccount(did)
62 if (isOk(result)) {
63 navigate(routes.dashboard)
64 } else {
65 submitting = false
66 }
67 }
68
69 function handleForgetAccount(did: Did, e: Event) {
70 e.stopPropagation()
71 forgetAccount(did)
72 }
73
74 async function handleOAuthLogin() {
75 submitting = true
76 const result = await loginWithOAuth()
77 if (isErr(result)) {
78 submitting = false
79 }
80 }
81
82 async function handleVerification(e: Event) {
83 e.preventDefault()
84 if (pageState.kind !== 'verification' || !verificationCode.trim()) return
85
86 submitting = true
87 const result = await confirmSignup(pageState.did, verificationCode.trim())
88 if (isOk(result)) {
89 navigate(routes.dashboard)
90 } else {
91 submitting = false
92 }
93 }
94
95 async function handleResendCode() {
96 if (pageState.kind !== 'verification' || resendingCode) return
97
98 resendingCode = true
99 resendMessage = null
100 const result = await resendVerification(pageState.did)
101 if (isOk(result)) {
102 resendMessage = $_('verification.resent')
103 }
104 resendingCode = false
105 }
106
107 function backToLogin() {
108 pageState = { kind: 'login' }
109 verificationCode = ''
110 resendMessage = null
111 }
112
113 const savedAccounts = $derived(getSavedAccounts())
114 const loading = $derived(isLoading())
115</script>
116
117<div class="login-page">
118 {#if pageState.kind === 'verification'}
119 <header class="page-header">
120 <h1>{$_('verification.title')}</h1>
121 <p class="subtitle">{$_('verification.subtitle')}</p>
122 </header>
123
124 {#if resendMessage}
125 <div class="message success">{resendMessage}</div>
126 {/if}
127
128 <form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}>
129 <div class="field">
130 <label for="verification-code">{$_('verification.codeLabel')}</label>
131 <input
132 id="verification-code"
133 type="text"
134 bind:value={verificationCode}
135 placeholder={$_('verification.codePlaceholder')}
136 disabled={submitting}
137 required
138 maxlength="6"
139 pattern="[0-9]{6}"
140 autocomplete="one-time-code"
141 />
142 </div>
143 <div class="actions">
144 <button type="submit" disabled={submitting || !verificationCode.trim()}>
145 {submitting ? $_('common.verifying') : $_('common.verify')}
146 </button>
147 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
148 {resendingCode ? $_('common.sending') : $_('common.resendCode')}
149 </button>
150 <button type="button" class="tertiary" onclick={backToLogin}>
151 {$_('common.backToLogin')}
152 </button>
153 </div>
154 </form>
155
156 {:else}
157 <header class="page-header">
158 <h1>{$_('login.title')}</h1>
159 {#if savedAccounts.length > 0}
160 <p class="subtitle">{$_('login.chooseAccount')}</p>
161 {/if}
162 </header>
163
164 <div class="login-content">
165 {#if savedAccounts.length > 0}
166 <div class="saved-accounts" class:grid={savedAccounts.length > 1}>
167 {#each savedAccounts as account}
168 <div
169 class="account-item"
170 class:disabled={submitting}
171 role="button"
172 tabindex="0"
173 onclick={() => !submitting && handleSwitchAccount(account.did)}
174 onkeydown={(e) => e.key === 'Enter' && !submitting && handleSwitchAccount(account.did)}
175 >
176 <div class="account-info">
177 <span class="account-handle">@{account.handle}</span>
178 <span class="account-did">{account.did}</span>
179 </div>
180 <button
181 type="button"
182 class="forget-btn"
183 onclick={(e) => handleForgetAccount(account.did, e)}
184 title={$_('login.removeAccount')}
185 >
186 ×
187 </button>
188 </div>
189 {/each}
190 </div>
191
192 <p class="or-divider">{$_('login.signInToAnother')}</p>
193 {/if}
194
195 <button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || loading}>
196 {submitting ? $_('login.redirecting') : $_('login.button')}
197 </button>
198
199 <p class="forgot-links">
200 <a href="/app/reset-password">{$_('login.forgotPassword')}</a>
201 <span class="separator">·</span>
202 <a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a>
203 </p>
204
205 <p class="link-text">
206 {$_('login.noAccount')} <a href="/app/register">{$_('login.createAccount')}</a>
207 </p>
208 </div>
209 {/if}
210</div>
211
212<style>
213 .login-page {
214 max-width: var(--width-lg);
215 margin: var(--space-9) auto;
216 padding: var(--space-7);
217 }
218
219 .page-header {
220 margin-bottom: var(--space-6);
221 text-align: center;
222 }
223
224 h1 {
225 margin: 0 0 var(--space-3) 0;
226 }
227
228 .subtitle {
229 color: var(--text-secondary);
230 margin: 0;
231 }
232
233 .login-content {
234 max-width: var(--width-md);
235 margin: 0 auto;
236 }
237
238 form {
239 display: flex;
240 flex-direction: column;
241 gap: var(--space-4);
242 max-width: var(--width-sm);
243 margin: 0 auto;
244 }
245
246 .actions {
247 display: flex;
248 flex-direction: column;
249 gap: var(--space-3);
250 margin-top: var(--space-3);
251 }
252
253 @media (min-width: 600px) {
254 .actions {
255 flex-direction: row;
256 }
257
258 .actions button {
259 flex: 1;
260 }
261 }
262
263 .oauth-btn {
264 width: 100%;
265 padding: var(--space-5);
266 font-size: var(--text-lg);
267 }
268
269 .forgot-links {
270 margin-top: var(--space-4);
271 font-size: var(--text-sm);
272 color: var(--text-secondary);
273 text-align: center;
274 }
275
276 .forgot-links a {
277 color: var(--accent);
278 }
279
280 .separator {
281 margin: 0 var(--space-2);
282 }
283
284 .link-text {
285 margin-top: var(--space-6);
286 font-size: var(--text-sm);
287 color: var(--text-secondary);
288 text-align: center;
289 }
290
291 .link-text a {
292 color: var(--accent);
293 }
294
295 .saved-accounts {
296 display: flex;
297 flex-direction: column;
298 gap: var(--space-3);
299 margin-bottom: var(--space-5);
300 }
301
302 .saved-accounts.grid {
303 display: grid;
304 grid-template-columns: 1fr;
305 }
306
307 @media (min-width: 700px) {
308 .saved-accounts.grid {
309 grid-template-columns: repeat(2, 1fr);
310 }
311 }
312
313 .account-item {
314 display: flex;
315 align-items: center;
316 justify-content: space-between;
317 padding: var(--space-5);
318 background: var(--bg-card);
319 border: 1px solid var(--border-color);
320 border-radius: var(--radius-xl);
321 cursor: pointer;
322 transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
323 }
324
325 .account-item:hover:not(.disabled) {
326 border-color: var(--accent);
327 box-shadow: var(--shadow-md);
328 }
329
330 .account-item.disabled {
331 opacity: 0.6;
332 cursor: not-allowed;
333 }
334
335 .account-info {
336 display: flex;
337 flex-direction: column;
338 gap: var(--space-1);
339 min-width: 0;
340 }
341
342 .account-handle {
343 font-weight: var(--font-medium);
344 color: var(--text-primary);
345 }
346
347 .account-did {
348 font-size: var(--text-xs);
349 color: var(--text-muted);
350 font-family: var(--font-mono);
351 overflow: hidden;
352 text-overflow: ellipsis;
353 }
354
355 .forget-btn {
356 flex-shrink: 0;
357 padding: var(--space-2) var(--space-3);
358 background: transparent;
359 border: none;
360 color: var(--text-muted);
361 cursor: pointer;
362 font-size: var(--text-xl);
363 line-height: 1;
364 border-radius: var(--radius-md);
365 }
366
367 .forget-btn:hover {
368 background: var(--error-bg);
369 color: var(--error-text);
370 }
371
372 .or-divider {
373 text-align: center;
374 color: var(--text-muted);
375 font-size: var(--text-sm);
376 margin: var(--space-5) 0;
377 }
378</style>