this repo has no description
at main 9.6 kB view raw
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 &times; 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">&middot;</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>