this repo has no description
at main 5.7 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 <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>