@ternkit/tern 1.6.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 +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime/builtin.js +42 -0
- package/dist/ui/Form.svelte +19 -1
- package/dist/ui/LinkedAccounts.svelte +54 -0
- package/dist/ui/LinkedAccounts.svelte.d.ts +27 -0
- package/dist/ui/SignIn.svelte +14 -2
- package/dist/ui/SignIn.svelte.d.ts +1 -1
- 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/index.d.ts +3 -0
- package/dist/ui/index.js +3 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -18,4 +18,4 @@ export { default as Image } from './image/Image.svelte';
|
|
|
18
18
|
export { t, i18n, setLocale } from './runtime/i18n.svelte.js';
|
|
19
19
|
export { passkeysSupported, registerPasskey, signInWithPasskey, PasskeyError } from './runtime/passkeys.js';
|
|
20
20
|
/** The version of the Tern client runtime. */
|
|
21
|
-
export const VERSION = '1.
|
|
21
|
+
export const VERSION = '1.7.0';
|
package/dist/runtime/builtin.js
CHANGED
|
@@ -2,10 +2,19 @@
|
|
|
2
2
|
// An app translates them in src/locales like its own messages. Generated
|
|
3
3
|
// from internal/i18n/builtin.json; a Go test keeps the two the same.
|
|
4
4
|
export const builtin = {
|
|
5
|
+
"tern.accounts.change.button": "Confirm my new address",
|
|
6
|
+
"tern.accounts.change.subject": "Confirm your new email address",
|
|
7
|
+
"tern.accounts.change.text": "Hello {name},\n\nConfirm that this is your account’s new email address by opening this link:\n{link}\n\nIf you didn’t ask for this, ignore this email: your address stays as it is.",
|
|
8
|
+
"tern.accounts.changed.button": "",
|
|
9
|
+
"tern.accounts.changed.subject": "Your email address was changed",
|
|
10
|
+
"tern.accounts.changed.text": "Hello {name},\n\nYour account’s email address was changed, and this address no longer signs in. If you didn’t do this, contact us right away.",
|
|
5
11
|
"tern.accounts.error.credentials": "Wrong email or password.",
|
|
6
12
|
"tern.accounts.error.emailTaken": "An account with this email address already exists.",
|
|
13
|
+
"tern.accounts.error.invalidCode": "That code didn’t work. Check the time on your phone, or use a recovery code.",
|
|
7
14
|
"tern.accounts.error.invalidToken": "This link is invalid or has expired.",
|
|
15
|
+
"tern.accounts.error.lastSignIn": "Add a password or a passkey first: this is the only way to sign in to your account.",
|
|
8
16
|
"tern.accounts.error.passkey": "That passkey didn’t work.",
|
|
17
|
+
"tern.accounts.error.tooMany": "Too many attempts. Try again in a few minutes.",
|
|
9
18
|
"tern.accounts.error.wrongPassword": "That isn’t your current password.",
|
|
10
19
|
"tern.accounts.reset.button": "Choose a new password",
|
|
11
20
|
"tern.accounts.reset.subject": "Reset your password",
|
|
@@ -57,6 +66,11 @@ export const builtin = {
|
|
|
57
66
|
"tern.ui.forgotPassword.submit": "Send link",
|
|
58
67
|
"tern.ui.forgotPassword.title": "Reset your password",
|
|
59
68
|
"tern.ui.form.summary": "{n, plural, one {# field needs attention.} other {# fields need attention.}}",
|
|
69
|
+
"tern.ui.linked.connect": "Connect",
|
|
70
|
+
"tern.ui.linked.description": "Sign in with these as well as your password or passkeys.",
|
|
71
|
+
"tern.ui.linked.disconnect": "Disconnect",
|
|
72
|
+
"tern.ui.linked.since": "connected {when}",
|
|
73
|
+
"tern.ui.linked.title": "Connected accounts",
|
|
60
74
|
"tern.ui.navigationMenu.label": "Main",
|
|
61
75
|
"tern.ui.pagination.label": "Pagination",
|
|
62
76
|
"tern.ui.pagination.next": "Next ›",
|
|
@@ -91,6 +105,7 @@ export const builtin = {
|
|
|
91
105
|
"tern.ui.signIn.failed": "Signing in didn’t work. Try again, or choose another way.",
|
|
92
106
|
"tern.ui.signIn.forgot": "Forgot your password?",
|
|
93
107
|
"tern.ui.signIn.new": "New here?",
|
|
108
|
+
"tern.ui.signIn.noEmail": "The provider didn’t share your email address, which an account needs. Allow it there, or sign up with email.",
|
|
94
109
|
"tern.ui.signIn.or": "or",
|
|
95
110
|
"tern.ui.signIn.passkey": "Sign in with a passkey",
|
|
96
111
|
"tern.ui.signIn.passkeyFailed": "That passkey didn’t work. Try again, or sign in another way.",
|
|
@@ -98,7 +113,9 @@ export const builtin = {
|
|
|
98
113
|
"tern.ui.signIn.remember": "Keep me signed in",
|
|
99
114
|
"tern.ui.signIn.signUp": "Create an account",
|
|
100
115
|
"tern.ui.signIn.submit": "Sign in",
|
|
116
|
+
"tern.ui.signIn.taken": "That account is already connected to another user.",
|
|
101
117
|
"tern.ui.signIn.title": "Sign in",
|
|
118
|
+
"tern.ui.signIn.unverified": "An account with this email address exists. Sign in to it another way, then connect this provider from your account.",
|
|
102
119
|
"tern.ui.signUp.agree": "I agree to the",
|
|
103
120
|
"tern.ui.signUp.confirm": "Confirm password",
|
|
104
121
|
"tern.ui.signUp.email": "Email",
|
|
@@ -114,6 +131,31 @@ export const builtin = {
|
|
|
114
131
|
"tern.ui.table.empty": "Nothing to show",
|
|
115
132
|
"tern.ui.toaster.dismiss": "Dismiss",
|
|
116
133
|
"tern.ui.toaster.label": "Notifications",
|
|
134
|
+
"tern.ui.twoStep.cantScan": "Can’t scan it?",
|
|
135
|
+
"tern.ui.twoStep.codeDescription": "Open your authenticator app and enter the code it shows for this account.",
|
|
136
|
+
"tern.ui.twoStep.codeLabel": "Code from the app",
|
|
137
|
+
"tern.ui.twoStep.codeTitle": "Enter your code",
|
|
138
|
+
"tern.ui.twoStep.codesHelp": "Each signs in once if you lose your phone. They won’t be shown again.",
|
|
139
|
+
"tern.ui.twoStep.codesLeft": "{n, plural, one {# recovery code left} other {# recovery codes left}}",
|
|
140
|
+
"tern.ui.twoStep.codesTitle": "Save your recovery codes",
|
|
141
|
+
"tern.ui.twoStep.copied": "Copied",
|
|
142
|
+
"tern.ui.twoStep.copy": "Copy codes",
|
|
143
|
+
"tern.ui.twoStep.description": "After your password, sign in with a code from an authenticator app on your phone.",
|
|
144
|
+
"tern.ui.twoStep.newCodes": "New recovery codes",
|
|
145
|
+
"tern.ui.twoStep.offLabel": "A code from the app, or a recovery code",
|
|
146
|
+
"tern.ui.twoStep.on": "On",
|
|
147
|
+
"tern.ui.twoStep.openApp": "Open in an authenticator app on this device",
|
|
148
|
+
"tern.ui.twoStep.qrAlt": "QR code to add this account to an authenticator app",
|
|
149
|
+
"tern.ui.twoStep.recoveryDescription": "Enter one of the recovery codes you saved. Each works once.",
|
|
150
|
+
"tern.ui.twoStep.recoveryLabel": "Recovery code",
|
|
151
|
+
"tern.ui.twoStep.scan": "Scan this QR code with your authenticator app.",
|
|
152
|
+
"tern.ui.twoStep.setUp": "Set up two-step sign-in",
|
|
153
|
+
"tern.ui.twoStep.title": "Two-step sign-in",
|
|
154
|
+
"tern.ui.twoStep.turnOff": "Turn off",
|
|
155
|
+
"tern.ui.twoStep.turnOn": "Turn on",
|
|
156
|
+
"tern.ui.twoStep.useApp": "Use the authenticator app",
|
|
157
|
+
"tern.ui.twoStep.useRecovery": "Use a recovery code",
|
|
158
|
+
"tern.ui.twoStep.verify": "Continue",
|
|
117
159
|
"tern.validate.after": "{field} must be on or after {date}.",
|
|
118
160
|
"tern.validate.before": "{field} must be on or before {date}.",
|
|
119
161
|
"tern.validate.checked": "{field} must be checked.",
|
package/dist/ui/Form.svelte
CHANGED
|
@@ -72,9 +72,15 @@
|
|
|
72
72
|
// inputs, its Field's.
|
|
73
73
|
const nameOf = (t: EventTarget | null) => (t as HTMLInputElement | null)?.name || (t as HTMLElement | null)?.closest?.('[data-tern-field]')?.getAttribute('data-tern-field') || '';
|
|
74
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
|
+
|
|
75
80
|
function oninput(e: Event) {
|
|
76
81
|
const name = nameOf(e.target);
|
|
77
82
|
if (!name) return;
|
|
83
|
+
if (name in posted && valueOf(name) === posted[name]) return;
|
|
78
84
|
edited = { ...edited, [name]: true };
|
|
79
85
|
if (touched[name] || submitted) check(related(name).filter((n) => touched[n] || submitted || n === name));
|
|
80
86
|
}
|
|
@@ -101,7 +107,12 @@
|
|
|
101
107
|
return false;
|
|
102
108
|
}
|
|
103
109
|
|
|
104
|
-
|
|
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 }) => {
|
|
105
116
|
const errors = validate(rules, formData);
|
|
106
117
|
if (Object.keys(errors).length > 0) {
|
|
107
118
|
cancel();
|
|
@@ -112,7 +123,14 @@
|
|
|
112
123
|
client = {};
|
|
113
124
|
summary = '';
|
|
114
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');
|
|
115
131
|
return async ({ update, result }: { update: (o?: { reset?: boolean }) => Promise<void>; result: { type: string; data?: unknown } }) => {
|
|
132
|
+
if (mine !== latest) return;
|
|
133
|
+
inflight = null;
|
|
116
134
|
await update({ reset });
|
|
117
135
|
submitting = false;
|
|
118
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;
|
package/dist/ui/SignIn.svelte
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
rules?: FormField[];
|
|
36
36
|
/** The page's form prop, for the server's errors. */
|
|
37
37
|
form?: unknown;
|
|
38
|
-
/** 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. */
|
|
39
39
|
error?: string | null;
|
|
40
40
|
/** A "Keep me signed in" checkbox, posted as remember. */
|
|
41
41
|
remember?: boolean;
|
|
@@ -71,7 +71,19 @@
|
|
|
71
71
|
|
|
72
72
|
let submitting = $state(false);
|
|
73
73
|
const errorText = $derived(
|
|
74
|
-
!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')
|
|
75
87
|
);
|
|
76
88
|
let el = $state<HTMLElement>();
|
|
77
89
|
let passkeyBusy = $state(false);
|
|
@@ -25,7 +25,7 @@ type Props = {
|
|
|
25
25
|
rules?: FormField[];
|
|
26
26
|
/** The page's form prop, for the server's errors. */
|
|
27
27
|
form?: unknown;
|
|
28
|
-
/** 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. */
|
|
29
29
|
error?: string | null;
|
|
30
30
|
/** A "Keep me signed in" checkbox, posted as remember. */
|
|
31
31
|
remember?: boolean;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
3
|
+
import AccountSection from './AccountSection.svelte';
|
|
4
|
+
import Alert from './Alert.svelte';
|
|
5
|
+
import Badge from './Badge.svelte';
|
|
6
|
+
import Button from './Button.svelte';
|
|
7
|
+
import Field from './Field.svelte';
|
|
8
|
+
import Form from './Form.svelte';
|
|
9
|
+
import Input from './Input.svelte';
|
|
10
|
+
import InputOTP from './InputOTP.svelte';
|
|
11
|
+
|
|
12
|
+
type Setup = { secret: string; uri: string; qr: string };
|
|
13
|
+
type Props = {
|
|
14
|
+
title?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
level?: 2 | 3 | 4;
|
|
17
|
+
/** User.twoStep: whether it's on. */
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
/** From acc.SetUpTwoStep or acc.PendingTwoStepSetup, while turning it on. */
|
|
20
|
+
setup?: Setup | null;
|
|
21
|
+
/** Recovery codes to show once: from acc.ConfirmTwoStep or acc.RecoveryCodes. */
|
|
22
|
+
recoveryCodes?: string[] | null;
|
|
23
|
+
/** How many recovery codes are left, from acc.RecoveryCodesLeft. */
|
|
24
|
+
codesLeft?: number;
|
|
25
|
+
/** The action that calls acc.SetUpTwoStep. */
|
|
26
|
+
setupAction?: string;
|
|
27
|
+
/** The action that calls acc.ConfirmTwoStep with the posted code. */
|
|
28
|
+
confirmAction?: string;
|
|
29
|
+
/** The action that calls acc.TurnOffTwoStep with the posted code. */
|
|
30
|
+
offAction?: string;
|
|
31
|
+
/** The action that calls acc.RecoveryCodes. */
|
|
32
|
+
codesAction?: string;
|
|
33
|
+
form?: unknown;
|
|
34
|
+
class?: string;
|
|
35
|
+
};
|
|
36
|
+
let { title, description, level = 2, enabled, setup, recoveryCodes, codesLeft, setupAction, confirmAction, offAction, codesAction, form, class: klass = '' }: Props = $props();
|
|
37
|
+
let turningOff = $state(false);
|
|
38
|
+
let copied = $state(false);
|
|
39
|
+
async function copy() {
|
|
40
|
+
try {
|
|
41
|
+
await navigator.clipboard.writeText((recoveryCodes ?? []).join('\n'));
|
|
42
|
+
copied = true;
|
|
43
|
+
} catch {
|
|
44
|
+
copied = false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<AccountSection id="tern-two-step-settings" title={title ?? tb('tern.ui.twoStep.title')} description={description ?? tb('tern.ui.twoStep.description')} {level} class={klass}>
|
|
50
|
+
{#if recoveryCodes?.length}
|
|
51
|
+
<Alert tone="warning" title={tb('tern.ui.twoStep.codesTitle')}>{tb('tern.ui.twoStep.codesHelp')}</Alert>
|
|
52
|
+
<ol class="codes" data-testid="recovery-codes">
|
|
53
|
+
{#each recoveryCodes as c (c)}<li><code>{c}</code></li>{/each}
|
|
54
|
+
</ol>
|
|
55
|
+
<div><Button type="button" variant="secondary" size="sm" onclick={copy}>{copied ? tb('tern.ui.twoStep.copied') : tb('tern.ui.twoStep.copy')}</Button></div>
|
|
56
|
+
{/if}
|
|
57
|
+
{#if enabled}
|
|
58
|
+
<p class="status">
|
|
59
|
+
<Badge tone="success">{tb('tern.ui.twoStep.on')}</Badge>
|
|
60
|
+
{#if codesLeft !== undefined}<span class="tern-account-meta">{tb('tern.ui.twoStep.codesLeft', { n: codesLeft })}</span>{/if}
|
|
61
|
+
</p>
|
|
62
|
+
<div class="row">
|
|
63
|
+
{#if codesAction}
|
|
64
|
+
<Form action={codesAction} reset={false}><Button type="submit" variant="secondary" size="sm">{tb('tern.ui.twoStep.newCodes')}</Button></Form>
|
|
65
|
+
{/if}
|
|
66
|
+
{#if offAction && !turningOff}
|
|
67
|
+
<Button type="button" variant="ghost" size="sm" onclick={() => (turningOff = true)}>{tb('tern.ui.twoStep.turnOff')}</Button>
|
|
68
|
+
{/if}
|
|
69
|
+
</div>
|
|
70
|
+
{#if offAction && turningOff}
|
|
71
|
+
<Form action={offAction} {form} reset={false}>
|
|
72
|
+
<Field label={tb('tern.ui.twoStep.offLabel')} name="code"><Input autocomplete="one-time-code" spellcheck="false" /></Field>
|
|
73
|
+
<div class="row">
|
|
74
|
+
<Button type="submit" variant="danger" size="sm">{tb('tern.ui.twoStep.turnOff')}</Button>
|
|
75
|
+
<Button type="button" variant="ghost" size="sm" onclick={() => (turningOff = false)}>{tb('tern.ui.alertDialog.cancel')}</Button>
|
|
76
|
+
</div>
|
|
77
|
+
</Form>
|
|
78
|
+
{/if}
|
|
79
|
+
{:else if setup}
|
|
80
|
+
<ol class="steps">
|
|
81
|
+
<li>
|
|
82
|
+
{tb('tern.ui.twoStep.scan')}
|
|
83
|
+
<img src={setup.qr} alt={tb('tern.ui.twoStep.qrAlt')} width="200" height="200" />
|
|
84
|
+
<details>
|
|
85
|
+
<summary>{tb('tern.ui.twoStep.cantScan')}</summary>
|
|
86
|
+
<p><code data-testid="two-step-secret">{setup.secret}</code></p>
|
|
87
|
+
<p><a href={setup.uri}>{tb('tern.ui.twoStep.openApp')}</a></p>
|
|
88
|
+
</details>
|
|
89
|
+
</li>
|
|
90
|
+
<li>
|
|
91
|
+
<Form action={confirmAction} {form} reset={false}>
|
|
92
|
+
<Field label={tb('tern.ui.twoStep.codeLabel')} name="code"><InputOTP length={6} /></Field>
|
|
93
|
+
<div><Button type="submit" size="sm">{tb('tern.ui.twoStep.turnOn')}</Button></div>
|
|
94
|
+
</Form>
|
|
95
|
+
</li>
|
|
96
|
+
</ol>
|
|
97
|
+
{:else if setupAction}
|
|
98
|
+
<Form action={setupAction} reset={false}>
|
|
99
|
+
<div><Button type="submit" variant="secondary" size="sm">{tb('tern.ui.twoStep.setUp')}</Button></div>
|
|
100
|
+
</Form>
|
|
101
|
+
{/if}
|
|
102
|
+
</AccountSection>
|
|
103
|
+
|
|
104
|
+
<style>
|
|
105
|
+
.codes {
|
|
106
|
+
display: grid;
|
|
107
|
+
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
|
|
108
|
+
gap: 0.5rem;
|
|
109
|
+
margin: 0;
|
|
110
|
+
padding: 1rem 1rem 1rem 2.25rem;
|
|
111
|
+
border: 1px solid var(--tern-border);
|
|
112
|
+
border-radius: var(--tern-radius);
|
|
113
|
+
background: var(--tern-surface);
|
|
114
|
+
}
|
|
115
|
+
.codes code,
|
|
116
|
+
.steps code {
|
|
117
|
+
font-family: var(--tern-mono, ui-monospace, monospace);
|
|
118
|
+
font-size: 0.9375rem;
|
|
119
|
+
}
|
|
120
|
+
.status,
|
|
121
|
+
.row {
|
|
122
|
+
display: flex;
|
|
123
|
+
flex-wrap: wrap;
|
|
124
|
+
align-items: center;
|
|
125
|
+
gap: 0.75rem;
|
|
126
|
+
margin: 0;
|
|
127
|
+
}
|
|
128
|
+
.steps {
|
|
129
|
+
display: grid;
|
|
130
|
+
gap: 1.25rem;
|
|
131
|
+
margin: 0;
|
|
132
|
+
padding-left: 1.25rem;
|
|
133
|
+
}
|
|
134
|
+
.steps li {
|
|
135
|
+
display: grid;
|
|
136
|
+
gap: 0.75rem;
|
|
137
|
+
justify-items: start;
|
|
138
|
+
}
|
|
139
|
+
.steps img {
|
|
140
|
+
border-radius: var(--tern-radius);
|
|
141
|
+
background: #fff;
|
|
142
|
+
padding: 0.5rem;
|
|
143
|
+
image-rendering: pixelated;
|
|
144
|
+
}
|
|
145
|
+
details {
|
|
146
|
+
font-size: 0.875rem;
|
|
147
|
+
color: var(--tern-muted);
|
|
148
|
+
}
|
|
149
|
+
summary {
|
|
150
|
+
cursor: pointer;
|
|
151
|
+
}
|
|
152
|
+
.steps a {
|
|
153
|
+
color: var(--tern-accent);
|
|
154
|
+
}
|
|
155
|
+
</style>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
type Setup = {
|
|
2
|
+
secret: string;
|
|
3
|
+
uri: string;
|
|
4
|
+
qr: string;
|
|
5
|
+
};
|
|
6
|
+
type Props = {
|
|
7
|
+
title?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
level?: 2 | 3 | 4;
|
|
10
|
+
/** User.twoStep: whether it's on. */
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
/** From acc.SetUpTwoStep or acc.PendingTwoStepSetup, while turning it on. */
|
|
13
|
+
setup?: Setup | null;
|
|
14
|
+
/** Recovery codes to show once: from acc.ConfirmTwoStep or acc.RecoveryCodes. */
|
|
15
|
+
recoveryCodes?: string[] | null;
|
|
16
|
+
/** How many recovery codes are left, from acc.RecoveryCodesLeft. */
|
|
17
|
+
codesLeft?: number;
|
|
18
|
+
/** The action that calls acc.SetUpTwoStep. */
|
|
19
|
+
setupAction?: string;
|
|
20
|
+
/** The action that calls acc.ConfirmTwoStep with the posted code. */
|
|
21
|
+
confirmAction?: string;
|
|
22
|
+
/** The action that calls acc.TurnOffTwoStep with the posted code. */
|
|
23
|
+
offAction?: string;
|
|
24
|
+
/** The action that calls acc.RecoveryCodes. */
|
|
25
|
+
codesAction?: string;
|
|
26
|
+
form?: unknown;
|
|
27
|
+
class?: string;
|
|
28
|
+
};
|
|
29
|
+
declare const TwoStep: import("svelte").Component<Props, {}, "">;
|
|
30
|
+
type TwoStep = ReturnType<typeof TwoStep>;
|
|
31
|
+
export default TwoStep;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
3
|
+
import type { FormField } from '../runtime/validate.js';
|
|
4
|
+
import AuthCard from './AuthCard.svelte';
|
|
5
|
+
import Button from './Button.svelte';
|
|
6
|
+
import Field from './Field.svelte';
|
|
7
|
+
import Form from './Form.svelte';
|
|
8
|
+
import Input from './Input.svelte';
|
|
9
|
+
import InputOTP from './InputOTP.svelte';
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
title?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
level?: 1 | 2 | 3;
|
|
15
|
+
/** The action that calls acc.CompleteSignIn with the posted code. */
|
|
16
|
+
action?: string;
|
|
17
|
+
rules?: FormField[];
|
|
18
|
+
form?: unknown;
|
|
19
|
+
/** Where to go back to sign in. */
|
|
20
|
+
signInHref?: string;
|
|
21
|
+
class?: string;
|
|
22
|
+
};
|
|
23
|
+
let { title, description, level = 1, action, rules, form, signInHref, class: klass = '' }: Props = $props();
|
|
24
|
+
|
|
25
|
+
let submitting = $state(false);
|
|
26
|
+
let recovery = $state(false);
|
|
27
|
+
let el = $state<HTMLElement>();
|
|
28
|
+
// A full code posts the form, as typing the last digit should.
|
|
29
|
+
const submit = () => el?.querySelector('form')?.requestSubmit();
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<div bind:this={el} style="display: contents">
|
|
33
|
+
<AuthCard id="tern-two-step-title" title={title ?? tb('tern.ui.twoStep.codeTitle')} description={description ?? (recovery ? tb('tern.ui.twoStep.recoveryDescription') : tb('tern.ui.twoStep.codeDescription'))} {level} class={klass}>
|
|
34
|
+
<Form {rules} {form} {action} bind:submitting reset={false}>
|
|
35
|
+
{#if recovery}
|
|
36
|
+
<Field label={tb('tern.ui.twoStep.recoveryLabel')} name="code">
|
|
37
|
+
<Input autocomplete="off" spellcheck="false" autocapitalize="off" />
|
|
38
|
+
</Field>
|
|
39
|
+
{:else}
|
|
40
|
+
<Field label={tb('tern.ui.twoStep.codeLabel')} name="code">
|
|
41
|
+
<InputOTP length={6} oncomplete={submit} />
|
|
42
|
+
</Field>
|
|
43
|
+
{/if}
|
|
44
|
+
<Button type="submit" loading={submitting}>{tb('tern.ui.twoStep.verify')}</Button>
|
|
45
|
+
</Form>
|
|
46
|
+
<p class="tern-auth-note">
|
|
47
|
+
<button type="button" class="link" onclick={() => (recovery = !recovery)}>
|
|
48
|
+
{recovery ? tb('tern.ui.twoStep.useApp') : tb('tern.ui.twoStep.useRecovery')}
|
|
49
|
+
</button>
|
|
50
|
+
</p>
|
|
51
|
+
{#if signInHref}<p class="tern-auth-note"><a href={signInHref}>{tb('tern.ui.forgotPassword.back')}</a></p>{/if}
|
|
52
|
+
</AuthCard>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<style>
|
|
56
|
+
.link {
|
|
57
|
+
padding: 0;
|
|
58
|
+
border: 0;
|
|
59
|
+
background: none;
|
|
60
|
+
color: var(--tern-accent);
|
|
61
|
+
font: inherit;
|
|
62
|
+
font-weight: 550;
|
|
63
|
+
cursor: pointer;
|
|
64
|
+
}
|
|
65
|
+
.link:focus-visible {
|
|
66
|
+
outline: 2px solid var(--tern-ring);
|
|
67
|
+
outline-offset: 2px;
|
|
68
|
+
border-radius: 2px;
|
|
69
|
+
}
|
|
70
|
+
</style>
|
|
@@ -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.CompleteSignIn with the posted code. */
|
|
7
|
+
action?: string;
|
|
8
|
+
rules?: FormField[];
|
|
9
|
+
form?: unknown;
|
|
10
|
+
/** Where to go back to sign in. */
|
|
11
|
+
signInHref?: string;
|
|
12
|
+
class?: string;
|
|
13
|
+
};
|
|
14
|
+
declare const TwoStepCode: import("svelte").Component<Props, {}, "">;
|
|
15
|
+
type TwoStepCode = ReturnType<typeof TwoStepCode>;
|
|
16
|
+
export default TwoStepCode;
|
package/dist/ui/index.d.ts
CHANGED
|
@@ -55,6 +55,9 @@ export { default as ForgotPassword } from './ForgotPassword.svelte';
|
|
|
55
55
|
export { default as ResetPassword } from './ResetPassword.svelte';
|
|
56
56
|
export { default as Devices } from './Devices.svelte';
|
|
57
57
|
export { default as Passkeys } from './Passkeys.svelte';
|
|
58
|
+
export { default as LinkedAccounts } from './LinkedAccounts.svelte';
|
|
59
|
+
export { default as TwoStep } from './TwoStep.svelte';
|
|
60
|
+
export { default as TwoStepCode } from './TwoStepCode.svelte';
|
|
58
61
|
export { default as Skeleton } from './Skeleton.svelte';
|
|
59
62
|
export { default as Slider } from './Slider.svelte';
|
|
60
63
|
export { default as Sparkline } from './Sparkline.svelte';
|
package/dist/ui/index.js
CHANGED
|
@@ -58,6 +58,9 @@ export { default as ForgotPassword } from './ForgotPassword.svelte';
|
|
|
58
58
|
export { default as ResetPassword } from './ResetPassword.svelte';
|
|
59
59
|
export { default as Devices } from './Devices.svelte';
|
|
60
60
|
export { default as Passkeys } from './Passkeys.svelte';
|
|
61
|
+
export { default as LinkedAccounts } from './LinkedAccounts.svelte';
|
|
62
|
+
export { default as TwoStep } from './TwoStep.svelte';
|
|
63
|
+
export { default as TwoStepCode } from './TwoStepCode.svelte';
|
|
61
64
|
export { default as Skeleton } from './Skeleton.svelte';
|
|
62
65
|
export { default as Slider } from './Slider.svelte';
|
|
63
66
|
export { default as Sparkline } from './Sparkline.svelte';
|