forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
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
123 {#if error}
124 <div class="error">{error}</div>
125 {/if}
126
127 <div class="passkey-status">
128 {#if loading}
129 <div class="loading-indicator">
130 <div class="spinner"></div>
131 <p>{t('oauth.passkey.waiting')}</p>
132 </div>
133 {:else}
134 <button type="button" class="passkey-btn" onclick={startPasskeyAuth} disabled={loading}>
135 {t('oauth.passkey.title')}
136 </button>
137 {/if}
138 </div>
139
140 <div class="actions">
141 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={loading}>
142 {t('common.cancel')}
143 </button>
144 </div>
145</div>
146
147<style>
148 .oauth-passkey-container {
149 max-width: 400px;
150 margin: 4rem auto;
151 padding: 2rem;
152 text-align: center;
153 }
154
155 h1 {
156 margin: 0 0 1.5rem 0;
157 }
158
159 .error {
160 padding: 0.75rem;
161 background: var(--error-bg);
162 border: 1px solid var(--error-border);
163 border-radius: 4px;
164 color: var(--error-text);
165 margin-bottom: 1.5rem;
166 text-align: left;
167 }
168
169 .passkey-status {
170 padding: 2rem;
171 background: var(--bg-secondary);
172 border-radius: 8px;
173 margin-bottom: 1.5rem;
174 }
175
176 .loading-indicator {
177 display: flex;
178 flex-direction: column;
179 align-items: center;
180 gap: 1rem;
181 }
182
183 .spinner {
184 width: 40px;
185 height: 40px;
186 border: 3px solid var(--border-color);
187 border-top-color: var(--accent);
188 border-radius: 50%;
189 animation: spin 1s linear infinite;
190 }
191
192 .loading-indicator p {
193 margin: 0;
194 color: var(--text-secondary);
195 }
196
197 .passkey-btn {
198 width: 100%;
199 padding: 1rem;
200 background: var(--accent);
201 color: white;
202 border: none;
203 border-radius: 4px;
204 font-size: 1rem;
205 cursor: pointer;
206 transition: background-color 0.15s;
207 }
208
209 .passkey-btn:hover:not(:disabled) {
210 background: var(--accent-hover);
211 }
212
213 .passkey-btn:disabled {
214 opacity: 0.6;
215 cursor: not-allowed;
216 }
217
218 .actions {
219 display: flex;
220 justify-content: center;
221 margin-bottom: 1.5rem;
222 }
223
224 .cancel-btn {
225 padding: 0.75rem 2rem;
226 background: var(--bg-secondary);
227 color: var(--text-primary);
228 border: 1px solid var(--border-color);
229 border-radius: 4px;
230 font-size: 1rem;
231 cursor: pointer;
232 transition: background-color 0.15s;
233 }
234
235 .cancel-btn:hover:not(:disabled) {
236 background: var(--error-bg);
237 border-color: var(--error-border);
238 color: var(--error-text);
239 }
240
241 .cancel-btn:disabled {
242 opacity: 0.6;
243 cursor: not-allowed;
244 }
245</style>