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>