@atlasauth/svelte 0.2.0 → 0.3.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 ADDED
@@ -0,0 +1,68 @@
1
+ # @atlasauth/svelte changelog
2
+
3
+ ## 0.3.0
4
+ - **`<SignUp>` now creates accounts.** Its first step is a create-account form (the instance's
5
+ configured sign-up fields, email, password and the legal-consent row) that posts
6
+ `POST /v1/client/sign_ups`; the emailed-code step and the ticket exchange then run through the shared
7
+ flow. Previously `<SignUp>` rendered the sign-in identifier step and could not create an account.
8
+ - **Captcha renders automatically.** When the instance turns captcha on for a flow, `<SignUp>` and
9
+ `<SignIn>` show the provider's widget (Turnstile, hCaptcha, reCAPTCHA v2/v3/Enterprise) in the
10
+ configured mode (`visible`, `managed`, or `invisible` — run on submit), send its token as
11
+ `captcha_token`, answer the sign-in `needs_captcha` step (continuing as soon as it is solved), and on
12
+ `CAPTCHA_REQUIRED` / `CAPTCHA_FAILED` show a localized message and a fresh widget.
13
+ - **Added to `FlowController`** (returned by `signIn()` / `signUp()`): `create(values, consent?)`,
14
+ `settings: Readable<InstanceSettings | null>`, `captcha: CaptchaController`,
15
+ `signUpCaptcha: CaptchaController`; `destroy()` now also removes the captcha widgets.
16
+ - **Added type re-exports** `InstanceSettings`, `PublicSignUpField`, `CaptchaController`,
17
+ `CaptchaFlowKey`, `CaptchaWidgetMode`, `FlowCaptcha` from `@atlasauth/js`.
18
+ - **Added catalog keys** (en-US): `signUp.emailLabel`, `signUp.passwordLabel`, `signUp.optionalField`,
19
+ `signUp.selectPlaceholder`, `signUp.consentAgree`, `signUp.consentNotice`, `signUp.terms`,
20
+ `signUp.privacy`, `signUp.and`, `captcha.prompt`, `captcha.required`, `captcha.failed`.
21
+ - New element classes `atlas-captcha`, `atlas-consent`, `atlas-choices`, `atlas-choice`, `atlas-text`,
22
+ `atlas-link`; the flow's inputs now carry `name` attributes.
23
+ - **`frontendApi` is validated at `initAtlas()` / `<AtlasProvider>`.** A missing (or non-string) or malformed value now throws an
24
+ `AtlasConfigError` (from `@atlasauth/js`; `code: 'FRONTEND_API_REQUIRED'` or `'FRONTEND_API_INVALID'`)
25
+ instead of silently sending requests to relative URLs on the app's own origin. Accepted: an absolute
26
+ `https:` URL (`http:` only for localhost / local development hosts), or `"same-origin"` / a root-relative path such as `"/atlas"` for a reverse proxy.
27
+ - **Deprecated:** `frontendApi: ""` (the original same-origin spelling) still works as same-origin, with a
28
+ one-time console warning; use `"same-origin"` instead.
29
+
30
+ ## 0.2.1
31
+ - **Front-channel logout on sign-out.** `signOut()` now loads the `frontchannel_logout_urls` the
32
+ Frontend API returns (apps signed in with "Sign in with Atlas" through this session) in hidden,
33
+ sandboxed iframes, so those apps clear their sessions in this browser too. It waits until the frames
34
+ load or 5 s pass, never fails the sign-out, only loads `https:` URLs (and `http://localhost`), and
35
+ does nothing during SSR. Pass `signOut({ frontchannel: false })` to skip it.
36
+
37
+ ## 0.2.0
38
+ - **Design system.** `<AtlasProvider>` now applies the design configured in the Atlas dashboard
39
+ automatically (fetched after mount; opt out with `appearance.instanceDesign: false`).
40
+ - **New `appearance.preset` and `appearance.design`** — the same settings and semantics as
41
+ `@atlasauth/react`: dashboard design < `preset` < `variables` < `design`. Invalid values are dropped
42
+ with a development warning and never reach CSS. The provider root gets stable `data-atlas-*`
43
+ attributes and the `--atlas-*` design variables; web fonts are linked from your Frontend API.
44
+ - The client gains `loadInstanceDesign()`.
45
+ - `appearance.variables` / `baseTheme` are unchanged when no design is in play.
46
+ - New dependency: `@atlasauth/design`.
47
+
48
+ ## 0.1.5
49
+ - **`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.
50
+ - README uses the published `@atlasauth/*` package names.
51
+
52
+ ## 0.1.4
53
+ - **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.
54
+
55
+ ## 0.1.3
56
+ - **Metadata.** Added `repository` (monorepo + package directory), `homepage`, and `bugs` so the npm page links back to source.
57
+
58
+ ## 0.1.2
59
+ - **Docs fix.** README Frontend API examples now use the real host shape `https://your-instance.fapi.atlasauth.net` (was a non-resolving `.atlas.dev`).
60
+
61
+ ## 0.1.1
62
+ - First published release.
63
+ - **Client and context.** Install with `initAtlas()` or `<AtlasProvider>`; `createAtlasClient()` and `useAtlas()` give direct access to the client and its stores.
64
+ - **Store accessors.** `getUser()`, `getSession()`, `getAuth()` and `getOrganization()` return reactive stores; `getToken()`, `signIn()`, `signUp()` and `signOut()` are the action helpers.
65
+ - **Components.** `SignedIn`, `SignedOut`, `AtlasLoading`, `AtlasLoaded`, `Protect`, `SignIn`, `SignUp`, `UserButton`, `UserProfile`, `OrganizationSwitcher` and `OrganizationProfile`.
66
+ - **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.
67
+ - Appearance, localization and authorization helpers shared with the other Atlas UI SDKs.
68
+
@@ -1,5 +1,5 @@
1
1
  import { type Readable } from 'svelte/store';
2
- import { type FlowState, type ProviderToken } from '@atlasauth/js';
2
+ import { type CaptchaController, type FlowState, type InstanceSettings, type ProviderToken, type SignOutOptions } 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,7 @@ 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
- signOut(): Promise<void>;
51
+ signOut(options?: SignOutOptions): Promise<void>;
52
52
  /** Mirrors `<Protect>`, for logic that is not a render decision. */
53
53
  has(condition: ProtectCondition): boolean;
54
54
  }
@@ -64,7 +64,7 @@ export declare function getOrganization(): Readable<UseOrganization>;
64
64
  /** Refresh-if-stale, then hand over the JWT. The `useAuth().getToken` shortcut. */
65
65
  export declare function getToken(): Promise<string | null>;
66
66
  /** Revoke the session and drop all in-memory state. */
67
- export declare function signOut(): Promise<void>;
67
+ export declare function signOut(options?: SignOutOptions): Promise<void>;
68
68
  /**
69
69
  * A running sign-in / sign-up attempt: a reactive `state` store plus `submit`.
70
70
  *
@@ -77,10 +77,31 @@ export declare function signOut(): Promise<void>;
77
77
  export interface FlowController {
78
78
  state: Readable<FlowState>;
79
79
  submit(values: Record<string, string>): void;
80
- /** Stop the magic-link poll loop — call from `onDestroy`. */
80
+ /**
81
+ * Create an account (`POST /v1/client/sign_ups`) from a create-account form's
82
+ * values — email, password and any configured sign-up fields — carrying the
83
+ * sign-up captcha's token when the instance requires one. The emailed code is
84
+ * then posted with {@link submit}.
85
+ */
86
+ create(values: Record<string, string>, consent?: boolean): void;
87
+ /**
88
+ * The instance's public sign-in / sign-up settings (captcha, sign-up fields,
89
+ * legal consent) from `GET /v1/appearance`; null until loaded or when the
90
+ * server could not be reached.
91
+ */
92
+ settings: Readable<InstanceSettings | null>;
93
+ /** The sign-in captcha (identifier step / `needs_captcha` challenge). */
94
+ captcha: CaptchaController;
95
+ /** The sign-up captcha (the create-account step). */
96
+ signUpCaptcha: CaptchaController;
97
+ /** Stop the magic-link poll loop and remove the captcha widgets — call from `onDestroy`. */
81
98
  destroy(): void;
82
99
  }
83
100
  /** Start (or resume) a sign-in flow. Drives `<SignIn>`; usable standalone too. */
84
101
  export declare function signIn(): FlowController;
85
- /** Start a sign-up flow. Drives `<SignUp>`; usable standalone too. */
102
+ /**
103
+ * Start a sign-up flow. Drives `<SignUp>`; usable standalone too:
104
+ * `create(values)` posts `/v1/client/sign_ups` (the account is created there),
105
+ * then `submit({ code })` verifies the emailed code and completes the session.
106
+ */
86
107
  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, advance, flowFromPending, pollAttempt, shouldKeepPolling, } from '@atlasauth/js';
2
+ import { FapiClient, advanceWithCaptcha, createCaptchaController, createSignUp, fetchInstanceSettings, flowNeedsCaptcha, signInCaptchaOptions, flowFromPending, pollAttempt, 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();
@@ -54,8 +55,8 @@ export function getToken() {
54
55
  return useAtlas().getToken();
55
56
  }
56
57
  /** Revoke the session and drop all in-memory state. */
57
- export function signOut() {
58
- return useAtlas().signOut();
58
+ export function signOut(options) {
59
+ return useAtlas().signOut(options);
59
60
  }
60
61
  function createFlow() {
61
62
  const atlas = useAtlas();
@@ -66,6 +67,25 @@ function createFlow() {
66
67
  // Seeded from a redirect that came back mid-flow (e.g. OAuth needing a second
67
68
  // factor), so the user resumes at the demanded step instead of starting over.
68
69
  const state = writable(flowFromPending(atlas.pendingAttempt));
70
+ const settings = writable(null);
71
+ const t = (key) => translate(atlas.catalog, key);
72
+ // §5 the per-flow captcha, driven by the instance's settings so turning it on
73
+ // in the dashboard needs no code change. Kept in step with the settings and
74
+ // the flow's step synchronously, before the components re-render.
75
+ const captcha = createCaptchaController();
76
+ const signUpCaptcha = createCaptchaController();
77
+ let identifier = '';
78
+ const unsubscribeCaptcha = derived([settings, state], (pair) => pair).subscribe(([current, flow]) => {
79
+ captcha.configure(signInCaptchaOptions(current?.captchaFlows.signIn, flow, () => submit({})));
80
+ const signUpFlow = current?.captchaFlows.signUp;
81
+ signUpCaptcha.configure({ flow: signUpFlow, active: !flow.attempt && flowNeedsCaptcha(signUpFlow) });
82
+ });
83
+ if (typeof window !== 'undefined') {
84
+ void fetchInstanceSettings(client).then((loaded) => {
85
+ if (loaded)
86
+ settings.set(loaded);
87
+ });
88
+ }
69
89
  let pollTimer = null;
70
90
  function stopPolling() {
71
91
  if (pollTimer)
@@ -94,35 +114,63 @@ function createFlow() {
94
114
  })();
95
115
  }, 2_000);
96
116
  }
117
+ // On completion the response carries a one-time ticket (§7.1). Exchange it
118
+ // for cookies over a same-origin request — FAPI is cross-origin to the app
119
+ // and cannot set the cookie on the completion response itself — THEN
120
+ // re-boot so the rest of the app sees a signed-in user.
121
+ async function finish(next) {
122
+ state.set(next);
123
+ if (next.attempt?.status === 'complete') {
124
+ if (next.ticket) {
125
+ await client.post('/v1/client/tickets/exchange', {
126
+ attempt_id: next.attempt.id,
127
+ ticket: next.ticket,
128
+ });
129
+ }
130
+ await atlas.reload();
131
+ return;
132
+ }
133
+ maybeStartPolling();
134
+ }
97
135
  function submit(values) {
98
136
  void (async () => {
99
137
  state.update((current) => ({ ...current, busy: true, errors: [] }));
100
- const next = await advance(client, get(state), values);
101
- state.set(next);
102
- // On completion the response carries a one-time ticket (§7.1). Exchange it
103
- // for cookies over a same-origin request — FAPI is cross-origin to the app
104
- // and cannot set the cookie on the completion response itself — THEN
105
- // re-boot so the rest of the app sees a signed-in user.
106
- if (next.attempt?.status === 'complete') {
107
- if (next.ticket) {
108
- await client.post('/v1/client/tickets/exchange', {
109
- attempt_id: next.attempt.id,
110
- ticket: next.ticket,
111
- });
112
- }
113
- await atlas.reload();
114
- return;
115
- }
116
- maybeStartPolling();
138
+ const out = await advanceWithCaptcha(client, get(state), values, { captcha, identifier, t });
139
+ identifier = out.identifier;
140
+ await finish(out.state);
117
141
  })();
118
142
  }
119
- return { state: { subscribe: state.subscribe }, submit, destroy: stopPolling };
143
+ function create(values, consent = true) {
144
+ void (async () => {
145
+ state.update((current) => ({ ...current, busy: true, errors: [] }));
146
+ await finish(await createSignUp(client, values, { consent, captcha: signUpCaptcha, t }));
147
+ })();
148
+ }
149
+ function destroy() {
150
+ stopPolling();
151
+ unsubscribeCaptcha();
152
+ captcha.destroy();
153
+ signUpCaptcha.destroy();
154
+ }
155
+ return {
156
+ state: { subscribe: state.subscribe },
157
+ submit,
158
+ create,
159
+ settings: { subscribe: settings.subscribe },
160
+ captcha,
161
+ signUpCaptcha,
162
+ destroy,
163
+ };
120
164
  }
121
165
  /** Start (or resume) a sign-in flow. Drives `<SignIn>`; usable standalone too. */
122
166
  export function signIn() {
123
167
  return createFlow();
124
168
  }
125
- /** Start a sign-up flow. Drives `<SignUp>`; usable standalone too. */
169
+ /**
170
+ * Start a sign-up flow. Drives `<SignUp>`; usable standalone too:
171
+ * `create(values)` posts `/v1/client/sign_ups` (the account is created there),
172
+ * then `submit({ code })` verifies the emailed code and completes the session.
173
+ */
126
174
  export function signUp() {
127
175
  return createFlow();
128
176
  }
@@ -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(() => {
@@ -2,11 +2,13 @@
2
2
  import { useAtlas } from "../context";
3
3
  import { classFor } from "../appearance";
4
4
  import { translate } from "../i18n";
5
+ import CaptchaSlot from "./CaptchaSlot.svelte";
5
6
  export let attempt = null;
6
7
  export let busy = false;
7
8
  export let errors = [];
8
9
  export let titleKey;
9
10
  export let onSubmit;
11
+ export let captcha = null;
10
12
  const atlas = useAtlas();
11
13
  const cls = (key, base) => classFor(key, atlas.appearance, base);
12
14
  const t = (key, vars) => translate(atlas.catalog, key, vars);
@@ -41,6 +43,7 @@ $: message = (() => {
41
43
  return t("signIn.checkOtherTab");
42
44
  case "restart":
43
45
  return t("error.generic");
46
+ case "collect_captcha":
44
47
  case "collect_identifier":
45
48
  case "collect_first_factor":
46
49
  case "collect_second_factor":
@@ -53,6 +56,7 @@ $: message = (() => {
53
56
  }
54
57
  })();
55
58
  $: showSubmit = step.kind !== "done" && step.kind !== "await_oauth" && message === null;
59
+ $: showCaptcha = !!captcha && (step.kind === "collect_identifier" || step.kind === "collect_captcha");
56
60
  function handleSubmit(event) {
57
61
  event.preventDefault();
58
62
  onSubmit(values);
@@ -70,12 +74,18 @@ function handleSubmit(event) {
70
74
  {#if message !== null}
71
75
  <p>{message}</p>
72
76
  {:else}
77
+ {#if step.kind === 'collect_captcha'}
78
+ <!-- §5 the server withheld the attempt until a captcha clears. Solving
79
+ the widget resubmits the identifier on its own; the button retries. -->
80
+ <p class="atlas-text">{t('captcha.prompt')}</p>
81
+ {/if}
73
82
  {#each fields as f (f.name)}
74
83
  <label class="atlas-field">
75
84
  <span>{t(f.labelKey)}</span>
76
85
  <input
77
86
  class={cls('formFieldInput', 'atlas-input')}
78
87
  type={f.type}
88
+ name={f.name}
79
89
  value={values[f.name] ?? ''}
80
90
  on:input={(e) => (values[f.name] = e.currentTarget.value)}
81
91
  />
@@ -86,6 +96,10 @@ function handleSubmit(event) {
86
96
  {/each}
87
97
  {/if}
88
98
 
99
+ {#if showCaptcha && captcha}
100
+ <CaptchaSlot controller={captcha} />
101
+ {/if}
102
+
89
103
  {#if showSubmit}
90
104
  <button class={cls('formButtonPrimary', 'atlas-button')} disabled={busy} type="submit">
91
105
  {t('signIn.submit')}
@@ -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 FieldError } from '@atlasauth/js';
3
3
  declare const __propDef: {
4
4
  props: {
5
5
  /**
@@ -14,6 +14,11 @@ 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;
17
22
  };
18
23
  events: {
19
24
  [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,5 @@ onDestroy(() => flow.destroy());
13
16
  errors={$flowState.errors}
14
17
  titleKey="signIn.title"
15
18
  onSubmit={flow.submit}
19
+ {captcha}
16
20
  />
@@ -4,7 +4,8 @@ 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).
8
9
  */ afterUrl?: string | undefined;
9
10
  };
10
11
  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
- <AttemptFlow
11
- attempt={$flowState.attempt}
12
- busy={$flowState.busy}
13
- errors={$flowState.errors}
14
- titleKey="signUp.title"
15
- onSubmit={flow.submit}
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/>` — the server-driven registration flow. Same shell as
6
- * `<SignIn/>`, only the title differs; the step sequence comes from the server.
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,183 @@
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
+ const label = (f) => f.required ? f.label : t("signUp.optionalField", { label: f.label });
20
+ const inputType = (f) => ["email", "tel", "url", "number", "date"].includes(f.type) ? f.type : "text";
21
+ const picked = (key) => values[key] ? values[key].split(", ") : [];
22
+ function toggle(f, option, on) {
23
+ if (f.type === "radio") values[f.key] = option;
24
+ else {
25
+ const current = picked(f.key);
26
+ values[f.key] = (on ? [...current, option] : current.filter((p) => p !== option)).join(", ");
27
+ }
28
+ }
29
+ function handleSubmit(event) {
30
+ event.preventDefault();
31
+ if (!consentOk) return;
32
+ onSubmit({ ...values }, legal?.required ? agreed : true);
33
+ }
34
+ </script>
35
+
36
+ <div class={cls('card', 'atlas-card')}>
37
+ <h1 class={cls('headerTitle', 'atlas-title')}>{t('signUp.title')}</h1>
38
+
39
+ {#each errors.filter((e) => !e.param) as error (error.code)}
40
+ <p class={cls('formFieldError', 'atlas-error')} role="alert">{error.message}</p>
41
+ {/each}
42
+
43
+ <form on:submit={handleSubmit}>
44
+ {#each fields as f (f.key)}
45
+ {#if f.type === 'radio' || f.type === 'multiselect'}
46
+ <!-- A choice group holds several inputs, so it can't sit inside one <label>. -->
47
+ <div class={cls('formField', 'atlas-field')}>
48
+ <span class={cls('formFieldLabel', 'atlas-field-label')}>{label(f)}</span>
49
+ <span class="atlas-choices">
50
+ {#each f.options as o (o)}
51
+ <label class="atlas-choice">
52
+ <input
53
+ type={f.type === 'radio' ? 'radio' : 'checkbox'}
54
+ name={f.key}
55
+ value={o}
56
+ checked={picked(f.key).includes(o)}
57
+ on:change={(e) => toggle(f, o, e.currentTarget.checked)}
58
+ />
59
+ {o}
60
+ </label>
61
+ {/each}
62
+ </span>
63
+ {#each errors.filter((e) => e.param === f.key) as error (error.code)}
64
+ <span class={cls('formFieldError', 'atlas-error')}>{error.message}</span>
65
+ {/each}
66
+ </div>
67
+ {:else}
68
+ <label class={cls('formField', 'atlas-field')}>
69
+ <span class={cls('formFieldLabel', 'atlas-field-label')}>{label(f)}</span>
70
+ {#if f.type === 'checkbox'}
71
+ <input
72
+ type="checkbox"
73
+ name={f.key}
74
+ checked={values[f.key] === 'true'}
75
+ on:change={(e) => (values[f.key] = e.currentTarget.checked ? 'true' : '')}
76
+ />
77
+ {:else if f.type === 'select'}
78
+ <select
79
+ class={cls('formFieldInput', 'atlas-input')}
80
+ name={f.key}
81
+ value={values[f.key] ?? ''}
82
+ required={f.required}
83
+ on:change={(e) => (values[f.key] = e.currentTarget.value)}
84
+ >
85
+ <option value="">{f.placeholder ?? t('signUp.selectPlaceholder')}</option>
86
+ {#each f.options as o (o)}
87
+ <option value={o}>{o}</option>
88
+ {/each}
89
+ </select>
90
+ {:else if f.type === 'textarea'}
91
+ <textarea
92
+ class={cls('formFieldInput', 'atlas-input')}
93
+ name={f.key}
94
+ rows="3"
95
+ value={values[f.key] ?? ''}
96
+ required={f.required}
97
+ placeholder={f.placeholder ?? undefined}
98
+ on:input={(e) => (values[f.key] = e.currentTarget.value)}
99
+ />
100
+ {:else}
101
+ <input
102
+ class={cls('formFieldInput', 'atlas-input')}
103
+ type={inputType(f)}
104
+ name={f.key}
105
+ value={values[f.key] ?? ''}
106
+ required={f.required}
107
+ placeholder={f.placeholder ?? undefined}
108
+ on:input={(e) => (values[f.key] = e.currentTarget.value)}
109
+ />
110
+ {/if}
111
+ {#each errors.filter((e) => e.param === f.key) as error (error.code)}
112
+ <span class={cls('formFieldError', 'atlas-error')}>{error.message}</span>
113
+ {/each}
114
+ </label>
115
+ {/if}
116
+ {/each}
117
+
118
+ <label class={cls('formField', 'atlas-field')}>
119
+ <span class={cls('formFieldLabel', 'atlas-field-label')}>{t('signUp.emailLabel')}</span>
120
+ <input
121
+ class={cls('formFieldInput', 'atlas-input')}
122
+ type="email"
123
+ name="email"
124
+ autocomplete="email"
125
+ required
126
+ value={values.email ?? ''}
127
+ on:input={(e) => (values.email = e.currentTarget.value)}
128
+ />
129
+ {#each errors.filter((e) => e.param === 'email') as error (error.code)}
130
+ <span class={cls('formFieldError', 'atlas-error')}>{error.message}</span>
131
+ {/each}
132
+ </label>
133
+
134
+ <label class={cls('formField', 'atlas-field')}>
135
+ <span class={cls('formFieldLabel', 'atlas-field-label')}>{t('signUp.passwordLabel')}</span>
136
+ <input
137
+ class={cls('formFieldInput', 'atlas-input')}
138
+ type="password"
139
+ name="password"
140
+ autocomplete="new-password"
141
+ required
142
+ value={values.password ?? ''}
143
+ on:input={(e) => (values.password = e.currentTarget.value)}
144
+ />
145
+ {#each errors.filter((e) => e.param === 'password') as error (error.code)}
146
+ <span class={cls('formFieldError', 'atlas-error')}>{error.message}</span>
147
+ {/each}
148
+ </label>
149
+
150
+ {#if showLegal && legal}
151
+ <label class={cls('formField', 'atlas-consent')}>
152
+ {#if legal.required}
153
+ <input type="checkbox" name="consent" bind:checked={agreed} />
154
+ {/if}
155
+ <span class="atlas-text">
156
+ {t(legal.required ? 'signUp.consentAgree' : 'signUp.consentNotice')}
157
+ {#if legal.termsUrl}
158
+ <a class="atlas-link" href={legal.termsUrl} target="_blank" rel="noopener noreferrer"
159
+ >{t('signUp.terms')}</a
160
+ >{/if}{#if legal.termsUrl && legal.privacyUrl}{' '}{t('signUp.and')}{' '}{/if}{#if legal.privacyUrl}
161
+ <a class="atlas-link" href={legal.privacyUrl} target="_blank" rel="noopener noreferrer"
162
+ >{t('signUp.privacy')}</a
163
+ >{/if}.
164
+ </span>
165
+ {#each errors.filter((e) => e.param === 'consent') as error (error.code)}
166
+ <span class={cls('formFieldError', 'atlas-error')}>{error.message}</span>
167
+ {/each}
168
+ </label>
169
+ {/if}
170
+
171
+ {#if captcha}
172
+ <CaptchaSlot controller={captcha} />
173
+ {/if}
174
+
175
+ <button
176
+ class={cls('formButtonPrimary', 'atlas-button')}
177
+ disabled={busy || !consentOk}
178
+ type="submit"
179
+ >
180
+ {t('signUp.submit')}
181
+ </button>
182
+ </form>
183
+ </div>
@@ -0,0 +1,28 @@
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
+ */ settings?: InstanceSettings | null;
11
+ busy?: boolean;
12
+ errors?: FieldError[];
13
+ captcha?: CaptchaController | null;
14
+ onSubmit: (values: Record<string, string>, consent: boolean) => void;
15
+ };
16
+ events: {
17
+ [evt: string]: CustomEvent<any>;
18
+ };
19
+ slots: {};
20
+ exports?: {} | undefined;
21
+ bindings?: string | undefined;
22
+ };
23
+ export type SignUpFormProps = typeof __propDef.props;
24
+ export type SignUpFormEvents = typeof __propDef.events;
25
+ export type SignUpFormSlots = typeof __propDef.slots;
26
+ export default class SignUpForm extends SvelteComponent<SignUpFormProps, SignUpFormEvents, SignUpFormSlots> {
27
+ }
28
+ export {};
package/dist/context.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Readable } from 'svelte/store';
2
- import { type ProviderToken } from '@atlasauth/js';
2
+ import { type ProviderToken, type SignOutOptions } 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
- /** The instance's FAPI origin. Empty for same-origin. */
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,7 @@ 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
- signOut(): Promise<void>;
81
+ signOut(options?: SignOutOptions): Promise<void>;
75
82
  setActiveOrganization(organizationId: string | null): Promise<void>;
76
83
  reload(): Promise<void>;
77
84
  /**
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, stripRedirectParams, } from '@atlasauth/js';
3
+ import { FapiClient, TokenCache, getProviderToken as fetchProviderToken, msUntilRefresh, readExpiry, readRedirectResult, resolveFrontendApi, stripRedirectParams, runFrontChannelLogoutFromResponse, } 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
- const baseUrl = options.frontendApi ?? '';
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');
@@ -148,10 +152,16 @@ export function createAtlasClient(options) {
148
152
  function getProviderToken(provider) {
149
153
  return fetchProviderToken(fapiClient, provider);
150
154
  }
151
- async function signOut() {
155
+ async function signOut(options) {
152
156
  const current = cache.peek();
153
157
  if (current) {
154
- await call(`/v1/client/sessions/${current.sessionId}/revoke`, { method: 'POST' }).catch(() => undefined);
158
+ const revoked = await call(`/v1/client/sessions/${current.sessionId}/revoke`, {
159
+ method: 'POST',
160
+ }).catch(() => undefined);
161
+ // OIDC Front-Channel Logout: let the apps signed in through this session
162
+ // clear theirs in this browser (hidden iframes, capped at 5 s). Best-effort.
163
+ if (options?.frontchannel !== false)
164
+ await runFrontChannelLogoutFromResponse(revoked);
155
165
  }
156
166
  if (timer)
157
167
  clearTimeout(timer);
package/dist/i18n.js CHANGED
@@ -27,6 +27,18 @@ export const EN_US = {
27
27
  'signUp.submit': 'Continue',
28
28
  'signUp.haveAccount': 'Already have an account?',
29
29
  'signUp.signInLink': 'Sign in',
30
+ 'signUp.emailLabel': 'Email address',
31
+ 'signUp.passwordLabel': 'Password',
32
+ 'signUp.optionalField': '{label} (optional)',
33
+ 'signUp.selectPlaceholder': 'Select…',
34
+ 'signUp.consentAgree': 'I agree to the',
35
+ 'signUp.consentNotice': 'By continuing you agree to the',
36
+ 'signUp.terms': 'Terms of Service',
37
+ 'signUp.privacy': 'Privacy Policy',
38
+ 'signUp.and': 'and',
39
+ 'captcha.prompt': 'Complete the security check to continue.',
40
+ 'captcha.required': 'Please complete the security check, then try again.',
41
+ 'captcha.failed': "The security check didn't pass. Please try again.",
30
42
  'mfa.title': 'Two-step verification',
31
43
  'mfa.subtitle': 'Enter the code from your authenticator app.',
32
44
  'mfa.codeLabel': 'Code',
package/dist/index.d.ts CHANGED
@@ -29,3 +29,4 @@ 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, PublicSignUpField, CaptchaController, CaptchaFlowKey, CaptchaWidgetMode, FlowCaptcha, } from '@atlasauth/js';
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@atlasauth/svelte",
3
- "version": "0.2.0",
3
+ "version": "0.3.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": {
25
+ "@atlasauth/js": "0.6.0",
26
+ "@atlasauth/backend": "0.7.0",
24
27
  "@atlasauth/design": "0.1.0",
25
- "@atlasauth/authz": "0.1.1",
26
- "@atlasauth/js": "0.4.0",
27
- "@atlasauth/backend": "0.3.0"
28
+ "@atlasauth/authz": "0.1.1"
28
29
  },
29
30
  "peerDependencies": {
30
31
  "svelte": ">=4",