@atlasauth/svelte 0.8.0 → 0.10.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 +22 -0
- package/dist/accessors.d.ts +66 -1
- package/dist/accessors.js +110 -3
- package/dist/appearance.d.ts +1 -1
- package/dist/components/AttemptFlow.svelte +188 -4
- package/dist/components/AttemptFlow.svelte.d.ts +28 -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/PolicyAcceptStep.svelte +52 -0
- package/dist/components/PolicyAcceptStep.svelte.d.ts +30 -0
- package/dist/components/PolicyClickwrap.svelte +23 -0
- package/dist/components/PolicyClickwrap.svelte.d.ts +19 -0
- package/dist/components/PolicyConsent.svelte +23 -0
- package/dist/components/PolicyConsent.svelte.d.ts +24 -0
- package/dist/components/PolicyGate.svelte +83 -0
- package/dist/components/PolicyGate.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 +21 -1
- package/dist/components/SignUp.svelte +22 -3
- package/dist/components/SignUpForm.svelte +84 -19
- package/dist/components/SignUpForm.svelte.d.ts +6 -1
- package/dist/context.d.ts +9 -1
- package/dist/context.js +18 -1
- package/dist/i18n.js +58 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.js +8 -1
- package/dist/policies.d.ts +6 -0
- package/dist/policies.js +17 -0
- 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,27 @@
|
|
|
1
1
|
# @atlasauth/svelte changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0 — 2026-10-24
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Policy acceptance** in `<SignUp>` and `<SignIn>` (consent block with in-page viewer, clickwrap line, unskippable scroll dialog, the `needs_policy_acceptance` step), plus `<PolicyGate>`, `<PolicyConsent>`, `<PolicyClickwrap>`, `<PolicyAcceptStep>`; `signUp()` exposes `policies`. New `policy.*` strings.
|
|
8
|
+
|
|
9
|
+
## 0.9.0 — 2026-10-23
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `<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`.
|
|
14
|
+
- **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.
|
|
15
|
+
- **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.
|
|
16
|
+
- **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.
|
|
17
|
+
- **"Text me a code instead"** at the second-factor step where the app offers texted codes, beside the existing "Email me a code instead".
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Built on `@atlasauth/js` 0.16.0.
|
|
24
|
+
|
|
3
25
|
## 0.8.0
|
|
4
26
|
|
|
5
27
|
### 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, PolicyAcceptanceController, 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,10 +156,24 @@ 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). */
|
|
111
167
|
signUpCaptcha: CaptchaController;
|
|
168
|
+
/**
|
|
169
|
+
* The instance's policies (terms, privacy, …) for the create-account form:
|
|
170
|
+
* `create` sends their acceptances and reloads them after a refusal.
|
|
171
|
+
*/
|
|
172
|
+
policies: PolicyAcceptanceController;
|
|
173
|
+
/** Load the sign-up's policies into {@link policies} (`<SignUp>` calls it on mount). */
|
|
174
|
+
loadPolicies(): Promise<void>;
|
|
175
|
+
/** The user declined a policy a sign-in was waiting on: cancels the attempt and says why. */
|
|
176
|
+
declinePolicy(title: string): void;
|
|
112
177
|
/** Stop the magic-link poll loop and remove the captcha widgets — call from `onDestroy`. */
|
|
113
178
|
destroy(): void;
|
|
114
179
|
}
|
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, PolicyAcceptanceController, loadPolicies as loadPolicyList, initialFlow as emptyFlow, } 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;
|
|
@@ -156,10 +191,67 @@ function createFlow() {
|
|
|
156
191
|
await finish(out.state);
|
|
157
192
|
})();
|
|
158
193
|
}
|
|
194
|
+
const policies = new PolicyAcceptanceController([]);
|
|
195
|
+
async function loadPolicies() {
|
|
196
|
+
policies.setPolicies(await loadPolicyList(client, { locale: atlas.locale ?? undefined }));
|
|
197
|
+
}
|
|
198
|
+
function declinePolicy(title) {
|
|
199
|
+
state.set({ ...emptyFlow, errors: [{ code: 'POLICY_DECLINED', message: translate(atlas.catalog, 'policy.declined', { policy: title }) }] });
|
|
200
|
+
}
|
|
159
201
|
function create(values, consent = true) {
|
|
160
202
|
void (async () => {
|
|
161
203
|
state.update((current) => ({ ...current, busy: true, errors: [] }));
|
|
162
|
-
await finish(await createSignUp(client, values, { consent, captcha: signUpCaptcha, t }));
|
|
204
|
+
await finish(await createSignUp(client, values, { consent, captcha: signUpCaptcha, t, policies }));
|
|
205
|
+
})();
|
|
206
|
+
}
|
|
207
|
+
function createWithPasskey(values, consent = true) {
|
|
208
|
+
void (async () => {
|
|
209
|
+
state.update((current) => ({ ...current, busy: true, errors: [] }));
|
|
210
|
+
await finish(await createPasskeySignUp(client, values, { consent, captcha: signUpCaptcha, t, policies }));
|
|
211
|
+
})();
|
|
212
|
+
}
|
|
213
|
+
async function sendSecondFactorCode(strategy) {
|
|
214
|
+
const id = get(state).attempt?.id;
|
|
215
|
+
if (!id)
|
|
216
|
+
return { error: t('error.generic') };
|
|
217
|
+
try {
|
|
218
|
+
return await prepareSecondFactorCode(client, id, strategy);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
return { error: error instanceof Error ? error.message : t('error.generic') };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const sendEmailSecondFactor = () => sendSecondFactorCode('email_code');
|
|
225
|
+
const sendSmsSecondFactor = () => sendSecondFactorCode('sms');
|
|
226
|
+
// The MFA enrolment step: started while the flow is at needs_mfa_enrollment.
|
|
227
|
+
const mfaChoices = () => mfaChoicesFrom(get(settings)?.account);
|
|
228
|
+
const enrollment = createMfaEnrollment(client, mfaChoices);
|
|
229
|
+
const enrollmentState = writable(enrollment.state);
|
|
230
|
+
const unsubscribeEnrollment = enrollment.subscribe((next) => enrollmentState.set(next));
|
|
231
|
+
let enrollingFor = null;
|
|
232
|
+
const unsubscribeEnrollmentStep = state.subscribe((flow) => {
|
|
233
|
+
const id = flow.attempt?.status === 'needs_mfa_enrollment' ? (flow.attempt.id ?? null) : null;
|
|
234
|
+
if (id === enrollingFor)
|
|
235
|
+
return;
|
|
236
|
+
enrollingFor = id;
|
|
237
|
+
if (id)
|
|
238
|
+
enrollment.start(id);
|
|
239
|
+
else
|
|
240
|
+
enrollment.reset();
|
|
241
|
+
});
|
|
242
|
+
function saveRecoveryCodes() {
|
|
243
|
+
void finish(acknowledgeRecoveryCodes(get(state)));
|
|
244
|
+
}
|
|
245
|
+
async function resendSignUp() {
|
|
246
|
+
state.update((current) => ({ ...current, busy: true, errors: [] }));
|
|
247
|
+
const next = await resendSignUpVerification(client, get(state));
|
|
248
|
+
state.set(next);
|
|
249
|
+
return next.errors.length === 0;
|
|
250
|
+
}
|
|
251
|
+
function createPasskey() {
|
|
252
|
+
void (async () => {
|
|
253
|
+
state.update((current) => ({ ...current, busy: true, errors: [] }));
|
|
254
|
+
await finish(await finishPasskeySignUp(client, get(state), { t }));
|
|
163
255
|
})();
|
|
164
256
|
}
|
|
165
257
|
async function prepare(strategy) {
|
|
@@ -172,6 +264,8 @@ function createFlow() {
|
|
|
172
264
|
function destroy() {
|
|
173
265
|
stopPolling();
|
|
174
266
|
unsubscribeCaptcha();
|
|
267
|
+
unsubscribeEnrollment();
|
|
268
|
+
unsubscribeEnrollmentStep();
|
|
175
269
|
captcha.destroy();
|
|
176
270
|
signUpCaptcha.destroy();
|
|
177
271
|
}
|
|
@@ -179,10 +273,23 @@ function createFlow() {
|
|
|
179
273
|
state: { subscribe: state.subscribe },
|
|
180
274
|
submit,
|
|
181
275
|
create,
|
|
276
|
+
createWithPasskey,
|
|
277
|
+
createPasskey,
|
|
278
|
+
sendEmailSecondFactor,
|
|
279
|
+
sendSmsSecondFactor,
|
|
280
|
+
mfaChoices,
|
|
281
|
+
enrollment,
|
|
282
|
+
enrollmentState: { subscribe: enrollmentState.subscribe },
|
|
283
|
+
saveRecoveryCodes,
|
|
284
|
+
resendSignUp,
|
|
182
285
|
prepare,
|
|
183
286
|
settings: { subscribe: settings.subscribe },
|
|
287
|
+
lastMethod: { subscribe: lastMethod.subscribe },
|
|
184
288
|
captcha,
|
|
185
289
|
signUpCaptcha,
|
|
290
|
+
policies,
|
|
291
|
+
loadPolicies,
|
|
292
|
+
declinePolicy,
|
|
186
293
|
destroy,
|
|
187
294
|
};
|
|
188
295
|
}
|
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' | 'alert' | 'text' | 'link';
|
|
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,
|
|
@@ -8,6 +11,7 @@ import { useAtlas } from "../context";
|
|
|
8
11
|
import { classFor } from "../appearance";
|
|
9
12
|
import { translate } from "../i18n";
|
|
10
13
|
import CaptchaSlot from "./CaptchaSlot.svelte";
|
|
14
|
+
import PolicyAcceptStep from "./PolicyAcceptStep.svelte";
|
|
11
15
|
export let attempt = null;
|
|
12
16
|
export let busy = false;
|
|
13
17
|
export let errors = [];
|
|
@@ -17,6 +21,13 @@ export let onSubmit;
|
|
|
17
21
|
export let captcha = null;
|
|
18
22
|
export let emailDelivery = null;
|
|
19
23
|
export let onPrepare = null;
|
|
24
|
+
export let onEmailSecondFactor = null;
|
|
25
|
+
export let onSmsSecondFactor = null;
|
|
26
|
+
export let enrollment = null;
|
|
27
|
+
export let enrollmentState = null;
|
|
28
|
+
export let emailVerification = "code";
|
|
29
|
+
export let onResend = null;
|
|
30
|
+
export let onPolicyDecline = null;
|
|
20
31
|
const atlas = useAtlas();
|
|
21
32
|
const cls = (key, base) => classFor(key, atlas.appearance, base);
|
|
22
33
|
const t = (key, vars) => translate(atlas.catalog, key, vars);
|
|
@@ -42,6 +53,31 @@ $: if (step.kind === "collect_first_factor" && onPrepare && plan.autoSend && att
|
|
|
42
53
|
}
|
|
43
54
|
$: sending = step.kind === "collect_first_factor" && sent === null && !!onPrepare && !plan.password && emailOptions.length > 0;
|
|
44
55
|
$: identifier = attempt?.identifier ?? "";
|
|
56
|
+
let emailed = null;
|
|
57
|
+
$: emailedNow = emailed && emailed.attemptId === (attempt?.id ?? null) ? emailed : null;
|
|
58
|
+
async function emailSecondFactor() {
|
|
59
|
+
if (!onEmailSecondFactor) return;
|
|
60
|
+
const attemptId = attempt?.id ?? null;
|
|
61
|
+
emailed = { attemptId, busy: true };
|
|
62
|
+
const r = await onEmailSecondFactor();
|
|
63
|
+
emailed = r.error ? { attemptId, error: r.error } : { attemptId, sentTo: r.sentTo ?? "" };
|
|
64
|
+
}
|
|
65
|
+
let texted = null;
|
|
66
|
+
$: textedNow = texted && texted.attemptId === (attempt?.id ?? null) ? texted : null;
|
|
67
|
+
async function smsSecondFactor() {
|
|
68
|
+
if (!onSmsSecondFactor) return;
|
|
69
|
+
const attemptId = attempt?.id ?? null;
|
|
70
|
+
texted = { attemptId, busy: true };
|
|
71
|
+
const r = await onSmsSecondFactor();
|
|
72
|
+
texted = r.error ? { attemptId, error: r.error } : { attemptId, sentTo: r.sentTo ?? "" };
|
|
73
|
+
}
|
|
74
|
+
$: enrolling = step.kind === "enroll_second_factor" && !!enrollment && !!enrollmentState;
|
|
75
|
+
$: enrolSubmitKey = enrolling && enrollmentState ? enrollmentSubmitKey(enrollmentState) : null;
|
|
76
|
+
function chooseMethod(m) {
|
|
77
|
+
values = {};
|
|
78
|
+
void enrollment?.choose(m);
|
|
79
|
+
}
|
|
80
|
+
$: linkOnly = awaitingSignUpLink(attempt, emailVerification);
|
|
45
81
|
function usePassword() {
|
|
46
82
|
prepared = null;
|
|
47
83
|
values = {};
|
|
@@ -59,12 +95,13 @@ $: fields = (() => {
|
|
|
59
95
|
case "collect_second_factor":
|
|
60
96
|
return [{ name: "code", labelKey: "mfa.codeLabel", type: "text" }];
|
|
61
97
|
case "enroll_second_factor":
|
|
98
|
+
if (enrollment && enrollmentState) return [];
|
|
62
99
|
return [
|
|
63
100
|
{ name: "code", labelKey: "mfa.enrollFirstCodeLabel", type: "text" },
|
|
64
101
|
{ name: "secondCode", labelKey: "mfa.enrollSecondCodeLabel", type: "text" }
|
|
65
102
|
];
|
|
66
103
|
case "collect_email_code":
|
|
67
|
-
return [{ name: "code", labelKey: "signIn.emailCodeLabel", type: "text" }];
|
|
104
|
+
return linkOnly ? [] : [{ name: "code", labelKey: "signIn.emailCodeLabel", type: "text" }];
|
|
68
105
|
case "collect_new_password":
|
|
69
106
|
return [{ name: "password", labelKey: "reset.newPasswordLabel", type: "password" }];
|
|
70
107
|
default:
|
|
@@ -89,12 +126,13 @@ $: message = (() => {
|
|
|
89
126
|
case "enroll_second_factor":
|
|
90
127
|
case "collect_email_code":
|
|
91
128
|
case "collect_new_password":
|
|
129
|
+
case "accept_policies":
|
|
92
130
|
return null;
|
|
93
131
|
default:
|
|
94
132
|
return t("error.generic");
|
|
95
133
|
}
|
|
96
134
|
})();
|
|
97
|
-
$: showSubmit = step.kind !== "done" && step.kind !== "await_oauth" && message === null && // Only emailed factors on offer: the option buttons are the way forward.
|
|
135
|
+
$: showSubmit = (!enrolling || enrolSubmitKey !== null) && step.kind !== "done" && step.kind !== "await_oauth" && step.kind !== "accept_policies" && message === null && !linkOnly && // Only emailed factors on offer: the option buttons are the way forward.
|
|
98
136
|
(step.kind !== "collect_first_factor" || fields.length > 0);
|
|
99
137
|
$: showEmailOptions = step.kind === "collect_first_factor" && message === null && sent === null && emailOptions.length > 0 && !(sending && busy);
|
|
100
138
|
$: showCaptcha = !!captcha && (step.kind === "collect_identifier" || step.kind === "collect_captcha");
|
|
@@ -104,6 +142,12 @@ function handleSubmit(event) {
|
|
|
104
142
|
onSubmit(sent === "email_code" ? { code: values.code ?? "" } : { password: values.password ?? "" });
|
|
105
143
|
return;
|
|
106
144
|
}
|
|
145
|
+
if (enrolling && enrollment) {
|
|
146
|
+
void enrollment.submit(values).then((next) => {
|
|
147
|
+
if (next) onSubmit(next);
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
107
151
|
onSubmit(values);
|
|
108
152
|
}
|
|
109
153
|
</script>
|
|
@@ -123,6 +167,10 @@ function handleSubmit(event) {
|
|
|
123
167
|
<p>{message}</p>
|
|
124
168
|
{/if}
|
|
125
169
|
{:else}
|
|
170
|
+
{#if step.kind === 'accept_policies'}
|
|
171
|
+
<!-- A required policy changed: scroll-mode ones in their dialog, the rest as checkboxes. -->
|
|
172
|
+
<PolicyAcceptStep {attempt} {busy} {onSubmit} onDecline={(policy) => onPolicyDecline?.(policy.title)} />
|
|
173
|
+
{/if}
|
|
126
174
|
{#if step.kind === 'collect_captcha'}
|
|
127
175
|
<!-- §5 the server withheld the attempt until a captcha clears. Solving
|
|
128
176
|
the widget resubmits the identifier on its own; the button retries. -->
|
|
@@ -137,6 +185,102 @@ function handleSubmit(event) {
|
|
|
137
185
|
{#if sending}
|
|
138
186
|
<p class="atlas-text" aria-live="polite">{t('signIn.sending')}</p>
|
|
139
187
|
{/if}
|
|
188
|
+
{#if linkOnly}
|
|
189
|
+
<p class="atlas-text" data-atlas-link-sent aria-live="polite">{t('signUp.linkSentTo', { email: identifier })}</p>
|
|
190
|
+
{#if onResend}
|
|
191
|
+
<button
|
|
192
|
+
class={cls('formButtonSecondary', 'atlas-button-secondary')}
|
|
193
|
+
type="button"
|
|
194
|
+
data-atlas-resend
|
|
195
|
+
disabled={busy}
|
|
196
|
+
on:click={() => void onResend?.()}
|
|
197
|
+
>
|
|
198
|
+
{t('signIn.resendLink')}
|
|
199
|
+
</button>
|
|
200
|
+
{/if}
|
|
201
|
+
{/if}
|
|
202
|
+
{#if enrolling && enrollmentState}
|
|
203
|
+
{#if enrollmentState.method === null}
|
|
204
|
+
<p class="atlas-text">{t('mfa.enrollChoose')}</p>
|
|
205
|
+
{#each enrollmentState.options as m (m)}
|
|
206
|
+
<button
|
|
207
|
+
class={cls('formButtonSecondary', 'atlas-button-secondary')}
|
|
208
|
+
type="button"
|
|
209
|
+
data-atlas-mfa-method={m}
|
|
210
|
+
disabled={busy || enrollmentState.busy}
|
|
211
|
+
on:click={() => chooseMethod(m)}
|
|
212
|
+
>
|
|
213
|
+
{t(m === 'totp' ? 'mfa.enrollTotpOption' : 'mfa.enrollSmsOption')}
|
|
214
|
+
</button>
|
|
215
|
+
{/each}
|
|
216
|
+
{:else if enrollmentState.method === 'totp'}
|
|
217
|
+
{#if !enrollmentState.enrollment}
|
|
218
|
+
<p class="atlas-text" aria-live="polite">{enrollmentState.busy ? t('mfa.enrollPreparing') : ''}</p>
|
|
219
|
+
{:else}
|
|
220
|
+
<p class="atlas-text">{t('mfa.enrollScan')}</p>
|
|
221
|
+
{#if enrollmentState.enrollment.qrCode}
|
|
222
|
+
<img class="atlas-mfa-qr" data-atlas="mfa-qr" src={enrollmentState.enrollment.qrCode} alt={t('mfa.enrollQrAlt')} width="180" height="180" />
|
|
223
|
+
{/if}
|
|
224
|
+
{#if enrollmentState.enrollment.secret}
|
|
225
|
+
<p class="atlas-text" data-atlas="mfa-setup-key">
|
|
226
|
+
{t('mfa.enrollSetupKey')}: <code>{groupSetupKey(enrollmentState.enrollment.secret)}</code>
|
|
227
|
+
</p>
|
|
228
|
+
{/if}
|
|
229
|
+
<label class="atlas-field">
|
|
230
|
+
<span>{t('mfa.enrollFirstCodeLabel')}</span>
|
|
231
|
+
<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)} />
|
|
232
|
+
</label>
|
|
233
|
+
{#if enrollmentState.totpCodes >= 2}
|
|
234
|
+
<label class="atlas-field">
|
|
235
|
+
<span>{t('mfa.enrollSecondCodeLabel')}</span>
|
|
236
|
+
<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)} />
|
|
237
|
+
</label>
|
|
238
|
+
{/if}
|
|
239
|
+
{#each errors.filter((e) => e.param === 'codes') as error (error.code)}
|
|
240
|
+
<span class={cls('formFieldError', 'atlas-error')}>{errorText(error)}</span>
|
|
241
|
+
{/each}
|
|
242
|
+
{/if}
|
|
243
|
+
{:else if !enrollmentState.enrollment}
|
|
244
|
+
<label class="atlas-field">
|
|
245
|
+
<span>{t('mfa.phoneLabel')}</span>
|
|
246
|
+
<input class={cls('formFieldInput', 'atlas-input')} type="tel" name="phone" autocomplete="tel" value={values.phone ?? ''} on:input={(e) => (values.phone = e.currentTarget.value)} />
|
|
247
|
+
</label>
|
|
248
|
+
<p class="atlas-text">{t('mfa.phoneHint')}</p>
|
|
249
|
+
{:else}
|
|
250
|
+
<p class="atlas-text" data-atlas="mfa-code-texted" aria-live="polite">
|
|
251
|
+
{t('mfa.codeTextedTo', { phone: enrollmentState.enrollment.phone ?? '' })}
|
|
252
|
+
</p>
|
|
253
|
+
<label class="atlas-field">
|
|
254
|
+
<span>{t('mfa.codeLabel')}</span>
|
|
255
|
+
<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)} />
|
|
256
|
+
</label>
|
|
257
|
+
<button
|
|
258
|
+
class={cls('formButtonSecondary', 'atlas-button-secondary')}
|
|
259
|
+
type="button"
|
|
260
|
+
data-atlas-resend
|
|
261
|
+
disabled={busy || enrollmentState.busy}
|
|
262
|
+
on:click={() => void enrollment?.resend()}
|
|
263
|
+
>
|
|
264
|
+
{t('signIn.resendCode')}
|
|
265
|
+
</button>
|
|
266
|
+
{/if}
|
|
267
|
+
{#each enrollmentState.errors as error (`${error.code}-${error.param ?? ''}`)}
|
|
268
|
+
<p class={cls('formFieldError', 'atlas-error')} role="alert">{error.message}</p>
|
|
269
|
+
{/each}
|
|
270
|
+
{#if enrollmentState.method !== null && enrollmentState.options.length > 1}
|
|
271
|
+
{#each enrollmentState.options.filter((m) => m !== enrollmentState?.method) as other (other)}
|
|
272
|
+
<button
|
|
273
|
+
class={cls('formButtonSecondary', 'atlas-button-secondary')}
|
|
274
|
+
type="button"
|
|
275
|
+
data-atlas-mfa-method={other}
|
|
276
|
+
disabled={busy || enrollmentState.busy}
|
|
277
|
+
on:click={() => chooseMethod(other)}
|
|
278
|
+
>
|
|
279
|
+
{t(other === 'totp' ? 'mfa.enrollTotpOption' : 'mfa.enrollSmsOption')}
|
|
280
|
+
</button>
|
|
281
|
+
{/each}
|
|
282
|
+
{/if}
|
|
283
|
+
{/if}
|
|
140
284
|
{#each fields as f (f.name)}
|
|
141
285
|
<label class="atlas-field">
|
|
142
286
|
<span>{t(f.labelKey)}</span>
|
|
@@ -174,6 +318,46 @@ function handleSubmit(event) {
|
|
|
174
318
|
</button>
|
|
175
319
|
{/if}
|
|
176
320
|
{/if}
|
|
321
|
+
{#if step.kind === 'collect_second_factor' && onSmsSecondFactor}
|
|
322
|
+
{#if textedNow && textedNow.sentTo !== undefined}
|
|
323
|
+
<p class="atlas-text" data-atlas="sms-second-factor-sent" aria-live="polite">
|
|
324
|
+
{t('mfa.codeTextedTo', { phone: textedNow.sentTo })}
|
|
325
|
+
</p>
|
|
326
|
+
{:else}
|
|
327
|
+
<button
|
|
328
|
+
class={cls('formButtonSecondary', 'atlas-button-secondary')}
|
|
329
|
+
type="button"
|
|
330
|
+
data-atlas="sms-second-factor"
|
|
331
|
+
disabled={busy || !!textedNow?.busy}
|
|
332
|
+
on:click={() => void smsSecondFactor()}
|
|
333
|
+
>
|
|
334
|
+
{textedNow?.busy ? t('signIn.sending') : t('mfa.smsCodeOption')}
|
|
335
|
+
</button>
|
|
336
|
+
{#if textedNow?.error}
|
|
337
|
+
<p class={cls('formFieldError', 'atlas-error')} role="alert">{textedNow.error}</p>
|
|
338
|
+
{/if}
|
|
339
|
+
{/if}
|
|
340
|
+
{/if}
|
|
341
|
+
{#if step.kind === 'collect_second_factor' && onEmailSecondFactor}
|
|
342
|
+
{#if emailedNow && emailedNow.sentTo !== undefined}
|
|
343
|
+
<p class="atlas-text" data-atlas="email-second-factor-sent" aria-live="polite">
|
|
344
|
+
{t('signIn.codeSentTo', { email: emailedNow.sentTo })}
|
|
345
|
+
</p>
|
|
346
|
+
{:else}
|
|
347
|
+
<button
|
|
348
|
+
class={cls('formButtonSecondary', 'atlas-button-secondary')}
|
|
349
|
+
type="button"
|
|
350
|
+
data-atlas="email-second-factor"
|
|
351
|
+
disabled={busy || !!emailedNow?.busy}
|
|
352
|
+
on:click={() => void emailSecondFactor()}
|
|
353
|
+
>
|
|
354
|
+
{emailedNow?.busy ? t('signIn.sending') : t('signIn.emailCodeOption')}
|
|
355
|
+
</button>
|
|
356
|
+
{#if emailedNow?.error}
|
|
357
|
+
<p class={cls('formFieldError', 'atlas-error')} role="alert">{emailedNow.error}</p>
|
|
358
|
+
{/if}
|
|
359
|
+
{/if}
|
|
360
|
+
{/if}
|
|
177
361
|
{#if showEmailOptions}
|
|
178
362
|
{#each emailOptions as strategy (strategy)}
|
|
179
363
|
<button
|
|
@@ -194,8 +378,8 @@ function handleSubmit(event) {
|
|
|
194
378
|
{/if}
|
|
195
379
|
|
|
196
380
|
{#if showSubmit}
|
|
197
|
-
<button class={cls('formButtonPrimary', 'atlas-button')} disabled={busy} type="submit">
|
|
198
|
-
{t('signIn.submit')}
|
|
381
|
+
<button class={cls('formButtonPrimary', 'atlas-button')} disabled={busy || (enrolling && !!enrollmentState?.busy)} type="submit">
|
|
382
|
+
{t(enrolSubmitKey ?? 'signIn.submit')}
|
|
199
383
|
</button>
|
|
200
384
|
{/if}
|
|
201
385
|
</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,33 @@ 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;
|
|
57
|
+
/** The user declined a policy the sign-in was waiting on (`needs_policy_acceptance`): cancel the attempt. */ onPolicyDecline?: ((title: string) => void) | null;
|
|
31
58
|
};
|
|
32
59
|
events: {
|
|
33
60
|
[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}
|