this repo has no description
1<script lang="ts"> 2 import { navigate } from '../lib/router.svelte' 3 import { _ } from '../lib/i18n' 4 5 let code = $state('') 6 let trustDevice = $state(false) 7 let submitting = $state(false) 8 let error = $state<string | null>(null) 9 10 function getRequestUri(): string | null { 11 const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 12 return params.get('request_uri') 13 } 14 15 async function handleSubmit(e: Event) { 16 e.preventDefault() 17 const requestUri = getRequestUri() 18 if (!requestUri) { 19 error = $_('common.error') 20 return 21 } 22 23 submitting = true 24 error = null 25 26 try { 27 const response = await fetch('/oauth/authorize/2fa', { 28 method: 'POST', 29 headers: { 30 'Content-Type': 'application/json', 31 'Accept': 'application/json' 32 }, 33 body: JSON.stringify({ 34 request_uri: requestUri, 35 code: code.trim().toUpperCase(), 36 trust_device: trustDevice 37 }) 38 }) 39 40 const data = await response.json() 41 42 if (!response.ok) { 43 error = data.error_description || data.error || $_('common.error') 44 submitting = false 45 return 46 } 47 48 if (data.redirect_uri) { 49 window.location.href = data.redirect_uri 50 return 51 } 52 53 error = $_('common.error') 54 submitting = false 55 } catch { 56 error = $_('common.error') 57 submitting = false 58 } 59 } 60 61 function handleCancel() { 62 const requestUri = getRequestUri() 63 if (requestUri) { 64 navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`) 65 } else { 66 window.history.back() 67 } 68 } 69 70 let isBackupCode = $derived(code.trim().length === 8 && /^[A-Z0-9]+$/i.test(code.trim())) 71 let isTotpCode = $derived(code.trim().length === 6 && /^[0-9]+$/.test(code.trim())) 72 let canSubmit = $derived(isBackupCode || isTotpCode) 73</script> 74 75<div class="oauth-totp-container"> 76 <h1>{$_('oauth.totp.title')}</h1> 77 <p class="subtitle"> 78 {$_('oauth.totp.subtitle')} 79 </p> 80 81 {#if error} 82 <div class="error">{error}</div> 83 {/if} 84 85 <form onsubmit={handleSubmit}> 86 <div class="field"> 87 <label for="code">{$_('oauth.totp.codePlaceholder')}</label> 88 <input 89 id="code" 90 type="text" 91 bind:value={code} 92 placeholder={isBackupCode ? $_('oauth.totp.backupCodePlaceholder') : $_('oauth.totp.codePlaceholder')} 93 disabled={submitting} 94 required 95 maxlength="8" 96 autocomplete="one-time-code" 97 autocapitalize="characters" 98 /> 99 <p class="hint"> 100 {#if isBackupCode} 101 {$_('oauth.totp.hintBackupCode')} 102 {:else if isTotpCode} 103 {$_('oauth.totp.hintTotpCode')} 104 {:else} 105 {$_('oauth.totp.hintDefault')} 106 {/if} 107 </p> 108 </div> 109 110 <label class="trust-device-label"> 111 <input 112 type="checkbox" 113 bind:checked={trustDevice} 114 disabled={submitting} 115 /> 116 <span>{$_('oauth.totp.trustDevice')}</span> 117 </label> 118 119 <div class="actions"> 120 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 121 {$_('common.cancel')} 122 </button> 123 <button type="submit" class="submit-btn" disabled={submitting || !canSubmit}> 124 {submitting ? $_('oauth.totp.verifying') : $_('oauth.totp.verify')} 125 </button> 126 </div> 127 </form> 128</div> 129 130<style> 131 .oauth-totp-container { 132 max-width: var(--width-sm); 133 margin: var(--space-9) auto; 134 padding: var(--space-7); 135 } 136 137 h1 { 138 margin: 0 0 var(--space-2) 0; 139 } 140 141 .subtitle { 142 color: var(--text-secondary); 143 margin: 0 0 var(--space-7) 0; 144 } 145 146 form { 147 display: flex; 148 flex-direction: column; 149 gap: var(--space-4); 150 } 151 152 .field { 153 display: flex; 154 flex-direction: column; 155 gap: var(--space-1); 156 } 157 158 label { 159 font-size: var(--text-sm); 160 font-weight: var(--font-medium); 161 } 162 163 input { 164 padding: var(--space-3); 165 border: 1px solid var(--border-color); 166 border-radius: var(--radius-md); 167 font-size: var(--text-xl); 168 letter-spacing: 0.25em; 169 text-align: center; 170 background: var(--bg-input); 171 color: var(--text-primary); 172 text-transform: uppercase; 173 } 174 175 input:focus { 176 outline: none; 177 border-color: var(--accent); 178 } 179 180 .hint { 181 font-size: var(--text-xs); 182 color: var(--text-muted); 183 margin: var(--space-1) 0 0 0; 184 text-align: center; 185 } 186 187 .error { 188 padding: var(--space-3); 189 background: var(--error-bg); 190 border: 1px solid var(--error-border); 191 border-radius: var(--radius-md); 192 color: var(--error-text); 193 margin-bottom: var(--space-4); 194 } 195 196 .actions { 197 display: flex; 198 gap: var(--space-4); 199 margin-top: var(--space-2); 200 } 201 202 .actions button { 203 flex: 1; 204 padding: var(--space-3); 205 border: none; 206 border-radius: var(--radius-md); 207 font-size: var(--text-base); 208 cursor: pointer; 209 transition: background-color var(--transition-fast); 210 } 211 212 .actions button:disabled { 213 opacity: 0.6; 214 cursor: not-allowed; 215 } 216 217 .cancel-btn { 218 background: var(--bg-secondary); 219 color: var(--text-primary); 220 border: 1px solid var(--border-color); 221 } 222 223 .cancel-btn:hover:not(:disabled) { 224 background: var(--error-bg); 225 border-color: var(--error-border); 226 color: var(--error-text); 227 } 228 229 .submit-btn { 230 background: var(--accent); 231 color: var(--text-inverse); 232 } 233 234 .submit-btn:hover:not(:disabled) { 235 background: var(--accent-hover); 236 } 237 238 .trust-device-label { 239 display: flex; 240 align-items: center; 241 gap: var(--space-2); 242 cursor: pointer; 243 font-size: var(--text-sm); 244 color: var(--text-secondary); 245 margin-top: var(--space-2); 246 } 247 248 .trust-device-label input[type="checkbox"] { 249 width: auto; 250 margin: 0; 251 } 252</style>