@ternkit/tern 1.5.0 → 1.7.0
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/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/runtime/builtin.d.ts +1 -0
- package/dist/runtime/builtin.js +182 -0
- package/dist/runtime/deferred.d.ts +1 -0
- package/dist/runtime/deferred.js +4 -2
- package/dist/runtime/i18n.svelte.d.ts +6 -0
- package/dist/runtime/i18n.svelte.js +10 -0
- package/dist/runtime/passkeys.d.ts +21 -0
- package/dist/runtime/passkeys.js +107 -0
- package/dist/runtime/router.svelte.js +2 -1
- package/dist/runtime/types.d.ts +2 -0
- package/dist/runtime/validate.d.ts +2 -1
- package/dist/runtime/validate.js +20 -18
- package/dist/server/index.d.ts +3 -1
- package/dist/server/index.js +8 -4
- package/dist/ui/AccountSection.svelte +75 -0
- package/dist/ui/AccountSection.svelte.d.ts +12 -0
- package/dist/ui/AlertDialog.svelte +5 -2
- package/dist/ui/AuthCard.svelte +64 -0
- package/dist/ui/AuthCard.svelte.d.ts +12 -0
- package/dist/ui/BarChart.svelte +2 -1
- package/dist/ui/Breadcrumb.svelte +2 -1
- package/dist/ui/Calendar.svelte +3 -2
- package/dist/ui/Carousel.svelte +4 -3
- package/dist/ui/ChartFrame.svelte +2 -1
- package/dist/ui/Combobox.svelte +6 -3
- package/dist/ui/Command.svelte +5 -1
- package/dist/ui/ConsentScreen.svelte +7 -6
- package/dist/ui/DataTable.svelte +7 -5
- package/dist/ui/DatePicker.svelte +8 -5
- package/dist/ui/Devices.svelte +50 -0
- package/dist/ui/Devices.svelte.d.ts +23 -0
- package/dist/ui/Dialog.svelte +2 -1
- package/dist/ui/DonutChart.svelte +3 -1
- package/dist/ui/Field.svelte +1 -1
- package/dist/ui/ForgotPassword.svelte +43 -0
- package/dist/ui/ForgotPassword.svelte.d.ts +16 -0
- package/dist/ui/Form.svelte +27 -4
- package/dist/ui/LinkedAccounts.svelte +54 -0
- package/dist/ui/LinkedAccounts.svelte.d.ts +27 -0
- package/dist/ui/NavigationMenu.svelte +3 -1
- package/dist/ui/Pagination.svelte +5 -3
- package/dist/ui/Passkeys.svelte +73 -0
- package/dist/ui/Passkeys.svelte.d.ts +21 -0
- package/dist/ui/PasswordInput.svelte +2 -1
- package/dist/ui/ResetPassword.svelte +54 -0
- package/dist/ui/ResetPassword.svelte.d.ts +23 -0
- package/dist/ui/Resizable.svelte +3 -1
- package/dist/ui/SelectMenu.svelte +5 -2
- package/dist/ui/Sidebar.svelte +5 -3
- package/dist/ui/SignIn.svelte +61 -13
- package/dist/ui/SignIn.svelte.d.ts +5 -1
- package/dist/ui/SignUp.svelte +10 -8
- package/dist/ui/Slider.svelte +2 -1
- package/dist/ui/Spinner.svelte +3 -1
- package/dist/ui/Switch.svelte +2 -1
- package/dist/ui/Table.svelte +3 -1
- package/dist/ui/Toaster.svelte +3 -2
- package/dist/ui/Toggle.svelte +2 -0
- package/dist/ui/ToggleGroup.svelte +2 -0
- package/dist/ui/TwoStep.svelte +155 -0
- package/dist/ui/TwoStep.svelte.d.ts +31 -0
- package/dist/ui/TwoStepCode.svelte +70 -0
- package/dist/ui/TwoStepCode.svelte.d.ts +16 -0
- package/dist/ui/accountDates.d.ts +1 -0
- package/dist/ui/accountDates.js +15 -0
- package/dist/ui/announce.d.ts +4 -0
- package/dist/ui/announce.js +14 -0
- package/dist/ui/index.d.ts +7 -0
- package/dist/ui/index.js +7 -0
- package/dist/vite/native.js +21 -4
- package/package.json +1 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FormField } from '../runtime/validate.js';
|
|
2
|
+
type Props = {
|
|
3
|
+
title?: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
level?: 1 | 2 | 3;
|
|
6
|
+
/** The action that calls acc.RequestPasswordReset with the posted email. */
|
|
7
|
+
action?: string;
|
|
8
|
+
rules?: FormField[];
|
|
9
|
+
/** The page's form prop. An action returning { sent: true } shows that the email is on its way. */
|
|
10
|
+
form?: unknown;
|
|
11
|
+
signInHref?: string;
|
|
12
|
+
class?: string;
|
|
13
|
+
};
|
|
14
|
+
declare const ForgotPassword: import("svelte").Component<Props, {}, "">;
|
|
15
|
+
type ForgotPassword = ReturnType<typeof ForgotPassword>;
|
|
16
|
+
export default ForgotPassword;
|
package/dist/ui/Form.svelte
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
2
3
|
import { tick, type Snippet } from 'svelte';
|
|
3
4
|
import type { HTMLFormAttributes } from 'svelte/elements';
|
|
4
5
|
import { enhance as enhanceAction } from '../runtime/forms.js';
|
|
@@ -67,14 +68,24 @@
|
|
|
67
68
|
// A field and the fields that must match it, such as a password and its confirmation.
|
|
68
69
|
const related = (name: string) => [name, ...rules.filter((f) => f.rules?.some((r) => r.name === 'same' && r.arg === name)).map((f) => f.name)];
|
|
69
70
|
|
|
71
|
+
// A control's name: its own, or for controls posting through hidden
|
|
72
|
+
// inputs, its Field's.
|
|
73
|
+
const nameOf = (t: EventTarget | null) => (t as HTMLInputElement | null)?.name || (t as HTMLElement | null)?.closest?.('[data-tern-field]')?.getAttribute('data-tern-field') || '';
|
|
74
|
+
|
|
75
|
+
// What was last posted, by name: a field whose value is still that
|
|
76
|
+
// hasn't been edited, though a change event fires as focus leaves it.
|
|
77
|
+
let posted: Record<string, string> = {};
|
|
78
|
+
const valueOf = (name: string) => (el ? new FormData(el).getAll(name).map(String).join('\u0000') : '');
|
|
79
|
+
|
|
70
80
|
function oninput(e: Event) {
|
|
71
|
-
const name = (e.target
|
|
81
|
+
const name = nameOf(e.target);
|
|
72
82
|
if (!name) return;
|
|
83
|
+
if (name in posted && valueOf(name) === posted[name]) return;
|
|
73
84
|
edited = { ...edited, [name]: true };
|
|
74
85
|
if (touched[name] || submitted) check(related(name).filter((n) => touched[n] || submitted || n === name));
|
|
75
86
|
}
|
|
76
87
|
function onfocusout(e: FocusEvent) {
|
|
77
|
-
const name = (e.target
|
|
88
|
+
const name = nameOf(e.target);
|
|
78
89
|
// Don't complain about a field the user only passed through.
|
|
79
90
|
if (!name || !byName.has(name) || (!edited[name] && !submitted)) return;
|
|
80
91
|
touched = { ...touched, [name]: true };
|
|
@@ -91,12 +102,17 @@
|
|
|
91
102
|
summary = '';
|
|
92
103
|
return true;
|
|
93
104
|
}
|
|
94
|
-
summary =
|
|
105
|
+
summary = tb('tern.ui.form.summary', { n: names.length });
|
|
95
106
|
void tick().then(() => el?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus());
|
|
96
107
|
return false;
|
|
97
108
|
}
|
|
98
109
|
|
|
99
|
-
|
|
110
|
+
// The newest post wins: a submit while another is in flight, such as
|
|
111
|
+
// Enter right after a code completed itself, aborts the older one, and
|
|
112
|
+
// an older answer that still arrives is dropped.
|
|
113
|
+
let latest = 0;
|
|
114
|
+
let inflight: AbortController | null = null;
|
|
115
|
+
const submit = ({ formData, cancel, controller }: { formData: FormData; cancel(): void; controller: AbortController }) => {
|
|
100
116
|
const errors = validate(rules, formData);
|
|
101
117
|
if (Object.keys(errors).length > 0) {
|
|
102
118
|
cancel();
|
|
@@ -107,7 +123,14 @@
|
|
|
107
123
|
client = {};
|
|
108
124
|
summary = '';
|
|
109
125
|
submitting = true;
|
|
126
|
+
inflight?.abort();
|
|
127
|
+
inflight = controller;
|
|
128
|
+
const mine = ++latest;
|
|
129
|
+
posted = {};
|
|
130
|
+
for (const k of new Set(formData.keys())) posted[k] = formData.getAll(k).map(String).join('\u0000');
|
|
110
131
|
return async ({ update, result }: { update: (o?: { reset?: boolean }) => Promise<void>; result: { type: string; data?: unknown } }) => {
|
|
132
|
+
if (mine !== latest) return;
|
|
133
|
+
inflight = null;
|
|
111
134
|
await update({ reset });
|
|
112
135
|
submitting = false;
|
|
113
136
|
if (result.type === 'success') {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
3
|
+
import AccountSection from './AccountSection.svelte';
|
|
4
|
+
import Button from './Button.svelte';
|
|
5
|
+
import Form from './Form.svelte';
|
|
6
|
+
import { when } from './accountDates.js';
|
|
7
|
+
|
|
8
|
+
type Linked = { provider: string; email: string; createdAt: string };
|
|
9
|
+
type Props = {
|
|
10
|
+
title?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
level?: 2 | 3 | 4;
|
|
13
|
+
/** From acc.Identities. */
|
|
14
|
+
identities: Linked[];
|
|
15
|
+
/** plugins/oauth's providers, such as plugin.Providers(): each one not linked yet gets a Connect button. */
|
|
16
|
+
providers: { name: string; label: string }[];
|
|
17
|
+
/** Where plugins/oauth lives. */
|
|
18
|
+
authPath?: string;
|
|
19
|
+
/** Where to come back to after connecting: this page. */
|
|
20
|
+
next?: string;
|
|
21
|
+
/** The action that calls acc.Unlink with the posted provider. */
|
|
22
|
+
unlinkAction?: string;
|
|
23
|
+
class?: string;
|
|
24
|
+
};
|
|
25
|
+
let { title, description, level = 2, identities, providers, authPath = '/auth', next, unlinkAction, class: klass = '' }: Props = $props();
|
|
26
|
+
const label = (name: string) => providers.find((p) => p.name === name)?.label ?? name;
|
|
27
|
+
const linked = $derived(new Set(identities.map((i) => i.provider)));
|
|
28
|
+
const connect = (name: string) => `${authPath}/${encodeURIComponent(name)}/login${next ? `?next=${encodeURIComponent(next)}` : ''}`;
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<AccountSection id="tern-linked-title" title={title ?? tb('tern.ui.linked.title')} description={description ?? tb('tern.ui.linked.description')} {level} class={klass}>
|
|
32
|
+
<ul>
|
|
33
|
+
{#each identities as i (i.provider)}
|
|
34
|
+
<li>
|
|
35
|
+
<div class="tern-account-text">
|
|
36
|
+
<span class="tern-account-name">{label(i.provider)}</span>
|
|
37
|
+
<span class="tern-account-meta">{i.email}{i.email ? ' · ' : ''}{tb('tern.ui.linked.since', { when: when(i.createdAt) })}</span>
|
|
38
|
+
</div>
|
|
39
|
+
{#if unlinkAction}
|
|
40
|
+
<Form action={unlinkAction} reset={false}>
|
|
41
|
+
<input type="hidden" name="provider" value={i.provider} />
|
|
42
|
+
<Button type="submit" variant="ghost" size="sm">{tb('tern.ui.linked.disconnect')}</Button>
|
|
43
|
+
</Form>
|
|
44
|
+
{/if}
|
|
45
|
+
</li>
|
|
46
|
+
{/each}
|
|
47
|
+
{#each providers.filter((p) => !linked.has(p.name)) as p (p.name)}
|
|
48
|
+
<li>
|
|
49
|
+
<div class="tern-account-text"><span class="tern-account-name">{p.label}</span></div>
|
|
50
|
+
<Button variant="secondary" size="sm" href={connect(p.name)}>{tb('tern.ui.linked.connect')}</Button>
|
|
51
|
+
</li>
|
|
52
|
+
{/each}
|
|
53
|
+
</ul>
|
|
54
|
+
</AccountSection>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
type Linked = {
|
|
2
|
+
provider: string;
|
|
3
|
+
email: string;
|
|
4
|
+
createdAt: string;
|
|
5
|
+
};
|
|
6
|
+
type Props = {
|
|
7
|
+
title?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
level?: 2 | 3 | 4;
|
|
10
|
+
/** From acc.Identities. */
|
|
11
|
+
identities: Linked[];
|
|
12
|
+
/** plugins/oauth's providers, such as plugin.Providers(): each one not linked yet gets a Connect button. */
|
|
13
|
+
providers: {
|
|
14
|
+
name: string;
|
|
15
|
+
label: string;
|
|
16
|
+
}[];
|
|
17
|
+
/** Where plugins/oauth lives. */
|
|
18
|
+
authPath?: string;
|
|
19
|
+
/** Where to come back to after connecting: this page. */
|
|
20
|
+
next?: string;
|
|
21
|
+
/** The action that calls acc.Unlink with the posted provider. */
|
|
22
|
+
unlinkAction?: string;
|
|
23
|
+
class?: string;
|
|
24
|
+
};
|
|
25
|
+
declare const LinkedAccounts: import("svelte").Component<Props, {}, "">;
|
|
26
|
+
type LinkedAccounts = ReturnType<typeof LinkedAccounts>;
|
|
27
|
+
export default LinkedAccounts;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
2
3
|
import { page } from '../runtime/state.svelte.js';
|
|
3
4
|
import { float } from './floating.js';
|
|
4
5
|
|
|
@@ -9,7 +10,8 @@
|
|
|
9
10
|
label?: string;
|
|
10
11
|
class?: string;
|
|
11
12
|
};
|
|
12
|
-
let { items, label
|
|
13
|
+
let { items, label: labelProp, class: klass = '' }: Props = $props();
|
|
14
|
+
const label = $derived(labelProp ?? tb('tern.ui.navigationMenu.label'));
|
|
13
15
|
|
|
14
16
|
const uid = $props.id();
|
|
15
17
|
let open = $state<number | null>(null);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
2
3
|
type Props = {
|
|
3
4
|
/** The current page, from 1; bind it. */
|
|
4
5
|
page?: number;
|
|
@@ -12,7 +13,8 @@
|
|
|
12
13
|
class?: string;
|
|
13
14
|
onchange?: (page: number) => void;
|
|
14
15
|
};
|
|
15
|
-
let { page = $bindable(1), pages, href, siblings = 1, label
|
|
16
|
+
let { page = $bindable(1), pages, href, siblings = 1, label: labelProp, class: klass = '', onchange }: Props = $props();
|
|
17
|
+
const label = $derived(labelProp ?? tb('tern.ui.pagination.label'));
|
|
16
18
|
|
|
17
19
|
// 1 … 4 5 6 … 20: the ends, the current page and its siblings.
|
|
18
20
|
const shown = $derived.by(() => {
|
|
@@ -45,7 +47,7 @@
|
|
|
45
47
|
|
|
46
48
|
<nav class="tern-pagination {klass}" aria-label={label}>
|
|
47
49
|
<ul>
|
|
48
|
-
<li>{@render link(page - 1, '
|
|
50
|
+
<li>{@render link(page - 1, tb('tern.ui.pagination.previous'), tb('tern.ui.pagination.previousLabel'), page <= 1, 'step')}</li>
|
|
49
51
|
{#each shown as p, i (i)}
|
|
50
52
|
<li>
|
|
51
53
|
{#if p === 'gap'}
|
|
@@ -55,7 +57,7 @@
|
|
|
55
57
|
{/if}
|
|
56
58
|
</li>
|
|
57
59
|
{/each}
|
|
58
|
-
<li>{@render link(page + 1, '
|
|
60
|
+
<li>{@render link(page + 1, tb('tern.ui.pagination.next'), tb('tern.ui.pagination.nextLabel'), page >= pages, 'step')}</li>
|
|
59
61
|
</ul>
|
|
60
62
|
</nav>
|
|
61
63
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
3
|
+
import { invalidateAll } from '../runtime/router.svelte.js';
|
|
4
|
+
import { passkeysSupported, registerPasskey } from '../runtime/passkeys.js';
|
|
5
|
+
import Alert from './Alert.svelte';
|
|
6
|
+
import AccountSection from './AccountSection.svelte';
|
|
7
|
+
import Button from './Button.svelte';
|
|
8
|
+
import Form from './Form.svelte';
|
|
9
|
+
import { when } from './accountDates.js';
|
|
10
|
+
|
|
11
|
+
type Passkey = { id: string; name: string; createdAt: string; lastUsed: string | null };
|
|
12
|
+
type Props = {
|
|
13
|
+
title?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
level?: 2 | 3 | 4;
|
|
16
|
+
/** From acc.Passkeys. */
|
|
17
|
+
passkeys: Passkey[];
|
|
18
|
+
/** The action that calls acc.RemovePasskey with the posted id. */
|
|
19
|
+
removeAction?: string;
|
|
20
|
+
/** Where the accounts package mounts its passkey endpoints. */
|
|
21
|
+
path?: string;
|
|
22
|
+
class?: string;
|
|
23
|
+
};
|
|
24
|
+
let { title, description, level = 2, passkeys, removeAction, path, class: klass = '' }: Props = $props();
|
|
25
|
+
|
|
26
|
+
let supported = $state(true);
|
|
27
|
+
$effect(() => {
|
|
28
|
+
supported = passkeysSupported();
|
|
29
|
+
});
|
|
30
|
+
let adding = $state(false);
|
|
31
|
+
let failed = $state(false);
|
|
32
|
+
async function add() {
|
|
33
|
+
adding = true;
|
|
34
|
+
failed = false;
|
|
35
|
+
try {
|
|
36
|
+
await registerPasskey({ path });
|
|
37
|
+
await invalidateAll();
|
|
38
|
+
} catch (e) {
|
|
39
|
+
if (!(e as { cancelled?: boolean }).cancelled) failed = true;
|
|
40
|
+
} finally {
|
|
41
|
+
adding = false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<AccountSection id="tern-passkeys-title" title={title ?? tb('tern.ui.passkeys.title')} description={description ?? tb('tern.ui.passkeys.description')} {level} class={klass}>
|
|
47
|
+
{#if failed}<Alert tone="danger" live>{tb('tern.ui.passkeys.failed')}</Alert>{/if}
|
|
48
|
+
{#if passkeys.length}
|
|
49
|
+
<ul>
|
|
50
|
+
{#each passkeys as p (p.id)}
|
|
51
|
+
<li>
|
|
52
|
+
<div class="tern-account-text">
|
|
53
|
+
<span class="tern-account-name">{p.name}</span>
|
|
54
|
+
<span class="tern-account-meta">{tb('tern.ui.passkeys.added', { when: when(p.createdAt) })}{' · '}{p.lastUsed ? tb('tern.ui.passkeys.lastUsed', { when: when(p.lastUsed) }) : tb('tern.ui.passkeys.never')}</span>
|
|
55
|
+
</div>
|
|
56
|
+
{#if removeAction}
|
|
57
|
+
<Form action={removeAction} reset={false}>
|
|
58
|
+
<input type="hidden" name="id" value={p.id} />
|
|
59
|
+
<Button type="submit" variant="ghost" size="sm">{tb('tern.ui.passkeys.remove')}</Button>
|
|
60
|
+
</Form>
|
|
61
|
+
{/if}
|
|
62
|
+
</li>
|
|
63
|
+
{/each}
|
|
64
|
+
</ul>
|
|
65
|
+
{:else}
|
|
66
|
+
<p class="tern-account-empty">{tb('tern.ui.passkeys.empty')}</p>
|
|
67
|
+
{/if}
|
|
68
|
+
{#if supported}
|
|
69
|
+
<div><Button type="button" variant="secondary" size="sm" loading={adding} onclick={add}>{tb('tern.ui.passkeys.add')}</Button></div>
|
|
70
|
+
{:else}
|
|
71
|
+
<p class="tern-account-empty">{tb('tern.ui.passkeys.unsupported')}</p>
|
|
72
|
+
{/if}
|
|
73
|
+
</AccountSection>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type Passkey = {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
createdAt: string;
|
|
5
|
+
lastUsed: string | null;
|
|
6
|
+
};
|
|
7
|
+
type Props = {
|
|
8
|
+
title?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
level?: 2 | 3 | 4;
|
|
11
|
+
/** From acc.Passkeys. */
|
|
12
|
+
passkeys: Passkey[];
|
|
13
|
+
/** The action that calls acc.RemovePasskey with the posted id. */
|
|
14
|
+
removeAction?: string;
|
|
15
|
+
/** Where the accounts package mounts its passkey endpoints. */
|
|
16
|
+
path?: string;
|
|
17
|
+
class?: string;
|
|
18
|
+
};
|
|
19
|
+
declare const Passkeys: import("svelte").Component<Props, {}, "">;
|
|
20
|
+
type Passkeys = ReturnType<typeof Passkeys>;
|
|
21
|
+
export default Passkeys;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
2
3
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
3
4
|
import { getField } from './field.js';
|
|
4
5
|
|
|
@@ -37,7 +38,7 @@
|
|
|
37
38
|
<button
|
|
38
39
|
type="button"
|
|
39
40
|
class="toggle"
|
|
40
|
-
aria-label=
|
|
41
|
+
aria-label={tb('tern.ui.password.show')}
|
|
41
42
|
aria-pressed={visible}
|
|
42
43
|
aria-controls={id}
|
|
43
44
|
disabled={field?.disabled || rest.disabled}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
3
|
+
import type { FormField } from '../runtime/validate.js';
|
|
4
|
+
import Alert from './Alert.svelte';
|
|
5
|
+
import AuthCard from './AuthCard.svelte';
|
|
6
|
+
import Button from './Button.svelte';
|
|
7
|
+
import Field from './Field.svelte';
|
|
8
|
+
import Form from './Form.svelte';
|
|
9
|
+
import PasswordInput from './PasswordInput.svelte';
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
title?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
level?: 1 | 2 | 3;
|
|
15
|
+
/** The token from the email's link (?token=), posted as token. */
|
|
16
|
+
token: string;
|
|
17
|
+
/** The action that calls acc.ResetPassword with the posted token and password. */
|
|
18
|
+
action?: string;
|
|
19
|
+
/** Its rules; they also set the password hint. */
|
|
20
|
+
rules?: FormField[];
|
|
21
|
+
form?: unknown;
|
|
22
|
+
/** Ask to type the password twice, posted as confirm. */
|
|
23
|
+
confirm?: boolean;
|
|
24
|
+
/** The link has been found invalid or expired: say so instead of showing the form. */
|
|
25
|
+
invalid?: boolean;
|
|
26
|
+
/** Where to ask for a new link. */
|
|
27
|
+
forgotHref?: string;
|
|
28
|
+
class?: string;
|
|
29
|
+
};
|
|
30
|
+
let { title, description, level = 1, token, action, rules, form, confirm = true, invalid = false, forgotHref, class: klass = '' }: Props = $props();
|
|
31
|
+
|
|
32
|
+
let submitting = $state(false);
|
|
33
|
+
const minPassword = $derived(rules?.find((f) => f.name === 'password')?.rules?.find((r) => r.name === 'min')?.arg);
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<AuthCard id="tern-reset-title" title={title ?? tb('tern.ui.resetPassword.title')} {description} {level} class={klass}>
|
|
37
|
+
{#if invalid || !token}
|
|
38
|
+
<Alert tone="danger">{tb('tern.ui.resetPassword.invalid')}</Alert>
|
|
39
|
+
{#if forgotHref}<Button variant="secondary" href={forgotHref}>{tb('tern.ui.resetPassword.again')}</Button>{/if}
|
|
40
|
+
{:else}
|
|
41
|
+
<Form {rules} {form} {action} bind:submitting reset={false}>
|
|
42
|
+
<input type="hidden" name="token" value={token} />
|
|
43
|
+
<Field label={tb('tern.ui.resetPassword.password')} name="password" description={minPassword ? tb('tern.ui.signUp.minPassword', { n: minPassword }) : undefined}>
|
|
44
|
+
<PasswordInput autocomplete="new-password" />
|
|
45
|
+
</Field>
|
|
46
|
+
{#if confirm}
|
|
47
|
+
<Field label={tb('tern.ui.resetPassword.confirm')} name="confirm">
|
|
48
|
+
<PasswordInput autocomplete="new-password" />
|
|
49
|
+
</Field>
|
|
50
|
+
{/if}
|
|
51
|
+
<Button type="submit" loading={submitting}>{tb('tern.ui.resetPassword.submit')}</Button>
|
|
52
|
+
</Form>
|
|
53
|
+
{/if}
|
|
54
|
+
</AuthCard>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { FormField } from '../runtime/validate.js';
|
|
2
|
+
type Props = {
|
|
3
|
+
title?: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
level?: 1 | 2 | 3;
|
|
6
|
+
/** The token from the email's link (?token=), posted as token. */
|
|
7
|
+
token: string;
|
|
8
|
+
/** The action that calls acc.ResetPassword with the posted token and password. */
|
|
9
|
+
action?: string;
|
|
10
|
+
/** Its rules; they also set the password hint. */
|
|
11
|
+
rules?: FormField[];
|
|
12
|
+
form?: unknown;
|
|
13
|
+
/** Ask to type the password twice, posted as confirm. */
|
|
14
|
+
confirm?: boolean;
|
|
15
|
+
/** The link has been found invalid or expired: say so instead of showing the form. */
|
|
16
|
+
invalid?: boolean;
|
|
17
|
+
/** Where to ask for a new link. */
|
|
18
|
+
forgotHref?: string;
|
|
19
|
+
class?: string;
|
|
20
|
+
};
|
|
21
|
+
declare const ResetPassword: import("svelte").Component<Props, {}, "">;
|
|
22
|
+
type ResetPassword = ReturnType<typeof ResetPassword>;
|
|
23
|
+
export default ResetPassword;
|
package/dist/ui/Resizable.svelte
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
2
3
|
import type { Snippet } from 'svelte';
|
|
3
4
|
|
|
4
5
|
type Props = {
|
|
@@ -13,7 +14,8 @@
|
|
|
13
14
|
second: Snippet;
|
|
14
15
|
class?: string;
|
|
15
16
|
};
|
|
16
|
-
let { size = $bindable(50), min = 15, max = 85, direction = 'horizontal', label
|
|
17
|
+
let { size = $bindable(50), min = 15, max = 85, direction = 'horizontal', label: labelProp, first, second, class: klass = '' }: Props = $props();
|
|
18
|
+
const label = $derived(labelProp ?? tb('tern.ui.resizable.label'));
|
|
17
19
|
|
|
18
20
|
const uid = $props.id();
|
|
19
21
|
let root = $state<HTMLDivElement>();
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { announce } from './announce.js';
|
|
3
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
2
4
|
import { getField } from './field.js';
|
|
3
5
|
import { float } from './floating.js';
|
|
4
6
|
import { grouped, type Option } from './options.js';
|
|
@@ -18,7 +20,8 @@
|
|
|
18
20
|
onchange?: (value: string, option: Option) => void;
|
|
19
21
|
};
|
|
20
22
|
const field = getField();
|
|
21
|
-
let { options, value = $bindable(field?.value ?? null), placeholder
|
|
23
|
+
let { options, value = $bindable(field?.value ?? null), placeholder: placeholderProp, label, name: nameProp, disabled = false, class: klass = '', onchange }: Props = $props();
|
|
24
|
+
const placeholder = $derived(placeholderProp ?? tb('tern.ui.selectMenu.placeholder'));
|
|
22
25
|
const name = $derived(nameProp ?? field?.name);
|
|
23
26
|
|
|
24
27
|
const uid = $props.id();
|
|
@@ -158,7 +161,7 @@
|
|
|
158
161
|
<span class="value" class:placeholder={!selected} id="{id}-value">{selected?.label ?? placeholder}</span>
|
|
159
162
|
<svg aria-hidden="true" viewBox="0 0 16 16" width="14" height="14"><path d="M5 6.5l3-3 3 3M5 9.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
160
163
|
</button>
|
|
161
|
-
{#if name}<input type="hidden" {name} value={value ?? ''} />{/if}
|
|
164
|
+
{#if name}<input type="hidden" {name} value={value ?? ''} use:announce={value} />{/if}
|
|
162
165
|
<div bind:this={list} id="{id}-list" class="tern-selectmenu-list" class:open role="listbox" aria-label={label} aria-labelledby={field?.labelId} tabindex="-1" popover="manual">
|
|
163
166
|
{#if open}
|
|
164
167
|
{#each grouped(options) as g, gi (gi)}
|
package/dist/ui/Sidebar.svelte
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
2
3
|
import type { Snippet } from 'svelte';
|
|
3
4
|
import { page } from '../runtime/state.svelte.js';
|
|
4
5
|
import Dialog from './Dialog.svelte';
|
|
@@ -29,7 +30,8 @@
|
|
|
29
30
|
children: Snippet;
|
|
30
31
|
class?: string;
|
|
31
32
|
};
|
|
32
|
-
let { groups, header, footer, collapsed = $bindable(false), label
|
|
33
|
+
let { groups, header, footer, collapsed = $bindable(false), label: labelProp, children, class: klass = '' }: Props = $props();
|
|
34
|
+
const label = $derived(labelProp ?? tb('tern.ui.sidebar.label'));
|
|
33
35
|
|
|
34
36
|
let mobileOpen = $state(false);
|
|
35
37
|
const path = $derived(page.url?.pathname ?? '');
|
|
@@ -85,7 +87,7 @@
|
|
|
85
87
|
{#if footer && !collapsed}<div class="footer">{@render footer()}</div>{/if}
|
|
86
88
|
</aside>
|
|
87
89
|
<div class="mobilebar">
|
|
88
|
-
<button type="button" class="collapse" aria-label=
|
|
90
|
+
<button type="button" class="collapse" aria-label={tb('tern.ui.sidebar.open')} aria-expanded={mobileOpen} onclick={() => (mobileOpen = true)}>
|
|
89
91
|
<svg aria-hidden="true" viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2.5 4h11M2.5 8h11M2.5 12h11" /></svg>
|
|
90
92
|
</button>
|
|
91
93
|
{#if header}<div class="header">{@render header()}</div>{/if}
|
|
@@ -93,7 +95,7 @@
|
|
|
93
95
|
<div class="main">{@render children()}</div>
|
|
94
96
|
</div>
|
|
95
97
|
|
|
96
|
-
<Dialog bind:open={mobileOpen} title=
|
|
98
|
+
<Dialog bind:open={mobileOpen} title={tb('tern.ui.sidebar.title')} side="left" class="tern-sidebar-sheet">
|
|
97
99
|
{@render nav(false)}
|
|
98
100
|
{#if footer}<div class="footer">{@render footer()}</div>{/if}
|
|
99
101
|
</Dialog>
|
package/dist/ui/SignIn.svelte
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
2
3
|
import type { Snippet } from 'svelte';
|
|
3
4
|
import type { FormField } from '../runtime/validate.js';
|
|
4
5
|
import Alert from './Alert.svelte';
|
|
@@ -9,6 +10,8 @@
|
|
|
9
10
|
import Input from './Input.svelte';
|
|
10
11
|
import PasswordInput from './PasswordInput.svelte';
|
|
11
12
|
import Separator from './Separator.svelte';
|
|
13
|
+
import { goto } from '../runtime/router.svelte.js';
|
|
14
|
+
import { passkeysSupported, signInWithPasskey } from '../runtime/passkeys.js';
|
|
12
15
|
|
|
13
16
|
type Props = {
|
|
14
17
|
title?: string;
|
|
@@ -19,6 +22,10 @@
|
|
|
19
22
|
providers?: { name: string; label: string }[];
|
|
20
23
|
/** Where plugins/oauth lives. */
|
|
21
24
|
authPath?: string;
|
|
25
|
+
/** A "Sign in with a passkey" button, for the accounts package. */
|
|
26
|
+
passkey?: boolean;
|
|
27
|
+
/** Where the accounts package mounts its passkey endpoints. */
|
|
28
|
+
passkeyPath?: string;
|
|
22
29
|
/** Where to go after signing in, a path on this site. */
|
|
23
30
|
next?: string;
|
|
24
31
|
/** Show the email and password form, which posts to action. */
|
|
@@ -28,7 +35,7 @@
|
|
|
28
35
|
rules?: FormField[];
|
|
29
36
|
/** The page's form prop, for the server's errors. */
|
|
30
37
|
form?: unknown;
|
|
31
|
-
/** The ?error= code plugins/oauth sends back after a failed sign-in. */
|
|
38
|
+
/** The ?error= code plugins/oauth sends back after a failed sign-in, such as denied or unverified. */
|
|
32
39
|
error?: string | null;
|
|
33
40
|
/** A "Keep me signed in" checkbox, posted as remember. */
|
|
34
41
|
remember?: boolean;
|
|
@@ -41,12 +48,14 @@
|
|
|
41
48
|
class?: string;
|
|
42
49
|
};
|
|
43
50
|
let {
|
|
44
|
-
title
|
|
51
|
+
title: titleProp,
|
|
45
52
|
description,
|
|
46
53
|
level = 1,
|
|
47
54
|
providers = [],
|
|
48
55
|
authPath = '/auth',
|
|
49
56
|
next,
|
|
57
|
+
passkey = false,
|
|
58
|
+
passkeyPath,
|
|
50
59
|
password = true,
|
|
51
60
|
action,
|
|
52
61
|
rules,
|
|
@@ -58,45 +67,84 @@
|
|
|
58
67
|
footer,
|
|
59
68
|
class: klass = ''
|
|
60
69
|
}: Props = $props();
|
|
70
|
+
const title = $derived(titleProp ?? tb('tern.ui.signIn.title'));
|
|
61
71
|
|
|
62
72
|
let submitting = $state(false);
|
|
63
73
|
const errorText = $derived(
|
|
64
|
-
!error
|
|
74
|
+
!error
|
|
75
|
+
? null
|
|
76
|
+
: error === 'access_denied' || error === 'denied'
|
|
77
|
+
? tb('tern.ui.signIn.cancelled')
|
|
78
|
+
: error === 'unverified'
|
|
79
|
+
? tb('tern.ui.signIn.unverified')
|
|
80
|
+
: error === 'taken'
|
|
81
|
+
? tb('tern.ui.signIn.taken')
|
|
82
|
+
: error === 'no_email'
|
|
83
|
+
? tb('tern.ui.signIn.noEmail')
|
|
84
|
+
: error === 'limited'
|
|
85
|
+
? tb('tern.accounts.error.tooMany')
|
|
86
|
+
: tb('tern.ui.signIn.failed')
|
|
65
87
|
);
|
|
88
|
+
let el = $state<HTMLElement>();
|
|
89
|
+
let passkeyBusy = $state(false);
|
|
90
|
+
let passkeyError = $state(false);
|
|
91
|
+
// Rendered on the server too, where passkeys can't be checked: shown
|
|
92
|
+
// once the browser says it can use them.
|
|
93
|
+
let canPasskey = $state(false);
|
|
94
|
+
$effect(() => {
|
|
95
|
+
canPasskey = passkey && passkeysSupported();
|
|
96
|
+
});
|
|
97
|
+
async function usePasskey() {
|
|
98
|
+
passkeyBusy = true;
|
|
99
|
+
passkeyError = false;
|
|
100
|
+
try {
|
|
101
|
+
const rememberMe = !!el?.querySelector<HTMLInputElement>('input[name="remember"]')?.checked;
|
|
102
|
+
await signInWithPasskey({ path: passkeyPath, remember: rememberMe });
|
|
103
|
+
await goto(next && next.startsWith('/') && !next.startsWith('//') ? next : '/', { invalidateAll: true, replaceState: true });
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (!(e as { cancelled?: boolean }).cancelled) passkeyError = true;
|
|
106
|
+
} finally {
|
|
107
|
+
passkeyBusy = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
66
110
|
const login = (name: string) => `${authPath}/${encodeURIComponent(name)}/login${next ? `?next=${encodeURIComponent(next)}` : ''}`;
|
|
67
111
|
</script>
|
|
68
112
|
|
|
69
|
-
<section class="tern-auth {klass}" aria-labelledby="tern-signin-title">
|
|
113
|
+
<section class="tern-auth {klass}" aria-labelledby="tern-signin-title" bind:this={el}>
|
|
70
114
|
<header>
|
|
71
115
|
<svelte:element this={`h${level}`} id="tern-signin-title">{title}</svelte:element>
|
|
72
116
|
{#if description}<p>{description}</p>{/if}
|
|
73
117
|
</header>
|
|
74
118
|
{#if errorText}<Alert tone="danger" live>{errorText}</Alert>{/if}
|
|
75
|
-
{#if
|
|
119
|
+
{#if passkeyError}<Alert tone="danger" live>{tb('tern.ui.signIn.passkeyFailed')}</Alert>{/if}
|
|
120
|
+
{#if providers.length || canPasskey}
|
|
76
121
|
<div class="providers">
|
|
122
|
+
{#if canPasskey}
|
|
123
|
+
<Button variant="secondary" type="button" loading={passkeyBusy} onclick={usePasskey}>{tb('tern.ui.signIn.passkey')}</Button>
|
|
124
|
+
{/if}
|
|
77
125
|
{#each providers as p (p.name)}
|
|
78
|
-
<Button variant="secondary" href={login(p.name)}>
|
|
126
|
+
<Button variant="secondary" href={login(p.name)}>{tb('tern.ui.signIn.continueWith', { provider: p.label })}</Button>
|
|
79
127
|
{/each}
|
|
80
128
|
</div>
|
|
81
129
|
{/if}
|
|
82
|
-
{#if providers.length && password}<div class="or"><Separator /><span>or</span><Separator /></div>{/if}
|
|
130
|
+
{#if (providers.length || canPasskey) && password}<div class="or"><Separator /><span>{tb('tern.ui.signIn.or')}</span><Separator /></div>{/if}
|
|
83
131
|
{#if password}
|
|
84
132
|
<Form {rules} {form} {action} bind:submitting reset={false}>
|
|
85
133
|
{#if next}<input type="hidden" name="next" value={next} />{/if}
|
|
86
|
-
<Field label=
|
|
134
|
+
<Field label={tb('tern.ui.signIn.email')} name="email">
|
|
87
135
|
<Input type="email" autocomplete="username" spellcheck="false" />
|
|
88
136
|
</Field>
|
|
89
|
-
<Field label=
|
|
137
|
+
<Field label={tb('tern.ui.signIn.password')} name="password">
|
|
90
138
|
<PasswordInput autocomplete="current-password" />
|
|
91
139
|
</Field>
|
|
92
140
|
<div class="row">
|
|
93
|
-
{#if remember}<Checkbox name="remember">
|
|
94
|
-
{#if forgotHref}<a href={forgotHref}>
|
|
141
|
+
{#if remember}<Checkbox name="remember">{tb('tern.ui.signIn.remember')}</Checkbox>{/if}
|
|
142
|
+
{#if forgotHref}<a href={forgotHref}>{tb('tern.ui.signIn.forgot')}</a>{/if}
|
|
95
143
|
</div>
|
|
96
|
-
<Button type="submit" loading={submitting}>
|
|
144
|
+
<Button type="submit" loading={submitting}>{tb('tern.ui.signIn.submit')}</Button>
|
|
97
145
|
</Form>
|
|
98
146
|
{/if}
|
|
99
|
-
{#if signUpHref}<p class="switch">
|
|
147
|
+
{#if signUpHref}<p class="switch">{tb('tern.ui.signIn.new')} <a href={signUpHref}>{tb('tern.ui.signIn.signUp')}</a></p>{/if}
|
|
100
148
|
{#if footer}<div class="footer">{@render footer()}</div>{/if}
|
|
101
149
|
</section>
|
|
102
150
|
|
|
@@ -12,6 +12,10 @@ type Props = {
|
|
|
12
12
|
}[];
|
|
13
13
|
/** Where plugins/oauth lives. */
|
|
14
14
|
authPath?: string;
|
|
15
|
+
/** A "Sign in with a passkey" button, for the accounts package. */
|
|
16
|
+
passkey?: boolean;
|
|
17
|
+
/** Where the accounts package mounts its passkey endpoints. */
|
|
18
|
+
passkeyPath?: string;
|
|
15
19
|
/** Where to go after signing in, a path on this site. */
|
|
16
20
|
next?: string;
|
|
17
21
|
/** Show the email and password form, which posts to action. */
|
|
@@ -21,7 +25,7 @@ type Props = {
|
|
|
21
25
|
rules?: FormField[];
|
|
22
26
|
/** The page's form prop, for the server's errors. */
|
|
23
27
|
form?: unknown;
|
|
24
|
-
/** The ?error= code plugins/oauth sends back after a failed sign-in. */
|
|
28
|
+
/** The ?error= code plugins/oauth sends back after a failed sign-in, such as denied or unverified. */
|
|
25
29
|
error?: string | null;
|
|
26
30
|
/** A "Keep me signed in" checkbox, posted as remember. */
|
|
27
31
|
remember?: boolean;
|