@antelopejs/dms-frontend 0.4.0 → 0.5.1

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/README.md CHANGED
@@ -188,17 +188,28 @@ The SDK also exposes `use` for Vue plugins. Entries execute by descending manife
188
188
 
189
189
  Within one setup, the first module to register a name keeps it. `ajs-dms dev` sets every module up again each time a change to a layer reaches the server renderer, into empty registries, so the next server render uses the edited component without a restart. A `setup` may therefore run more than once in a process: it should only register, and keep no state of its own between runs.
190
190
 
191
- ### Private components
191
+ ### Component prefix
192
192
 
193
- A component registers as public by default: it joins the application's global components and resolves by name anywhere. A component that only exists to render the module's own backend pages registers as private instead:
193
+ Every registered component joins the application's global components and resolves by name anywhere, in templates and in backend page trees. To keep the components of two modules from colliding, a module declares a `componentPrefix`, put in front of the name of every component it registers:
194
194
 
195
195
  ```ts
196
- sdk.registerComponent("BillingTotals", BillingTotals, { private: true });
196
+ const frontendModule: DmsFrontendModule = {
197
+ componentPrefix: "Acme",
198
+ setup(sdk) {
199
+ sdk.registerComponent("Banner", Banner); // registered as AcmeBanner
200
+ },
201
+ };
197
202
  ```
198
203
 
199
- A private component is not registered with `app.component`, so no template or other module reaches it by name. `resolveDmsComponent(name, owner)` returns it only when `owner` is the registering module, and then ahead of a public component of the same name; without an owner, only public components resolve. The generic page renderer passes the page's owner, so a backend page tree naming the component renders it, and the page preloader and the server render's hydration handover cover it too.
204
+ The full name, prefix included, is the one a template uses (`<AcmeBanner>`) and a backend sends (`CustomComponent("AcmeBanner")`, or `acme-banner`). Names are compared ignoring case, a leading `lazy` and non-alphanumeric characters; a leading `Dms` is no longer dropped, since it is the DMS module's own prefix.
205
+
206
+ When two modules register the same name, the module of higher priority keeps it and a warning names both. A module that means to replace another's component registers its full name without its own prefix, which is not reported:
207
+
208
+ ```ts
209
+ sdk.registerComponent("AcmeBanner", MyBanner, { prefix: false });
210
+ ```
200
211
 
201
- The owner is the page payload's `module` field: the name of the frontend module that owns the page's component tree, as the backend added it (the module's `name` in the frontend manifest, which the generated loader hands each module's setup). A backend that does not send `module` reaches no private component, whatever the tree names.
212
+ A module's `app/build/` directory is never auto-imported (see below): its components are registered so backend pages can name them, but they are the module's internals, not a surface other modules should build on.
202
213
 
203
214
  ### Auto-imports (`dms.frontend.build.ts`)
204
215
 
@@ -241,7 +252,7 @@ It stays `false` on the server and during hydration, which render the page resol
241
252
 
242
253
  ### Interface language
243
254
 
244
- The interface language follows the session (`useUserSession().user.language`), not the props of the page on screen: the first render takes it from the session the page arrived with, and it changes when the session's language does, after `useUserSession().fetch()` or a visit with a newer session. A page Inertia restores from history or serves from its prefetch cache carries the session it was fetched with: the frontend server stamps every session it writes (`session.updatedAt`), and a page carrying an older stamp of the same account updates neither the session nor the language. A session without a language, such as a signed-out one, keeps the language on screen; `$i18n.setLocale()` still switches it at any time.
255
+ The interface language follows the session (`useUserSession().user.language`), not the props of the page on screen: the first render takes it from the session the page arrived with, and it changes when the session's language does, after `useUserSession().fetch()` or a visit with a newer session. A page Inertia restores from history or serves from its prefetch cache carries the session it was fetched with: the frontend server stamps every session it writes (`session.updatedAt`), and a page carrying an older stamp of the same account updates neither the session nor the language. A session without a language, such as a signed-out one, keeps the language on screen; `$i18n.setLocale()` still switches it at any time. It also remembers a language the application offers in the `dms_locale` cookie (one year, `SameSite=Lax`), and a page without a signed-in user's language, such as a public sign-in page, first renders in that one: a visitor's choice survives a reload, while a signed-in user's own language still comes first.
245
256
 
246
257
  ### Declaring the loader releases a module supports
247
258
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-frontend",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
5
5
  "keywords": [
6
6
  "antelope",
@@ -37,7 +37,6 @@ interface PageRoute {
37
37
  interface PagePayload {
38
38
  layout?: PageLayout;
39
39
  route?: PageRoute;
40
- module?: string;
41
40
  }
42
41
 
43
42
  interface DynamicPageProps {
@@ -56,8 +55,7 @@ function resolveDefinition(
56
55
  return {
57
56
  id,
58
57
  component: definition.componentName
59
- ? resolveDmsComponent(definition.componentName, props.page.module) ||
60
- "div"
58
+ ? resolveDmsComponent(definition.componentName) || "div"
61
59
  : "div",
62
60
  componentName: definition.componentName,
63
61
  options: definition.options,
@@ -35,6 +35,7 @@ import {
35
35
  installDmsPlugins,
36
36
  preloadDmsPage,
37
37
  provideDmsFrontendRuntime,
38
+ readDmsLocaleCookie,
38
39
  resolveDmsAsyncComponents,
39
40
  resolveDmsComponent,
40
41
  serializeDmsAsyncData,
@@ -260,7 +261,12 @@ export async function configureDmsApp(
260
261
  options.serverFetch as typeof import("ofetch").ofetch,
261
262
  readServerRendered<Record<string, unknown>>(SSR_ASYNC_DATA_ID, {}),
262
263
  );
263
- const locale = userLocale(options.initialPageProps.user) ?? DEFAULT_LOCALE;
264
+ // The signed-in user's language, else the one this browser picked, which is
265
+ // how a visitor's choice on a public page survives a reload.
266
+ const locale =
267
+ userLocale(options.initialPageProps.user) ??
268
+ readDmsLocaleCookie(runtime, supportedLocales) ??
269
+ DEFAULT_LOCALE;
264
270
  const messages = await loadLocaleMessages(locale);
265
271
  const i18n = createI18n({
266
272
  legacy: false,
@@ -15,6 +15,8 @@ import {
15
15
  type Component,
16
16
  type ComponentPublicInstance,
17
17
  type ComputedRef,
18
+ camelize,
19
+ capitalize,
18
20
  computed,
19
21
  defineComponent,
20
22
  type EffectScope,
@@ -51,12 +53,6 @@ export interface DmsPagePayload {
51
53
  layoutUrl?: string;
52
54
  layout?: DmsPageLayout;
53
55
  shared?: DmsSharedPagePayload;
54
- /**
55
- * The frontend module that owns the page's component tree, by the name the
56
- * backend added it under (its manifest name). Components that module
57
- * registered with `{ private: true }` resolve in this page's tree only.
58
- */
59
- module?: string;
60
56
  [key: string]: unknown;
61
57
  }
62
58
  export interface DmsSharedPagePayload {
@@ -236,12 +232,11 @@ export interface DmsPluginRegistrationOptions {
236
232
  export type DmsComponentPreloader = () => Promise<unknown>;
237
233
  export interface DmsComponentRegistrationOptions {
238
234
  /**
239
- * Keeps the component out of the application's global components: it
240
- * resolves by name only in the component trees of the backend pages the
241
- * registering module owns (see `DmsPagePayload.module`), where it takes
242
- * precedence over a public component of the same name. Defaults to `false`.
235
+ * Registers the component under `name` as given, without the module's
236
+ * `componentPrefix`: the way to replace a component another module
237
+ * registers, from a module of higher priority. Defaults to `true`.
243
238
  */
244
- private?: boolean;
239
+ prefix?: boolean;
245
240
  }
246
241
  export interface DmsFrontendSdk {
247
242
  options: DmsModuleOptions;
@@ -288,13 +283,17 @@ export interface DmsFrontendSdk {
288
283
  use(plugin: Plugin): void;
289
284
  }
290
285
  export interface DmsFrontendModule {
286
+ /**
287
+ * Put in front of the name of every component the module registers, so
288
+ * the components of two modules never collide: with `"Acme"`,
289
+ * `registerComponent("Banner", ...)` registers `AcmeBanner`, the name the
290
+ * backend sends and templates use.
291
+ */
292
+ componentPrefix?: string;
291
293
  setup(sdk: DmsFrontendSdk): void | Promise<void>;
292
294
  }
293
295
  export interface DmsFrontendModuleRegistration {
294
- /**
295
- * The module's manifest name, which its backend pages carry as their
296
- * `module`. Without it, the module's private components never resolve.
297
- */
296
+ /** The module's manifest name, which collision warnings name it by. */
298
297
  name?: string;
299
298
  module: DmsFrontendModule;
300
299
  options: DmsModuleOptions;
@@ -315,8 +314,10 @@ export interface DmsRuntimeHooks {
315
314
  interface DmsComponentRegistration {
316
315
  name: string;
317
316
  component: Component;
318
- /** The registering module, for a private component. */
317
+ /** The registering module, for a collision warning. */
319
318
  module?: string;
319
+ /** Registered without the module's prefix, to replace another module's. */
320
+ unprefixed?: boolean;
320
321
  }
321
322
 
322
323
  interface DmsFrontendEntry {
@@ -339,8 +340,6 @@ interface DmsPluginRegistration {
339
340
  */
340
341
  interface DmsFrontendRegistry {
341
342
  components: Map<string, DmsComponentRegistration>;
342
- /** Private components, by registering module, then by normalized name. */
343
- privateComponents: Map<string, Map<string, DmsComponentRegistration>>;
344
343
  pages: Map<string, DmsFrontendEntry>;
345
344
  dynamicPages: Map<string, DmsFrontendEntry>;
346
345
  layouts: Map<string, DmsFrontendEntry>;
@@ -389,7 +388,6 @@ export interface DmsFrontendRuntime {
389
388
  function createDmsFrontendRegistry(): DmsFrontendRegistry {
390
389
  return {
391
390
  components: new Map(),
392
- privateComponents: new Map(),
393
391
  pages: new Map(),
394
392
  dynamicPages: new Map(),
395
393
  layouts: new Map(),
@@ -1145,6 +1143,35 @@ function createCookieRef<T>(
1145
1143
  });
1146
1144
  }
1147
1145
 
1146
+ // The language a visitor picks, kept by the browser so that a reload renders
1147
+ // in it. A signed-in user's own language still comes first.
1148
+ const LOCALE_COOKIE = "dms_locale";
1149
+ const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
1150
+
1151
+ /**
1152
+ * The language this browser picked: read from `document.cookie`, or in a
1153
+ * server render from the request. Only one of `supportedLocales` counts.
1154
+ */
1155
+ export function readDmsLocaleCookie(
1156
+ runtime: DmsFrontendRuntime,
1157
+ supportedLocales: string[],
1158
+ ): string | undefined {
1159
+ const source =
1160
+ typeof document === "undefined" ? runtime.requestCookies : document.cookie;
1161
+ try {
1162
+ const locale = parseCookieValue<unknown>(
1163
+ readCookie(source, LOCALE_COOKIE),
1164
+ {},
1165
+ );
1166
+ return typeof locale === "string" && supportedLocales.includes(locale)
1167
+ ? locale
1168
+ : undefined;
1169
+ } catch {
1170
+ // A value that is not URI-encoded: as if the browser had picked none.
1171
+ return undefined;
1172
+ }
1173
+ }
1174
+
1148
1175
  /**
1149
1176
  * A cookie as a ref, shared by every caller of the same name — so a plugin and
1150
1177
  * a page reading one preference see each other's writes — and read on the
@@ -1256,53 +1283,60 @@ export function dmsPageSuspenseProps(): Record<string, () => void> {
1256
1283
  };
1257
1284
  }
1258
1285
 
1286
+ /**
1287
+ * The key a component name is registered and resolved under: case, a leading
1288
+ * `lazy` and every non-alphanumeric character ignored, so PascalCase,
1289
+ * kebab-case and lowercase spellings match. Unlike `normalizeDmsName`, a
1290
+ * leading `Dms` is kept: it is the DMS module's prefix, and dropping it would
1291
+ * make `DmsBanner` and another module's `Banner` the same component.
1292
+ */
1293
+ function normalizeDmsComponentName(name: string): string {
1294
+ return name
1295
+ .replace(/^lazy/i, "")
1296
+ .replace(/[^a-z0-9]/gi, "")
1297
+ .toLowerCase();
1298
+ }
1299
+
1300
+ function moduleLabel(module: string | undefined): string {
1301
+ return module ? `module "${module}"` : "an unnamed module";
1302
+ }
1303
+
1304
+ /**
1305
+ * A component registered under a name already taken is ignored: the first
1306
+ * registration, from the module of higher priority, keeps the name. That is
1307
+ * how a module replaces another's component, by registering its full name
1308
+ * without a prefix, and only a collision neither side asked for is reported.
1309
+ */
1259
1310
  function registerDmsComponentEntry(
1260
1311
  entries: Map<string, DmsComponentRegistration>,
1261
1312
  registration: DmsComponentRegistration,
1262
1313
  ): void {
1263
- const key = normalizeDmsName(registration.name);
1264
- if (!entries.has(key)) entries.set(key, registration);
1314
+ const key = normalizeDmsComponentName(registration.name);
1315
+ const existing = entries.get(key);
1316
+ if (!existing) {
1317
+ entries.set(key, registration);
1318
+ return;
1319
+ }
1320
+ if (existing.unprefixed || registration.unprefixed) return;
1321
+ console.warn(
1322
+ `[dms] Component "${registration.name}" from ${moduleLabel(registration.module)} is ignored: ` +
1323
+ `${moduleLabel(existing.module)} already registers "${existing.name}". ` +
1324
+ "Give the modules different component prefixes.",
1325
+ );
1265
1326
  }
1266
1327
  /** Registers a component unless a higher-priority module already owns its name. */
1267
1328
  export function registerDmsComponent(name: string, component: Component): void {
1268
1329
  registerDmsComponentEntry(registry.components, { name, component });
1269
1330
  }
1270
1331
  /**
1271
- * Resolves a registered component by name. `owner` is the `module` of the
1272
- * backend page whose component tree is rendered: the private components that
1273
- * module registered resolve there, ahead of a public one of the same name.
1274
- * Without it, only public components resolve.
1332
+ * Resolves a registered component by its full name, prefix included. Case,
1333
+ * a leading `lazy` and non-alphanumeric characters are ignored.
1275
1334
  */
1276
- export function resolveDmsComponent(
1277
- name: string,
1278
- owner?: string,
1279
- ): Component | undefined {
1280
- const key = normalizeDmsName(name);
1281
- const own = owner
1282
- ? registry.privateComponents.get(owner)?.get(key)
1283
- : undefined;
1284
- return (own ?? registry.components.get(key))?.component;
1335
+ export function resolveDmsComponent(name: string): Component | undefined {
1336
+ return registry.components.get(normalizeDmsComponentName(name))?.component;
1285
1337
  }
1286
1338
  export const getDmsComponent = resolveDmsComponent;
1287
1339
 
1288
- const PRIVATE_COMPONENT_SEPARATOR = "::";
1289
-
1290
- function registeredComponents(): DmsComponentRegistration[] {
1291
- return [
1292
- ...registry.components.values(),
1293
- ...[...registry.privateComponents.values()].flatMap((entries) => [
1294
- ...entries.values(),
1295
- ]),
1296
- ];
1297
- }
1298
-
1299
- /** Names a registered component across the server render and hydration. */
1300
- function componentReference({ name, module }: DmsComponentRegistration) {
1301
- return module === undefined
1302
- ? name
1303
- : `${module}${PRIVATE_COMPONENT_SEPARATOR}${name}`;
1304
- }
1305
-
1306
1340
  /**
1307
1341
  * Records, by registered name, every registered async component a server
1308
1342
  * render reaches, so the client can resolve the same ones before hydrating.
@@ -1312,9 +1346,9 @@ function componentReference({ name, module }: DmsComponentRegistration) {
1312
1346
  */
1313
1347
  export function trackDmsAsyncComponents(app: App): Set<string> {
1314
1348
  const names = new Map<Component, string>();
1315
- registeredComponents().forEach((registration) => {
1316
- if ((registration.component as DmsAsyncComponent).__asyncLoader)
1317
- names.set(registration.component, componentReference(registration));
1349
+ registry.components.forEach(({ component, name }) => {
1350
+ if ((component as DmsAsyncComponent).__asyncLoader)
1351
+ names.set(component, name);
1318
1352
  });
1319
1353
  const rendered = new Set<string>();
1320
1354
  app.mixin({
@@ -1357,18 +1391,10 @@ export function trackDmsServerScopes(app: App): () => void {
1357
1391
  export async function resolveDmsAsyncComponents(
1358
1392
  names: readonly string[],
1359
1393
  ): Promise<void> {
1360
- const components = new Map(
1361
- registeredComponents().map((registration) => [
1362
- componentReference(registration),
1363
- registration.component,
1364
- ]),
1365
- );
1366
1394
  await Promise.all(
1367
1395
  names.map((name) =>
1368
1396
  (
1369
- (components.get(name) ?? resolveDmsComponent(name)) as
1370
- | DmsAsyncComponent
1371
- | undefined
1397
+ resolveDmsComponent(name) as DmsAsyncComponent | undefined
1372
1398
  )?.__asyncLoader?.(),
1373
1399
  ),
1374
1400
  );
@@ -1410,7 +1436,6 @@ function collectRenderedComponentNames(
1410
1436
 
1411
1437
  async function preloadDmsLayoutComponents(
1412
1438
  layout: DmsPageLayout | undefined,
1413
- owner: string | undefined,
1414
1439
  ): Promise<void> {
1415
1440
  const names = new Set<string>();
1416
1441
  collectRenderedComponentNames(layout?.layout, names);
@@ -1419,7 +1444,7 @@ async function preloadDmsLayoutComponents(
1419
1444
  });
1420
1445
  await Promise.all(
1421
1446
  [...names].map((name) => {
1422
- const component = resolveDmsComponent(name, owner) as
1447
+ const component = resolveDmsComponent(name) as
1423
1448
  | DmsAsyncComponent
1424
1449
  | undefined;
1425
1450
  return component?.__asyncLoader?.();
@@ -1561,8 +1586,7 @@ export async function preloadDmsPage(props: DmsPageProps): Promise<void> {
1561
1586
  (entry?.component as DmsAsyncComponent | undefined)?.__asyncLoader?.(),
1562
1587
  ]),
1563
1588
  );
1564
- if (!props.error)
1565
- await preloadDmsLayoutComponents(props.page.layout, props.page.module);
1589
+ if (!props.error) await preloadDmsLayoutComponents(props.page.layout);
1566
1590
  }
1567
1591
 
1568
1592
  export function useDmsInjection<T>(
@@ -1580,29 +1604,28 @@ function registerDmsFrontendEntry(
1580
1604
  if (!entries.has(name)) entries.set(name, { component, preload });
1581
1605
  }
1582
1606
 
1583
- function privateComponentsOf(
1584
- target: DmsFrontendRegistry,
1585
- owner: string,
1586
- ): Map<string, DmsComponentRegistration> {
1587
- const entries = target.privateComponents.get(owner) ?? new Map();
1588
- target.privateComponents.set(owner, entries);
1589
- return entries;
1607
+ /** `name` behind `prefix`, as a PascalCase component name. */
1608
+ function prefixedComponentName(prefix: string, name: string): string {
1609
+ return prefix ? `${prefix}${capitalize(camelize(name))}` : name;
1590
1610
  }
1591
1611
 
1592
1612
  function createSdk(
1593
1613
  target: DmsFrontendRegistry,
1594
- { name: owner = "", options }: DmsFrontendModuleRegistration,
1614
+ {
1615
+ name: module,
1616
+ module: { componentPrefix = "" },
1617
+ options,
1618
+ }: DmsFrontendModuleRegistration,
1595
1619
  ): DmsFrontendSdk {
1596
1620
  return {
1597
1621
  options,
1598
- registerComponent: (name, component, registration = {}) =>
1599
- registration.private
1600
- ? registerDmsComponentEntry(privateComponentsOf(target, owner), {
1601
- name,
1602
- component,
1603
- module: owner,
1604
- })
1605
- : registerDmsComponentEntry(target.components, { name, component }),
1622
+ registerComponent: (name, component, { prefix = true } = {}) =>
1623
+ registerDmsComponentEntry(target.components, {
1624
+ name: prefix ? prefixedComponentName(componentPrefix, name) : name,
1625
+ component,
1626
+ module,
1627
+ unprefixed: !prefix,
1628
+ }),
1606
1629
  registerPage: (name, component, preload) => {
1607
1630
  const key = normalizeDmsPageKey(name);
1608
1631
  registerDmsFrontendEntry(target.pages, key, component, preload);
@@ -1713,6 +1736,12 @@ export async function installDmsPlugins(
1713
1736
  const messages = await loadLocaleMessages(locale);
1714
1737
  i18n.setLocaleMessage(locale, messages);
1715
1738
  i18n.locale.value = locale;
1739
+ if (typeof document !== "undefined" && supportedLocales.includes(locale))
1740
+ writeCookie(LOCALE_COOKIE, locale, {
1741
+ maxAge: LOCALE_COOKIE_MAX_AGE,
1742
+ sameSite: "lax",
1743
+ secure: globalThis.location?.protocol === "https:",
1744
+ });
1716
1745
  };
1717
1746
  dmsI18n.locales = computed(() =>
1718
1747
  supportedLocales.map((code) => ({
@@ -197,7 +197,13 @@ export function oauthHandoff(request, response) {
197
197
  json(response, 200, handoff);
198
198
  }
199
199
 
200
- export function sessionFrom(result) {
200
+ /**
201
+ * The session a token pair opens.
202
+ *
203
+ * @param result Backend answer carrying the tokens and the user
204
+ * @param persistent Whether the session outlives the browser
205
+ */
206
+ export function sessionFrom(result, persistent = true) {
201
207
  const payload = JSON.parse(
202
208
  Buffer.from(
203
209
  result.access_token.split(".")[1] ?? "",
@@ -209,5 +215,6 @@ export function sessionFrom(result) {
209
215
  accessToken: result.access_token,
210
216
  refreshToken: result.refresh_token,
211
217
  activeTenantId: payload.tenantId ?? "default",
218
+ persistent,
212
219
  };
213
220
  }
@@ -9,17 +9,28 @@ import {
9
9
  sessionFrom,
10
10
  } from "./oauth.mjs";
11
11
  import {
12
+ clearSealedCookie,
12
13
  clearSession,
14
+ isPersistent,
13
15
  persistAccountSession,
14
16
  readAccount,
17
+ readSealedCookie,
15
18
  readSession,
16
19
  removeAccount,
17
20
  setRequestSession,
21
+ storeAccount,
22
+ writeSealedCookie,
18
23
  } from "./session.mjs";
19
24
 
20
25
  const REFRESH_TTL_MS = 15_000;
21
26
  const refreshes = new Map();
22
27
 
28
+ // Carries the "keep me signed in" choice of a login across its 2FA step, whose
29
+ // request names only the challenge. Lives as long as the challenge token.
30
+ const PENDING_LOGIN = "dms_pending_login";
31
+ const PENDING_LOGIN_PATH = "/auth/verify-2fa";
32
+ const PENDING_LOGIN_LIFETIME_S = 300;
33
+
23
34
  const PASSTHROUGH = new Map([
24
35
  ["/auth/request-2fa-email", "/api/auth/request-2fa-email"],
25
36
  ]);
@@ -101,14 +112,30 @@ function singleFlight(token, operation) {
101
112
  return promise;
102
113
  }
103
114
 
104
- async function establishWith(request, response, endpoint, payload) {
115
+ async function establishWith(
116
+ request,
117
+ response,
118
+ endpoint,
119
+ payload,
120
+ persistent = true,
121
+ ) {
105
122
  const result = await backend(endpoint, request, {
106
123
  method: "POST",
107
124
  body: payload,
108
125
  });
126
+ if (result.requires_2fa && typeof result.two_factor_token === "string")
127
+ writeSealedCookie(
128
+ response,
129
+ PENDING_LOGIN,
130
+ { token: result.two_factor_token, persistent },
131
+ PENDING_LOGIN_LIFETIME_S,
132
+ PENDING_LOGIN_PATH,
133
+ );
109
134
  if (result.requires_2fa || result.requires_tenant_assignment)
110
135
  return json(response, 200, result);
111
- const session = sessionFrom(result);
136
+ if (readSealedCookie(request, PENDING_LOGIN))
137
+ clearSealedCookie(response, PENDING_LOGIN, PENDING_LOGIN_PATH);
138
+ const session = sessionFrom(result, persistent);
112
139
  const account = persistAccountSession(request, response, session);
113
140
  json(response, 200, { user: result.user, account });
114
141
  }
@@ -117,6 +144,42 @@ async function establish(request, response, endpoint) {
117
144
  return establishWith(request, response, endpoint, await body(request));
118
145
  }
119
146
 
147
+ /**
148
+ * Sign in with a password. Only a login that asked to be kept signed in
149
+ * outlives the browser; any other ends when the browser closes.
150
+ */
151
+ async function login(request, response) {
152
+ const input = await body(request);
153
+ return establishWith(
154
+ request,
155
+ response,
156
+ "/api/auth/login",
157
+ input,
158
+ input.keep_login === true,
159
+ );
160
+ }
161
+
162
+ /**
163
+ * Finish a login on its 2FA step, with the lifetime the login chose. A
164
+ * challenge that no login of this browser left a choice for, such as the one
165
+ * an OAuth sign-in hands over, keeps the default.
166
+ */
167
+ async function verifyTwoFactor(request, response) {
168
+ const input = await body(request);
169
+ const pending = readSealedCookie(request, PENDING_LOGIN);
170
+ const persistent =
171
+ typeof input.token === "string" && pending?.token === input.token
172
+ ? isPersistent(pending)
173
+ : true;
174
+ return establishWith(
175
+ request,
176
+ response,
177
+ "/api/auth/verify-2fa",
178
+ input,
179
+ persistent,
180
+ );
181
+ }
182
+
120
183
  /**
121
184
  * Backend endpoints this deployment lets a module open a session from.
122
185
  *
@@ -196,7 +259,7 @@ export async function refreshSession(request, response) {
196
259
  const user = await backend("/api/auth/me", request, {
197
260
  token: tokens.access_token,
198
261
  });
199
- const refreshed = sessionFrom({ ...tokens, user });
262
+ const refreshed = sessionFrom({ ...tokens, user }, isPersistent(session));
200
263
  persistAccountSession(request, response, refreshed, session.accountId);
201
264
  setRequestSession(request, refreshed);
202
265
  return refreshed;
@@ -246,7 +309,7 @@ async function switchTenant(request, response) {
246
309
  const user = await backend("/api/auth/me", request, {
247
310
  token: tokens.access_token,
248
311
  });
249
- const switched = sessionFrom({ ...tokens, user });
312
+ const switched = sessionFrom({ ...tokens, user }, isPersistent(session));
250
313
  persistAccountSession(request, response, switched, session.accountId);
251
314
  json(response, 200, { success: true, user });
252
315
  }
@@ -263,7 +326,7 @@ async function switchAccount(request, response) {
263
326
  const user = await backend("/api/auth/me", request, {
264
327
  token: tokens.access_token,
265
328
  });
266
- const session = sessionFrom({ ...tokens, user });
329
+ const session = sessionFrom({ ...tokens, user }, isPersistent(account));
267
330
  const descriptor = persistAccountSession(
268
331
  request,
269
332
  response,
@@ -281,6 +344,36 @@ async function switchAccount(request, response) {
281
344
  }
282
345
  }
283
346
 
347
+ /**
348
+ * Keep the tokens a validation refreshed, without changing which account is
349
+ * signed in. Another account only has its own cookie updated. The current one
350
+ * also has the session updated, since a refresh may have rotated the token the
351
+ * session holds; it keeps its user as the backend gave it.
352
+ */
353
+ function storeValidatedAccount(request, response, account, accountId, tokens) {
354
+ const current = readSession(request);
355
+ if (current?.accountId === accountId) {
356
+ persistAccountSession(
357
+ request,
358
+ response,
359
+ sessionFrom({ ...tokens, user: current.user }, isPersistent(current)),
360
+ accountId,
361
+ );
362
+ return;
363
+ }
364
+ const user = {
365
+ _id: account.userId,
366
+ email: account.email,
367
+ name: account.name,
368
+ };
369
+ storeAccount(
370
+ request,
371
+ response,
372
+ sessionFrom({ ...tokens, user }, isPersistent(account)),
373
+ accountId,
374
+ );
375
+ }
376
+
284
377
  async function validateAccount(request, response) {
285
378
  const input = await body(request);
286
379
  const account = readAccount(request, input.accountId);
@@ -290,15 +383,7 @@ async function validateAccount(request, response) {
290
383
  method: "POST",
291
384
  body: { token: account.refreshToken },
292
385
  });
293
- persistAccountSession(
294
- request,
295
- response,
296
- sessionFrom({
297
- ...tokens,
298
- user: { id: account.userId, email: account.email, name: account.name },
299
- }),
300
- input.accountId,
301
- );
386
+ storeValidatedAccount(request, response, account, input.accountId, tokens);
302
387
  json(response, 200, { valid: true });
303
388
  } catch (error) {
304
389
  const rejected =
@@ -309,12 +394,10 @@ async function validateAccount(request, response) {
309
394
  }
310
395
 
311
396
  const actions = {
312
- "/auth/login": (request, response) =>
313
- establish(request, response, "/api/auth/login"),
397
+ "/auth/login": login,
314
398
  "/auth/signup": (request, response) =>
315
399
  establish(request, response, "/api/auth/signup"),
316
- "/auth/verify-2fa": (request, response) =>
317
- establish(request, response, "/api/auth/verify-2fa"),
400
+ "/auth/verify-2fa": verifyTwoFactor,
318
401
  "/auth/establish": establishFromEndpoint,
319
402
  "/auth/switch-account": switchAccount,
320
403
  "/auth/switch-tenant": switchTenant,
@@ -60,9 +60,27 @@ function cookieValue(request, name) {
60
60
  .find(([key]) => key === name)?.[1];
61
61
  }
62
62
 
63
+ /**
64
+ * Cookie attributes. Without a `maxAge` the cookie lasts as long as the
65
+ * browser session: it is gone once the browser is closed.
66
+ */
63
67
  function attributes(maxAge, path = "/") {
64
68
  const secure = process.env.DMS_COOKIE_SECURE !== "false" ? "; Secure" : "";
65
- return `Path=${path}; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`;
69
+ const lifetime = maxAge === undefined ? "" : `; Max-Age=${maxAge}`;
70
+ return `Path=${path}; HttpOnly; SameSite=Lax${lifetime}${secure}`;
71
+ }
72
+
73
+ /**
74
+ * Whether a session, or a stored account, outlives the browser. Only a login
75
+ * that left "keep me signed in" unchecked says no; a cookie written before
76
+ * the flag existed carries none, and keeps the lifetime it was given then.
77
+ */
78
+ export function isPersistent(session) {
79
+ return session?.persistent !== false;
80
+ }
81
+
82
+ function lifetime(persistent) {
83
+ return persistent ? MAX_AGE : undefined;
66
84
  }
67
85
 
68
86
  function appendCookie(response, cookie) {
@@ -87,6 +105,7 @@ function descriptor(session, accountId) {
87
105
  email: session.user?.email,
88
106
  name: session.user?.name,
89
107
  activeTenantId: session.activeTenantId,
108
+ persistent: isPersistent(session),
90
109
  };
91
110
  }
92
111
 
@@ -103,7 +122,7 @@ export function setRequestSession(request, session) {
103
122
  export function writeSession(response, session) {
104
123
  appendCookie(
105
124
  response,
106
- `${SESSION_COOKIE}=${encode(session)}; ${attributes(MAX_AGE)}`,
125
+ `${SESSION_COOKIE}=${encode(session)}; ${attributes(lifetime(isPersistent(session)))}`,
107
126
  );
108
127
  }
109
128
 
@@ -117,9 +136,10 @@ export function readAccounts(request) {
117
136
  }
118
137
 
119
138
  function writeAccountIndex(response, accounts) {
139
+ const persistent = Object.values(accounts).some(isPersistent);
120
140
  appendCookie(
121
141
  response,
122
- `${ACCOUNT_INDEX_COOKIE}=${encode(accounts)}; ${attributes(MAX_AGE)}`,
142
+ `${ACCOUNT_INDEX_COOKIE}=${encode(accounts)}; ${attributes(lifetime(persistent))}`,
123
143
  );
124
144
  }
125
145
 
@@ -136,7 +156,7 @@ export function storeAccount(request, response, session, accountId) {
136
156
  writeAccountIndex(response, accounts);
137
157
  appendCookie(
138
158
  response,
139
- `${accountCookieName(id)}=${encode({ refreshToken: session.refreshToken, ...accountDescriptor })}; ${attributes(MAX_AGE)}`,
159
+ `${accountCookieName(id)}=${encode({ refreshToken: session.refreshToken, ...accountDescriptor })}; ${attributes(lifetime(accountDescriptor.persistent))}`,
140
160
  );
141
161
  return accountDescriptor;
142
162
  }
@@ -177,13 +197,19 @@ export function readSealedCookie(request, name) {
177
197
  return value ? decode(value) : undefined;
178
198
  }
179
199
 
180
- export function writeSealedCookie(response, name, value, maxAge) {
200
+ export function writeSealedCookie(
201
+ response,
202
+ name,
203
+ value,
204
+ maxAge,
205
+ path = "/auth/oauth",
206
+ ) {
181
207
  appendCookie(
182
208
  response,
183
- `${name}=${encode(value)}; ${attributes(maxAge, "/auth/oauth")}`,
209
+ `${name}=${encode(value)}; ${attributes(maxAge, path)}`,
184
210
  );
185
211
  }
186
212
 
187
- export function clearSealedCookie(response, name) {
188
- appendCookie(response, `${name}=; ${attributes(0, "/auth/oauth")}`);
213
+ export function clearSealedCookie(response, name, path = "/auth/oauth") {
214
+ appendCookie(response, `${name}=; ${attributes(0, path)}`);
189
215
  }