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