@mrgnw/anahtar 0.0.13 → 0.0.15
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/PasskeyPrompt.svelte +21 -3
- package/dist/components/index.d.ts +7 -6
- package/dist/components/index.js +6 -5
- package/dist/i18n/af.js +1 -1
- package/dist/i18n/am.js +1 -1
- package/dist/i18n/ar.js +1 -1
- package/dist/i18n/as.js +1 -1
- package/dist/i18n/az.js +1 -1
- package/dist/i18n/be.js +1 -1
- package/dist/i18n/bg.js +1 -1
- package/dist/i18n/bn.js +1 -1
- package/dist/i18n/ca.js +1 -1
- package/dist/i18n/el.js +1 -1
- package/dist/i18n/es.js +1 -1
- package/dist/i18n/fa.js +1 -1
- package/dist/i18n/fil.js +1 -1
- package/dist/i18n/fr.js +1 -1
- package/dist/i18n/gu.js +1 -1
- package/dist/i18n/ha.js +1 -1
- package/dist/i18n/he.js +1 -1
- package/dist/i18n/hi.js +1 -1
- package/dist/i18n/ht.js +1 -1
- package/dist/i18n/hy.js +1 -1
- package/dist/i18n/id.js +1 -1
- package/dist/i18n/ig.js +1 -1
- package/dist/i18n/index.js +99 -43
- package/dist/i18n/it.js +1 -1
- package/dist/i18n/ja.js +1 -1
- package/dist/i18n/jv.js +1 -1
- package/dist/i18n/kk.js +1 -1
- package/dist/i18n/km.js +1 -1
- package/dist/i18n/kn.js +1 -1
- package/dist/i18n/ko.js +1 -1
- package/dist/i18n/lo.js +1 -1
- package/dist/i18n/mg.js +1 -1
- package/dist/i18n/ml.js +1 -1
- package/dist/i18n/mn.js +1 -1
- package/dist/i18n/mr.js +1 -1
- package/dist/i18n/ms.js +1 -1
- package/dist/i18n/my.js +1 -1
- package/dist/i18n/ne.js +1 -1
- package/dist/i18n/or.js +1 -1
- package/dist/i18n/pa.js +1 -1
- package/dist/i18n/ps.js +1 -1
- package/dist/i18n/pt.js +1 -1
- package/dist/i18n/ro.js +1 -1
- package/dist/i18n/ru.js +1 -1
- package/dist/i18n/rw.js +1 -1
- package/dist/i18n/sd.js +1 -1
- package/dist/i18n/si.js +1 -1
- package/dist/i18n/so.js +1 -1
- package/dist/i18n/sq.js +1 -1
- package/dist/i18n/sr.js +1 -1
- package/dist/i18n/st.js +1 -1
- package/dist/i18n/su.js +1 -1
- package/dist/i18n/sw.js +1 -1
- package/dist/i18n/ta.js +1 -1
- package/dist/i18n/te.js +1 -1
- package/dist/i18n/tg.js +1 -1
- package/dist/i18n/th.js +1 -1
- package/dist/i18n/ti.js +1 -1
- package/dist/i18n/tk.js +1 -1
- package/dist/i18n/tr.js +1 -1
- package/dist/i18n/ts.js +1 -1
- package/dist/i18n/tt.js +1 -1
- package/dist/i18n/ug.js +1 -1
- package/dist/i18n/uk.js +1 -1
- package/dist/i18n/uz.js +1 -1
- package/dist/i18n/vi.js +1 -1
- package/dist/i18n/yo.js +1 -1
- package/dist/i18n/zh.js +1 -1
- package/dist/i18n/zu.js +1 -1
- package/dist/kit/handlers.js +16 -0
- 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;
|