Our Personal Data Server from scratch!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 245 lines 5.5 kB view raw
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>