@antelopejs/dms-frontend 0.5.0 → 0.5.2

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
@@ -252,7 +252,7 @@ It stays `false` on the server and during hydration, which render the page resol
252
252
 
253
253
  ### Interface language
254
254
 
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.
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.
256
256
 
257
257
  ### Declaring the loader releases a module supports
258
258
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-frontend",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
5
5
  "keywords": [
6
6
  "antelope",
@@ -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,
@@ -537,11 +537,54 @@ function parseRoute(runtime: DmsFrontendRuntime, url: string): DmsRoute {
537
537
  };
538
538
  }
539
539
 
540
+ function hasSameEntries(
541
+ left: Record<string, string>,
542
+ right: Record<string, string>,
543
+ ): boolean {
544
+ const keys = Object.keys(left);
545
+ return (
546
+ keys.length === Object.keys(right).length &&
547
+ keys.every((key) => right[key] === left[key])
548
+ );
549
+ }
550
+
551
+ // Every parse builds a new query, params and matched: assigning them as they
552
+ // come would re-run each watcher of the route on every call of useDmsRoute,
553
+ // though the URL did not change. They are only replaced when their content did.
540
554
  function updateRoute(runtime: DmsFrontendRuntime, url: string): void {
541
555
  // The meta is the page's, not the URL's: a page declares it, then reads its
542
556
  // route, and the middleware the meta selected must still find it.
543
- const { meta: _pageMeta, ...location } = parseRoute(runtime, url);
544
- Object.assign(runtime.route, location);
557
+ const {
558
+ meta: _pageMeta,
559
+ query,
560
+ params,
561
+ matched,
562
+ ...location
563
+ } = parseRoute(runtime, url);
564
+ const route = runtime.route;
565
+ if (!hasSameEntries(query, route.query)) route.query = query;
566
+ if (!hasSameEntries(params, route.params)) route.params = params;
567
+ if (matched.length !== route.matched.length) route.matched = matched;
568
+ Object.assign(route, location);
569
+ }
570
+
571
+ const URL_BASE = "http://frontend.local";
572
+
573
+ // The URL the browser shows: it may be ahead of the route, which follows
574
+ // Inertia's page, when a component wrote it in place (history.replaceState).
575
+ function currentUrl(runtime: DmsFrontendRuntime): string {
576
+ if (runtime.isServer || typeof window === "undefined")
577
+ return new URL(runtime.route.fullPath, URL_BASE).href;
578
+ return window.location.href;
579
+ }
580
+
581
+ // One spelling per URL: `a b`, `a+b` and `a%20b` are the same query, as are
582
+ // `:` and `%3A`. The origin is kept, so a URL of another site never matches
583
+ // the current one, whatever its path.
584
+ function normalizeUrl(url: string, base: string): string {
585
+ const parsed = new URL(url, base);
586
+ const search = parsed.searchParams.toString();
587
+ return `${parsed.origin}${parsed.pathname}${search ? `?${search}` : ""}${parsed.hash}`;
545
588
  }
546
589
 
547
590
  function locationToUrl(
@@ -645,10 +688,13 @@ export function useDmsRoute(pattern?: string): DmsRoute {
645
688
 
646
689
  export function useDmsRouter(): DmsRouter {
647
690
  const runtime = useDmsRuntime();
648
- const navigate = (to: RouteLocationRaw, options?: DmsNavigationOptions) =>
649
- locationToUrl(runtime, to) === runtime.route.fullPath
691
+ const navigate = (to: RouteLocationRaw, options?: DmsNavigationOptions) => {
692
+ const current = currentUrl(runtime);
693
+ return normalizeUrl(locationToUrl(runtime, to), current) ===
694
+ normalizeUrl(current, current)
650
695
  ? Promise.resolve()
651
696
  : visit(runtime, to, options);
697
+ };
652
698
  return {
653
699
  currentRoute: computed(() => runtime.route),
654
700
  push: navigate,
@@ -1143,6 +1189,35 @@ function createCookieRef<T>(
1143
1189
  });
1144
1190
  }
1145
1191
 
1192
+ // The language a visitor picks, kept by the browser so that a reload renders
1193
+ // in it. A signed-in user's own language still comes first.
1194
+ const LOCALE_COOKIE = "dms_locale";
1195
+ const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
1196
+
1197
+ /**
1198
+ * The language this browser picked: read from `document.cookie`, or in a
1199
+ * server render from the request. Only one of `supportedLocales` counts.
1200
+ */
1201
+ export function readDmsLocaleCookie(
1202
+ runtime: DmsFrontendRuntime,
1203
+ supportedLocales: string[],
1204
+ ): string | undefined {
1205
+ const source =
1206
+ typeof document === "undefined" ? runtime.requestCookies : document.cookie;
1207
+ try {
1208
+ const locale = parseCookieValue<unknown>(
1209
+ readCookie(source, LOCALE_COOKIE),
1210
+ {},
1211
+ );
1212
+ return typeof locale === "string" && supportedLocales.includes(locale)
1213
+ ? locale
1214
+ : undefined;
1215
+ } catch {
1216
+ // A value that is not URI-encoded: as if the browser had picked none.
1217
+ return undefined;
1218
+ }
1219
+ }
1220
+
1146
1221
  /**
1147
1222
  * A cookie as a ref, shared by every caller of the same name — so a plugin and
1148
1223
  * a page reading one preference see each other's writes — and read on the
@@ -1707,6 +1782,12 @@ export async function installDmsPlugins(
1707
1782
  const messages = await loadLocaleMessages(locale);
1708
1783
  i18n.setLocaleMessage(locale, messages);
1709
1784
  i18n.locale.value = locale;
1785
+ if (typeof document !== "undefined" && supportedLocales.includes(locale))
1786
+ writeCookie(LOCALE_COOKIE, locale, {
1787
+ maxAge: LOCALE_COOKIE_MAX_AGE,
1788
+ sameSite: "lax",
1789
+ secure: globalThis.location?.protocol === "https:",
1790
+ });
1710
1791
  };
1711
1792
  dmsI18n.locales = computed(() =>
1712
1793
  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
  }