@mrgnw/anahtar 0.0.14 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -2
- package/dist/components/AuthPill.svelte +650 -0
- package/dist/components/AuthPill.svelte.d.ts +22 -0
- package/dist/components/index.d.ts +7 -6
- package/dist/components/index.js +6 -5
- package/dist/kit/handlers.js +23 -1
- package/package.json +9 -15
package/README.md
CHANGED
|
@@ -51,9 +51,10 @@ import { auth } from "$lib/server/auth";
|
|
|
51
51
|
export const { GET, POST } = auth.handlers;
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
Optional UI
|
|
54
|
+
Optional UI components:
|
|
55
55
|
|
|
56
56
|
```svelte
|
|
57
|
+
<!-- Full-page auth flow -->
|
|
57
58
|
<script>
|
|
58
59
|
import { AuthFlow } from '@mrgnw/anahtar/components';
|
|
59
60
|
import { goto } from '$app/navigation';
|
|
@@ -62,6 +63,20 @@ Optional UI component:
|
|
|
62
63
|
<AuthFlow onSuccess={() => goto('/')} />
|
|
63
64
|
```
|
|
64
65
|
|
|
66
|
+
```svelte
|
|
67
|
+
<!-- Compact inline pill (for headers, floating islands) -->
|
|
68
|
+
<script>
|
|
69
|
+
import { AuthPill } from '@mrgnw/anahtar/components';
|
|
70
|
+
import { invalidateAll } from '$app/navigation';
|
|
71
|
+
import { page } from '$app/stores';
|
|
72
|
+
let user = $derived($page.data.user);
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<AuthPill {user} onSuccess={() => invalidateAll()} />
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
All components auto-detect locale (88 languages). Override with `locale="fr"` or `messages={{ continue: 'Go' }}`.
|
|
79
|
+
|
|
65
80
|
## Tests
|
|
66
81
|
|
|
67
82
|
68 tests: 46 unit (node) + 22 component (happy-dom).
|
|
@@ -74,5 +89,5 @@ pnpm test
|
|
|
74
89
|
|
|
75
90
|
## Docs
|
|
76
91
|
|
|
77
|
-
- [Integration guide](docs/integration.md) — install, config, theming,
|
|
92
|
+
- [Integration guide](docs/integration.md) — install, config, components, i18n, theming, DB adapters
|
|
78
93
|
- [PLAN.md](PLAN.md) — architecture, DB adapter interface, test setup
|
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { guessDeviceName } from '../device.js';
|
|
3
|
+
import { resolveMessages, detectLocaleClient, type AuthMessages } from '../i18n/index.js';
|
|
4
|
+
import PasskeyPrompt from './PasskeyPrompt.svelte';
|
|
5
|
+
import { onMount } from 'svelte';
|
|
6
|
+
import { slide } from 'svelte/transition';
|
|
7
|
+
|
|
8
|
+
interface PasskeyInfo {
|
|
9
|
+
id: string;
|
|
10
|
+
credentialId?: string;
|
|
11
|
+
name?: string | null;
|
|
12
|
+
createdAt?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
apiBase?: string;
|
|
17
|
+
user?: { email: string } | null;
|
|
18
|
+
locale?: string;
|
|
19
|
+
messages?: Partial<AuthMessages>;
|
|
20
|
+
onSuccess?: () => void | Promise<void>;
|
|
21
|
+
onSignOut?: () => void | Promise<void>;
|
|
22
|
+
onPasskeysChange?: () => void | Promise<void>;
|
|
23
|
+
getPasskeys?: () => Promise<PasskeyInfo[]>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
apiBase = '/api/auth',
|
|
28
|
+
user = null,
|
|
29
|
+
locale,
|
|
30
|
+
messages: messageOverrides,
|
|
31
|
+
onSuccess,
|
|
32
|
+
onSignOut,
|
|
33
|
+
onPasskeysChange,
|
|
34
|
+
getPasskeys,
|
|
35
|
+
}: Props = $props();
|
|
36
|
+
|
|
37
|
+
let m = $derived(resolveMessages(locale ?? detectLocaleClient(), messageOverrides));
|
|
38
|
+
|
|
39
|
+
let email = $state('');
|
|
40
|
+
let loading = $state(false);
|
|
41
|
+
let error = $state('');
|
|
42
|
+
let otpStep = $state(false);
|
|
43
|
+
let otpDigits = $state<string[]>(['', '', '', '', '']);
|
|
44
|
+
let otpInputs = $state<HTMLInputElement[]>([]);
|
|
45
|
+
let showPasskeys = $state(false);
|
|
46
|
+
let hoveredKey = $state<string | null>(null);
|
|
47
|
+
let passkeyRefresh = $state(0);
|
|
48
|
+
let passkeyOnboarding = $state(false);
|
|
49
|
+
let conditionalAbort: AbortController | null = null;
|
|
50
|
+
|
|
51
|
+
const isAuthenticated = $derived(!!user);
|
|
52
|
+
const passkeyPromise = $derived(
|
|
53
|
+
isAuthenticated && getPasskeys && passkeyRefresh >= 0 ? getPasskeys() : null
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
onMount(() => {
|
|
57
|
+
if (!isAuthenticated) tryConditionalWebAuthn();
|
|
58
|
+
return () => conditionalAbort?.abort();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
async function handleSignOut() {
|
|
62
|
+
email = '';
|
|
63
|
+
otpStep = false;
|
|
64
|
+
passkeyOnboarding = false;
|
|
65
|
+
showPasskeys = false;
|
|
66
|
+
error = '';
|
|
67
|
+
await onSignOut?.();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function tryConditionalWebAuthn() {
|
|
71
|
+
try {
|
|
72
|
+
const { startAuthentication } = await import('@simplewebauthn/browser');
|
|
73
|
+
const res = await fetch(`${apiBase}/passkey/login-start`);
|
|
74
|
+
if (!res.ok) return;
|
|
75
|
+
const options = (await res.json()) as any;
|
|
76
|
+
conditionalAbort = new AbortController();
|
|
77
|
+
const authResponse = await startAuthentication({
|
|
78
|
+
optionsJSON: options,
|
|
79
|
+
useBrowserAutofill: true,
|
|
80
|
+
});
|
|
81
|
+
loading = true;
|
|
82
|
+
const verifyRes = await fetch(`${apiBase}/passkey/login-finish`, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify(authResponse),
|
|
86
|
+
});
|
|
87
|
+
if (verifyRes.ok) await onSuccess?.();
|
|
88
|
+
} catch {
|
|
89
|
+
/* not available or cancelled */
|
|
90
|
+
} finally {
|
|
91
|
+
loading = false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function handleEmailSubmit(e: SubmitEvent) {
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
if (!email.includes('@')) return;
|
|
98
|
+
conditionalAbort?.abort();
|
|
99
|
+
conditionalAbort = null;
|
|
100
|
+
loading = true;
|
|
101
|
+
error = '';
|
|
102
|
+
try {
|
|
103
|
+
// Passkey-first: check if user has passkeys before falling back to OTP
|
|
104
|
+
try {
|
|
105
|
+
const { startAuthentication } = await import('@simplewebauthn/browser');
|
|
106
|
+
const checkRes = await fetch(`${apiBase}/passkey/check-email`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
109
|
+
body: JSON.stringify({ email }),
|
|
110
|
+
});
|
|
111
|
+
if (checkRes.ok) {
|
|
112
|
+
const opts = (await checkRes.json()) as any;
|
|
113
|
+
if ((opts?.allowCredentials?.length ?? 0) > 0) {
|
|
114
|
+
try {
|
|
115
|
+
const authResp = await startAuthentication({ optionsJSON: opts });
|
|
116
|
+
const vRes = await fetch(`${apiBase}/passkey/login-finish`, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: { 'Content-Type': 'application/json' },
|
|
119
|
+
body: JSON.stringify(authResp),
|
|
120
|
+
});
|
|
121
|
+
if (vRes.ok) {
|
|
122
|
+
await onSuccess?.();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
/* cancelled */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
/* passkey check failed */
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const res = await fetch(`${apiBase}/start`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
body: JSON.stringify({ email }),
|
|
138
|
+
});
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
const d = ((await res.json().catch(() => ({}))) as any);
|
|
141
|
+
error = d?.error ?? m.errorGeneric;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const data = ((await res.json().catch(() => ({}))) as any);
|
|
145
|
+
otpDigits = ['', '', '', '', ''];
|
|
146
|
+
otpStep = true;
|
|
147
|
+
if (data?.devCode) {
|
|
148
|
+
const code = String(data.devCode);
|
|
149
|
+
for (let i = 0; i < 5; i++) otpDigits[i] = code[i] ?? '';
|
|
150
|
+
setTimeout(() => verifyOtp(code), 50);
|
|
151
|
+
} else {
|
|
152
|
+
setTimeout(() => otpInputs[0]?.focus(), 50);
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
error = m.errorGeneric;
|
|
156
|
+
} finally {
|
|
157
|
+
loading = false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function handleOtpInput(i: number, e: Event) {
|
|
162
|
+
const input = e.target as HTMLInputElement;
|
|
163
|
+
const val = input.value.replace(/\D/g, '');
|
|
164
|
+
if (val.length > 1) {
|
|
165
|
+
for (let j = 0; j < 5; j++) otpDigits[j] = val[j] ?? '';
|
|
166
|
+
const next = otpDigits.findIndex((d) => !d);
|
|
167
|
+
otpInputs[next === -1 ? 4 : next]?.focus();
|
|
168
|
+
} else {
|
|
169
|
+
otpDigits[i] = val.slice(0, 1);
|
|
170
|
+
input.value = otpDigits[i];
|
|
171
|
+
if (val && i < 4) otpInputs[i + 1]?.focus();
|
|
172
|
+
}
|
|
173
|
+
if (otpDigits.every((d) => d.length === 1)) verifyOtp(otpDigits.join(''));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function handleOtpKeydown(i: number, e: KeyboardEvent) {
|
|
177
|
+
if (e.key === 'Backspace' && !otpDigits[i] && i > 0) otpInputs[i - 1]?.focus();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function handleOtpPaste(e: ClipboardEvent) {
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
const pasted = (e.clipboardData?.getData('text') ?? '').replace(/\D/g, '');
|
|
183
|
+
for (let i = 0; i < 5; i++) otpDigits[i] = pasted[i] ?? '';
|
|
184
|
+
const next = otpDigits.findIndex((d) => !d);
|
|
185
|
+
otpInputs[next === -1 ? 4 : next]?.focus();
|
|
186
|
+
if (otpDigits.every((d) => d.length === 1)) verifyOtp(otpDigits.join(''));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function verifyOtp(code: string) {
|
|
190
|
+
loading = true;
|
|
191
|
+
error = '';
|
|
192
|
+
try {
|
|
193
|
+
const res = await fetch(`${apiBase}/verify`, {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers: { 'Content-Type': 'application/json' },
|
|
196
|
+
body: JSON.stringify({ email, code }),
|
|
197
|
+
});
|
|
198
|
+
if (!res.ok) {
|
|
199
|
+
const d = ((await res.json().catch(() => ({}))) as any);
|
|
200
|
+
error = d?.error ?? m.errorInvalidCode;
|
|
201
|
+
otpDigits = ['', '', '', '', ''];
|
|
202
|
+
setTimeout(() => otpInputs[0]?.focus(), 50);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const data = ((await res.json().catch(() => ({}))) as any);
|
|
206
|
+
await onSuccess?.();
|
|
207
|
+
otpStep = false;
|
|
208
|
+
if (!data.hasPasskey && !data.skipPasskeyPrompt) {
|
|
209
|
+
passkeyOnboarding = true;
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
error = m.errorGeneric;
|
|
213
|
+
} finally {
|
|
214
|
+
loading = false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function resend() {
|
|
219
|
+
loading = true;
|
|
220
|
+
try {
|
|
221
|
+
await fetch(`${apiBase}/start`, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: { 'Content-Type': 'application/json' },
|
|
224
|
+
body: JSON.stringify({ email }),
|
|
225
|
+
});
|
|
226
|
+
otpDigits = ['', '', '', '', ''];
|
|
227
|
+
setTimeout(() => otpInputs[0]?.focus(), 50);
|
|
228
|
+
} catch {
|
|
229
|
+
/* ignore */
|
|
230
|
+
} finally {
|
|
231
|
+
loading = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function handlePasskeyRegister() {
|
|
236
|
+
const { startRegistration } = await import('@simplewebauthn/browser');
|
|
237
|
+
const optRes = await fetch(`${apiBase}/passkey/register-start`, { method: 'POST' });
|
|
238
|
+
if (!optRes.ok) throw new Error('Failed to get registration options');
|
|
239
|
+
const options = (await optRes.json()) as any;
|
|
240
|
+
const regResponse = await startRegistration({ optionsJSON: options });
|
|
241
|
+
const res = await fetch(`${apiBase}/passkey/register-finish`, {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: { 'Content-Type': 'application/json' },
|
|
244
|
+
body: JSON.stringify({ ...regResponse, name: guessDeviceName() }),
|
|
245
|
+
});
|
|
246
|
+
if (!res.ok) throw new Error('Registration failed');
|
|
247
|
+
passkeyOnboarding = false;
|
|
248
|
+
passkeyRefresh++;
|
|
249
|
+
await onPasskeysChange?.();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function handlePasskeySkip() {
|
|
253
|
+
fetch(`${apiBase}/skip-passkey`, { method: 'POST' });
|
|
254
|
+
passkeyOnboarding = false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function addPasskey() {
|
|
258
|
+
loading = true;
|
|
259
|
+
error = '';
|
|
260
|
+
try {
|
|
261
|
+
const { startRegistration } = await import('@simplewebauthn/browser');
|
|
262
|
+
const startRes = await fetch(`${apiBase}/passkey/register-start`, { method: 'POST' });
|
|
263
|
+
if (!startRes.ok) {
|
|
264
|
+
error = 'Failed to start';
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const opts = (await startRes.json()) as any;
|
|
268
|
+
const regResponse = await startRegistration({ optionsJSON: opts });
|
|
269
|
+
const finishRes = await fetch(`${apiBase}/passkey/register-finish`, {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers: { 'Content-Type': 'application/json' },
|
|
272
|
+
body: JSON.stringify({ ...regResponse, name: guessDeviceName() }),
|
|
273
|
+
});
|
|
274
|
+
if (!finishRes.ok) {
|
|
275
|
+
error = 'Failed to register';
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
passkeyRefresh++;
|
|
279
|
+
await onPasskeysChange?.();
|
|
280
|
+
} catch (e) {
|
|
281
|
+
error = e instanceof Error ? e.message : 'Failed';
|
|
282
|
+
} finally {
|
|
283
|
+
loading = false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function removePasskey(id: string) {
|
|
288
|
+
loading = true;
|
|
289
|
+
try {
|
|
290
|
+
await fetch(`${apiBase}/passkey/remove`, {
|
|
291
|
+
method: 'POST',
|
|
292
|
+
headers: { 'Content-Type': 'application/json' },
|
|
293
|
+
body: JSON.stringify({ passkeyId: id }),
|
|
294
|
+
});
|
|
295
|
+
passkeyRefresh++;
|
|
296
|
+
await onPasskeysChange?.();
|
|
297
|
+
} catch {
|
|
298
|
+
/* ignore */
|
|
299
|
+
} finally {
|
|
300
|
+
loading = false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
</script>
|
|
304
|
+
|
|
305
|
+
<div class="anahtar-pill-island" class:anahtar-pill-loading={loading}>
|
|
306
|
+
<div class="anahtar-pill">
|
|
307
|
+
{#if isAuthenticated}
|
|
308
|
+
<span class="anahtar-pill-email">{user?.email}</span>
|
|
309
|
+
<span class="anahtar-pill-sep">·</span>
|
|
310
|
+
{#if getPasskeys}
|
|
311
|
+
<button
|
|
312
|
+
class="anahtar-pill-icon"
|
|
313
|
+
class:anahtar-pill-icon-active={showPasskeys}
|
|
314
|
+
onclick={() => (showPasskeys = !showPasskeys)}
|
|
315
|
+
title="Passkeys"
|
|
316
|
+
disabled={loading}
|
|
317
|
+
>
|
|
318
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
319
|
+
<circle cx="7.5" cy="15.5" r="5.5"/><path d="m11.5 12 4-4"/><path d="m15 7 2 2"/><path d="m17.5 4.5 2 2"/>
|
|
320
|
+
</svg>
|
|
321
|
+
</button>
|
|
322
|
+
<span class="anahtar-pill-sep">·</span>
|
|
323
|
+
{/if}
|
|
324
|
+
<button class="anahtar-pill-icon anahtar-pill-signout" onclick={handleSignOut} title="Sign out" disabled={loading}>
|
|
325
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
326
|
+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>
|
|
327
|
+
</svg>
|
|
328
|
+
</button>
|
|
329
|
+
|
|
330
|
+
{:else if otpStep}
|
|
331
|
+
<span class="anahtar-pill-otp-label">{email}</span>
|
|
332
|
+
<span class="anahtar-pill-sep">·</span>
|
|
333
|
+
<div class="anahtar-pill-otp-boxes">
|
|
334
|
+
{#each otpDigits as _, i}
|
|
335
|
+
<input
|
|
336
|
+
bind:this={otpInputs[i]}
|
|
337
|
+
class="anahtar-pill-otp-box"
|
|
338
|
+
type="text"
|
|
339
|
+
inputmode="numeric"
|
|
340
|
+
autocomplete={i === 0 ? 'one-time-code' : 'off'}
|
|
341
|
+
value={otpDigits[i]}
|
|
342
|
+
disabled={loading}
|
|
343
|
+
oninput={(e) => handleOtpInput(i, e)}
|
|
344
|
+
onkeydown={(e) => handleOtpKeydown(i, e)}
|
|
345
|
+
onpaste={handleOtpPaste}
|
|
346
|
+
/>
|
|
347
|
+
{/each}
|
|
348
|
+
</div>
|
|
349
|
+
|
|
350
|
+
{:else}
|
|
351
|
+
<form onsubmit={handleEmailSubmit} class="anahtar-pill-form">
|
|
352
|
+
<input
|
|
353
|
+
type="email"
|
|
354
|
+
bind:value={email}
|
|
355
|
+
placeholder={m.emailPlaceholder}
|
|
356
|
+
class="anahtar-pill-email-input"
|
|
357
|
+
autocomplete="username webauthn"
|
|
358
|
+
disabled={loading}
|
|
359
|
+
/>
|
|
360
|
+
<button type="submit" class="anahtar-pill-go" disabled={loading || !email.includes('@')}>
|
|
361
|
+
{loading ? '...' : m.continue}
|
|
362
|
+
</button>
|
|
363
|
+
</form>
|
|
364
|
+
{/if}
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
{#if otpStep}
|
|
368
|
+
<div class="anahtar-pill-otp-help" transition:slide={{ duration: 150 }}>
|
|
369
|
+
<span class="anahtar-pill-otp-help-text">{m.codeSentTo}</span>
|
|
370
|
+
<span class="anahtar-pill-otp-help-sep">·</span>
|
|
371
|
+
<button class="anahtar-pill-otp-help-link" onclick={() => { otpStep = false; error = ''; }}>{m.differentEmail}</button>
|
|
372
|
+
<span class="anahtar-pill-otp-help-sep">·</span>
|
|
373
|
+
<button class="anahtar-pill-otp-help-link" onclick={resend} disabled={loading}>{m.resend}</button>
|
|
374
|
+
</div>
|
|
375
|
+
{/if}
|
|
376
|
+
|
|
377
|
+
{#if error}
|
|
378
|
+
<p class="anahtar-pill-error" transition:slide={{ duration: 150 }}>{error}</p>
|
|
379
|
+
{/if}
|
|
380
|
+
|
|
381
|
+
{#if passkeyOnboarding && isAuthenticated}
|
|
382
|
+
<div class="anahtar-pill-onboarding" transition:slide={{ duration: 200 }}>
|
|
383
|
+
<PasskeyPrompt {m} onRegister={handlePasskeyRegister} onSkip={handlePasskeySkip} />
|
|
384
|
+
</div>
|
|
385
|
+
{/if}
|
|
386
|
+
|
|
387
|
+
{#if showPasskeys && isAuthenticated && !passkeyOnboarding}
|
|
388
|
+
<div class="anahtar-pill-passkeys" transition:slide={{ duration: 180 }}>
|
|
389
|
+
{#if passkeyPromise}
|
|
390
|
+
{#await passkeyPromise then keys}
|
|
391
|
+
{#each keys as key}
|
|
392
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
393
|
+
<div
|
|
394
|
+
class="anahtar-pill-key-row"
|
|
395
|
+
onmouseenter={() => (hoveredKey = key.id)}
|
|
396
|
+
onmouseleave={() => (hoveredKey = null)}
|
|
397
|
+
>
|
|
398
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
399
|
+
<circle cx="7.5" cy="15.5" r="5.5"/><path d="m11.5 12 4-4"/><path d="m15 7 2 2"/><path d="m17.5 4.5 2 2"/>
|
|
400
|
+
</svg>
|
|
401
|
+
<span class="anahtar-pill-key-name">{key.name ?? 'Passkey'}</span>
|
|
402
|
+
{#if hoveredKey === key.id}
|
|
403
|
+
<button
|
|
404
|
+
class="anahtar-pill-key-remove"
|
|
405
|
+
onclick={() => removePasskey(key.id)}
|
|
406
|
+
disabled={loading}
|
|
407
|
+
>×</button>
|
|
408
|
+
{/if}
|
|
409
|
+
</div>
|
|
410
|
+
{/each}
|
|
411
|
+
{/await}
|
|
412
|
+
{/if}
|
|
413
|
+
<button class="anahtar-pill-key-add" onclick={addPasskey} disabled={loading}>
|
|
414
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
415
|
+
<line x1="12" x2="12" y1="5" y2="19"/><line x1="5" x2="19" y1="12" y2="12"/>
|
|
416
|
+
</svg>
|
|
417
|
+
Add passkey
|
|
418
|
+
</button>
|
|
419
|
+
</div>
|
|
420
|
+
{/if}
|
|
421
|
+
</div>
|
|
422
|
+
|
|
423
|
+
<style>
|
|
424
|
+
.anahtar-pill-island {
|
|
425
|
+
display: flex;
|
|
426
|
+
flex-direction: column;
|
|
427
|
+
align-items: flex-end;
|
|
428
|
+
gap: 0.25rem;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.anahtar-pill-loading { opacity: 0.7; }
|
|
432
|
+
|
|
433
|
+
.anahtar-pill {
|
|
434
|
+
display: flex;
|
|
435
|
+
align-items: center;
|
|
436
|
+
gap: 0.4rem;
|
|
437
|
+
background: var(--anahtar-pill-bg, rgba(255, 255, 255, 0.9));
|
|
438
|
+
backdrop-filter: blur(8px);
|
|
439
|
+
border: 1px solid var(--anahtar-pill-border, rgba(0, 0, 0, 0.06));
|
|
440
|
+
border-radius: 9999px;
|
|
441
|
+
padding: 0.25rem 0.75rem;
|
|
442
|
+
box-shadow: var(--anahtar-pill-shadow, 0 2px 12px rgba(0, 0, 0, 0.08));
|
|
443
|
+
font-size: 0.875rem;
|
|
444
|
+
white-space: nowrap;
|
|
445
|
+
height: 2.25rem;
|
|
446
|
+
box-sizing: border-box;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.anahtar-pill-sep {
|
|
450
|
+
color: var(--anahtar-pill-sep, rgba(0, 0, 0, 0.2));
|
|
451
|
+
font-size: 0.75rem;
|
|
452
|
+
user-select: none;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.anahtar-pill-email {
|
|
456
|
+
font-size: 0.8125rem;
|
|
457
|
+
font-weight: 500;
|
|
458
|
+
color: var(--anahtar-pill-fg, #374151);
|
|
459
|
+
max-width: 200px;
|
|
460
|
+
overflow: hidden;
|
|
461
|
+
text-overflow: ellipsis;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
.anahtar-pill-icon {
|
|
465
|
+
display: flex;
|
|
466
|
+
align-items: center;
|
|
467
|
+
justify-content: center;
|
|
468
|
+
background: none;
|
|
469
|
+
border: none;
|
|
470
|
+
cursor: pointer;
|
|
471
|
+
padding: 0.2rem;
|
|
472
|
+
border-radius: 9999px;
|
|
473
|
+
color: var(--anahtar-pill-icon, #6b7280);
|
|
474
|
+
transition: color 0.15s, background 0.15s;
|
|
475
|
+
line-height: 1;
|
|
476
|
+
}
|
|
477
|
+
.anahtar-pill-icon:hover:not(:disabled) { color: var(--anahtar-pill-fg, #111827); background: rgba(0,0,0,0.06); }
|
|
478
|
+
.anahtar-pill-icon:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
479
|
+
.anahtar-pill-icon-active { color: var(--anahtar-primary, #3730a3); }
|
|
480
|
+
.anahtar-pill-signout:hover:not(:disabled) { color: var(--anahtar-error, #ef4444); }
|
|
481
|
+
|
|
482
|
+
/* Sign-in form */
|
|
483
|
+
.anahtar-pill-form {
|
|
484
|
+
display: flex;
|
|
485
|
+
align-items: center;
|
|
486
|
+
gap: 0.375rem;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
.anahtar-pill-email-input {
|
|
490
|
+
border: none;
|
|
491
|
+
background: transparent;
|
|
492
|
+
outline: none;
|
|
493
|
+
font-size: 0.875rem;
|
|
494
|
+
color: var(--anahtar-pill-fg, #111827);
|
|
495
|
+
width: 190px;
|
|
496
|
+
padding: 0.125rem 0;
|
|
497
|
+
}
|
|
498
|
+
.anahtar-pill-email-input::placeholder { color: var(--anahtar-pill-placeholder, #9ca3af); }
|
|
499
|
+
|
|
500
|
+
.anahtar-pill-go {
|
|
501
|
+
background: var(--anahtar-primary, #3730a3);
|
|
502
|
+
color: var(--anahtar-primary-fg, #fff);
|
|
503
|
+
border: none;
|
|
504
|
+
border-radius: 9999px;
|
|
505
|
+
padding: 0.25rem 0.75rem;
|
|
506
|
+
font-size: 0.8125rem;
|
|
507
|
+
font-weight: 500;
|
|
508
|
+
cursor: pointer;
|
|
509
|
+
transition: opacity 0.15s;
|
|
510
|
+
white-space: nowrap;
|
|
511
|
+
}
|
|
512
|
+
.anahtar-pill-go:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
513
|
+
.anahtar-pill-go:hover:not(:disabled) { opacity: 0.85; }
|
|
514
|
+
|
|
515
|
+
/* OTP */
|
|
516
|
+
.anahtar-pill-otp-label {
|
|
517
|
+
font-size: 0.8125rem;
|
|
518
|
+
color: var(--anahtar-pill-fg, #374151);
|
|
519
|
+
font-weight: 500;
|
|
520
|
+
max-width: 150px;
|
|
521
|
+
overflow: hidden;
|
|
522
|
+
text-overflow: ellipsis;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
.anahtar-pill-otp-boxes { display: flex; gap: 0.2rem; }
|
|
526
|
+
|
|
527
|
+
.anahtar-pill-otp-box {
|
|
528
|
+
width: 1.75rem;
|
|
529
|
+
height: 1.75rem;
|
|
530
|
+
text-align: center;
|
|
531
|
+
font-size: 0.9375rem;
|
|
532
|
+
border: 1px solid var(--anahtar-pill-border, rgba(0,0,0,0.15));
|
|
533
|
+
border-radius: 0.3rem;
|
|
534
|
+
background: var(--anahtar-pill-bg, rgba(255,255,255,0.8));
|
|
535
|
+
outline: none;
|
|
536
|
+
color: var(--anahtar-pill-fg, #111827);
|
|
537
|
+
}
|
|
538
|
+
.anahtar-pill-otp-box:focus {
|
|
539
|
+
border-color: var(--anahtar-primary, #3730a3);
|
|
540
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--anahtar-primary, #3730a3) 20%, transparent);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/* OTP helper row */
|
|
544
|
+
.anahtar-pill-otp-help {
|
|
545
|
+
display: flex;
|
|
546
|
+
align-items: center;
|
|
547
|
+
gap: 0.375rem;
|
|
548
|
+
padding: 0 0.5rem;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
.anahtar-pill-otp-help-text {
|
|
552
|
+
font-size: 0.75rem;
|
|
553
|
+
color: var(--anahtar-pill-icon, #6b7280);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
.anahtar-pill-otp-help-sep {
|
|
557
|
+
color: var(--anahtar-pill-sep, rgba(0, 0, 0, 0.15));
|
|
558
|
+
font-size: 0.625rem;
|
|
559
|
+
user-select: none;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
.anahtar-pill-otp-help-link {
|
|
563
|
+
font-size: 0.75rem;
|
|
564
|
+
color: var(--anahtar-primary, #3730a3);
|
|
565
|
+
background: none;
|
|
566
|
+
border: none;
|
|
567
|
+
cursor: pointer;
|
|
568
|
+
padding: 0;
|
|
569
|
+
transition: opacity 0.15s;
|
|
570
|
+
}
|
|
571
|
+
.anahtar-pill-otp-help-link:hover:not(:disabled) { opacity: 0.7; }
|
|
572
|
+
.anahtar-pill-otp-help-link:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
573
|
+
|
|
574
|
+
/* Passkey onboarding card */
|
|
575
|
+
.anahtar-pill-onboarding {
|
|
576
|
+
background: var(--anahtar-pill-bg, rgba(255,255,255,0.97));
|
|
577
|
+
backdrop-filter: blur(12px);
|
|
578
|
+
border: 1px solid var(--anahtar-pill-border, rgba(0,0,0,0.06));
|
|
579
|
+
border-radius: 1rem;
|
|
580
|
+
padding: 1.25rem 1.5rem;
|
|
581
|
+
box-shadow: var(--anahtar-pill-shadow, 0 4px 24px rgba(0,0,0,0.1));
|
|
582
|
+
min-width: 220px;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/* Passkey panel */
|
|
586
|
+
.anahtar-pill-passkeys {
|
|
587
|
+
background: var(--anahtar-pill-bg, rgba(255,255,255,0.95));
|
|
588
|
+
backdrop-filter: blur(8px);
|
|
589
|
+
border: 1px solid var(--anahtar-pill-border, rgba(0,0,0,0.06));
|
|
590
|
+
border-radius: 0.75rem;
|
|
591
|
+
padding: 0.5rem 0.75rem;
|
|
592
|
+
box-shadow: var(--anahtar-pill-shadow, 0 2px 12px rgba(0,0,0,0.08));
|
|
593
|
+
display: flex;
|
|
594
|
+
flex-direction: column;
|
|
595
|
+
gap: 0.25rem;
|
|
596
|
+
min-width: 180px;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
.anahtar-pill-key-row {
|
|
600
|
+
display: flex;
|
|
601
|
+
align-items: center;
|
|
602
|
+
gap: 0.375rem;
|
|
603
|
+
font-size: 0.8125rem;
|
|
604
|
+
color: var(--anahtar-pill-fg, #374151);
|
|
605
|
+
padding: 0.2rem 0;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.anahtar-pill-key-name {
|
|
609
|
+
flex: 1;
|
|
610
|
+
overflow: hidden;
|
|
611
|
+
text-overflow: ellipsis;
|
|
612
|
+
white-space: nowrap;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
.anahtar-pill-key-remove {
|
|
616
|
+
background: none;
|
|
617
|
+
border: none;
|
|
618
|
+
color: var(--anahtar-pill-icon, #9ca3af);
|
|
619
|
+
cursor: pointer;
|
|
620
|
+
font-size: 1rem;
|
|
621
|
+
line-height: 1;
|
|
622
|
+
padding: 0 0.125rem;
|
|
623
|
+
transition: color 0.15s;
|
|
624
|
+
}
|
|
625
|
+
.anahtar-pill-key-remove:hover { color: var(--anahtar-error, #ef4444); }
|
|
626
|
+
.anahtar-pill-key-remove:disabled { opacity: 0.4; }
|
|
627
|
+
|
|
628
|
+
.anahtar-pill-key-add {
|
|
629
|
+
display: flex;
|
|
630
|
+
align-items: center;
|
|
631
|
+
gap: 0.3rem;
|
|
632
|
+
font-size: 0.75rem;
|
|
633
|
+
color: var(--anahtar-pill-icon, #6b7280);
|
|
634
|
+
background: none;
|
|
635
|
+
border: none;
|
|
636
|
+
cursor: pointer;
|
|
637
|
+
padding: 0.2rem 0;
|
|
638
|
+
transition: color 0.15s;
|
|
639
|
+
margin-top: 0.125rem;
|
|
640
|
+
}
|
|
641
|
+
.anahtar-pill-key-add:hover:not(:disabled) { color: var(--anahtar-primary, #3730a3); }
|
|
642
|
+
.anahtar-pill-key-add:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
643
|
+
|
|
644
|
+
.anahtar-pill-error {
|
|
645
|
+
font-size: 0.75rem;
|
|
646
|
+
color: var(--anahtar-error, #ef4444);
|
|
647
|
+
text-align: right;
|
|
648
|
+
padding: 0 0.5rem;
|
|
649
|
+
}
|
|
650
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type AuthMessages } from '../i18n/index.js';
|
|
2
|
+
interface PasskeyInfo {
|
|
3
|
+
id: string;
|
|
4
|
+
credentialId?: string;
|
|
5
|
+
name?: string | null;
|
|
6
|
+
createdAt?: number;
|
|
7
|
+
}
|
|
8
|
+
interface Props {
|
|
9
|
+
apiBase?: string;
|
|
10
|
+
user?: {
|
|
11
|
+
email: string;
|
|
12
|
+
} | null;
|
|
13
|
+
locale?: string;
|
|
14
|
+
messages?: Partial<AuthMessages>;
|
|
15
|
+
onSuccess?: () => void | Promise<void>;
|
|
16
|
+
onSignOut?: () => void | Promise<void>;
|
|
17
|
+
onPasskeysChange?: () => void | Promise<void>;
|
|
18
|
+
getPasskeys?: () => Promise<PasskeyInfo[]>;
|
|
19
|
+
}
|
|
20
|
+
declare const AuthPill: import("svelte").Component<Props, {}, "">;
|
|
21
|
+
type AuthPill = ReturnType<typeof AuthPill>;
|
|
22
|
+
export default AuthPill;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
export { guessDeviceName } from
|
|
2
|
-
export { default as AuthFlow } from
|
|
3
|
-
export { default as
|
|
4
|
-
export { default as
|
|
5
|
-
export
|
|
6
|
-
export {
|
|
1
|
+
export { guessDeviceName } from "../device.js";
|
|
2
|
+
export { default as AuthFlow } from "./AuthFlow.svelte";
|
|
3
|
+
export { default as AuthPill } from "./AuthPill.svelte";
|
|
4
|
+
export { default as OtpInput } from "./OtpInput.svelte";
|
|
5
|
+
export { default as PasskeyPrompt } from "./PasskeyPrompt.svelte";
|
|
6
|
+
export type { AuthMessages } from "../i18n/types.js";
|
|
7
|
+
export { resolveMessages, detectLocaleClient, locales } from "../i18n/index.js";
|
package/dist/components/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export { guessDeviceName } from
|
|
2
|
-
export { default as AuthFlow } from
|
|
3
|
-
export { default as
|
|
4
|
-
export { default as
|
|
5
|
-
export {
|
|
1
|
+
export { guessDeviceName } from "../device.js";
|
|
2
|
+
export { default as AuthFlow } from "./AuthFlow.svelte";
|
|
3
|
+
export { default as AuthPill } from "./AuthPill.svelte";
|
|
4
|
+
export { default as OtpInput } from "./OtpInput.svelte";
|
|
5
|
+
export { default as PasskeyPrompt } from "./PasskeyPrompt.svelte";
|
|
6
|
+
export { resolveMessages, detectLocaleClient, locales } from "../i18n/index.js";
|
package/dist/kit/handlers.js
CHANGED
|
@@ -37,7 +37,13 @@ export function createHandlers(config) {
|
|
|
37
37
|
return json({ error: m.errorInvalidEmail }, { status: 400 });
|
|
38
38
|
}
|
|
39
39
|
const { code } = await generateOTP(config.db, body.email, config);
|
|
40
|
-
|
|
40
|
+
try {
|
|
41
|
+
await config.onSendOTP(body.email, code);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
const message = err instanceof Error ? err.message : m.errorGeneric;
|
|
45
|
+
return json({ error: message }, { status: 400 });
|
|
46
|
+
}
|
|
41
47
|
return json({ success: true });
|
|
42
48
|
},
|
|
43
49
|
},
|
|
@@ -169,6 +175,22 @@ export function createHandlers(config) {
|
|
|
169
175
|
return json({ success: true });
|
|
170
176
|
},
|
|
171
177
|
},
|
|
178
|
+
"passkey/list": {
|
|
179
|
+
method: "GET",
|
|
180
|
+
handler: async (event) => {
|
|
181
|
+
const m = getMessages(event, config);
|
|
182
|
+
const user = requireAuth(event, m);
|
|
183
|
+
if (user instanceof Response)
|
|
184
|
+
return user;
|
|
185
|
+
const passkeys = await config.db.getUserPasskeys(user.id);
|
|
186
|
+
return json(passkeys.map((p) => ({
|
|
187
|
+
id: p.id,
|
|
188
|
+
credentialId: p.credentialId,
|
|
189
|
+
name: p.name,
|
|
190
|
+
createdAt: p.createdAt,
|
|
191
|
+
})));
|
|
192
|
+
},
|
|
193
|
+
},
|
|
172
194
|
"skip-passkey": {
|
|
173
195
|
method: "POST",
|
|
174
196
|
handler: async (event) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrgnw/anahtar",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.16",
|
|
4
4
|
"description": "Opinionated, reusable auth for SvelteKit. Email+OTP + passkeys.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -12,14 +12,6 @@
|
|
|
12
12
|
"registry": "https://registry.npmjs.org/",
|
|
13
13
|
"access": "public"
|
|
14
14
|
},
|
|
15
|
-
"scripts": {
|
|
16
|
-
"build": "svelte-package",
|
|
17
|
-
"check": "svelte-check --tsconfig ./tsconfig.json",
|
|
18
|
-
"test": "vitest run --config vitest.unit.ts && vitest run --config vitest.browser.ts",
|
|
19
|
-
"test:unit": "vitest run --config vitest.unit.ts",
|
|
20
|
-
"test:browser": "vitest run --config vitest.browser.ts",
|
|
21
|
-
"prepublishOnly": "pnpm build"
|
|
22
|
-
},
|
|
23
15
|
"svelte": "./dist/index.js",
|
|
24
16
|
"types": "./dist/index.d.ts",
|
|
25
17
|
"exports": {
|
|
@@ -68,11 +60,6 @@
|
|
|
68
60
|
"@oslojs/encoding": "^1.1.0",
|
|
69
61
|
"@simplewebauthn/server": "^13.2.2"
|
|
70
62
|
},
|
|
71
|
-
"pnpm": {
|
|
72
|
-
"onlyBuiltDependencies": [
|
|
73
|
-
"better-sqlite3"
|
|
74
|
-
]
|
|
75
|
-
},
|
|
76
63
|
"devDependencies": {
|
|
77
64
|
"@simplewebauthn/browser": "^13.2.2",
|
|
78
65
|
"@sveltejs/kit": "^2.31.1",
|
|
@@ -88,5 +75,12 @@
|
|
|
88
75
|
"svelte-check": "^4.3.1",
|
|
89
76
|
"typescript": "^5.9.2",
|
|
90
77
|
"vitest": "^3.2.1"
|
|
78
|
+
},
|
|
79
|
+
"scripts": {
|
|
80
|
+
"build": "svelte-package",
|
|
81
|
+
"check": "svelte-check --tsconfig ./tsconfig.json",
|
|
82
|
+
"test": "vitest run --config vitest.unit.ts && vitest run --config vitest.browser.ts",
|
|
83
|
+
"test:unit": "vitest run --config vitest.unit.ts",
|
|
84
|
+
"test:browser": "vitest run --config vitest.browser.ts"
|
|
91
85
|
}
|
|
92
|
-
}
|
|
86
|
+
}
|