this repo has no description
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 <p class="subtitle">{savedAccounts.length > 0 ? $_('login.chooseAccount') : $_('login.subtitle')}</p>
160 </header>
161
162 <div class="split-layout sidebar-right">
163 <div class="main-section">
164 {#if savedAccounts.length > 0}
165 <div class="saved-accounts">
166 {#each savedAccounts as account}
167 <div
168 class="account-item"
169 class:disabled={submitting}
170 role="button"
171 tabindex="0"
172 onclick={() => !submitting && handleSwitchAccount(account.did)}
173 onkeydown={(e) => e.key === 'Enter' && !submitting && handleSwitchAccount(account.did)}
174 >
175 <div class="account-info">
176 <span class="account-handle">@{account.handle}</span>
177 <span class="account-did">{account.did}</span>
178 </div>
179 <button
180 type="button"
181 class="forget-btn"
182 onclick={(e) => handleForgetAccount(account.did, e)}
183 title={$_('login.removeAccount')}
184 >
185 ×
186 </button>
187 </div>
188 {/each}
189 </div>
190
191 <p class="or-divider">{$_('login.signInToAnother')}</p>
192 {/if}
193
194 <button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || loading}>
195 {submitting ? $_('login.redirecting') : $_('login.button')}
196 </button>
197
198 <p class="forgot-links">
199 <a href="/app/reset-password">{$_('login.forgotPassword')}</a>
200 <span class="separator">·</span>
201 <a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a>
202 </p>
203
204 <p class="link-text">
205 {$_('login.noAccount')} <a href="/app/register">{$_('login.createAccount')}</a>
206 </p>
207 </div>
208
209 <aside class="info-panel">
210 {#if savedAccounts.length > 0}
211 <h3>{$_('login.infoSavedAccountsTitle')}</h3>
212 <p>{$_('login.infoSavedAccountsDesc')}</p>
213
214 <h3>{$_('login.infoNewAccountTitle')}</h3>
215 <p>{$_('login.infoNewAccountDesc')}</p>
216 {:else}
217 <h3>{$_('login.infoSecureSignInTitle')}</h3>
218 <p>{$_('login.infoSecureSignInDesc')}</p>
219
220 <h3>{$_('login.infoStaySignedInTitle')}</h3>
221 <p>{$_('login.infoStaySignedInDesc')}</p>
222 {/if}
223
224 <h3>{$_('login.infoRecoveryTitle')}</h3>
225 <p>{$_('login.infoRecoveryDesc')}</p>
226 </aside>
227 </div>
228 {/if}
229</div>
230
231<style>
232 .login-page {
233 max-width: var(--width-lg);
234 margin: var(--space-9) auto;
235 padding: var(--space-7);
236 }
237
238 .page-header {
239 margin-bottom: var(--space-6);
240 }
241
242 h1 {
243 margin: 0 0 var(--space-3) 0;
244 }
245
246 .subtitle {
247 color: var(--text-secondary);
248 margin: 0;
249 }
250
251 .main-section {
252 min-width: 0;
253 }
254
255 form {
256 display: flex;
257 flex-direction: column;
258 gap: var(--space-4);
259 max-width: var(--width-sm);
260 }
261
262 .actions {
263 display: flex;
264 flex-direction: column;
265 gap: var(--space-3);
266 margin-top: var(--space-3);
267 }
268
269 @media (min-width: 600px) {
270 .actions {
271 flex-direction: row;
272 }
273
274 .actions button {
275 flex: 1;
276 }
277 }
278
279 .oauth-btn {
280 width: 100%;
281 padding: var(--space-5);
282 font-size: var(--text-lg);
283 }
284
285 .forgot-links {
286 margin-top: var(--space-4);
287 font-size: var(--text-sm);
288 color: var(--text-secondary);
289 }
290
291 .forgot-links a {
292 color: var(--accent);
293 }
294
295 .separator {
296 margin: 0 var(--space-2);
297 }
298
299 .link-text {
300 margin-top: var(--space-6);
301 font-size: var(--text-sm);
302 color: var(--text-secondary);
303 }
304
305 .link-text a {
306 color: var(--accent);
307 }
308
309 .saved-accounts {
310 display: flex;
311 flex-direction: column;
312 gap: var(--space-3);
313 margin-bottom: var(--space-5);
314 }
315
316 .account-item {
317 display: flex;
318 align-items: center;
319 justify-content: space-between;
320 padding: var(--space-5);
321 background: var(--bg-card);
322 border: 1px solid var(--border-color);
323 border-radius: var(--radius-xl);
324 cursor: pointer;
325 transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
326 }
327
328 .account-item:hover:not(.disabled) {
329 border-color: var(--accent);
330 box-shadow: var(--shadow-md);
331 }
332
333 .account-item.disabled {
334 opacity: 0.6;
335 cursor: not-allowed;
336 }
337
338 .account-info {
339 display: flex;
340 flex-direction: column;
341 gap: var(--space-1);
342 }
343
344 .account-handle {
345 font-weight: var(--font-medium);
346 color: var(--text-primary);
347 }
348
349 .account-did {
350 font-size: var(--text-xs);
351 color: var(--text-muted);
352 font-family: ui-monospace, monospace;
353 overflow: hidden;
354 text-overflow: ellipsis;
355 max-width: 250px;
356 }
357
358 .forget-btn {
359 padding: var(--space-2) var(--space-3);
360 background: transparent;
361 border: none;
362 color: var(--text-muted);
363 cursor: pointer;
364 font-size: var(--text-xl);
365 line-height: 1;
366 border-radius: var(--radius-md);
367 }
368
369 .forget-btn:hover {
370 background: var(--error-bg);
371 color: var(--error-text);
372 }
373
374 .or-divider {
375 text-align: center;
376 color: var(--text-muted);
377 font-size: var(--text-sm);
378 margin: var(--space-5) 0;
379 }
380</style>