this repo has no description
1<script lang="ts">
2 import { navigate, routes } from '../lib/router.svelte'
3 import { _ } from '../lib/i18n'
4 import {
5 prepareRequestOptions,
6 serializeAssertionResponse,
7 type WebAuthnRequestOptionsResponse,
8 } from '../lib/webauthn'
9
10 let loading = $state(false)
11 let error = $state<string | null>(null)
12 let autoStarted = $state(false)
13
14 function getRequestUri(): string | null {
15 const params = new URLSearchParams(window.location.search)
16 return params.get('request_uri')
17 }
18
19 const t = $_
20
21 async function startPasskeyAuth() {
22 const requestUri = getRequestUri()
23 if (!requestUri) {
24 error = t('common.error')
25 return
26 }
27
28 if (!window.PublicKeyCredential) {
29 error = t('common.error')
30 return
31 }
32
33 loading = true
34 error = null
35
36 try {
37 const startResponse = await fetch(`/oauth/authorize/passkey?request_uri=${encodeURIComponent(requestUri)}`, {
38 method: 'GET',
39 headers: {
40 'Accept': 'application/json'
41 }
42 })
43
44 if (!startResponse.ok) {
45 const data = await startResponse.json()
46 error = data.error_description || data.error || t('common.error')
47 loading = false
48 return
49 }
50
51 const { options } = await startResponse.json()
52 const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
53
54 const credential = await navigator.credentials.get({
55 publicKey: publicKeyOptions
56 })
57
58 if (!credential) {
59 error = t('common.error')
60 loading = false
61 return
62 }
63
64 const credentialResponse = serializeAssertionResponse(credential as PublicKeyCredential)
65
66 const finishResponse = await fetch('/oauth/authorize/passkey', {
67 method: 'POST',
68 headers: {
69 'Content-Type': 'application/json',
70 'Accept': 'application/json'
71 },
72 body: JSON.stringify({
73 request_uri: requestUri,
74 credential: credentialResponse
75 })
76 })
77
78 const finishData = await finishResponse.json()
79
80 if (!finishResponse.ok) {
81 error = finishData.error_description || finishData.error || t('common.error')
82 loading = false
83 return
84 }
85
86 if (finishData.redirect_uri) {
87 window.location.href = finishData.redirect_uri
88 return
89 }
90
91 error = t('common.error')
92 loading = false
93 } catch (e) {
94 if (e instanceof DOMException && e.name === 'NotAllowedError') {
95 error = t('common.error')
96 } else {
97 error = t('common.error')
98 }
99 loading = false
100 }
101 }
102
103 function handleCancel() {
104 const requestUri = getRequestUri()
105 if (requestUri) {
106 navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
107 } else {
108 window.history.back()
109 }
110 }
111
112 $effect(() => {
113 if (!autoStarted) {
114 autoStarted = true
115 startPasskeyAuth()
116 }
117 })
118</script>
119
120<div class="oauth-passkey-container">
121 <h1>{t('oauth.passkey.title')}</h1>
122 <p class="subtitle">
123 {t('oauth.passkey.subtitle')}
124 </p>
125
126 {#if error}
127 <div class="error">{error}</div>
128 {/if}
129
130 <div class="passkey-status">
131 {#if loading}
132 <div class="loading-indicator">
133 <div class="spinner"></div>
134 <p>{t('oauth.passkey.waiting')}</p>
135 </div>
136 {:else}
137 <button type="button" class="passkey-btn" onclick={startPasskeyAuth} disabled={loading}>
138 {t('oauth.passkey.title')}
139 </button>
140 {/if}
141 </div>
142
143 <div class="actions">
144 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={loading}>
145 {t('common.cancel')}
146 </button>
147 </div>
148</div>
149
150<style>
151 .oauth-passkey-container {
152 max-width: 400px;
153 margin: 4rem auto;
154 padding: 2rem;
155 text-align: center;
156 }
157
158 h1 {
159 margin: 0 0 0.5rem 0;
160 }
161
162 .subtitle {
163 color: var(--text-secondary);
164 margin: 0 0 2rem 0;
165 }
166
167 .error {
168 padding: 0.75rem;
169 background: var(--error-bg);
170 border: 1px solid var(--error-border);
171 border-radius: 4px;
172 color: var(--error-text);
173 margin-bottom: 1.5rem;
174 text-align: left;
175 }
176
177 .passkey-status {
178 padding: 2rem;
179 background: var(--bg-secondary);
180 border-radius: 8px;
181 margin-bottom: 1.5rem;
182 }
183
184 .loading-indicator {
185 display: flex;
186 flex-direction: column;
187 align-items: center;
188 gap: 1rem;
189 }
190
191 .spinner {
192 width: 40px;
193 height: 40px;
194 border: 3px solid var(--border-color);
195 border-top-color: var(--accent);
196 border-radius: 50%;
197 animation: spin 1s linear infinite;
198 }
199
200 @keyframes spin {
201 to {
202 transform: rotate(360deg);
203 }
204 }
205
206 .loading-indicator p {
207 margin: 0;
208 color: var(--text-secondary);
209 }
210
211 .passkey-btn {
212 width: 100%;
213 padding: 1rem;
214 background: var(--accent);
215 color: white;
216 border: none;
217 border-radius: 4px;
218 font-size: 1rem;
219 cursor: pointer;
220 transition: background-color 0.15s;
221 }
222
223 .passkey-btn:hover:not(:disabled) {
224 background: var(--accent-hover);
225 }
226
227 .passkey-btn:disabled {
228 opacity: 0.6;
229 cursor: not-allowed;
230 }
231
232 .actions {
233 display: flex;
234 justify-content: center;
235 margin-bottom: 1.5rem;
236 }
237
238 .cancel-btn {
239 padding: 0.75rem 2rem;
240 background: var(--bg-secondary);
241 color: var(--text-primary);
242 border: 1px solid var(--border-color);
243 border-radius: 4px;
244 font-size: 1rem;
245 cursor: pointer;
246 transition: background-color 0.15s;
247 }
248
249 .cancel-btn:hover:not(:disabled) {
250 background: var(--error-bg);
251 border-color: var(--error-border);
252 color: var(--error-text);
253 }
254
255 .cancel-btn:disabled {
256 opacity: 0.6;
257 cursor: not-allowed;
258 }
259</style>