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