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