@atlasauth/svelte 0.2.1 → 0.4.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 +99 -0
- package/dist/accessors.d.ts +42 -6
- package/dist/accessors.js +81 -21
- package/dist/components/AtlasProvider.svelte +1 -1
- package/dist/components/AttemptFlow.svelte +53 -4
- package/dist/components/AttemptFlow.svelte.d.ts +14 -1
- package/dist/components/CaptchaSlot.svelte +19 -0
- package/dist/components/CaptchaSlot.svelte.d.ts +23 -0
- package/dist/components/SignIn.svelte +6 -0
- package/dist/components/SignIn.svelte.d.ts +4 -1
- package/dist/components/SignUp.svelte +21 -7
- package/dist/components/SignUp.svelte.d.ts +5 -2
- package/dist/components/SignUpForm.svelte +188 -0
- package/dist/components/SignUpForm.svelte.d.ts +32 -0
- package/dist/context.d.ts +26 -4
- package/dist/context.js +69 -16
- package/dist/i18n.js +17 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/package.json +6 -5
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @atlasauth/svelte changelog
|
|
2
|
+
|
|
3
|
+
## 0.4.0
|
|
4
|
+
- **Safer sign-out.** `signOut(options?)` (on the client, `getAuth()` and the `signOut()` helper) now
|
|
5
|
+
resolves `SignOutResult` — `{ revoked: boolean; activeSessionId?: string }` — instead of `void`
|
|
6
|
+
(existing `await signOut()` call sites keep working). The session is revoked on the Frontend API (retried
|
|
7
|
+
once) even when no token is cached (the session is looked up from the cookies), and is remembered
|
|
8
|
+
(a tombstone in `localStorage`, per Frontend API) so a reload, a token refresh, a sibling tab or a tab
|
|
9
|
+
opened later never re-adopts it while the refresh cookie is still valid; a revoke the server did not
|
|
10
|
+
confirm is re-tried in the background and the tombstone dropped once it lands. A session the server
|
|
11
|
+
promotes on revoke (`active_session_id`) is adopted. `signOut({ strict: true })` rejects with a
|
|
12
|
+
`SignOutError` (after the local sign-out) when the revoke was not confirmed. Built on `SignOutGuard`
|
|
13
|
+
from `@atlasauth/js`.
|
|
14
|
+
- **Sibling-tab sign-out.** A tab signed in to a session another tab signed out of now signs out too.
|
|
15
|
+
- **Popup landing.** On boot the client completes a severed OAuth popup landing
|
|
16
|
+
(`#atlas_popup_result=…`): it relays the result to the opener and closes when the opener takes it,
|
|
17
|
+
otherwise exchanges the ticket itself (or follows the continuation URL). After exchanging a redirect
|
|
18
|
+
ticket inside the Atlas popup window it hands back to the opener and closes the popup.
|
|
19
|
+
- **Email delivery.** The prebuilt components read `settings.emailDelivery`:
|
|
20
|
+
`<SignUp>` shows a "can't send email" notice (`signUp.emailUnavailable`) instead of the email + password
|
|
21
|
+
form when `passwordSignUp` is false; `<SignIn>` offers emailed first factors only while `available`.
|
|
22
|
+
- **`<SignIn>` first-factor choice.** When the server offers `email_code` / `email_link`, the first-factor
|
|
23
|
+
step shows "Email me a code" / "Email me a sign-in link" buttons next to the password field.
|
|
24
|
+
- **Added to `FlowController`:** `prepare(strategy: 'email_code' | 'email_link'): Promise<boolean>`.
|
|
25
|
+
- **Changed types:** `AtlasClient.signOut`, `UseAuth.signOut` and the `signOut()` helper return
|
|
26
|
+
`Promise<SignOutResult>`.
|
|
27
|
+
- **Added re-exports** from `@atlasauth/js`: `SignOutError` (class), and the types `SignOutResult`,
|
|
28
|
+
`SignOutOptions`, `EmailDelivery`.
|
|
29
|
+
- **Added catalog keys** (en-US): `signUp.emailUnavailable`, `signIn.emailCodeOption`,
|
|
30
|
+
`signIn.emailLinkOption`.
|
|
31
|
+
- `destroy()` now also stops the sibling-tab watch and cancels background revoke re-tries.
|
|
32
|
+
- Requires `@atlasauth/js` 0.8.0.
|
|
33
|
+
|
|
34
|
+
## 0.3.0
|
|
35
|
+
- **`<SignUp>` now creates accounts.** Its first step is a create-account form (the instance's
|
|
36
|
+
configured sign-up fields, email, password and the legal-consent row) that posts
|
|
37
|
+
`POST /v1/client/sign_ups`; the emailed-code step and the ticket exchange then run through the shared
|
|
38
|
+
flow. Previously `<SignUp>` rendered the sign-in identifier step and could not create an account.
|
|
39
|
+
- **Captcha renders automatically.** When the instance turns captcha on for a flow, `<SignUp>` and
|
|
40
|
+
`<SignIn>` show the provider's widget (Turnstile, hCaptcha, reCAPTCHA v2/v3/Enterprise) in the
|
|
41
|
+
configured mode (`visible`, `managed`, or `invisible` — run on submit), send its token as
|
|
42
|
+
`captcha_token`, answer the sign-in `needs_captcha` step (continuing as soon as it is solved), and on
|
|
43
|
+
`CAPTCHA_REQUIRED` / `CAPTCHA_FAILED` show a localized message and a fresh widget.
|
|
44
|
+
- **Added to `FlowController`** (returned by `signIn()` / `signUp()`): `create(values, consent?)`,
|
|
45
|
+
`settings: Readable<InstanceSettings | null>`, `captcha: CaptchaController`,
|
|
46
|
+
`signUpCaptcha: CaptchaController`; `destroy()` now also removes the captcha widgets.
|
|
47
|
+
- **Added type re-exports** `InstanceSettings`, `PublicSignUpField`, `CaptchaController`,
|
|
48
|
+
`CaptchaFlowKey`, `CaptchaWidgetMode`, `FlowCaptcha` from `@atlasauth/js`.
|
|
49
|
+
- **Added catalog keys** (en-US): `signUp.emailLabel`, `signUp.passwordLabel`, `signUp.optionalField`,
|
|
50
|
+
`signUp.selectPlaceholder`, `signUp.consentAgree`, `signUp.consentNotice`, `signUp.terms`,
|
|
51
|
+
`signUp.privacy`, `signUp.and`, `captcha.prompt`, `captcha.required`, `captcha.failed`.
|
|
52
|
+
- New element classes `atlas-captcha`, `atlas-consent`, `atlas-choices`, `atlas-choice`, `atlas-text`,
|
|
53
|
+
`atlas-link`; the flow's inputs now carry `name` attributes.
|
|
54
|
+
- **`frontendApi` is validated at `initAtlas()` / `<AtlasProvider>`.** A missing (or non-string) or malformed value now throws an
|
|
55
|
+
`AtlasConfigError` (from `@atlasauth/js`; `code: 'FRONTEND_API_REQUIRED'` or `'FRONTEND_API_INVALID'`)
|
|
56
|
+
instead of silently sending requests to relative URLs on the app's own origin. Accepted: an absolute
|
|
57
|
+
`https:` URL (`http:` only for localhost / local development hosts), or `"same-origin"` / a root-relative path such as `"/atlas"` for a reverse proxy.
|
|
58
|
+
- **Deprecated:** `frontendApi: ""` (the original same-origin spelling) still works as same-origin, with a
|
|
59
|
+
one-time console warning; use `"same-origin"` instead.
|
|
60
|
+
|
|
61
|
+
## 0.2.1
|
|
62
|
+
- **Front-channel logout on sign-out.** `signOut()` now loads the `frontchannel_logout_urls` the
|
|
63
|
+
Frontend API returns (apps signed in with "Sign in with Atlas" through this session) in hidden,
|
|
64
|
+
sandboxed iframes, so those apps clear their sessions in this browser too. It waits until the frames
|
|
65
|
+
load or 5 s pass, never fails the sign-out, only loads `https:` URLs (and `http://localhost`), and
|
|
66
|
+
does nothing during SSR. Pass `signOut({ frontchannel: false })` to skip it.
|
|
67
|
+
|
|
68
|
+
## 0.2.0
|
|
69
|
+
- **Design system.** `<AtlasProvider>` now applies the design configured in the Atlas dashboard
|
|
70
|
+
automatically (fetched after mount; opt out with `appearance.instanceDesign: false`).
|
|
71
|
+
- **New `appearance.preset` and `appearance.design`** — the same settings and semantics as
|
|
72
|
+
`@atlasauth/react`: dashboard design < `preset` < `variables` < `design`. Invalid values are dropped
|
|
73
|
+
with a development warning and never reach CSS. The provider root gets stable `data-atlas-*`
|
|
74
|
+
attributes and the `--atlas-*` design variables; web fonts are linked from your Frontend API.
|
|
75
|
+
- The client gains `loadInstanceDesign()`.
|
|
76
|
+
- `appearance.variables` / `baseTheme` are unchanged when no design is in play.
|
|
77
|
+
- New dependency: `@atlasauth/design`.
|
|
78
|
+
|
|
79
|
+
## 0.1.5
|
|
80
|
+
- **`getAuth()` now also returns `status` and `session`.** `status` is the three-state `AuthStatus` (`loading` | `signed_in` | `signed_out`) and `session` is the active session or `null`; both come from the same source as `isLoaded` / `isSignedIn`. The `UseAuth` type gains both fields.
|
|
81
|
+
- README uses the published `@atlasauth/*` package names.
|
|
82
|
+
|
|
83
|
+
## 0.1.4
|
|
84
|
+
- **Docs fix.** Frontend API host examples use a single-label host (`your-instance-fapi.atlasauth.net`); a two-level `*.fapi.atlasauth.net` host isn't TLS-covered behind Cloudflare.
|
|
85
|
+
|
|
86
|
+
## 0.1.3
|
|
87
|
+
- **Metadata.** Added `repository` (monorepo + package directory), `homepage`, and `bugs` so the npm page links back to source.
|
|
88
|
+
|
|
89
|
+
## 0.1.2
|
|
90
|
+
- **Docs fix.** README Frontend API examples now use the real host shape `https://your-instance.fapi.atlasauth.net` (was a non-resolving `.atlas.dev`).
|
|
91
|
+
|
|
92
|
+
## 0.1.1
|
|
93
|
+
- First published release.
|
|
94
|
+
- **Client and context.** Install with `initAtlas()` or `<AtlasProvider>`; `createAtlasClient()` and `useAtlas()` give direct access to the client and its stores.
|
|
95
|
+
- **Store accessors.** `getUser()`, `getSession()`, `getAuth()` and `getOrganization()` return reactive stores; `getToken()`, `signIn()`, `signUp()` and `signOut()` are the action helpers.
|
|
96
|
+
- **Components.** `SignedIn`, `SignedOut`, `AtlasLoading`, `AtlasLoaded`, `Protect`, `SignIn`, `SignUp`, `UserButton`, `UserProfile`, `OrganizationSwitcher` and `OrganizationProfile`.
|
|
97
|
+
- **SvelteKit server.** The separate `@atlasauth/svelte/server` entry provides `handleAtlas()` (a `handle` hook that verifies the session) and `getServerAuth()` for load functions and endpoints, so the backend verifier never enters a browser bundle.
|
|
98
|
+
- Appearance, localization and authorization helpers shared with the other Atlas UI SDKs.
|
|
99
|
+
|
package/dist/accessors.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Readable } from 'svelte/store';
|
|
2
|
-
import { type FlowState, type ProviderToken, type SignOutOptions } from '@atlasauth/js';
|
|
2
|
+
import { type CaptchaController, type FlowState, type InstanceSettings, type ProviderToken, type SignOutOptions, type SignOutResult } 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';
|
|
@@ -48,7 +48,12 @@ export interface UseAuth {
|
|
|
48
48
|
getToken(): Promise<string | null>;
|
|
49
49
|
/** §6.6 the user's own live access token at a connected social provider. */
|
|
50
50
|
getProviderToken(provider: string): Promise<ProviderToken | null>;
|
|
51
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Revoke the session and drop all in-memory state. Resolves
|
|
53
|
+
* `{ revoked, activeSessionId? }`; `{ strict: true }` rejects with a
|
|
54
|
+
* `SignOutError` when the server could not confirm the revoke.
|
|
55
|
+
*/
|
|
56
|
+
signOut(options?: SignOutOptions): Promise<SignOutResult>;
|
|
52
57
|
/** Mirrors `<Protect>`, for logic that is not a render decision. */
|
|
53
58
|
has(condition: ProtectCondition): boolean;
|
|
54
59
|
}
|
|
@@ -63,8 +68,12 @@ export interface UseOrganization {
|
|
|
63
68
|
export declare function getOrganization(): Readable<UseOrganization>;
|
|
64
69
|
/** Refresh-if-stale, then hand over the JWT. The `useAuth().getToken` shortcut. */
|
|
65
70
|
export declare function getToken(): Promise<string | null>;
|
|
66
|
-
/**
|
|
67
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Revoke the session and drop all in-memory state. Resolves
|
|
73
|
+
* `{ revoked, activeSessionId? }`; `{ strict: true }` rejects with a
|
|
74
|
+
* `SignOutError` (after the local sign-out) when the revoke was not confirmed.
|
|
75
|
+
*/
|
|
76
|
+
export declare function signOut(options?: SignOutOptions): Promise<SignOutResult>;
|
|
68
77
|
/**
|
|
69
78
|
* A running sign-in / sign-up attempt: a reactive `state` store plus `submit`.
|
|
70
79
|
*
|
|
@@ -77,10 +86,37 @@ export declare function signOut(options?: SignOutOptions): Promise<void>;
|
|
|
77
86
|
export interface FlowController {
|
|
78
87
|
state: Readable<FlowState>;
|
|
79
88
|
submit(values: Record<string, string>): void;
|
|
80
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Create an account (`POST /v1/client/sign_ups`) from a create-account form's
|
|
91
|
+
* values — email, password and any configured sign-up fields — carrying the
|
|
92
|
+
* sign-up captcha's token when the instance requires one. The emailed code is
|
|
93
|
+
* then posted with {@link submit}.
|
|
94
|
+
*/
|
|
95
|
+
create(values: Record<string, string>, consent?: boolean): void;
|
|
96
|
+
/**
|
|
97
|
+
* Ask the server to email a sign-in code or link on the first-factor step
|
|
98
|
+
* (`POST …/prepare_first_factor`). Resolves true once it was sent; the code is
|
|
99
|
+
* then posted with {@link submit}, a link is collected by polling.
|
|
100
|
+
*/
|
|
101
|
+
prepare(strategy: 'email_code' | 'email_link'): Promise<boolean>;
|
|
102
|
+
/**
|
|
103
|
+
* The instance's public sign-in / sign-up settings (captcha, sign-up fields,
|
|
104
|
+
* legal consent, email delivery) from `GET /v1/appearance`; null until loaded
|
|
105
|
+
* or when the server could not be reached.
|
|
106
|
+
*/
|
|
107
|
+
settings: Readable<InstanceSettings | null>;
|
|
108
|
+
/** The sign-in captcha (identifier step / `needs_captcha` challenge). */
|
|
109
|
+
captcha: CaptchaController;
|
|
110
|
+
/** The sign-up captcha (the create-account step). */
|
|
111
|
+
signUpCaptcha: CaptchaController;
|
|
112
|
+
/** Stop the magic-link poll loop and remove the captcha widgets — call from `onDestroy`. */
|
|
81
113
|
destroy(): void;
|
|
82
114
|
}
|
|
83
115
|
/** Start (or resume) a sign-in flow. Drives `<SignIn>`; usable standalone too. */
|
|
84
116
|
export declare function signIn(): FlowController;
|
|
85
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Start a sign-up flow. Drives `<SignUp>`; usable standalone too:
|
|
119
|
+
* `create(values)` posts `/v1/client/sign_ups` (the account is created there),
|
|
120
|
+
* then `submit({ code })` verifies the emailed code and completes the session.
|
|
121
|
+
*/
|
|
86
122
|
export declare function signUp(): FlowController;
|
package/dist/accessors.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { derived, get, writable } from 'svelte/store';
|
|
2
|
-
import { FapiClient,
|
|
2
|
+
import { FapiClient, advanceWithCaptcha, createCaptchaController, createSignUp, fetchInstanceSettings, flowNeedsCaptcha, signInCaptchaOptions, flowFromPending, pollAttempt, prepareFactor, shouldKeepPolling, } from '@atlasauth/js';
|
|
3
3
|
import { useAtlas } from './context';
|
|
4
|
+
import { translate } from './i18n';
|
|
4
5
|
import { evaluate } from './protect';
|
|
5
6
|
export function getUser() {
|
|
6
7
|
const atlas = useAtlas();
|
|
@@ -53,7 +54,11 @@ export function getOrganization() {
|
|
|
53
54
|
export function getToken() {
|
|
54
55
|
return useAtlas().getToken();
|
|
55
56
|
}
|
|
56
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Revoke the session and drop all in-memory state. Resolves
|
|
59
|
+
* `{ revoked, activeSessionId? }`; `{ strict: true }` rejects with a
|
|
60
|
+
* `SignOutError` (after the local sign-out) when the revoke was not confirmed.
|
|
61
|
+
*/
|
|
57
62
|
export function signOut(options) {
|
|
58
63
|
return useAtlas().signOut(options);
|
|
59
64
|
}
|
|
@@ -66,6 +71,25 @@ function createFlow() {
|
|
|
66
71
|
// Seeded from a redirect that came back mid-flow (e.g. OAuth needing a second
|
|
67
72
|
// factor), so the user resumes at the demanded step instead of starting over.
|
|
68
73
|
const state = writable(flowFromPending(atlas.pendingAttempt));
|
|
74
|
+
const settings = writable(null);
|
|
75
|
+
const t = (key) => translate(atlas.catalog, key);
|
|
76
|
+
// §5 the per-flow captcha, driven by the instance's settings so turning it on
|
|
77
|
+
// in the dashboard needs no code change. Kept in step with the settings and
|
|
78
|
+
// the flow's step synchronously, before the components re-render.
|
|
79
|
+
const captcha = createCaptchaController();
|
|
80
|
+
const signUpCaptcha = createCaptchaController();
|
|
81
|
+
let identifier = '';
|
|
82
|
+
const unsubscribeCaptcha = derived([settings, state], (pair) => pair).subscribe(([current, flow]) => {
|
|
83
|
+
captcha.configure(signInCaptchaOptions(current?.captchaFlows.signIn, flow, () => submit({})));
|
|
84
|
+
const signUpFlow = current?.captchaFlows.signUp;
|
|
85
|
+
signUpCaptcha.configure({ flow: signUpFlow, active: !flow.attempt && flowNeedsCaptcha(signUpFlow) });
|
|
86
|
+
});
|
|
87
|
+
if (typeof window !== 'undefined') {
|
|
88
|
+
void fetchInstanceSettings(client).then((loaded) => {
|
|
89
|
+
if (loaded)
|
|
90
|
+
settings.set(loaded);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
69
93
|
let pollTimer = null;
|
|
70
94
|
function stopPolling() {
|
|
71
95
|
if (pollTimer)
|
|
@@ -94,35 +118,71 @@ function createFlow() {
|
|
|
94
118
|
})();
|
|
95
119
|
}, 2_000);
|
|
96
120
|
}
|
|
121
|
+
// On completion the response carries a one-time ticket (§7.1). Exchange it
|
|
122
|
+
// for cookies over a same-origin request — FAPI is cross-origin to the app
|
|
123
|
+
// and cannot set the cookie on the completion response itself — THEN
|
|
124
|
+
// re-boot so the rest of the app sees a signed-in user.
|
|
125
|
+
async function finish(next) {
|
|
126
|
+
state.set(next);
|
|
127
|
+
if (next.attempt?.status === 'complete') {
|
|
128
|
+
if (next.ticket) {
|
|
129
|
+
await client.post('/v1/client/tickets/exchange', {
|
|
130
|
+
attempt_id: next.attempt.id,
|
|
131
|
+
ticket: next.ticket,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
await atlas.reload();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
maybeStartPolling();
|
|
138
|
+
}
|
|
97
139
|
function submit(values) {
|
|
98
140
|
void (async () => {
|
|
99
141
|
state.update((current) => ({ ...current, busy: true, errors: [] }));
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
attempt_id: next.attempt.id,
|
|
110
|
-
ticket: next.ticket,
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
await atlas.reload();
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
maybeStartPolling();
|
|
142
|
+
const out = await advanceWithCaptcha(client, get(state), values, { captcha, identifier, t });
|
|
143
|
+
identifier = out.identifier;
|
|
144
|
+
await finish(out.state);
|
|
145
|
+
})();
|
|
146
|
+
}
|
|
147
|
+
function create(values, consent = true) {
|
|
148
|
+
void (async () => {
|
|
149
|
+
state.update((current) => ({ ...current, busy: true, errors: [] }));
|
|
150
|
+
await finish(await createSignUp(client, values, { consent, captcha: signUpCaptcha, t }));
|
|
117
151
|
})();
|
|
118
152
|
}
|
|
119
|
-
|
|
153
|
+
async function prepare(strategy) {
|
|
154
|
+
state.update((current) => ({ ...current, busy: true, errors: [] }));
|
|
155
|
+
const next = await prepareFactor(client, get(state), strategy);
|
|
156
|
+
state.set({ ...next, busy: false });
|
|
157
|
+
maybeStartPolling();
|
|
158
|
+
return next.errors.length === 0;
|
|
159
|
+
}
|
|
160
|
+
function destroy() {
|
|
161
|
+
stopPolling();
|
|
162
|
+
unsubscribeCaptcha();
|
|
163
|
+
captcha.destroy();
|
|
164
|
+
signUpCaptcha.destroy();
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
state: { subscribe: state.subscribe },
|
|
168
|
+
submit,
|
|
169
|
+
create,
|
|
170
|
+
prepare,
|
|
171
|
+
settings: { subscribe: settings.subscribe },
|
|
172
|
+
captcha,
|
|
173
|
+
signUpCaptcha,
|
|
174
|
+
destroy,
|
|
175
|
+
};
|
|
120
176
|
}
|
|
121
177
|
/** Start (or resume) a sign-in flow. Drives `<SignIn>`; usable standalone too. */
|
|
122
178
|
export function signIn() {
|
|
123
179
|
return createFlow();
|
|
124
180
|
}
|
|
125
|
-
/**
|
|
181
|
+
/**
|
|
182
|
+
* Start a sign-up flow. Drives `<SignUp>`; usable standalone too:
|
|
183
|
+
* `create(values)` posts `/v1/client/sign_ups` (the account is created there),
|
|
184
|
+
* then `submit({ code })` verifies the emailed code and completes the session.
|
|
185
|
+
*/
|
|
126
186
|
export function signUp() {
|
|
127
187
|
return createFlow();
|
|
128
188
|
}
|
|
@@ -14,7 +14,7 @@ const client = initAtlas({
|
|
|
14
14
|
fetchImpl
|
|
15
15
|
});
|
|
16
16
|
let instanceDesign = null;
|
|
17
|
-
$: rendered = renderAppearance(appearance, instanceDesign, { frontendApi });
|
|
17
|
+
$: rendered = renderAppearance(appearance, instanceDesign, { frontendApi: client.frontendApi });
|
|
18
18
|
$: style = Object.entries(rendered.style).map(([name, value]) => `${name}: ${value}`).join("; ");
|
|
19
19
|
$: designStyle = rendered.css ? `<style data-atlas-design="${rendered.key}">${rendered.css}</style>` : "";
|
|
20
20
|
onMount(() => {
|
|
@@ -1,23 +1,44 @@
|
|
|
1
|
-
<script>import {
|
|
1
|
+
<script>import {
|
|
2
|
+
nextStep,
|
|
3
|
+
offeredFirstFactors
|
|
4
|
+
} from "@atlasauth/js";
|
|
2
5
|
import { useAtlas } from "../context";
|
|
3
6
|
import { classFor } from "../appearance";
|
|
4
7
|
import { translate } from "../i18n";
|
|
8
|
+
import CaptchaSlot from "./CaptchaSlot.svelte";
|
|
5
9
|
export let attempt = null;
|
|
6
10
|
export let busy = false;
|
|
7
11
|
export let errors = [];
|
|
8
12
|
export let titleKey;
|
|
9
13
|
export let onSubmit;
|
|
14
|
+
export let captcha = null;
|
|
15
|
+
export let emailDelivery = null;
|
|
16
|
+
export let onPrepare = null;
|
|
10
17
|
const atlas = useAtlas();
|
|
11
18
|
const cls = (key, base) => classFor(key, atlas.appearance, base);
|
|
12
19
|
const t = (key, vars) => translate(atlas.catalog, key, vars);
|
|
13
20
|
let values = {};
|
|
14
21
|
$: step = attempt ? nextStep(attempt) : { kind: "collect_identifier" };
|
|
22
|
+
const strategyName = (s) => typeof s === "string" ? s : String(s?.strategy ?? "");
|
|
23
|
+
$: offered = step.kind === "collect_first_factor" ? offeredFirstFactors(step.strategies.map(strategyName), emailDelivery) : [];
|
|
24
|
+
$: emailOptions = onPrepare ? offered.filter((s) => s === "email_code" || s === "email_link") : [];
|
|
25
|
+
$: showPassword = offered.includes("password") || emailOptions.length === 0;
|
|
26
|
+
let prepared = null;
|
|
27
|
+
$: sent = prepared && prepared.attemptId === (attempt?.id ?? null) ? prepared.strategy : null;
|
|
28
|
+
async function prepare(strategy) {
|
|
29
|
+
if (!onPrepare) return;
|
|
30
|
+
const attemptId = attempt?.id ?? null;
|
|
31
|
+
if (await onPrepare(strategy)) prepared = { strategy, attemptId };
|
|
32
|
+
}
|
|
15
33
|
$: fields = (() => {
|
|
16
34
|
switch (step.kind) {
|
|
17
35
|
case "collect_identifier":
|
|
18
36
|
return [{ name: "identifier", labelKey: "signIn.identifierLabel", type: "email" }];
|
|
19
37
|
case "collect_first_factor":
|
|
20
|
-
|
|
38
|
+
if (sent === "email_code") {
|
|
39
|
+
return [{ name: "code", labelKey: "signIn.emailCodeLabel", type: "text" }];
|
|
40
|
+
}
|
|
41
|
+
return showPassword ? [{ name: "password", labelKey: "signIn.passwordLabel", type: "password" }] : [];
|
|
21
42
|
case "collect_second_factor":
|
|
22
43
|
return [{ name: "code", labelKey: "mfa.codeLabel", type: "text" }];
|
|
23
44
|
case "enroll_second_factor":
|
|
@@ -41,8 +62,10 @@ $: message = (() => {
|
|
|
41
62
|
return t("signIn.checkOtherTab");
|
|
42
63
|
case "restart":
|
|
43
64
|
return t("error.generic");
|
|
44
|
-
case "collect_identifier":
|
|
45
65
|
case "collect_first_factor":
|
|
66
|
+
return sent === "email_link" ? t("signIn.magicLinkSent") : null;
|
|
67
|
+
case "collect_captcha":
|
|
68
|
+
case "collect_identifier":
|
|
46
69
|
case "collect_second_factor":
|
|
47
70
|
case "enroll_second_factor":
|
|
48
71
|
case "collect_email_code":
|
|
@@ -52,7 +75,10 @@ $: message = (() => {
|
|
|
52
75
|
return t("error.generic");
|
|
53
76
|
}
|
|
54
77
|
})();
|
|
55
|
-
$: showSubmit = step.kind !== "done" && step.kind !== "await_oauth" && message === null
|
|
78
|
+
$: showSubmit = step.kind !== "done" && step.kind !== "await_oauth" && message === null && // Only emailed factors on offer: the option buttons are the way forward.
|
|
79
|
+
(step.kind !== "collect_first_factor" || fields.length > 0);
|
|
80
|
+
$: showEmailOptions = step.kind === "collect_first_factor" && message === null && sent === null && emailOptions.length > 0;
|
|
81
|
+
$: showCaptcha = !!captcha && (step.kind === "collect_identifier" || step.kind === "collect_captcha");
|
|
56
82
|
function handleSubmit(event) {
|
|
57
83
|
event.preventDefault();
|
|
58
84
|
onSubmit(values);
|
|
@@ -70,12 +96,18 @@ function handleSubmit(event) {
|
|
|
70
96
|
{#if message !== null}
|
|
71
97
|
<p>{message}</p>
|
|
72
98
|
{:else}
|
|
99
|
+
{#if step.kind === 'collect_captcha'}
|
|
100
|
+
<!-- §5 the server withheld the attempt until a captcha clears. Solving
|
|
101
|
+
the widget resubmits the identifier on its own; the button retries. -->
|
|
102
|
+
<p class="atlas-text">{t('captcha.prompt')}</p>
|
|
103
|
+
{/if}
|
|
73
104
|
{#each fields as f (f.name)}
|
|
74
105
|
<label class="atlas-field">
|
|
75
106
|
<span>{t(f.labelKey)}</span>
|
|
76
107
|
<input
|
|
77
108
|
class={cls('formFieldInput', 'atlas-input')}
|
|
78
109
|
type={f.type}
|
|
110
|
+
name={f.name}
|
|
79
111
|
value={values[f.name] ?? ''}
|
|
80
112
|
on:input={(e) => (values[f.name] = e.currentTarget.value)}
|
|
81
113
|
/>
|
|
@@ -84,6 +116,23 @@ function handleSubmit(event) {
|
|
|
84
116
|
{/each}
|
|
85
117
|
</label>
|
|
86
118
|
{/each}
|
|
119
|
+
{#if showEmailOptions}
|
|
120
|
+
{#each emailOptions as strategy (strategy)}
|
|
121
|
+
<button
|
|
122
|
+
class={cls('formButtonSecondary', 'atlas-button-secondary')}
|
|
123
|
+
type="button"
|
|
124
|
+
data-strategy={strategy}
|
|
125
|
+
disabled={busy}
|
|
126
|
+
on:click={() => void prepare(strategy)}
|
|
127
|
+
>
|
|
128
|
+
{t(strategy === 'email_code' ? 'signIn.emailCodeOption' : 'signIn.emailLinkOption')}
|
|
129
|
+
</button>
|
|
130
|
+
{/each}
|
|
131
|
+
{/if}
|
|
132
|
+
{/if}
|
|
133
|
+
|
|
134
|
+
{#if showCaptcha && captcha}
|
|
135
|
+
<CaptchaSlot controller={captcha} />
|
|
87
136
|
{/if}
|
|
88
137
|
|
|
89
138
|
{#if showSubmit}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SvelteComponent } from "svelte";
|
|
2
|
-
import { type AttemptView, type FieldError } from '@atlasauth/js';
|
|
2
|
+
import { type AttemptView, type CaptchaController, type EmailDelivery, type FieldError } from '@atlasauth/js';
|
|
3
3
|
declare const __propDef: {
|
|
4
4
|
props: {
|
|
5
5
|
/**
|
|
@@ -14,6 +14,19 @@ declare const __propDef: {
|
|
|
14
14
|
errors?: FieldError[];
|
|
15
15
|
titleKey: string;
|
|
16
16
|
onSubmit: (values: Record<string, string>) => void;
|
|
17
|
+
/**
|
|
18
|
+
* The sign-in captcha, drawn above the submit button on the identifier step
|
|
19
|
+
* (an always-on sign-in captcha) and on the challenge step the server demands
|
|
20
|
+
* with `needs_captcha`. Null when the flow draws none.
|
|
21
|
+
*/ captcha?: CaptchaController | null;
|
|
22
|
+
/**
|
|
23
|
+
* The instance's email-delivery facts (`settings.emailDelivery`). While the app
|
|
24
|
+
* cannot send email the first-factor step offers no email code / link option.
|
|
25
|
+
*/ emailDelivery?: EmailDelivery | null;
|
|
26
|
+
/**
|
|
27
|
+
* Ask the server to email a sign-in code or link (`FlowController.prepare`).
|
|
28
|
+
* Without it the first-factor step offers the password only.
|
|
29
|
+
*/ onPrepare?: ((strategy: "email_code" | "email_link") => Promise<boolean>) | null;
|
|
17
30
|
};
|
|
18
31
|
events: {
|
|
19
32
|
[evt: string]: CustomEvent<any>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script>export let controller;
|
|
2
|
+
function attach(node, c) {
|
|
3
|
+
let current = c;
|
|
4
|
+
current.attach(node);
|
|
5
|
+
return {
|
|
6
|
+
update(next) {
|
|
7
|
+
if (next === current) return;
|
|
8
|
+
current.attach(null);
|
|
9
|
+
current = next;
|
|
10
|
+
current.attach(node);
|
|
11
|
+
},
|
|
12
|
+
destroy() {
|
|
13
|
+
current.attach(null);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<div class="atlas-captcha" data-atlas-captcha="" use:attach={controller} />
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { SvelteComponent } from "svelte";
|
|
2
|
+
import type { CaptchaController } from '@atlasauth/js';
|
|
3
|
+
declare const __propDef: {
|
|
4
|
+
props: {
|
|
5
|
+
/**
|
|
6
|
+
* The element a flow's captcha widget draws into (see `createCaptchaController`
|
|
7
|
+
* in `@atlasauth/js`). The parent renders it only while the flow needs a
|
|
8
|
+
* captcha; the action attaches / detaches the controller with the element.
|
|
9
|
+
*/ controller: CaptchaController;
|
|
10
|
+
};
|
|
11
|
+
events: {
|
|
12
|
+
[evt: string]: CustomEvent<any>;
|
|
13
|
+
};
|
|
14
|
+
slots: {};
|
|
15
|
+
exports?: {} | undefined;
|
|
16
|
+
bindings?: string | undefined;
|
|
17
|
+
};
|
|
18
|
+
export type CaptchaSlotProps = typeof __propDef.props;
|
|
19
|
+
export type CaptchaSlotEvents = typeof __propDef.events;
|
|
20
|
+
export type CaptchaSlotSlots = typeof __propDef.slots;
|
|
21
|
+
export default class CaptchaSlot extends SvelteComponent<CaptchaSlotProps, CaptchaSlotEvents, CaptchaSlotSlots> {
|
|
22
|
+
}
|
|
23
|
+
export {};
|
|
@@ -4,7 +4,10 @@ import AttemptFlow from "./AttemptFlow.svelte";
|
|
|
4
4
|
export let afterUrl = void 0;
|
|
5
5
|
const flow = signIn();
|
|
6
6
|
const flowState = flow.state;
|
|
7
|
+
const settings = flow.settings;
|
|
7
8
|
onDestroy(() => flow.destroy());
|
|
9
|
+
const drawn = (..._deps) => flow.captcha.needed ? flow.captcha : null;
|
|
10
|
+
$: captcha = drawn($settings, $flowState);
|
|
8
11
|
</script>
|
|
9
12
|
|
|
10
13
|
<AttemptFlow
|
|
@@ -13,4 +16,7 @@ onDestroy(() => flow.destroy());
|
|
|
13
16
|
errors={$flowState.errors}
|
|
14
17
|
titleKey="signIn.title"
|
|
15
18
|
onSubmit={flow.submit}
|
|
19
|
+
onPrepare={flow.prepare}
|
|
20
|
+
emailDelivery={$settings?.emailDelivery ?? null}
|
|
21
|
+
{captcha}
|
|
16
22
|
/>
|
|
@@ -4,7 +4,10 @@ declare const __propDef: {
|
|
|
4
4
|
/**
|
|
5
5
|
* §10.2 `<SignIn/>` — a full multi-step flow driven entirely by the
|
|
6
6
|
* server-side attempt status. Contains no decision about what comes next; it
|
|
7
|
-
* renders whatever `status` the server returned.
|
|
7
|
+
* renders whatever `status` the server returned. Draws the instance's sign-in
|
|
8
|
+
* captcha when it is required (always-on, or a `needs_captcha` challenge).
|
|
9
|
+
* Offers emailed first factors (code / link) only while the app can deliver
|
|
10
|
+
* email (`settings.emailDelivery`).
|
|
8
11
|
*/ afterUrl?: string | undefined;
|
|
9
12
|
};
|
|
10
13
|
events: {
|
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
<script>import { onDestroy } from "svelte";
|
|
2
2
|
import { signUp } from "../accessors";
|
|
3
3
|
import AttemptFlow from "./AttemptFlow.svelte";
|
|
4
|
+
import SignUpForm from "./SignUpForm.svelte";
|
|
4
5
|
export let afterUrl = void 0;
|
|
5
6
|
const flow = signUp();
|
|
6
7
|
const flowState = flow.state;
|
|
8
|
+
const settings = flow.settings;
|
|
7
9
|
onDestroy(() => flow.destroy());
|
|
10
|
+
const drawn = (..._deps) => flow.signUpCaptcha.needed ? flow.signUpCaptcha : null;
|
|
11
|
+
$: captcha = drawn($settings, $flowState);
|
|
8
12
|
</script>
|
|
9
13
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
{#if $flowState.attempt}
|
|
15
|
+
<AttemptFlow
|
|
16
|
+
attempt={$flowState.attempt}
|
|
17
|
+
busy={$flowState.busy}
|
|
18
|
+
errors={$flowState.errors}
|
|
19
|
+
titleKey="signUp.title"
|
|
20
|
+
onSubmit={flow.submit}
|
|
21
|
+
/>
|
|
22
|
+
{:else}
|
|
23
|
+
<SignUpForm
|
|
24
|
+
settings={$settings}
|
|
25
|
+
busy={$flowState.busy}
|
|
26
|
+
errors={$flowState.errors}
|
|
27
|
+
{captcha}
|
|
28
|
+
onSubmit={flow.create}
|
|
29
|
+
/>
|
|
30
|
+
{/if}
|
|
@@ -2,8 +2,11 @@ import { SvelteComponent } from "svelte";
|
|
|
2
2
|
declare const __propDef: {
|
|
3
3
|
props: {
|
|
4
4
|
/**
|
|
5
|
-
* §10.2 `<SignUp/>` —
|
|
6
|
-
*
|
|
5
|
+
* §10.2 `<SignUp/>` — creates an account. The first step is the
|
|
6
|
+
* create-account form (the instance's configured fields, email, password,
|
|
7
|
+
* legal consent and — when required — the captcha), which POSTs
|
|
8
|
+
* `/v1/client/sign_ups`; from there the flow is server-driven exactly like
|
|
9
|
+
* `<SignIn/>` (the emailed-code step, then the ticket exchange).
|
|
7
10
|
*/ afterUrl?: string | undefined;
|
|
8
11
|
};
|
|
9
12
|
events: {
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
<script>import { useAtlas } from "../context";
|
|
2
|
+
import { classFor } from "../appearance";
|
|
3
|
+
import { translate } from "../i18n";
|
|
4
|
+
import CaptchaSlot from "./CaptchaSlot.svelte";
|
|
5
|
+
export let settings = null;
|
|
6
|
+
export let busy = false;
|
|
7
|
+
export let errors = [];
|
|
8
|
+
export let captcha = null;
|
|
9
|
+
export let onSubmit;
|
|
10
|
+
const atlas = useAtlas();
|
|
11
|
+
const cls = (key, base) => classFor(key, atlas.appearance, base);
|
|
12
|
+
const t = (key, vars) => translate(atlas.catalog, key, vars);
|
|
13
|
+
let values = {};
|
|
14
|
+
let agreed = false;
|
|
15
|
+
$: legal = settings?.legal;
|
|
16
|
+
$: showLegal = !!legal && (!!legal.termsUrl || !!legal.privacyUrl);
|
|
17
|
+
$: consentOk = !legal?.required || agreed;
|
|
18
|
+
$: fields = settings?.signUpFields ?? [];
|
|
19
|
+
$: emailUnavailable = settings?.emailDelivery?.passwordSignUp === false;
|
|
20
|
+
const label = (f) => f.required ? f.label : t("signUp.optionalField", { label: f.label });
|
|
21
|
+
const inputType = (f) => ["email", "tel", "url", "number", "date"].includes(f.type) ? f.type : "text";
|
|
22
|
+
const picked = (key) => values[key] ? values[key].split(", ") : [];
|
|
23
|
+
function toggle(f, option, on) {
|
|
24
|
+
if (f.type === "radio") values[f.key] = option;
|
|
25
|
+
else {
|
|
26
|
+
const current = picked(f.key);
|
|
27
|
+
values[f.key] = (on ? [...current, option] : current.filter((p) => p !== option)).join(", ");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function handleSubmit(event) {
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
if (!consentOk) return;
|
|
33
|
+
onSubmit({ ...values }, legal?.required ? agreed : true);
|
|
34
|
+
}
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<div class={cls('card', 'atlas-card')}>
|
|
38
|
+
<h1 class={cls('headerTitle', 'atlas-title')}>{t('signUp.title')}</h1>
|
|
39
|
+
|
|
40
|
+
{#each errors.filter((e) => !e.param) as error (error.code)}
|
|
41
|
+
<p class={cls('formFieldError', 'atlas-error')} role="alert">{error.message}</p>
|
|
42
|
+
{/each}
|
|
43
|
+
|
|
44
|
+
{#if emailUnavailable}
|
|
45
|
+
<p class={cls('formFieldError', 'atlas-error')} role="alert">{t('signUp.emailUnavailable')}</p>
|
|
46
|
+
{:else}
|
|
47
|
+
<form on:submit={handleSubmit}>
|
|
48
|
+
{#each fields as f (f.key)}
|
|
49
|
+
{#if f.type === 'radio' || f.type === 'multiselect'}
|
|
50
|
+
<!-- A choice group holds several inputs, so it can't sit inside one <label>. -->
|
|
51
|
+
<div class={cls('formField', 'atlas-field')}>
|
|
52
|
+
<span class={cls('formFieldLabel', 'atlas-field-label')}>{label(f)}</span>
|
|
53
|
+
<span class="atlas-choices">
|
|
54
|
+
{#each f.options as o (o)}
|
|
55
|
+
<label class="atlas-choice">
|
|
56
|
+
<input
|
|
57
|
+
type={f.type === 'radio' ? 'radio' : 'checkbox'}
|
|
58
|
+
name={f.key}
|
|
59
|
+
value={o}
|
|
60
|
+
checked={picked(f.key).includes(o)}
|
|
61
|
+
on:change={(e) => toggle(f, o, e.currentTarget.checked)}
|
|
62
|
+
/>
|
|
63
|
+
{o}
|
|
64
|
+
</label>
|
|
65
|
+
{/each}
|
|
66
|
+
</span>
|
|
67
|
+
{#each errors.filter((e) => e.param === f.key) as error (error.code)}
|
|
68
|
+
<span class={cls('formFieldError', 'atlas-error')}>{error.message}</span>
|
|
69
|
+
{/each}
|
|
70
|
+
</div>
|
|
71
|
+
{:else}
|
|
72
|
+
<label class={cls('formField', 'atlas-field')}>
|
|
73
|
+
<span class={cls('formFieldLabel', 'atlas-field-label')}>{label(f)}</span>
|
|
74
|
+
{#if f.type === 'checkbox'}
|
|
75
|
+
<input
|
|
76
|
+
type="checkbox"
|
|
77
|
+
name={f.key}
|
|
78
|
+
checked={values[f.key] === 'true'}
|
|
79
|
+
on:change={(e) => (values[f.key] = e.currentTarget.checked ? 'true' : '')}
|
|
80
|
+
/>
|
|
81
|
+
{:else if f.type === 'select'}
|
|
82
|
+
<select
|
|
83
|
+
class={cls('formFieldInput', 'atlas-input')}
|
|
84
|
+
name={f.key}
|
|
85
|
+
value={values[f.key] ?? ''}
|
|
86
|
+
required={f.required}
|
|
87
|
+
on:change={(e) => (values[f.key] = e.currentTarget.value)}
|
|
88
|
+
>
|
|
89
|
+
<option value="">{f.placeholder ?? t('signUp.selectPlaceholder')}</option>
|
|
90
|
+
{#each f.options as o (o)}
|
|
91
|
+
<option value={o}>{o}</option>
|
|
92
|
+
{/each}
|
|
93
|
+
</select>
|
|
94
|
+
{:else if f.type === 'textarea'}
|
|
95
|
+
<textarea
|
|
96
|
+
class={cls('formFieldInput', 'atlas-input')}
|
|
97
|
+
name={f.key}
|
|
98
|
+
rows="3"
|
|
99
|
+
value={values[f.key] ?? ''}
|
|
100
|
+
required={f.required}
|
|
101
|
+
placeholder={f.placeholder ?? undefined}
|
|
102
|
+
on:input={(e) => (values[f.key] = e.currentTarget.value)}
|
|
103
|
+
/>
|
|
104
|
+
{:else}
|
|
105
|
+
<input
|
|
106
|
+
class={cls('formFieldInput', 'atlas-input')}
|
|
107
|
+
type={inputType(f)}
|
|
108
|
+
name={f.key}
|
|
109
|
+
value={values[f.key] ?? ''}
|
|
110
|
+
required={f.required}
|
|
111
|
+
placeholder={f.placeholder ?? undefined}
|
|
112
|
+
on:input={(e) => (values[f.key] = e.currentTarget.value)}
|
|
113
|
+
/>
|
|
114
|
+
{/if}
|
|
115
|
+
{#each errors.filter((e) => e.param === f.key) as error (error.code)}
|
|
116
|
+
<span class={cls('formFieldError', 'atlas-error')}>{error.message}</span>
|
|
117
|
+
{/each}
|
|
118
|
+
</label>
|
|
119
|
+
{/if}
|
|
120
|
+
{/each}
|
|
121
|
+
|
|
122
|
+
<label class={cls('formField', 'atlas-field')}>
|
|
123
|
+
<span class={cls('formFieldLabel', 'atlas-field-label')}>{t('signUp.emailLabel')}</span>
|
|
124
|
+
<input
|
|
125
|
+
class={cls('formFieldInput', 'atlas-input')}
|
|
126
|
+
type="email"
|
|
127
|
+
name="email"
|
|
128
|
+
autocomplete="email"
|
|
129
|
+
required
|
|
130
|
+
value={values.email ?? ''}
|
|
131
|
+
on:input={(e) => (values.email = e.currentTarget.value)}
|
|
132
|
+
/>
|
|
133
|
+
{#each errors.filter((e) => e.param === 'email') as error (error.code)}
|
|
134
|
+
<span class={cls('formFieldError', 'atlas-error')}>{error.message}</span>
|
|
135
|
+
{/each}
|
|
136
|
+
</label>
|
|
137
|
+
|
|
138
|
+
<label class={cls('formField', 'atlas-field')}>
|
|
139
|
+
<span class={cls('formFieldLabel', 'atlas-field-label')}>{t('signUp.passwordLabel')}</span>
|
|
140
|
+
<input
|
|
141
|
+
class={cls('formFieldInput', 'atlas-input')}
|
|
142
|
+
type="password"
|
|
143
|
+
name="password"
|
|
144
|
+
autocomplete="new-password"
|
|
145
|
+
required
|
|
146
|
+
value={values.password ?? ''}
|
|
147
|
+
on:input={(e) => (values.password = e.currentTarget.value)}
|
|
148
|
+
/>
|
|
149
|
+
{#each errors.filter((e) => e.param === 'password') as error (error.code)}
|
|
150
|
+
<span class={cls('formFieldError', 'atlas-error')}>{error.message}</span>
|
|
151
|
+
{/each}
|
|
152
|
+
</label>
|
|
153
|
+
|
|
154
|
+
{#if showLegal && legal}
|
|
155
|
+
<label class={cls('formField', 'atlas-consent')}>
|
|
156
|
+
{#if legal.required}
|
|
157
|
+
<input type="checkbox" name="consent" bind:checked={agreed} />
|
|
158
|
+
{/if}
|
|
159
|
+
<span class="atlas-text">
|
|
160
|
+
{t(legal.required ? 'signUp.consentAgree' : 'signUp.consentNotice')}
|
|
161
|
+
{#if legal.termsUrl}
|
|
162
|
+
<a class="atlas-link" href={legal.termsUrl} target="_blank" rel="noopener noreferrer"
|
|
163
|
+
>{t('signUp.terms')}</a
|
|
164
|
+
>{/if}{#if legal.termsUrl && legal.privacyUrl}{' '}{t('signUp.and')}{' '}{/if}{#if legal.privacyUrl}
|
|
165
|
+
<a class="atlas-link" href={legal.privacyUrl} target="_blank" rel="noopener noreferrer"
|
|
166
|
+
>{t('signUp.privacy')}</a
|
|
167
|
+
>{/if}.
|
|
168
|
+
</span>
|
|
169
|
+
{#each errors.filter((e) => e.param === 'consent') as error (error.code)}
|
|
170
|
+
<span class={cls('formFieldError', 'atlas-error')}>{error.message}</span>
|
|
171
|
+
{/each}
|
|
172
|
+
</label>
|
|
173
|
+
{/if}
|
|
174
|
+
|
|
175
|
+
{#if captcha}
|
|
176
|
+
<CaptchaSlot controller={captcha} />
|
|
177
|
+
{/if}
|
|
178
|
+
|
|
179
|
+
<button
|
|
180
|
+
class={cls('formButtonPrimary', 'atlas-button')}
|
|
181
|
+
disabled={busy || !consentOk}
|
|
182
|
+
type="submit"
|
|
183
|
+
>
|
|
184
|
+
{t('signUp.submit')}
|
|
185
|
+
</button>
|
|
186
|
+
</form>
|
|
187
|
+
{/if}
|
|
188
|
+
</div>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { SvelteComponent } from "svelte";
|
|
2
|
+
import type { CaptchaController, FieldError, InstanceSettings } from '@atlasauth/js';
|
|
3
|
+
declare const __propDef: {
|
|
4
|
+
props: {
|
|
5
|
+
/**
|
|
6
|
+
* The create-account step of `<SignUp/>`: the instance's configured extra
|
|
7
|
+
* fields, email + password, the legal-consent row and — when the instance
|
|
8
|
+
* requires one on sign-up — the captcha widget. Values live in local state so
|
|
9
|
+
* a refused submit never clears them.
|
|
10
|
+
*
|
|
11
|
+
* When the app cannot send email (`settings.emailDelivery.passwordSignUp` is
|
|
12
|
+
* false) an email + password sign-up could never be verified, so the form is
|
|
13
|
+
* replaced by a "can't send email" notice (`signUp.emailUnavailable`).
|
|
14
|
+
*/ settings?: InstanceSettings | null;
|
|
15
|
+
busy?: boolean;
|
|
16
|
+
errors?: FieldError[];
|
|
17
|
+
captcha?: CaptchaController | null;
|
|
18
|
+
onSubmit: (values: Record<string, string>, consent: boolean) => void;
|
|
19
|
+
};
|
|
20
|
+
events: {
|
|
21
|
+
[evt: string]: CustomEvent<any>;
|
|
22
|
+
};
|
|
23
|
+
slots: {};
|
|
24
|
+
exports?: {} | undefined;
|
|
25
|
+
bindings?: string | undefined;
|
|
26
|
+
};
|
|
27
|
+
export type SignUpFormProps = typeof __propDef.props;
|
|
28
|
+
export type SignUpFormEvents = typeof __propDef.events;
|
|
29
|
+
export type SignUpFormSlots = typeof __propDef.slots;
|
|
30
|
+
export default class SignUpForm extends SvelteComponent<SignUpFormProps, SignUpFormEvents, SignUpFormSlots> {
|
|
31
|
+
}
|
|
32
|
+
export {};
|
package/dist/context.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Readable } from 'svelte/store';
|
|
2
|
-
import { type ProviderToken, type SignOutOptions } from '@atlasauth/js';
|
|
2
|
+
import { type ProviderToken, type SignOutOptions, type SignOutResult } from '@atlasauth/js';
|
|
3
3
|
import { type Catalog } from './i18n';
|
|
4
4
|
import { type Appearance, type InstanceDesign } from './appearance';
|
|
5
5
|
import type { AtlasOrganizationMembership, AtlasSession, AtlasUser, SessionClaims } from './types';
|
|
@@ -23,7 +23,14 @@ export type AuthStatus = 'loading' | 'signed_in' | 'signed_out';
|
|
|
23
23
|
export declare const ATLAS_KEY: unique symbol;
|
|
24
24
|
export interface AtlasInitOptions {
|
|
25
25
|
publishableKey: string;
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* The instance's Frontend API: an absolute URL such as
|
|
28
|
+
* `"https://auth.example.com"`, or `"same-origin"` / a path such as `"/atlas"`
|
|
29
|
+
* behind a reverse proxy on the app's own origin. Required at runtime — a
|
|
30
|
+
* missing or malformed value throws an `AtlasConfigError`
|
|
31
|
+
* (`FRONTEND_API_REQUIRED` / `FRONTEND_API_INVALID`). Optional in the type
|
|
32
|
+
* only so existing call sites keep compiling.
|
|
33
|
+
*/
|
|
27
34
|
frontendApi?: string;
|
|
28
35
|
appearance?: Appearance;
|
|
29
36
|
localization?: Catalog;
|
|
@@ -71,7 +78,19 @@ export interface AtlasClient extends AtlasStores {
|
|
|
71
78
|
* exposed; the server refreshes a stale token on read.
|
|
72
79
|
*/
|
|
73
80
|
getProviderToken(provider: string): Promise<ProviderToken | null>;
|
|
74
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Sign out of the active session: revoke it on the Frontend API (retried
|
|
83
|
+
* once), run front-channel logout, and drop all in-memory state.
|
|
84
|
+
*
|
|
85
|
+
* Resolves `{ revoked, activeSessionId? }`. `revoked: false` means the server
|
|
86
|
+
* could not confirm the revoke; the user is still signed out locally and this
|
|
87
|
+
* browser will not re-adopt the session (a reload, a refresh, a sibling tab or
|
|
88
|
+
* a tab opened later), while the revoke is re-tried in the background. When
|
|
89
|
+
* the server promotes another session signed in on this browser
|
|
90
|
+
* (`activeSessionId`), the client adopts it. With `{ strict: true }` a failed
|
|
91
|
+
* revoke rejects with a `SignOutError` (after the local sign-out).
|
|
92
|
+
*/
|
|
93
|
+
signOut(options?: SignOutOptions): Promise<SignOutResult>;
|
|
75
94
|
setActiveOrganization(organizationId: string | null): Promise<void>;
|
|
76
95
|
reload(): Promise<void>;
|
|
77
96
|
/**
|
|
@@ -85,7 +104,10 @@ export interface AtlasClient extends AtlasStores {
|
|
|
85
104
|
* when there is none or it cannot be read. `<AtlasProvider>` applies it.
|
|
86
105
|
*/
|
|
87
106
|
loadInstanceDesign(): Promise<InstanceDesign | null>;
|
|
88
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* Cancel the scheduled refresh, the sibling-tab sign-out watch and any
|
|
109
|
+
* background revoke re-tries — call from a component's `onDestroy`.
|
|
110
|
+
*/
|
|
89
111
|
destroy(): void;
|
|
90
112
|
}
|
|
91
113
|
/**
|
package/dist/context.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getContext, setContext } from 'svelte';
|
|
2
2
|
import { derived, get, writable } from 'svelte/store';
|
|
3
|
-
import { FapiClient, TokenCache, getProviderToken as fetchProviderToken, msUntilRefresh, readExpiry, readRedirectResult,
|
|
3
|
+
import { FapiClient, SignOutGuard, TokenCache, closeOAuthPopupAfterSignIn, completeOAuthPopupLanding, getProviderToken as fetchProviderToken, mapOAuthPopupPayload, msUntilRefresh, readExpiry, readRedirectResult, resolveFrontendApi, stripRedirectParams, } from '@atlasauth/js';
|
|
4
4
|
import { EN_US, withFallback } from './i18n';
|
|
5
5
|
import { instanceDesignFrom } from './appearance';
|
|
6
6
|
/** The context key. A `Symbol` so it cannot collide with an app's own context. */
|
|
@@ -22,7 +22,11 @@ function decodeClaims(jwt) {
|
|
|
22
22
|
*/
|
|
23
23
|
export function createAtlasClient(options) {
|
|
24
24
|
const { publishableKey } = options;
|
|
25
|
-
|
|
25
|
+
// Throws on a missing/invalid value rather than quietly sending every request
|
|
26
|
+
// to a relative path on the app's origin.
|
|
27
|
+
const baseUrl = resolveFrontendApi(options.frontendApi, {
|
|
28
|
+
component: '<AtlasProvider> / initAtlas()',
|
|
29
|
+
});
|
|
26
30
|
const doFetch = options.fetchImpl ?? (typeof fetch !== 'undefined' ? fetch : undefined);
|
|
27
31
|
const catalog = options.localization ? withFallback(options.localization) : EN_US;
|
|
28
32
|
const status = writable('loading');
|
|
@@ -61,7 +65,25 @@ export function createAtlasClient(options) {
|
|
|
61
65
|
},
|
|
62
66
|
});
|
|
63
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* The sessions this browser signed out of (tombstones, shared by every tab via
|
|
70
|
+
* localStorage per Frontend API). Nothing here re-adopts one — not a reload, a
|
|
71
|
+
* refresh, nor a tab opened later — even while the refresh cookie the revoke
|
|
72
|
+
* did not clear is still valid; the revoke is re-tried in the background.
|
|
73
|
+
*/
|
|
74
|
+
const guard = new SignOutGuard({ request: call, frontendApi: baseUrl });
|
|
75
|
+
// Another tab signed out of THIS session (its tombstone reached the shared
|
|
76
|
+
// store): drop the token and re-check, which refuses the session.
|
|
77
|
+
const stopWatch = typeof window !== 'undefined'
|
|
78
|
+
? guard.watch(() => cache.peek()?.sessionId ?? get(session)?.id, () => {
|
|
79
|
+
cache.clear();
|
|
80
|
+
void reload();
|
|
81
|
+
})
|
|
82
|
+
: () => undefined;
|
|
64
83
|
function applyToken(jwt, sessionId) {
|
|
84
|
+
// An in-flight refresh must not resurrect a session this browser signed out of.
|
|
85
|
+
if (guard.isSignedOut(sessionId))
|
|
86
|
+
return;
|
|
65
87
|
const expiresAt = readExpiry(jwt);
|
|
66
88
|
if (!expiresAt)
|
|
67
89
|
return;
|
|
@@ -95,7 +117,9 @@ export function createAtlasClient(options) {
|
|
|
95
117
|
if (!response.ok)
|
|
96
118
|
throw new Error('boot failed');
|
|
97
119
|
const body = (await response.json());
|
|
98
|
-
|
|
120
|
+
// A session this browser signed out of (the revoke never landed) is
|
|
121
|
+
// refused: stay signed out; the guard re-tries the revoke in the background.
|
|
122
|
+
if (!body.session || !body.user || guard.refuse(body.session.id)) {
|
|
99
123
|
clearSession();
|
|
100
124
|
return;
|
|
101
125
|
}
|
|
@@ -149,20 +173,21 @@ export function createAtlasClient(options) {
|
|
|
149
173
|
return fetchProviderToken(fapiClient, provider);
|
|
150
174
|
}
|
|
151
175
|
async function signOut(options) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
// clear theirs in this browser (hidden iframes, capped at 5 s). Best-effort.
|
|
159
|
-
if (options?.frontchannel !== false)
|
|
160
|
-
await runFrontChannelLogoutFromResponse(revoked);
|
|
161
|
-
}
|
|
176
|
+
// The session to end: from the token cache, else the booted session, else
|
|
177
|
+
// (no state yet) whatever session the cookies carry — the guard looks it up,
|
|
178
|
+
// so a missing in-memory token never skips the server-side sign-out. It
|
|
179
|
+
// tombstones the session first, revokes it (retried once) and runs OIDC
|
|
180
|
+
// front-channel logout.
|
|
181
|
+
const attempt = await guard.revoke(cache.peek()?.sessionId ?? get(session)?.id ?? null, options);
|
|
162
182
|
if (timer)
|
|
163
183
|
clearTimeout(timer);
|
|
164
184
|
timer = null;
|
|
165
185
|
clearSession();
|
|
186
|
+
// The server promoted another session signed in on this browser: adopt it
|
|
187
|
+
// deliberately (the revoke response already set its cookies).
|
|
188
|
+
if (guard.promoted(attempt))
|
|
189
|
+
await reload();
|
|
190
|
+
return guard.settle(attempt, options);
|
|
166
191
|
}
|
|
167
192
|
async function setActiveOrganization(organizationId) {
|
|
168
193
|
const current = cache.peek();
|
|
@@ -190,18 +215,44 @@ export function createAtlasClient(options) {
|
|
|
190
215
|
* cookies, scrub the URL so a reload can't replay it, THEN boot. A redirect
|
|
191
216
|
* that only carries a `__atlas_status` (a second factor is still owed) has no
|
|
192
217
|
* ticket, so nothing is exchanged and the app boots signed-out.
|
|
218
|
+
*
|
|
219
|
+
* §oauth popup mode: this page may also be running INSIDE the sign-in popup.
|
|
220
|
+
* - As the landing of a popup whose opener was severed by a
|
|
221
|
+
* Cross-Origin-Opener-Policy (`#atlas_popup_result=…`): relay the result
|
|
222
|
+
* to the opener over BroadcastChannel and close. If no opener takes it
|
|
223
|
+
* (its tab is gone), finish the sign-in here like a redirect.
|
|
224
|
+
* - After finishing a sign-in here (the redirect flow ran in the popup):
|
|
225
|
+
* tell the waiting opener and close the popup.
|
|
193
226
|
*/
|
|
194
|
-
const
|
|
227
|
+
const fromUrl = typeof window !== 'undefined' ? readRedirectResult(window.location.search) : null;
|
|
228
|
+
let result = fromUrl;
|
|
229
|
+
const landing = typeof window !== 'undefined' ? await completeOAuthPopupLanding().catch(() => null) : null;
|
|
230
|
+
if (landing && !landing.delivered) {
|
|
231
|
+
const outcome = mapOAuthPopupPayload(landing.payload);
|
|
232
|
+
if (outcome?.ticket && outcome.attemptId) {
|
|
233
|
+
result = { attemptId: outcome.attemptId, ticket: outcome.ticket };
|
|
234
|
+
}
|
|
235
|
+
else if (outcome?.continuationUrl) {
|
|
236
|
+
// A second factor / connect / provider error: the redirect flow's page.
|
|
237
|
+
window.location.replace(outcome.continuationUrl);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
let exchanged = false;
|
|
195
242
|
if (result?.ticket) {
|
|
196
|
-
await call('/v1/client/tickets/exchange', {
|
|
243
|
+
const response = await call('/v1/client/tickets/exchange', {
|
|
197
244
|
method: 'POST',
|
|
198
245
|
body: JSON.stringify({ attempt_id: result.attemptId, ticket: result.ticket }),
|
|
199
246
|
}).catch(() => undefined);
|
|
247
|
+
exchanged = !!response?.ok;
|
|
200
248
|
}
|
|
201
|
-
if (
|
|
249
|
+
if (fromUrl && typeof window !== 'undefined') {
|
|
202
250
|
window.history.replaceState({}, document.title, stripRedirectParams(window.location.href));
|
|
203
251
|
}
|
|
204
252
|
await reload();
|
|
253
|
+
// Signed in inside the Atlas popup: hand back to the opener and close.
|
|
254
|
+
if (exchanged)
|
|
255
|
+
void closeOAuthPopupAfterSignIn();
|
|
205
256
|
}
|
|
206
257
|
async function loadInstanceDesign() {
|
|
207
258
|
try {
|
|
@@ -216,6 +267,8 @@ export function createAtlasClient(options) {
|
|
|
216
267
|
if (timer)
|
|
217
268
|
clearTimeout(timer);
|
|
218
269
|
timer = null;
|
|
270
|
+
stopWatch();
|
|
271
|
+
guard.dispose();
|
|
219
272
|
}
|
|
220
273
|
const isSignedIn = derived(status, ($status) => $status === 'signed_in');
|
|
221
274
|
const isLoaded = derived(status, ($status) => $status !== 'loading');
|
package/dist/i18n.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* A missing key renders the KEY, not an empty string. A blank button is a bug
|
|
10
10
|
* nobody can report; a button reading `signIn.submit` is one anyone can.
|
|
11
11
|
*/
|
|
12
|
+
import { EMAIL_UNAVAILABLE_MESSAGE } from '@atlasauth/js';
|
|
12
13
|
export const EN_US = {
|
|
13
14
|
'signIn.title': 'Sign in',
|
|
14
15
|
'signIn.subtitle': 'to continue to {application}',
|
|
@@ -21,12 +22,28 @@ export const EN_US = {
|
|
|
21
22
|
'signIn.orDivider': 'or',
|
|
22
23
|
'signIn.socialButton': 'Continue with {provider}',
|
|
23
24
|
'signIn.emailCodeLabel': 'Verification code',
|
|
25
|
+
'signIn.emailCodeOption': 'Email me a code',
|
|
26
|
+
'signIn.emailLinkOption': 'Email me a sign-in link',
|
|
24
27
|
'signIn.magicLinkSent': 'Check your email for a sign-in link.',
|
|
25
28
|
'signIn.checkOtherTab': 'You can close this tab — you are signed in where you started.',
|
|
26
29
|
'signUp.title': 'Create your account',
|
|
27
30
|
'signUp.submit': 'Continue',
|
|
28
31
|
'signUp.haveAccount': 'Already have an account?',
|
|
29
32
|
'signUp.signInLink': 'Sign in',
|
|
33
|
+
'signUp.emailLabel': 'Email address',
|
|
34
|
+
'signUp.passwordLabel': 'Password',
|
|
35
|
+
'signUp.optionalField': '{label} (optional)',
|
|
36
|
+
'signUp.selectPlaceholder': 'Select…',
|
|
37
|
+
'signUp.consentAgree': 'I agree to the',
|
|
38
|
+
'signUp.consentNotice': 'By continuing you agree to the',
|
|
39
|
+
'signUp.terms': 'Terms of Service',
|
|
40
|
+
'signUp.privacy': 'Privacy Policy',
|
|
41
|
+
'signUp.and': 'and',
|
|
42
|
+
// Shown instead of the email + password form when the app cannot send email.
|
|
43
|
+
'signUp.emailUnavailable': EMAIL_UNAVAILABLE_MESSAGE,
|
|
44
|
+
'captcha.prompt': 'Complete the security check to continue.',
|
|
45
|
+
'captcha.required': 'Please complete the security check, then try again.',
|
|
46
|
+
'captcha.failed': "The security check didn't pass. Please try again.",
|
|
30
47
|
'mfa.title': 'Two-step verification',
|
|
31
48
|
'mfa.subtitle': 'Enter the code from your authenticator app.',
|
|
32
49
|
'mfa.codeLabel': 'Code',
|
package/dist/index.d.ts
CHANGED
|
@@ -29,3 +29,6 @@ export { default as UserButton } from './components/UserButton.svelte';
|
|
|
29
29
|
export { default as UserProfile } from './components/UserProfile.svelte';
|
|
30
30
|
export { default as OrganizationSwitcher } from './components/OrganizationSwitcher.svelte';
|
|
31
31
|
export { default as OrganizationProfile } from './components/OrganizationProfile.svelte';
|
|
32
|
+
export type { InstanceSettings, EmailDelivery, PublicSignUpField, CaptchaController, CaptchaFlowKey, CaptchaWidgetMode, FlowCaptcha, } from '@atlasauth/js';
|
|
33
|
+
export { SignOutError } from '@atlasauth/js';
|
|
34
|
+
export type { SignOutOptions, SignOutResult } from '@atlasauth/js';
|
package/dist/index.js
CHANGED
|
@@ -33,3 +33,5 @@ export { default as UserButton } from './components/UserButton.svelte';
|
|
|
33
33
|
export { default as UserProfile } from './components/UserProfile.svelte';
|
|
34
34
|
export { default as OrganizationSwitcher } from './components/OrganizationSwitcher.svelte';
|
|
35
35
|
export { default as OrganizationProfile } from './components/OrganizationProfile.svelte';
|
|
36
|
+
// What `signOut()` resolves with, and what it rejects with under `{ strict: true }`.
|
|
37
|
+
export { SignOutError } from '@atlasauth/js';
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atlasauth/svelte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"svelte": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
7
7
|
"files": [
|
|
8
8
|
"dist",
|
|
9
|
-
"README.md"
|
|
9
|
+
"README.md",
|
|
10
|
+
"CHANGELOG.md"
|
|
10
11
|
],
|
|
11
12
|
"exports": {
|
|
12
13
|
".": {
|
|
@@ -21,10 +22,10 @@
|
|
|
21
22
|
"./package.json": "./package.json"
|
|
22
23
|
},
|
|
23
24
|
"dependencies": {
|
|
24
|
-
"@atlasauth/
|
|
25
|
+
"@atlasauth/design": "0.2.0",
|
|
25
26
|
"@atlasauth/authz": "0.1.1",
|
|
26
|
-
"@atlasauth/js": "0.
|
|
27
|
-
"@atlasauth/
|
|
27
|
+
"@atlasauth/js": "0.8.0",
|
|
28
|
+
"@atlasauth/backend": "0.7.2"
|
|
28
29
|
},
|
|
29
30
|
"peerDependencies": {
|
|
30
31
|
"svelte": ">=4",
|