@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 +17 -6
- package/package.json +1 -1
- package/templates/vue/DmsDynamicPage.vue +1 -3
- package/templates/vue/app-runtime.ts +7 -1
- package/templates/vue/frontend-module.ts +113 -84
- package/templates/vue/server/auth/oauth.mjs +8 -1
- package/templates/vue/server/auth/routes.mjs +101 -18
- package/templates/vue/server/auth/session.mjs +34 -8
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
|
-
###
|
|
191
|
+
### Component prefix
|
|
192
192
|
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
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
|
-
|
|
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
|
|
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 =
|
|
1264
|
-
|
|
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
|
|
1272
|
-
*
|
|
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
|
|
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
|
-
|
|
1316
|
-
if ((
|
|
1317
|
-
names.set(
|
|
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
|
-
(
|
|
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
|
|
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
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
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
|
-
{
|
|
1614
|
+
{
|
|
1615
|
+
name: module,
|
|
1616
|
+
module: { componentPrefix = "" },
|
|
1617
|
+
options,
|
|
1618
|
+
}: DmsFrontendModuleRegistration,
|
|
1595
1619
|
): DmsFrontendSdk {
|
|
1596
1620
|
return {
|
|
1597
1621
|
options,
|
|
1598
|
-
registerComponent: (name, component,
|
|
1599
|
-
|
|
1600
|
-
?
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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":
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
213
|
+
export function clearSealedCookie(response, name, path = "/auth/oauth") {
|
|
214
|
+
appendCookie(response, `${name}=; ${attributes(0, path)}`);
|
|
189
215
|
}
|