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 &times; 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">&middot;</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>