@ternkit/tern 1.6.0 → 1.8.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 CHANGED
@@ -30,4 +30,4 @@ export type { MessageKey } from './runtime/i18n.svelte.js';
30
30
  export interface Messages {
31
31
  }
32
32
  /** The version of the Tern client runtime. */
33
- export declare const VERSION = "1.6.0";
33
+ export declare const VERSION = "1.8.0";
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.6.0';
21
+ export const VERSION = '1.8.0';
@@ -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.",
@@ -1,5 +1,6 @@
1
1
  import type { Component } from 'svelte';
2
2
  import { type I18nConfig } from '../runtime/i18n.svelte.js';
3
+ import type { Payload } from '../runtime/types.js';
3
4
  /** The server manifest generated by `tern gen` in .tern/generated/server.js. */
4
5
  export interface ServerManifest {
5
6
  layouts: {
@@ -36,7 +37,7 @@ export interface RenderResult {
36
37
  * exactly the same tree and hydration matches.
37
38
  */
38
39
  export declare function createRenderer(manifest: ServerManifest): ((payload: string, href: string) => string) & {
39
- raw: (payload: string, href: string) => RenderResult;
40
+ raw: (payload: string | Payload, href: string) => RenderResult;
40
41
  };
41
42
  export { createEmailRenderer } from './email.js';
42
43
  export type { EmailModule, EmailResult } from './email.js';
@@ -17,9 +17,10 @@ export function createRenderer(manifest) {
17
17
  configureI18n(manifest.i18n ?? null);
18
18
  setBase(manifest.base ?? '');
19
19
  // raw returns the result as an object, for a native renderer to read
20
- // without encoding it as JSON first.
21
- const raw = (payloadJSON, href) => {
22
- const p = JSON.parse(payloadJSON);
20
+ // without encoding it as JSON first. Native rendering also passes the
21
+ // payload as an object, made from Go values without the JSON.
22
+ const raw = (payload, href) => {
23
+ const p = typeof payload === 'string' ? JSON.parse(payload) : payload;
23
24
  // Deferred values are still loading: render their pending state.
24
25
  if (p.deferred)
25
26
  p.nodes = revive(p.nodes, () => new Promise(() => { }));
@@ -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
- const submit = ({ formData, cancel }: { formData: FormData; cancel(): void }) => {
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;
@@ -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 ? null : error === 'access_denied' ? tb('tern.ui.signIn.cancelled') : tb('tern.ui.signIn.failed')
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;
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ternkit/tern",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Client runtime and Vite plugin for Tern: Svelte on the client, Go on the server",
5
5
  "license": "MIT",
6
6
  "type": "module",