@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 +16 -0
- package/dist/accessors.d.ts +57 -1
- package/dist/accessors.js +99 -2
- package/dist/appearance.d.ts +1 -1
- package/dist/components/AttemptFlow.svelte +181 -4
- package/dist/components/AttemptFlow.svelte.d.ts +27 -1
- package/dist/components/ImpersonationBanner.svelte +38 -0
- package/dist/components/ImpersonationBanner.svelte.d.ts +19 -0
- package/dist/components/OrganizationAuditLog.svelte +45 -0
- package/dist/components/OrganizationAuditLog.svelte.d.ts +31 -0
- package/dist/components/OrganizationProfile.svelte +308 -12
- package/dist/components/OrganizationProfile.svelte.d.ts +2 -0
- package/dist/components/PasskeySignUpStep.svelte +23 -0
- package/dist/components/PasskeySignUpStep.svelte.d.ts +24 -0
- package/dist/components/RecoveryCodes.svelte +55 -0
- package/dist/components/RecoveryCodes.svelte.d.ts +25 -0
- package/dist/components/SignIn.svelte +20 -1
- package/dist/components/SignUp.svelte +17 -2
- package/dist/components/SignUpForm.svelte +41 -16
- package/dist/components/SignUpForm.svelte.d.ts +4 -0
- package/dist/context.d.ts +8 -1
- package/dist/context.js +17 -0
- package/dist/i18n.js +55 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/types.d.ts +6 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -4
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
|
package/dist/accessors.d.ts
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,
|
package/dist/appearance.d.ts
CHANGED
|
@@ -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}
|