@atlasauth/svelte 0.8.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,21 @@
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
+
3
19
  ## 0.8.0
4
20
 
5
21
  ### Changed
@@ -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;
@@ -131,6 +161,11 @@ function createFlow() {
131
161
  // Stays busy until this browser actually holds the session: the completion
132
162
  // alone says nothing about whether the cookie was kept.
133
163
  async function finish(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
+ }
134
169
  if (next.attempt?.status === 'complete') {
135
170
  state.set({ ...next, busy: true });
136
171
  let exchanged = true;
@@ -162,6 +197,56 @@ function createFlow() {
162
197
  await finish(await createSignUp(client, values, { consent, captcha: signUpCaptcha, t }));
163
198
  })();
164
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
+ }
165
250
  async function prepare(strategy) {
166
251
  state.update((current) => ({ ...current, busy: true, errors: [] }));
167
252
  const next = await prepareFactor(client, get(state), strategy);
@@ -172,6 +257,8 @@ function createFlow() {
172
257
  function destroy() {
173
258
  stopPolling();
174
259
  unsubscribeCaptcha();
260
+ unsubscribeEnrollment();
261
+ unsubscribeEnrollmentStep();
175
262
  captcha.destroy();
176
263
  signUpCaptcha.destroy();
177
264
  }
@@ -179,8 +266,18 @@ function createFlow() {
179
266
  state: { subscribe: state.subscribe },
180
267
  submit,
181
268
  create,
269
+ createWithPasskey,
270
+ createPasskey,
271
+ sendEmailSecondFactor,
272
+ sendSmsSecondFactor,
273
+ mfaChoices,
274
+ enrollment,
275
+ enrollmentState: { subscribe: enrollmentState.subscribe },
276
+ saveRecoveryCodes,
277
+ resendSignUp,
182
278
  prepare,
183
279
  settings: { subscribe: settings.subscribe },
280
+ lastMethod: { subscribe: lastMethod.subscribe },
184
281
  captcha,
185
282
  signUpCaptcha,
186
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,
@@ -17,6 +20,12 @@ export let onSubmit;
17
20
  export let captcha = null;
18
21
  export let emailDelivery = null;
19
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;
20
29
  const atlas = useAtlas();
21
30
  const cls = (key, base) => classFor(key, atlas.appearance, base);
22
31
  const t = (key, vars) => translate(atlas.catalog, key, vars);
@@ -42,6 +51,31 @@ $: if (step.kind === "collect_first_factor" && onPrepare && plan.autoSend && att
42
51
  }
43
52
  $: sending = step.kind === "collect_first_factor" && sent === null && !!onPrepare && !plan.password && emailOptions.length > 0;
44
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);
45
79
  function usePassword() {
46
80
  prepared = null;
47
81
  values = {};
@@ -59,12 +93,13 @@ $: fields = (() => {
59
93
  case "collect_second_factor":
60
94
  return [{ name: "code", labelKey: "mfa.codeLabel", type: "text" }];
61
95
  case "enroll_second_factor":
96
+ if (enrollment && enrollmentState) return [];
62
97
  return [
63
98
  { name: "code", labelKey: "mfa.enrollFirstCodeLabel", type: "text" },
64
99
  { name: "secondCode", labelKey: "mfa.enrollSecondCodeLabel", type: "text" }
65
100
  ];
66
101
  case "collect_email_code":
67
- return [{ name: "code", labelKey: "signIn.emailCodeLabel", type: "text" }];
102
+ return linkOnly ? [] : [{ name: "code", labelKey: "signIn.emailCodeLabel", type: "text" }];
68
103
  case "collect_new_password":
69
104
  return [{ name: "password", labelKey: "reset.newPasswordLabel", type: "password" }];
70
105
  default:
@@ -94,7 +129,7 @@ $: message = (() => {
94
129
  return t("error.generic");
95
130
  }
96
131
  })();
97
- $: 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.
98
133
  (step.kind !== "collect_first_factor" || fields.length > 0);
99
134
  $: showEmailOptions = step.kind === "collect_first_factor" && message === null && sent === null && emailOptions.length > 0 && !(sending && busy);
100
135
  $: showCaptcha = !!captcha && (step.kind === "collect_identifier" || step.kind === "collect_captcha");
@@ -104,6 +139,12 @@ function handleSubmit(event) {
104
139
  onSubmit(sent === "email_code" ? { code: values.code ?? "" } : { password: values.password ?? "" });
105
140
  return;
106
141
  }
142
+ if (enrolling && enrollment) {
143
+ void enrollment.submit(values).then((next) => {
144
+ if (next) onSubmit(next);
145
+ });
146
+ return;
147
+ }
107
148
  onSubmit(values);
108
149
  }
109
150
  </script>
@@ -137,6 +178,102 @@ function handleSubmit(event) {
137
178
  {#if sending}
138
179
  <p class="atlas-text" aria-live="polite">{t('signIn.sending')}</p>
139
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}
140
277
  {#each fields as f (f.name)}
141
278
  <label class="atlas-field">
142
279
  <span>{t(f.labelKey)}</span>
@@ -174,6 +311,46 @@ function handleSubmit(event) {
174
311
  </button>
175
312
  {/if}
176
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}
177
354
  {#if showEmailOptions}
178
355
  {#each emailOptions as strategy (strategy)}
179
356
  <button
@@ -194,8 +371,8 @@ function handleSubmit(event) {
194
371
  {/if}
195
372
 
196
373
  {#if showSubmit}
197
- <button class={cls('formButtonPrimary', 'atlas-button')} disabled={busy} type="submit">
198
- {t('signIn.submit')}
374
+ <button class={cls('formButtonPrimary', 'atlas-button')} disabled={busy || (enrolling && !!enrollmentState?.busy)} type="submit">
375
+ {t(enrolSubmitKey ?? 'signIn.submit')}
199
376
  </button>
200
377
  {/if}
201
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
  /**
@@ -28,6 +28,32 @@ declare const __propDef: {
28
28
  * Ask the server to email a sign-in code or link (`FlowController.prepare`).
29
29
  * Without it the first-factor step offers the password only.
30
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;
31
57
  };
32
58
  events: {
33
59
  [evt: string]: CustomEvent<any>;
@@ -0,0 +1,38 @@
1
+ <script>import { createEventDispatcher } from "svelte";
2
+ import { derived } from "svelte/store";
3
+ import { actorFromClaims } from "@atlasauth/js";
4
+ import { useAtlas } from "../context";
5
+ import { classFor } from "../appearance";
6
+ import { translate } from "../i18n";
7
+ const atlas = useAtlas();
8
+ const dispatch = createEventDispatcher();
9
+ const t = (key, vars) => translate(atlas.catalog, key, vars);
10
+ const state = derived([atlas.claims, atlas.session, atlas.user], ([$claims, $session, $user]) => {
11
+ const actor = actorFromClaims($claims) ?? $session?.actor ?? null;
12
+ const name = [$user?.first_name, $user?.last_name].filter(Boolean).join(" ") || $user?.username || $user?.email_addresses?.find((e) => e.primary)?.email_address || $user?.id || "";
13
+ return { impersonating: actor !== null, canReturn: actor !== null && Boolean($session?.actor_session_id), name };
14
+ });
15
+ let busy = false;
16
+ async function end() {
17
+ busy = true;
18
+ try {
19
+ dispatch("ended", await atlas.endImpersonation());
20
+ } finally {
21
+ busy = false;
22
+ }
23
+ }
24
+ </script>
25
+
26
+ {#if $state.impersonating}
27
+ <div class={classFor('card', atlas.appearance, 'atlas-alert atlas-impersonation-banner')} role="status">
28
+ <span class={classFor('headerSubtitle', atlas.appearance, 'atlas-text')}>{t('impersonation.banner', { name: $state.name })}</span>
29
+ <button
30
+ type="button"
31
+ class={classFor('formButtonPrimary', atlas.appearance, 'atlas-button')}
32
+ disabled={busy}
33
+ on:click={() => void end()}
34
+ >
35
+ {$state.canReturn ? t('impersonation.returnToAdmin') : t('impersonation.end')}
36
+ </button>
37
+ </div>
38
+ {/if}
@@ -0,0 +1,19 @@
1
+ import { SvelteComponent } from "svelte";
2
+ import { type EndImpersonationResult } from '@atlasauth/js';
3
+ declare const __propDef: {
4
+ props: Record<string, never>;
5
+ events: {
6
+ ended: CustomEvent<EndImpersonationResult>;
7
+ } & {
8
+ [evt: string]: CustomEvent<any>;
9
+ };
10
+ slots: {};
11
+ exports?: {} | undefined;
12
+ bindings?: string | undefined;
13
+ };
14
+ export type ImpersonationBannerProps = typeof __propDef.props;
15
+ export type ImpersonationBannerEvents = typeof __propDef.events;
16
+ export type ImpersonationBannerSlots = typeof __propDef.slots;
17
+ export default class ImpersonationBanner extends SvelteComponent<ImpersonationBannerProps, ImpersonationBannerEvents, ImpersonationBannerSlots> {
18
+ }
19
+ export {};
@@ -0,0 +1,45 @@
1
+ <script>import { describeAuditAction } from "@atlasauth/js";
2
+ import { useAtlas } from "../context";
3
+ import { getOrganization, getOrganizationAuditLog } from "../accessors";
4
+ import { classFor } from "../appearance";
5
+ export let pageSize = void 0;
6
+ export let action = void 0;
7
+ export let labels = {};
8
+ export let formatDate = (ms) => new Date(ms).toLocaleString();
9
+ const atlas = useAtlas();
10
+ const org = getOrganization();
11
+ const log = getOrganizationAuditLog({ pageSize, action });
12
+ const cls = (key, base) => classFor(key, atlas.appearance, base);
13
+ </script>
14
+
15
+ {#if $org.organization && !$log.forbidden}
16
+ <div class={cls('card', 'atlas-card')} data-atlas="organization-audit-log">
17
+ <h2 class={cls('headerTitle', 'atlas-title')}>{labels.title ?? 'Audit log'}</h2>
18
+ {#if $log.entries.length === 0 && !$log.loading}
19
+ <p class={cls('headerSubtitle', 'atlas-subtitle')}>{labels.empty ?? 'No activity recorded yet.'}</p>
20
+ {:else}
21
+ <ul class={cls('list', 'atlas-audit-list')}>
22
+ {#each $log.entries as entry (entry.id)}
23
+ <li class={cls('listItem', 'atlas-audit-item')} data-action={entry.action}>
24
+ <strong>{describeAuditAction(entry.action)}</strong>
25
+ <span class="atlas-audit-meta">
26
+ · {formatDate(entry.created_at)}{entry.actor.id
27
+ ? ` · ${labels.actor ?? 'by'} ${entry.actor.type} ${entry.actor.id}`
28
+ : ''}
29
+ </span>
30
+ </li>
31
+ {/each}
32
+ </ul>
33
+ {/if}
34
+ {#if $log.hasMore}
35
+ <button
36
+ type="button"
37
+ class={cls('formButtonSecondary', 'atlas-button-secondary')}
38
+ disabled={$log.loading}
39
+ on:click={() => void log.loadMore()}
40
+ >
41
+ {labels.loadMore ?? 'Load more'}
42
+ </button>
43
+ {/if}
44
+ </div>
45
+ {/if}