@atlasauth/svelte 0.3.0 → 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 CHANGED
@@ -1,5 +1,36 @@
1
1
  # @atlasauth/svelte changelog
2
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
+
3
34
  ## 0.3.0
4
35
  - **`<SignUp>` now creates accounts.** Its first step is a create-account form (the instance's
5
36
  configured sign-up fields, email, password and the legal-consent row) that posts
@@ -1,5 +1,5 @@
1
1
  import { type Readable } from 'svelte/store';
2
- import { type CaptchaController, type FlowState, type InstanceSettings, 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
- signOut(options?: SignOutOptions): Promise<void>;
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
- /** Revoke the session and drop all in-memory state. */
67
- export declare function signOut(options?: SignOutOptions): Promise<void>;
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
  *
@@ -84,10 +93,16 @@ export interface FlowController {
84
93
  * then posted with {@link submit}.
85
94
  */
86
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>;
87
102
  /**
88
103
  * 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.
104
+ * legal consent, email delivery) from `GET /v1/appearance`; null until loaded
105
+ * or when the server could not be reached.
91
106
  */
92
107
  settings: Readable<InstanceSettings | null>;
93
108
  /** The sign-in captcha (identifier step / `needs_captcha` challenge). */
package/dist/accessors.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { derived, get, writable } from 'svelte/store';
2
- import { FapiClient, advanceWithCaptcha, createCaptchaController, createSignUp, fetchInstanceSettings, flowNeedsCaptcha, signInCaptchaOptions, flowFromPending, pollAttempt, shouldKeepPolling, } from '@atlasauth/js';
2
+ import { FapiClient, advanceWithCaptcha, createCaptchaController, createSignUp, fetchInstanceSettings, flowNeedsCaptcha, signInCaptchaOptions, flowFromPending, pollAttempt, prepareFactor, shouldKeepPolling, } from '@atlasauth/js';
3
3
  import { useAtlas } from './context';
4
4
  import { translate } from './i18n';
5
5
  import { evaluate } from './protect';
@@ -54,7 +54,11 @@ export function getOrganization() {
54
54
  export function getToken() {
55
55
  return useAtlas().getToken();
56
56
  }
57
- /** Revoke the session and drop all in-memory state. */
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
+ */
58
62
  export function signOut(options) {
59
63
  return useAtlas().signOut(options);
60
64
  }
@@ -146,6 +150,13 @@ function createFlow() {
146
150
  await finish(await createSignUp(client, values, { consent, captcha: signUpCaptcha, t }));
147
151
  })();
148
152
  }
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
+ }
149
160
  function destroy() {
150
161
  stopPolling();
151
162
  unsubscribeCaptcha();
@@ -156,6 +167,7 @@ function createFlow() {
156
167
  state: { subscribe: state.subscribe },
157
168
  submit,
158
169
  create,
170
+ prepare,
159
171
  settings: { subscribe: settings.subscribe },
160
172
  captcha,
161
173
  signUpCaptcha,
@@ -1,4 +1,7 @@
1
- <script>import { nextStep } from "@atlasauth/js";
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";
@@ -9,17 +12,33 @@ export let errors = [];
9
12
  export let titleKey;
10
13
  export let onSubmit;
11
14
  export let captcha = null;
15
+ export let emailDelivery = null;
16
+ export let onPrepare = null;
12
17
  const atlas = useAtlas();
13
18
  const cls = (key, base) => classFor(key, atlas.appearance, base);
14
19
  const t = (key, vars) => translate(atlas.catalog, key, vars);
15
20
  let values = {};
16
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
+ }
17
33
  $: fields = (() => {
18
34
  switch (step.kind) {
19
35
  case "collect_identifier":
20
36
  return [{ name: "identifier", labelKey: "signIn.identifierLabel", type: "email" }];
21
37
  case "collect_first_factor":
22
- return [{ name: "password", labelKey: "signIn.passwordLabel", type: "password" }];
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" }] : [];
23
42
  case "collect_second_factor":
24
43
  return [{ name: "code", labelKey: "mfa.codeLabel", type: "text" }];
25
44
  case "enroll_second_factor":
@@ -43,9 +62,10 @@ $: message = (() => {
43
62
  return t("signIn.checkOtherTab");
44
63
  case "restart":
45
64
  return t("error.generic");
65
+ case "collect_first_factor":
66
+ return sent === "email_link" ? t("signIn.magicLinkSent") : null;
46
67
  case "collect_captcha":
47
68
  case "collect_identifier":
48
- case "collect_first_factor":
49
69
  case "collect_second_factor":
50
70
  case "enroll_second_factor":
51
71
  case "collect_email_code":
@@ -55,7 +75,9 @@ $: message = (() => {
55
75
  return t("error.generic");
56
76
  }
57
77
  })();
58
- $: 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;
59
81
  $: showCaptcha = !!captcha && (step.kind === "collect_identifier" || step.kind === "collect_captcha");
60
82
  function handleSubmit(event) {
61
83
  event.preventDefault();
@@ -94,6 +116,19 @@ function handleSubmit(event) {
94
116
  {/each}
95
117
  </label>
96
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}
97
132
  {/if}
98
133
 
99
134
  {#if showCaptcha && captcha}
@@ -1,5 +1,5 @@
1
1
  import { SvelteComponent } from "svelte";
2
- import { type AttemptView, type CaptchaController, 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
  /**
@@ -19,6 +19,14 @@ declare const __propDef: {
19
19
  * (an always-on sign-in captcha) and on the challenge step the server demands
20
20
  * with `needs_captcha`. Null when the flow draws none.
21
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;
22
30
  };
23
31
  events: {
24
32
  [evt: string]: CustomEvent<any>;
@@ -16,5 +16,7 @@ $: captcha = drawn($settings, $flowState);
16
16
  errors={$flowState.errors}
17
17
  titleKey="signIn.title"
18
18
  onSubmit={flow.submit}
19
+ onPrepare={flow.prepare}
20
+ emailDelivery={$settings?.emailDelivery ?? null}
19
21
  {captcha}
20
22
  />
@@ -6,6 +6,8 @@ declare const __propDef: {
6
6
  * server-side attempt status. Contains no decision about what comes next; it
7
7
  * renders whatever `status` the server returned. Draws the instance's sign-in
8
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`).
9
11
  */ afterUrl?: string | undefined;
10
12
  };
11
13
  events: {
@@ -16,6 +16,7 @@ $: legal = settings?.legal;
16
16
  $: showLegal = !!legal && (!!legal.termsUrl || !!legal.privacyUrl);
17
17
  $: consentOk = !legal?.required || agreed;
18
18
  $: fields = settings?.signUpFields ?? [];
19
+ $: emailUnavailable = settings?.emailDelivery?.passwordSignUp === false;
19
20
  const label = (f) => f.required ? f.label : t("signUp.optionalField", { label: f.label });
20
21
  const inputType = (f) => ["email", "tel", "url", "number", "date"].includes(f.type) ? f.type : "text";
21
22
  const picked = (key) => values[key] ? values[key].split(", ") : [];
@@ -40,144 +41,148 @@ function handleSubmit(event) {
40
41
  <p class={cls('formFieldError', 'atlas-error')} role="alert">{error.message}</p>
41
42
  {/each}
42
43
 
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>
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">
86
54
  {#each f.options as o (o)}
87
- <option value={o}>{o}</option>
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>
88
65
  {/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>
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}
131
120
  {/each}
132
- </label>
133
121
 
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>
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>
149
137
 
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)}
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)}
166
150
  <span class={cls('formFieldError', 'atlas-error')}>{error.message}</span>
167
151
  {/each}
168
152
  </label>
169
- {/if}
170
153
 
171
- {#if captcha}
172
- <CaptchaSlot controller={captcha} />
173
- {/if}
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}
174
178
 
175
- <button
176
- class={cls('formButtonPrimary', 'atlas-button')}
177
- disabled={busy || !consentOk}
178
- type="submit"
179
- >
180
- {t('signUp.submit')}
181
- </button>
182
- </form>
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}
183
188
  </div>
@@ -7,6 +7,10 @@ declare const __propDef: {
7
7
  * fields, email + password, the legal-consent row and — when the instance
8
8
  * requires one on sign-up — the captcha widget. Values live in local state so
9
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`).
10
14
  */ settings?: InstanceSettings | null;
11
15
  busy?: boolean;
12
16
  errors?: FieldError[];
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';
@@ -78,7 +78,19 @@ export interface AtlasClient extends AtlasStores {
78
78
  * exposed; the server refreshes a stale token on read.
79
79
  */
80
80
  getProviderToken(provider: string): Promise<ProviderToken | null>;
81
- signOut(options?: SignOutOptions): Promise<void>;
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>;
82
94
  setActiveOrganization(organizationId: string | null): Promise<void>;
83
95
  reload(): Promise<void>;
84
96
  /**
@@ -92,7 +104,10 @@ export interface AtlasClient extends AtlasStores {
92
104
  * when there is none or it cannot be read. `<AtlasProvider>` applies it.
93
105
  */
94
106
  loadInstanceDesign(): Promise<InstanceDesign | null>;
95
- /** Cancel the scheduled refresh — call from a component's `onDestroy`. */
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
+ */
96
111
  destroy(): void;
97
112
  }
98
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, resolveFrontendApi, stripRedirectParams, runFrontChannelLogoutFromResponse, } from '@atlasauth/js';
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. */
@@ -65,7 +65,25 @@ export function createAtlasClient(options) {
65
65
  },
66
66
  });
67
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;
68
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;
69
87
  const expiresAt = readExpiry(jwt);
70
88
  if (!expiresAt)
71
89
  return;
@@ -99,7 +117,9 @@ export function createAtlasClient(options) {
99
117
  if (!response.ok)
100
118
  throw new Error('boot failed');
101
119
  const body = (await response.json());
102
- if (!body.session || !body.user) {
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)) {
103
123
  clearSession();
104
124
  return;
105
125
  }
@@ -153,20 +173,21 @@ export function createAtlasClient(options) {
153
173
  return fetchProviderToken(fapiClient, provider);
154
174
  }
155
175
  async function signOut(options) {
156
- const current = cache.peek();
157
- if (current) {
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);
165
- }
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);
166
182
  if (timer)
167
183
  clearTimeout(timer);
168
184
  timer = null;
169
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);
170
191
  }
171
192
  async function setActiveOrganization(organizationId) {
172
193
  const current = cache.peek();
@@ -194,18 +215,44 @@ export function createAtlasClient(options) {
194
215
  * cookies, scrub the URL so a reload can't replay it, THEN boot. A redirect
195
216
  * that only carries a `__atlas_status` (a second factor is still owed) has no
196
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.
197
226
  */
198
- const result = typeof window !== 'undefined' ? readRedirectResult(window.location.search) : null;
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;
199
242
  if (result?.ticket) {
200
- await call('/v1/client/tickets/exchange', {
243
+ const response = await call('/v1/client/tickets/exchange', {
201
244
  method: 'POST',
202
245
  body: JSON.stringify({ attempt_id: result.attemptId, ticket: result.ticket }),
203
246
  }).catch(() => undefined);
247
+ exchanged = !!response?.ok;
204
248
  }
205
- if (result && typeof window !== 'undefined') {
249
+ if (fromUrl && typeof window !== 'undefined') {
206
250
  window.history.replaceState({}, document.title, stripRedirectParams(window.location.href));
207
251
  }
208
252
  await reload();
253
+ // Signed in inside the Atlas popup: hand back to the opener and close.
254
+ if (exchanged)
255
+ void closeOAuthPopupAfterSignIn();
209
256
  }
210
257
  async function loadInstanceDesign() {
211
258
  try {
@@ -220,6 +267,8 @@ export function createAtlasClient(options) {
220
267
  if (timer)
221
268
  clearTimeout(timer);
222
269
  timer = null;
270
+ stopWatch();
271
+ guard.dispose();
223
272
  }
224
273
  const isSignedIn = derived(status, ($status) => $status === 'signed_in');
225
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,6 +22,8 @@ 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',
@@ -36,6 +39,8 @@ export const EN_US = {
36
39
  'signUp.terms': 'Terms of Service',
37
40
  'signUp.privacy': 'Privacy Policy',
38
41
  'signUp.and': 'and',
42
+ // Shown instead of the email + password form when the app cannot send email.
43
+ 'signUp.emailUnavailable': EMAIL_UNAVAILABLE_MESSAGE,
39
44
  'captcha.prompt': 'Complete the security check to continue.',
40
45
  'captcha.required': 'Please complete the security check, then try again.',
41
46
  'captcha.failed': "The security check didn't pass. Please try again.",
package/dist/index.d.ts CHANGED
@@ -29,4 +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, PublicSignUpField, CaptchaController, CaptchaFlowKey, CaptchaWidgetMode, FlowCaptcha, } from '@atlasauth/js';
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlasauth/svelte",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "svelte": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -22,10 +22,10 @@
22
22
  "./package.json": "./package.json"
23
23
  },
24
24
  "dependencies": {
25
- "@atlasauth/js": "0.6.0",
26
- "@atlasauth/backend": "0.7.0",
27
- "@atlasauth/design": "0.1.0",
28
- "@atlasauth/authz": "0.1.1"
25
+ "@atlasauth/design": "0.2.0",
26
+ "@atlasauth/authz": "0.1.1",
27
+ "@atlasauth/js": "0.8.0",
28
+ "@atlasauth/backend": "0.7.2"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "svelte": ">=4",