@atlasauth/svelte 0.7.0 → 0.9.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/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @atlasauth/svelte changelog
2
2
 
3
+ ## 0.9.0 — 2026-10-23
4
+
5
+ ### Added
6
+
7
+ - `<OrganizationAuditLog/>` and `getOrganizationAuditLog()` (a store with `loadMore()` / `reload()`): the active organization's audit trail for its admins, paged, with labels as props. Renders nothing for a member who may not read it. Appearance `elements` gain `list` and `listItem`.
8
+ - **Recovery codes after enrolment.** Setting up a second factor at sign-in now shows the account's one-time recovery codes before the sign-in finishes, as the hosted page does: copy them, download them as a .txt file, then "I've saved my codes" continues. Before, the sign-in completed straight away and the codes were never shown. The codes are kept in memory only, never on the attempt or in storage. New strings (`mfa.recoveryCodesTitle`, `mfa.recoveryCodesHelp`, `mfa.recoveryCodesCopy`, `mfa.recoveryCodesCopied`, `mfa.recoveryCodesDownload`, `mfa.recoveryCodesSaved`) are in the catalog and in every `@atlasauth/localizations` bundle.
9
+ - **MFA enrolment at sign-in works end to end.** When the app requires a second factor and the account has none, `<SignIn>` now fetches the authenticator setup and shows its QR code and the setup key, then asks for the code (one or two, as the app requires). Before, the step asked for codes without ever showing the setup.
10
+ - **Text-message enrolment** where the app offers texted codes and can send them: the user picks "Use text messages", enters a phone number, and confirms with the texted code. When both methods are offered the user chooses, and can switch.
11
+ - **"Text me a code instead"** at the second-factor step where the app offers texted codes, beside the existing "Email me a code instead".
12
+ - The new strings are in the catalog (`mfa.enrollChoose`, `mfa.enrollTotpOption`, `mfa.enrollSmsOption`, `mfa.enrollScan`, `mfa.enrollQrAlt`, `mfa.enrollSetupKey`, `mfa.enrollPreparing`, `mfa.phoneLabel`, `mfa.phoneHint`, `mfa.sendCode`, `mfa.codeTextedTo`, `mfa.smsCodeOption`) and in every `@atlasauth/localizations` bundle.
13
+ - Texted codes are offered only where the server says the app can text (`account.mfa` on `/v1/appearance`). A server that does not say gets the authenticator app only.
14
+
15
+ ### Changed
16
+
17
+ - Built on `@atlasauth/js` 0.16.0.
18
+
19
+ ## 0.8.0
20
+
21
+ ### Changed
22
+
23
+ - `@atlasauth/js` 0.15.0: referral capture for any referral program and any consent manager (pass a custom program's reader page-wide with `configureReferralCapture({ referralSource })`).
24
+
25
+ ### Fixed
26
+
27
+ - Development instances: sign-in from `localhost` keeps working where the browser blocks the instance's cookie as third-party (Chrome, Safari). On a `pk_test_` key the SDK keeps the session's refresh token in `localStorage` and sends it as `x-refresh-token`; production keys never do.
28
+ - After a sign-in completes, the panel says "You're signed in" only once this browser holds the session, and says plainly when the browser didn't keep it, instead of "you are signed in where you started". That message is now shown only for a sign-in completed on another device.
29
+ - `<SignIn>` shows "No account? Sign up" on every step (`signUpUrl`; default the hosted sign-up page with the visitor's referral, `null` hides it), so a newcomer on a password-only instance always has a way to create an account.
30
+
3
31
  ## 0.7.0
4
32
  - **Referral attribution.** The prebuilt forms send the referral the visitor arrived with on sign-in and sign-up (through `@atlasauth/js` 0.13.0) when the instance has referrals on, and show the captcha for a referred sign-up when the instance requires it. New init options: `referral?: false` and `referralConsent?`.
5
33
 
@@ -1,5 +1,5 @@
1
1
  import { type Readable } from 'svelte/store';
2
- import { type CaptchaController, type FlowState, type InstanceSettings, type ProviderToken, type SignOutOptions, type SignOutResult } from '@atlasauth/js';
2
+ import { type OrganizationAuditLogOptions, type OrganizationAuditLogState, type CaptchaController, type FlowState, type InstanceSettings, type ProviderToken, type SignOutOptions, type SignOutResult, type MfaChoices, type MfaEnrollmentController, type MfaEnrollmentState } from '@atlasauth/js';
3
3
  import { type AuthStatus } from './context';
4
4
  import { type ProtectCondition } from './protect';
5
5
  import type { AtlasOrganizationMembership, AtlasSession, AtlasUser } from './types';
@@ -66,6 +66,18 @@ export interface UseOrganization {
66
66
  setActive(organizationId: string | null): Promise<void>;
67
67
  }
68
68
  export declare function getOrganization(): Readable<UseOrganization>;
69
+ /**
70
+ * The active organization's audit trail, for its admins: a store of
71
+ * `{ entries, loading, forbidden, hasMore }` (one page at a time, newest first)
72
+ * with `loadMore()` and `reload()`. `action` narrows it (exact, or a prefix
73
+ * ending in `.`). `forbidden` is true when the signed-in member may not read it.
74
+ * Follows the active organization while it has subscribers.
75
+ */
76
+ export interface OrganizationAuditLogStore extends Readable<OrganizationAuditLogState> {
77
+ loadMore(): Promise<void>;
78
+ reload(): Promise<void>;
79
+ }
80
+ export declare function getOrganizationAuditLog(options?: OrganizationAuditLogOptions): OrganizationAuditLogStore;
69
81
  /** Refresh-if-stale, then hand over the JWT. The `useAuth().getToken` shortcut. */
70
82
  export declare function getToken(): Promise<string | null>;
71
83
  /**
@@ -93,6 +105,45 @@ export interface FlowController {
93
105
  * then posted with {@link submit}.
94
106
  */
95
107
  create(values: Record<string, string>, consent?: boolean): void;
108
+ /** Start a passkey-only sign-up (`POST /v1/client/sign_ups/passkey`): no password. */
109
+ createWithPasskey(values: Record<string, string>, consent?: boolean): void;
110
+ /** The passkey-only sign-up's last step: create the passkey, and with it the account. */
111
+ createPasskey(): void;
112
+ /**
113
+ * "Email me a code instead" at the second factor (the instance's
114
+ * `account.emailSecondFactor`): mails a code to the account's verified email;
115
+ * it is then posted with {@link submit} like any 2FA code.
116
+ */
117
+ sendEmailSecondFactor(): Promise<{
118
+ sentTo?: string;
119
+ error?: string;
120
+ }>;
121
+ /**
122
+ * "Text me a code instead" at the second factor (where the app offers texted
123
+ * codes, `mfaChoices().sms`): texts a code to the account's enrolled phone;
124
+ * it is then posted with {@link submit}.
125
+ */
126
+ sendSmsSecondFactor(): Promise<{
127
+ sentTo?: string;
128
+ error?: string;
129
+ }>;
130
+ /** What the app lets the MFA steps offer (from {@link settings}). */
131
+ mfaChoices(): MfaChoices;
132
+ /**
133
+ * The MFA enrolment step (`needs_mfa_enrollment`): the authenticator app's
134
+ * setup (QR code, setup key) or a texted code. Started and reset with the flow.
135
+ */
136
+ enrollment: MfaEnrollmentController;
137
+ /** {@link enrollment}'s state as a store. */
138
+ enrollmentState: Readable<MfaEnrollmentState>;
139
+ /**
140
+ * "I've saved my codes": after an enrolment the flow holds its one-time
141
+ * recovery codes (`pendingRecoveryCodes($state)`, in memory only) and waits
142
+ * for this before exchanging the ticket and finishing the sign-in.
143
+ */
144
+ saveRecoveryCodes(): void;
145
+ /** Resend a sign-up's emailed code / link (the earlier one stops working). Resolves whether it was sent. */
146
+ resendSignUp(): Promise<boolean>;
96
147
  /**
97
148
  * Ask the server to email a sign-in code or link on the first-factor step
98
149
  * (`POST …/prepare_first_factor`). Resolves true once it was sent; the code is
@@ -105,6 +156,11 @@ export interface FlowController {
105
156
  * or when the server could not be reached.
106
157
  */
107
158
  settings: Readable<InstanceSettings | null>;
159
+ /**
160
+ * The method this browser last signed in with (`password`, `oauth_github`, …)
161
+ * for the "Last used" hint; null when none, or the instance turned it off.
162
+ */
163
+ lastMethod: Readable<string | null>;
108
164
  /** The sign-in captcha (identifier step / `needs_captcha` challenge). */
109
165
  captcha: CaptchaController;
110
166
  /** The sign-up captcha (the create-account step). */
package/dist/accessors.js CHANGED
@@ -1,5 +1,5 @@
1
- import { derived, get, writable } from 'svelte/store';
2
- import { FapiClient, advanceWithCaptcha, atlasCaptchaLabels, createCaptchaController, createSignUp, fetchInstanceSettings, flowNeedsCaptcha, signInCaptchaOptions, flowFromPending, pollAttempt, prepareFactor, shouldKeepPolling, } from '@atlasauth/js';
1
+ import { derived, get, readable, writable } from 'svelte/store';
2
+ import { FapiClient, advanceWithCaptcha, atlasCaptchaLabels, createCaptchaController, createPasskeySignUp, createSignUp, finishPasskeySignUp, fetchInstanceSettings, fetchLastSignInMethod, flowNeedsCaptcha, signInCaptchaOptions, flowFromPending, pollAttempt, prepareFactor, shouldKeepPolling, createOrganizationAuditLog, createMfaEnrollment, acknowledgeRecoveryCodes, pendingRecoveryCodes, mfaChoicesFrom, prepareSecondFactorCode, resendSignUpVerification, } from '@atlasauth/js';
3
3
  import { useAtlas } from './context';
4
4
  import { translate } from './i18n';
5
5
  import { evaluate } from './protect';
@@ -50,6 +50,32 @@ export function getOrganization() {
50
50
  };
51
51
  });
52
52
  }
53
+ export function getOrganizationAuditLog(options = {}) {
54
+ const atlas = useAtlas();
55
+ const organization = getOrganization();
56
+ const log = createOrganizationAuditLog(new FapiClient({
57
+ publishableKey: atlas.publishableKey,
58
+ baseUrl: atlas.frontendApi,
59
+ locale: atlas.locale,
60
+ sdk: ATLAS_SVELTE_SDK,
61
+ }), options);
62
+ const state = readable(log.getState(), (set) => {
63
+ const offState = log.subscribe(set);
64
+ let shown;
65
+ const offOrganization = organization.subscribe(($org) => {
66
+ const id = $org.organization?.id ?? null;
67
+ if (id === shown)
68
+ return;
69
+ shown = id;
70
+ void log.load(id);
71
+ });
72
+ return () => {
73
+ offOrganization();
74
+ offState();
75
+ };
76
+ });
77
+ return { subscribe: state.subscribe, loadMore: log.loadMore, reload: log.reload };
78
+ }
53
79
  /* ------------------------------------------------------------------ actions */
54
80
  /** Refresh-if-stale, then hand over the JWT. The `useAuth().getToken` shortcut. */
55
81
  export function getToken() {
@@ -75,6 +101,7 @@ function createFlow() {
75
101
  // factor), so the user resumes at the demanded step instead of starting over.
76
102
  const state = writable(flowFromPending(atlas.pendingAttempt));
77
103
  const settings = writable(null);
104
+ const lastMethod = writable(null);
78
105
  const t = (key) => translate(atlas.catalog, key);
79
106
  // §5 the per-flow captcha, driven by the instance's settings so turning it on
80
107
  // in the dashboard needs no code change. Kept in step with the settings and
@@ -93,6 +120,9 @@ function createFlow() {
93
120
  void fetchInstanceSettings(client).then((loaded) => {
94
121
  if (loaded)
95
122
  settings.set(loaded);
123
+ if (loaded?.account.lastSignInMethod) {
124
+ void fetchLastSignInMethod(client).then((m) => lastMethod.set(m));
125
+ }
96
126
  });
97
127
  }
98
128
  let pollTimer = null;
@@ -115,11 +145,12 @@ function createFlow() {
115
145
  return;
116
146
  // The ticket is exchanged by a normal request, so no session token has
117
147
  // ever appeared in a URL.
118
- await client.post('/v1/client/tickets/exchange', {
148
+ const res = await client.post('/v1/client/tickets/exchange', {
119
149
  attempt_id: next.attempt.id,
120
150
  ticket,
121
151
  });
122
- await atlas.reload();
152
+ const kept = res.ok && (await atlas.reload());
153
+ state.update((current) => ({ ...current, sessionKept: kept }));
123
154
  })();
124
155
  }, 2_000);
125
156
  }
@@ -127,18 +158,29 @@ function createFlow() {
127
158
  // for cookies over a same-origin request — FAPI is cross-origin to the app
128
159
  // and cannot set the cookie on the completion response itself — THEN
129
160
  // re-boot so the rest of the app sees a signed-in user.
161
+ // Stays busy until this browser actually holds the session: the completion
162
+ // alone says nothing about whether the cookie was kept.
130
163
  async function finish(next) {
131
- state.set(next);
164
+ // An enrolment's recovery codes are shown first; `saveRecoveryCodes` finishes.
165
+ if (pendingRecoveryCodes(next)) {
166
+ state.set({ ...next, busy: false });
167
+ return;
168
+ }
132
169
  if (next.attempt?.status === 'complete') {
170
+ state.set({ ...next, busy: true });
171
+ let exchanged = true;
133
172
  if (next.ticket) {
134
- await client.post('/v1/client/tickets/exchange', {
173
+ const res = await client.post('/v1/client/tickets/exchange', {
135
174
  attempt_id: next.attempt.id,
136
175
  ticket: next.ticket,
137
176
  });
177
+ exchanged = res.ok;
138
178
  }
139
- await atlas.reload();
179
+ const kept = exchanged && (await atlas.reload());
180
+ state.set({ ...next, busy: false, sessionKept: kept });
140
181
  return;
141
182
  }
183
+ state.set(next);
142
184
  maybeStartPolling();
143
185
  }
144
186
  function submit(values) {
@@ -155,6 +197,56 @@ function createFlow() {
155
197
  await finish(await createSignUp(client, values, { consent, captcha: signUpCaptcha, t }));
156
198
  })();
157
199
  }
200
+ function createWithPasskey(values, consent = true) {
201
+ void (async () => {
202
+ state.update((current) => ({ ...current, busy: true, errors: [] }));
203
+ await finish(await createPasskeySignUp(client, values, { consent, captcha: signUpCaptcha, t }));
204
+ })();
205
+ }
206
+ async function sendSecondFactorCode(strategy) {
207
+ const id = get(state).attempt?.id;
208
+ if (!id)
209
+ return { error: t('error.generic') };
210
+ try {
211
+ return await prepareSecondFactorCode(client, id, strategy);
212
+ }
213
+ catch (error) {
214
+ return { error: error instanceof Error ? error.message : t('error.generic') };
215
+ }
216
+ }
217
+ const sendEmailSecondFactor = () => sendSecondFactorCode('email_code');
218
+ const sendSmsSecondFactor = () => sendSecondFactorCode('sms');
219
+ // The MFA enrolment step: started while the flow is at needs_mfa_enrollment.
220
+ const mfaChoices = () => mfaChoicesFrom(get(settings)?.account);
221
+ const enrollment = createMfaEnrollment(client, mfaChoices);
222
+ const enrollmentState = writable(enrollment.state);
223
+ const unsubscribeEnrollment = enrollment.subscribe((next) => enrollmentState.set(next));
224
+ let enrollingFor = null;
225
+ const unsubscribeEnrollmentStep = state.subscribe((flow) => {
226
+ const id = flow.attempt?.status === 'needs_mfa_enrollment' ? (flow.attempt.id ?? null) : null;
227
+ if (id === enrollingFor)
228
+ return;
229
+ enrollingFor = id;
230
+ if (id)
231
+ enrollment.start(id);
232
+ else
233
+ enrollment.reset();
234
+ });
235
+ function saveRecoveryCodes() {
236
+ void finish(acknowledgeRecoveryCodes(get(state)));
237
+ }
238
+ async function resendSignUp() {
239
+ state.update((current) => ({ ...current, busy: true, errors: [] }));
240
+ const next = await resendSignUpVerification(client, get(state));
241
+ state.set(next);
242
+ return next.errors.length === 0;
243
+ }
244
+ function createPasskey() {
245
+ void (async () => {
246
+ state.update((current) => ({ ...current, busy: true, errors: [] }));
247
+ await finish(await finishPasskeySignUp(client, get(state), { t }));
248
+ })();
249
+ }
158
250
  async function prepare(strategy) {
159
251
  state.update((current) => ({ ...current, busy: true, errors: [] }));
160
252
  const next = await prepareFactor(client, get(state), strategy);
@@ -165,6 +257,8 @@ function createFlow() {
165
257
  function destroy() {
166
258
  stopPolling();
167
259
  unsubscribeCaptcha();
260
+ unsubscribeEnrollment();
261
+ unsubscribeEnrollmentStep();
168
262
  captcha.destroy();
169
263
  signUpCaptcha.destroy();
170
264
  }
@@ -172,8 +266,18 @@ function createFlow() {
172
266
  state: { subscribe: state.subscribe },
173
267
  submit,
174
268
  create,
269
+ createWithPasskey,
270
+ createPasskey,
271
+ sendEmailSecondFactor,
272
+ sendSmsSecondFactor,
273
+ mfaChoices,
274
+ enrollment,
275
+ enrollmentState: { subscribe: enrollmentState.subscribe },
276
+ saveRecoveryCodes,
277
+ resendSignUp,
175
278
  prepare,
176
279
  settings: { subscribe: settings.subscribe },
280
+ lastMethod: { subscribe: lastMethod.subscribe },
177
281
  captcha,
178
282
  signUpCaptcha,
179
283
  destroy,
@@ -31,7 +31,7 @@ export interface DesignTokens {
31
31
  spacingUnit?: string;
32
32
  }
33
33
  /** Every element a customer may target. Named for what it IS, not where it sits. */
34
- export type ElementKey = 'root' | 'card' | 'headerTitle' | 'headerSubtitle' | 'formField' | 'formFieldLabel' | 'formFieldInput' | 'formFieldError' | 'formButtonPrimary' | 'formButtonSecondary' | 'socialButton' | 'dividerText' | 'footerAction' | 'avatar' | 'menu' | 'menuItem' | 'badge';
34
+ export type ElementKey = 'root' | 'card' | 'headerTitle' | 'headerSubtitle' | 'formField' | 'formFieldLabel' | 'formFieldInput' | 'formFieldError' | 'formButtonPrimary' | 'formButtonSecondary' | 'socialButton' | 'dividerText' | 'footerAction' | 'avatar' | 'menu' | 'menuItem' | 'list' | 'listItem' | 'badge';
35
35
  export interface Appearance extends DesignAppearance {
36
36
  /** A prebuilt token theme to start from (pre-design-system themes). */
37
37
  baseTheme?: keyof typeof THEMES;
@@ -1,4 +1,7 @@
1
1
  <script>import {
2
+ awaitingSignUpLink,
3
+ enrollmentSubmitKey,
4
+ groupSetupKey,
2
5
  localizedErrorMessage,
3
6
  nextStep,
4
7
  offeredFirstFactors,
@@ -11,11 +14,18 @@ import CaptchaSlot from "./CaptchaSlot.svelte";
11
14
  export let attempt = null;
12
15
  export let busy = false;
13
16
  export let errors = [];
17
+ export let sessionKept = void 0;
14
18
  export let titleKey;
15
19
  export let onSubmit;
16
20
  export let captcha = null;
17
21
  export let emailDelivery = null;
18
22
  export let onPrepare = null;
23
+ export let onEmailSecondFactor = null;
24
+ export let onSmsSecondFactor = null;
25
+ export let enrollment = null;
26
+ export let enrollmentState = null;
27
+ export let emailVerification = "code";
28
+ export let onResend = null;
19
29
  const atlas = useAtlas();
20
30
  const cls = (key, base) => classFor(key, atlas.appearance, base);
21
31
  const t = (key, vars) => translate(atlas.catalog, key, vars);
@@ -41,6 +51,31 @@ $: if (step.kind === "collect_first_factor" && onPrepare && plan.autoSend && att
41
51
  }
42
52
  $: sending = step.kind === "collect_first_factor" && sent === null && !!onPrepare && !plan.password && emailOptions.length > 0;
43
53
  $: identifier = attempt?.identifier ?? "";
54
+ let emailed = null;
55
+ $: emailedNow = emailed && emailed.attemptId === (attempt?.id ?? null) ? emailed : null;
56
+ async function emailSecondFactor() {
57
+ if (!onEmailSecondFactor) return;
58
+ const attemptId = attempt?.id ?? null;
59
+ emailed = { attemptId, busy: true };
60
+ const r = await onEmailSecondFactor();
61
+ emailed = r.error ? { attemptId, error: r.error } : { attemptId, sentTo: r.sentTo ?? "" };
62
+ }
63
+ let texted = null;
64
+ $: textedNow = texted && texted.attemptId === (attempt?.id ?? null) ? texted : null;
65
+ async function smsSecondFactor() {
66
+ if (!onSmsSecondFactor) return;
67
+ const attemptId = attempt?.id ?? null;
68
+ texted = { attemptId, busy: true };
69
+ const r = await onSmsSecondFactor();
70
+ texted = r.error ? { attemptId, error: r.error } : { attemptId, sentTo: r.sentTo ?? "" };
71
+ }
72
+ $: enrolling = step.kind === "enroll_second_factor" && !!enrollment && !!enrollmentState;
73
+ $: enrolSubmitKey = enrolling && enrollmentState ? enrollmentSubmitKey(enrollmentState) : null;
74
+ function chooseMethod(m) {
75
+ values = {};
76
+ void enrollment?.choose(m);
77
+ }
78
+ $: linkOnly = awaitingSignUpLink(attempt, emailVerification);
44
79
  function usePassword() {
45
80
  prepared = null;
46
81
  values = {};
@@ -58,12 +93,13 @@ $: fields = (() => {
58
93
  case "collect_second_factor":
59
94
  return [{ name: "code", labelKey: "mfa.codeLabel", type: "text" }];
60
95
  case "enroll_second_factor":
96
+ if (enrollment && enrollmentState) return [];
61
97
  return [
62
98
  { name: "code", labelKey: "mfa.enrollFirstCodeLabel", type: "text" },
63
99
  { name: "secondCode", labelKey: "mfa.enrollSecondCodeLabel", type: "text" }
64
100
  ];
65
101
  case "collect_email_code":
66
- return [{ name: "code", labelKey: "signIn.emailCodeLabel", type: "text" }];
102
+ return linkOnly ? [] : [{ name: "code", labelKey: "signIn.emailCodeLabel", type: "text" }];
67
103
  case "collect_new_password":
68
104
  return [{ name: "password", labelKey: "reset.newPasswordLabel", type: "password" }];
69
105
  default:
@@ -75,6 +111,8 @@ $: message = (() => {
75
111
  case "await_oauth":
76
112
  return t("signIn.magicLinkSent");
77
113
  case "done":
114
+ if (sessionKept === true) return t("signIn.signedIn");
115
+ if (sessionKept === false) return t("signIn.sessionNotKept");
78
116
  return t("signIn.checkOtherTab");
79
117
  case "restart":
80
118
  return t("error.generic");
@@ -91,7 +129,7 @@ $: message = (() => {
91
129
  return t("error.generic");
92
130
  }
93
131
  })();
94
- $: showSubmit = step.kind !== "done" && step.kind !== "await_oauth" && message === null && // Only emailed factors on offer: the option buttons are the way forward.
132
+ $: showSubmit = (!enrolling || enrolSubmitKey !== null) && step.kind !== "done" && step.kind !== "await_oauth" && message === null && !linkOnly && // Only emailed factors on offer: the option buttons are the way forward.
95
133
  (step.kind !== "collect_first_factor" || fields.length > 0);
96
134
  $: showEmailOptions = step.kind === "collect_first_factor" && message === null && sent === null && emailOptions.length > 0 && !(sending && busy);
97
135
  $: showCaptcha = !!captcha && (step.kind === "collect_identifier" || step.kind === "collect_captcha");
@@ -101,6 +139,12 @@ function handleSubmit(event) {
101
139
  onSubmit(sent === "email_code" ? { code: values.code ?? "" } : { password: values.password ?? "" });
102
140
  return;
103
141
  }
142
+ if (enrolling && enrollment) {
143
+ void enrollment.submit(values).then((next) => {
144
+ if (next) onSubmit(next);
145
+ });
146
+ return;
147
+ }
104
148
  onSubmit(values);
105
149
  }
106
150
  </script>
@@ -114,7 +158,11 @@ function handleSubmit(event) {
114
158
 
115
159
  <form on:submit={handleSubmit}>
116
160
  {#if message !== null}
117
- <p>{message}</p>
161
+ {#if step.kind === 'done' && sessionKept === false}
162
+ <p class={cls('formFieldError', 'atlas-error')} role="alert">{message}</p>
163
+ {:else}
164
+ <p>{message}</p>
165
+ {/if}
118
166
  {:else}
119
167
  {#if step.kind === 'collect_captcha'}
120
168
  <!-- §5 the server withheld the attempt until a captcha clears. Solving
@@ -130,6 +178,102 @@ function handleSubmit(event) {
130
178
  {#if sending}
131
179
  <p class="atlas-text" aria-live="polite">{t('signIn.sending')}</p>
132
180
  {/if}
181
+ {#if linkOnly}
182
+ <p class="atlas-text" data-atlas-link-sent aria-live="polite">{t('signUp.linkSentTo', { email: identifier })}</p>
183
+ {#if onResend}
184
+ <button
185
+ class={cls('formButtonSecondary', 'atlas-button-secondary')}
186
+ type="button"
187
+ data-atlas-resend
188
+ disabled={busy}
189
+ on:click={() => void onResend?.()}
190
+ >
191
+ {t('signIn.resendLink')}
192
+ </button>
193
+ {/if}
194
+ {/if}
195
+ {#if enrolling && enrollmentState}
196
+ {#if enrollmentState.method === null}
197
+ <p class="atlas-text">{t('mfa.enrollChoose')}</p>
198
+ {#each enrollmentState.options as m (m)}
199
+ <button
200
+ class={cls('formButtonSecondary', 'atlas-button-secondary')}
201
+ type="button"
202
+ data-atlas-mfa-method={m}
203
+ disabled={busy || enrollmentState.busy}
204
+ on:click={() => chooseMethod(m)}
205
+ >
206
+ {t(m === 'totp' ? 'mfa.enrollTotpOption' : 'mfa.enrollSmsOption')}
207
+ </button>
208
+ {/each}
209
+ {:else if enrollmentState.method === 'totp'}
210
+ {#if !enrollmentState.enrollment}
211
+ <p class="atlas-text" aria-live="polite">{enrollmentState.busy ? t('mfa.enrollPreparing') : ''}</p>
212
+ {:else}
213
+ <p class="atlas-text">{t('mfa.enrollScan')}</p>
214
+ {#if enrollmentState.enrollment.qrCode}
215
+ <img class="atlas-mfa-qr" data-atlas="mfa-qr" src={enrollmentState.enrollment.qrCode} alt={t('mfa.enrollQrAlt')} width="180" height="180" />
216
+ {/if}
217
+ {#if enrollmentState.enrollment.secret}
218
+ <p class="atlas-text" data-atlas="mfa-setup-key">
219
+ {t('mfa.enrollSetupKey')}: <code>{groupSetupKey(enrollmentState.enrollment.secret)}</code>
220
+ </p>
221
+ {/if}
222
+ <label class="atlas-field">
223
+ <span>{t('mfa.enrollFirstCodeLabel')}</span>
224
+ <input class={cls('formFieldInput', 'atlas-input')} type="text" name="code" autocomplete="one-time-code" inputmode="numeric" value={values.code ?? ''} on:input={(e) => (values.code = e.currentTarget.value)} />
225
+ </label>
226
+ {#if enrollmentState.totpCodes >= 2}
227
+ <label class="atlas-field">
228
+ <span>{t('mfa.enrollSecondCodeLabel')}</span>
229
+ <input class={cls('formFieldInput', 'atlas-input')} type="text" name="secondCode" autocomplete="one-time-code" inputmode="numeric" value={values.secondCode ?? ''} on:input={(e) => (values.secondCode = e.currentTarget.value)} />
230
+ </label>
231
+ {/if}
232
+ {#each errors.filter((e) => e.param === 'codes') as error (error.code)}
233
+ <span class={cls('formFieldError', 'atlas-error')}>{errorText(error)}</span>
234
+ {/each}
235
+ {/if}
236
+ {:else if !enrollmentState.enrollment}
237
+ <label class="atlas-field">
238
+ <span>{t('mfa.phoneLabel')}</span>
239
+ <input class={cls('formFieldInput', 'atlas-input')} type="tel" name="phone" autocomplete="tel" value={values.phone ?? ''} on:input={(e) => (values.phone = e.currentTarget.value)} />
240
+ </label>
241
+ <p class="atlas-text">{t('mfa.phoneHint')}</p>
242
+ {:else}
243
+ <p class="atlas-text" data-atlas="mfa-code-texted" aria-live="polite">
244
+ {t('mfa.codeTextedTo', { phone: enrollmentState.enrollment.phone ?? '' })}
245
+ </p>
246
+ <label class="atlas-field">
247
+ <span>{t('mfa.codeLabel')}</span>
248
+ <input class={cls('formFieldInput', 'atlas-input')} type="text" name="code" autocomplete="one-time-code" inputmode="numeric" value={values.code ?? ''} on:input={(e) => (values.code = e.currentTarget.value)} />
249
+ </label>
250
+ <button
251
+ class={cls('formButtonSecondary', 'atlas-button-secondary')}
252
+ type="button"
253
+ data-atlas-resend
254
+ disabled={busy || enrollmentState.busy}
255
+ on:click={() => void enrollment?.resend()}
256
+ >
257
+ {t('signIn.resendCode')}
258
+ </button>
259
+ {/if}
260
+ {#each enrollmentState.errors as error (`${error.code}-${error.param ?? ''}`)}
261
+ <p class={cls('formFieldError', 'atlas-error')} role="alert">{error.message}</p>
262
+ {/each}
263
+ {#if enrollmentState.method !== null && enrollmentState.options.length > 1}
264
+ {#each enrollmentState.options.filter((m) => m !== enrollmentState?.method) as other (other)}
265
+ <button
266
+ class={cls('formButtonSecondary', 'atlas-button-secondary')}
267
+ type="button"
268
+ data-atlas-mfa-method={other}
269
+ disabled={busy || enrollmentState.busy}
270
+ on:click={() => chooseMethod(other)}
271
+ >
272
+ {t(other === 'totp' ? 'mfa.enrollTotpOption' : 'mfa.enrollSmsOption')}
273
+ </button>
274
+ {/each}
275
+ {/if}
276
+ {/if}
133
277
  {#each fields as f (f.name)}
134
278
  <label class="atlas-field">
135
279
  <span>{t(f.labelKey)}</span>
@@ -167,6 +311,46 @@ function handleSubmit(event) {
167
311
  </button>
168
312
  {/if}
169
313
  {/if}
314
+ {#if step.kind === 'collect_second_factor' && onSmsSecondFactor}
315
+ {#if textedNow && textedNow.sentTo !== undefined}
316
+ <p class="atlas-text" data-atlas="sms-second-factor-sent" aria-live="polite">
317
+ {t('mfa.codeTextedTo', { phone: textedNow.sentTo })}
318
+ </p>
319
+ {:else}
320
+ <button
321
+ class={cls('formButtonSecondary', 'atlas-button-secondary')}
322
+ type="button"
323
+ data-atlas="sms-second-factor"
324
+ disabled={busy || !!textedNow?.busy}
325
+ on:click={() => void smsSecondFactor()}
326
+ >
327
+ {textedNow?.busy ? t('signIn.sending') : t('mfa.smsCodeOption')}
328
+ </button>
329
+ {#if textedNow?.error}
330
+ <p class={cls('formFieldError', 'atlas-error')} role="alert">{textedNow.error}</p>
331
+ {/if}
332
+ {/if}
333
+ {/if}
334
+ {#if step.kind === 'collect_second_factor' && onEmailSecondFactor}
335
+ {#if emailedNow && emailedNow.sentTo !== undefined}
336
+ <p class="atlas-text" data-atlas="email-second-factor-sent" aria-live="polite">
337
+ {t('signIn.codeSentTo', { email: emailedNow.sentTo })}
338
+ </p>
339
+ {:else}
340
+ <button
341
+ class={cls('formButtonSecondary', 'atlas-button-secondary')}
342
+ type="button"
343
+ data-atlas="email-second-factor"
344
+ disabled={busy || !!emailedNow?.busy}
345
+ on:click={() => void emailSecondFactor()}
346
+ >
347
+ {emailedNow?.busy ? t('signIn.sending') : t('signIn.emailCodeOption')}
348
+ </button>
349
+ {#if emailedNow?.error}
350
+ <p class={cls('formFieldError', 'atlas-error')} role="alert">{emailedNow.error}</p>
351
+ {/if}
352
+ {/if}
353
+ {/if}
170
354
  {#if showEmailOptions}
171
355
  {#each emailOptions as strategy (strategy)}
172
356
  <button
@@ -187,8 +371,8 @@ function handleSubmit(event) {
187
371
  {/if}
188
372
 
189
373
  {#if showSubmit}
190
- <button class={cls('formButtonPrimary', 'atlas-button')} disabled={busy} type="submit">
191
- {t('signIn.submit')}
374
+ <button class={cls('formButtonPrimary', 'atlas-button')} disabled={busy || (enrolling && !!enrollmentState?.busy)} type="submit">
375
+ {t(enrolSubmitKey ?? 'signIn.submit')}
192
376
  </button>
193
377
  {/if}
194
378
  </form>
@@ -1,5 +1,5 @@
1
1
  import { SvelteComponent } from "svelte";
2
- import { type AttemptView, type CaptchaController, type EmailDelivery, type FieldError } from '@atlasauth/js';
2
+ import { type AttemptView, type CaptchaController, type EmailDelivery, type FieldError, type MfaEnrollmentController, type MfaEnrollmentState } from '@atlasauth/js';
3
3
  declare const __propDef: {
4
4
  props: {
5
5
  /**
@@ -12,6 +12,7 @@ declare const __propDef: {
12
12
  */ attempt?: AttemptView | null;
13
13
  busy?: boolean;
14
14
  errors?: FieldError[];
15
+ /** `FlowState.sessionKept`: whether this browser kept the session the sign-in created. */ sessionKept?: boolean | undefined;
15
16
  titleKey: string;
16
17
  onSubmit: (values: Record<string, string>) => void;
17
18
  /**
@@ -27,6 +28,32 @@ declare const __propDef: {
27
28
  * Ask the server to email a sign-in code or link (`FlowController.prepare`).
28
29
  * Without it the first-factor step offers the password only.
29
30
  */ onPrepare?: ((strategy: "email_code" | "email_link") => Promise<boolean>) | null;
31
+ /**
32
+ * "Email me a code instead" at the second factor (`account.emailSecondFactor`).
33
+ * Null: the step asks for the code only.
34
+ */ onEmailSecondFactor?: (() => Promise<{
35
+ sentTo?: string;
36
+ error?: string;
37
+ }>) | null;
38
+ /**
39
+ * "Text me a code instead" at the second factor (where the app offers texted
40
+ * codes). Null: the step offers no texted code.
41
+ */ onSmsSecondFactor?: (() => Promise<{
42
+ sentTo?: string;
43
+ error?: string;
44
+ }>) | null;
45
+ /**
46
+ * The MFA enrolment step (`FlowController.enrollment` and its state): the
47
+ * authenticator app's QR code and setup key, or a texted code. Null: the step
48
+ * asks for two authenticator codes only.
49
+ */ enrollment?: MfaEnrollmentController | null;
50
+ enrollmentState?: MfaEnrollmentState | null;
51
+ /**
52
+ * The instance's `account.emailVerification`. On `link` a sign-up's
53
+ * verification step has nothing to type: it says to open the emailed link
54
+ * while the flow polls, and offers a resend (`onResend`).
55
+ */ emailVerification?: string;
56
+ onResend?: (() => Promise<boolean>) | null;
30
57
  };
31
58
  events: {
32
59
  [evt: string]: CustomEvent<any>;