@ofidj/generator-fidj 1.0.1 → 1.0.3
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 +58 -14
- package/bin/create-fidj.cjs +9 -1
- package/generators/app/index.js +57 -15
- package/generators/app/templates/typescript/README.md +11 -2
- package/generators/app/templates/typescript/app.config.json +5 -1
- package/generators/app/templates/typescript/public/fidj-logo.png +0 -0
- package/generators/app/templates/typescript/public/fonts/IBMPlexMono-400-latin-ext.woff2 +0 -0
- package/generators/app/templates/typescript/public/fonts/IBMPlexMono-400-latin.woff2 +0 -0
- package/generators/app/templates/typescript/public/fonts/IBMPlexMono-500-latin-ext.woff2 +0 -0
- package/generators/app/templates/typescript/public/fonts/IBMPlexMono-500-latin.woff2 +0 -0
- package/generators/app/templates/typescript/public/fonts/IBMPlexSans-400_600-latin-ext.woff2 +0 -0
- package/generators/app/templates/typescript/public/fonts/IBMPlexSans-400_600-latin.woff2 +0 -0
- package/generators/app/templates/typescript/public/fonts/InstrumentSerif-400-latin-ext.woff2 +0 -0
- package/generators/app/templates/typescript/public/fonts/InstrumentSerif-400-latin.woff2 +0 -0
- package/generators/app/templates/typescript/scripts/build.mjs +3 -0
- package/generators/app/templates/typescript/scripts/render-content.mjs +2 -2
- package/generators/app/templates/typescript/server/index.ts +10 -1
- package/generators/app/templates/typescript/src/content.ts +112 -37
- package/generators/app/templates/typescript/src/fonts.css +92 -0
- package/generators/app/templates/typescript/src/main.ts +17 -9
- package/generators/app/templates/typescript/src/service-agreement.ts +40 -0
- package/generators/app/templates/typescript/src/style.css +640 -363
- package/generators/app/templates/typescript/src/tokens.css +56 -0
- package/lib/app-module.cjs +6 -1
- package/lib/scaffold.cjs +72 -3
- package/package.json +2 -6
package/README.md
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Generate a Fidj-integrated TypeScript app with a short command. Public identity and content are generator inputs; authentication and per-app privacy are reusable template behavior. Node 22 or 24 is the validation matrix.
|
|
4
4
|
|
|
5
|
+
Registry examples require published compatible versions. For current workspace development, supply a built local SDK with `--sdk-path`.
|
|
6
|
+
|
|
5
7
|
## Content app — static hosting
|
|
6
8
|
|
|
7
9
|
```sh
|
|
8
|
-
|
|
10
|
+
npx -y -p @ofidj/generator-fidj -p yo yo @ofidj/fidj my-app --app-id YOUR_FIDJ_ID --title "My App" --welcome "Welcome" --content "<p>About my app</p>" --domain example.com
|
|
9
11
|
cd my-app
|
|
10
12
|
npm install && npm run build-prod
|
|
11
13
|
```
|
|
@@ -18,27 +20,55 @@ Serve `www/` on a static host. It contains your public content, SDK sign-in/acco
|
|
|
18
20
|
|
|
19
21
|
Omit `--content` to generate Studio Notes: a browser client plus Node backend with private persisted notes. The backend revalidates the session and live app roles for every protected request. Owner/Editor may write; other members may read their own notes. This mode requires Node hosting and is separate from the static content app scenario.
|
|
20
22
|
|
|
23
|
+
Departure uses an in-app confirmation that states the membership, consent and notes being removed and preserves the membership when cancelled.
|
|
24
|
+
|
|
21
25
|
Configure the generated privacy adapter so departure from either Studio Notes or Fidj erases the same app data. Notes persist outside disposable output. Failed cleanup stays pending in Fidj and can be retried; completed receipts remain visible. See the generated README for server-side configuration and persistence limits.
|
|
22
26
|
|
|
23
27
|
## CLI and local development
|
|
24
28
|
|
|
25
|
-
The
|
|
29
|
+
The public entry is `yo @ofidj/fidj`; output goes to `<cwd>/<appname>`. Use the `npx` form above or install once:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npm install --no-save @ofidj/generator-fidj yo
|
|
33
|
+
npx --no-install yo @ofidj/fidj my-app --app-id YOUR_FIDJ_ID
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Repeat a flag for `--highlight` and `--badge`; the values are read from the
|
|
37
|
+
command line, so a comma inside one is safe. The old `app2021` positional command
|
|
38
|
+
and its Ionic/Cordova templates are gone; the `typescript` template is the only
|
|
39
|
+
one.
|
|
40
|
+
|
|
41
|
+
> Maintainers: `bin/create-fidj.cjs` is the same scaffolder without Yeoman, for
|
|
42
|
+
> tooling that runs from a checkout and cannot install `yo` —
|
|
43
|
+
> `scripts/local-stack.py` and `fidj-app`'s `create:local` use it.
|
|
44
|
+
> `test/parity.test.cjs` fails if either door ever gains an input the other
|
|
45
|
+
> lacks. It is not an entry point to hand to anyone building an app.
|
|
26
46
|
|
|
27
47
|
- `--anonymous true|false`: show or hide anonymous entry in content apps (default: `true`). Set `--anonymous false` for a sign-in-only entry flow, as mleweb does.
|
|
48
|
+
- `--highlight "<heading>|<body>"`: add a numbered cell to the sign-in panel, repeatable up to six. These are the app's own selling points, so an app that passes none simply shows its identity — mleweb passes none, Fidj passes four.
|
|
49
|
+
- `--badge <text>`: add a trust badge under the sign-in form, repeatable up to four, 40 characters each. None are supplied by default — "EU-hosted" or "GDPR art. 17 · 20" are claims about a particular app, not about every app the generator makes.
|
|
50
|
+
- `--logo <image>` and `--favicon <image>`: the app's own marks, copied into `public/brand/`. The logo sits beside the app name at the top of the sign-in panel; the favicon goes in the browser tab. Both fall back to the Fidj mark, so neither is ever blank. Accepts `.png`, `.svg`, `.gif`, `.jpg`, `.webp` or `.ico` under 512KB — an animated GIF works as a logo, and as a favicon in the browsers that animate one.
|
|
28
51
|
- `--api-endpoint`: select the API (default: hosted sandbox).
|
|
29
52
|
- `--sdk-path` or `FIDJ_SDK_DIR`: use a built local SDK during coordinated development.
|
|
30
53
|
- `--local` or `FIDJ_LOCAL=true`: use the loopback API/console. Test credentials stay in the validation guide, outside the content app UI. Supply the matching local app ID; `FIDJ_APP_ID` can override it.
|
|
31
54
|
- `--replace`: regenerate only a destination containing `.fidj-generated`; unmarked projects are protected.
|
|
32
55
|
|
|
56
|
+
The generated shell paints from one design system: `src/tokens.css` holds every
|
|
57
|
+
colour, family and radius, and `src/style.css` may not introduce a literal of
|
|
58
|
+
its own. Fonts are self-hosted under `public/fonts` and served from the build
|
|
59
|
+
manifest, so a generated site stays statically hostable and makes no
|
|
60
|
+
third-party request on sign-in. fidj-app consumes the same tokens, which is why
|
|
61
|
+
a style change lands here first.
|
|
62
|
+
|
|
33
63
|
Generation writes `.env.example`, `.env` and public `app.config.json`. Static configuration is embedded at build time: regenerate/rebuild when changing endpoints. Notes server configuration is read at runtime. Do not place secrets in any public configuration or content input.
|
|
34
64
|
|
|
35
|
-
|
|
65
|
+
For unpublished coordinated changes, build the sibling SDK and pass `--sdk-path` with its absolute `dist` path. Committed templates use registry dependency `@ofidj/node ^3.6.24`; a registry-only install is not validated until coordinated versions are published.
|
|
36
66
|
|
|
37
67
|
## Real validation repository
|
|
38
68
|
|
|
39
69
|
[mleweb](https://github.com/mlefree/mleweb) is the thin command-line validation fixture whose generated website is mlefree.com. Its `package.json` passes the original Mario GIF, welcome text and About/CV/contact links directly into this generator. No custom downstream renderer is required. GitHub CI runs the same generation/build path on Node 22/24 and uploads artifacts. Publishing the site to gh-pages, which is what mlefree.com serves, is a separate deliberate job: it runs on mleweb's `master` or on an explicit workflow dispatch, never from a version branch.
|
|
40
70
|
|
|
41
|
-
Run `npm test` for scaffolding/CLI safety tests. Generated projects include TypeScript checking and HTTP integration tests for live authorization and privacy isolation.
|
|
71
|
+
Run `npm test` for scaffolding/CLI safety tests. Generated projects include TypeScript checking and HTTP integration tests for live authorization and privacy isolation. See the [local acceptance walkthrough](../LOCAL-DEVELOPMENT.md). Follow TDD: add/run a failing generator or generated-app test before implementation, make it green, then refactor and rerun regression checks.
|
|
42
72
|
|
|
43
73
|
|
|
44
74
|
## Releasing to npm
|
|
@@ -49,8 +79,8 @@ with `NPM_TOKEN`; this package publishes from the repository root rather than
|
|
|
49
79
|
from a `dist` directory, because it ships its sources.
|
|
50
80
|
|
|
51
81
|
The package contains `bin/`, `lib/` and the `typescript` template only. The
|
|
52
|
-
`app2018` and `app2021` Yeoman templates
|
|
53
|
-
they accounted for 11.8MB of the
|
|
82
|
+
`app2018` and `app2021` Yeoman templates were deleted: nothing read them, and
|
|
83
|
+
they accounted for 11.8MB of the repository. `main` points at
|
|
54
84
|
`lib/scaffold.cjs`, so `require('@ofidj/generator-fidj')` returns `{ scaffold }`
|
|
55
85
|
alongside the `create-fidj` binary.
|
|
56
86
|
|
|
@@ -67,7 +97,7 @@ The generated content app opens on `/#/signin`. Sign in, or choose **Enter anony
|
|
|
67
97
|
Build your application for the `/module/` base URL, then pass its public output to the same generator:
|
|
68
98
|
|
|
69
99
|
```sh
|
|
70
|
-
|
|
100
|
+
npx -y -p @ofidj/generator-fidj -p yo yo @ofidj/fidj my-app --app-id YOUR_FIDJ_ID --title "My App" --welcome "Welcome back" --description "Your app description" --anonymous false --module ./built-console --module-entry 'index.html#/my'
|
|
71
101
|
cd my-app && npm install && npm run build-prod
|
|
72
102
|
```
|
|
73
103
|
|
|
@@ -96,14 +126,28 @@ Generated starters include `npm run privacy:check`: configure the server-only `F
|
|
|
96
126
|
|
|
97
127
|
Readiness confirms this handler only. Register its trusted URL and independent secret with the Fidj API operator; the app owner console can then check the same connection. Group roles granted in Fidj are enforced by the generated backend on each protected operation.
|
|
98
128
|
|
|
99
|
-
##
|
|
129
|
+
## Beta OIDC input
|
|
100
130
|
|
|
101
|
-
`--oidc-issuer`
|
|
131
|
+
`--oidc-issuer` selects the OIDC entry for content/module apps while preserving branding and anonymous-entry configuration. This is beta: the API ships an integrated provider from 3.6.26, but it serves no `/oidc` routes until an operator configures an issuer and signing keys, so confirm the issuer you pass actually answers discovery before offering it.
|
|
102
132
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
133
|
+
A compatible issuer, REST API and registered callback without a fragment are prerequisites. Validate that integration and coordinated SDK publication before offering it to app builders. Notes retains its existing authentication flow; no subject migration is implied.
|
|
134
|
+
|
|
135
|
+
## Required service agreement
|
|
136
|
+
|
|
137
|
+
All generated sign-in and account-creation forms (content, composed Fidj and
|
|
138
|
+
Notes) share an unchecked required agreement checkbox. Both submit buttons stay
|
|
139
|
+
disabled until it is checked. Reading the agreement opens an in-app dialog;
|
|
140
|
+
failed agreement loading keeps sign-in blocked. Anonymous entry, when enabled,
|
|
141
|
+
is not a login and does not record agreement acceptance.
|
|
106
142
|
|
|
107
|
-
|
|
143
|
+
The text and version come from the app's public API metadata, not copied generator
|
|
144
|
+
settings. The API records acceptance before issuing the app token, preserves
|
|
145
|
+
optional choices, and rejects a missing choice or stale version. Repeated login
|
|
146
|
+
with the same version does not duplicate the history. Administrative membership
|
|
147
|
+
creation does not fabricate a user's acceptance.
|
|
108
148
|
|
|
109
|
-
|
|
149
|
+
Before release, the owner sets `configurationAsJSON.serviceAgreement` to
|
|
150
|
+
`{"version":"2026-09-11","text":"Your complete app service agreement"}` through
|
|
151
|
+
`PUT /apps/:appId`, preserving its other configuration fields. Changing the text
|
|
152
|
+
requires a new version. Until configured, the API exposes the explicitly labelled
|
|
153
|
+
`starter-demo-1` demo agreement. No generated-source edit is necessary.
|
package/bin/create-fidj.cjs
CHANGED
|
@@ -13,6 +13,10 @@ try {
|
|
|
13
13
|
welcome: { type: "string" },
|
|
14
14
|
description: { type: "string" },
|
|
15
15
|
content: { type: "string" },
|
|
16
|
+
highlight: { type: "string", multiple: true },
|
|
17
|
+
badge: { type: "string", multiple: true },
|
|
18
|
+
logo: { type: "string" },
|
|
19
|
+
favicon: { type: "string" },
|
|
16
20
|
anonymous: { type: "string" },
|
|
17
21
|
domain: { type: "string" },
|
|
18
22
|
module: { type: "string" },
|
|
@@ -24,7 +28,7 @@ try {
|
|
|
24
28
|
});
|
|
25
29
|
if (values.help || positionals.length !== 1) {
|
|
26
30
|
console.log(
|
|
27
|
-
"Usage: create-fidj <directory> --app-id <fidjId> [--api-endpoint <url>] [--title <text> --welcome <text> --description <text> --content <html> --domain <hostname>] [--module <built-directory> --module-entry <index.html#/route>] [--oidc-issuer https://api.example/oidc] [--anonymous true|false] [--local] [--replace]",
|
|
31
|
+
"Usage: create-fidj <directory> --app-id <fidjId> [--api-endpoint <url>] [--title <text> --welcome <text> --description <text> --content <html> --domain <hostname>] [--highlight '<heading>|<body>' ...] [--badge <text> ...] [--logo <image>] [--favicon <image>] [--module <built-directory> --module-entry <index.html#/route>] [--oidc-issuer https://api.example/oidc] [--anonymous true|false] [--local] [--replace]",
|
|
28
32
|
);
|
|
29
33
|
process.exitCode = values.help ? 0 : 1;
|
|
30
34
|
} else {
|
|
@@ -37,6 +41,10 @@ try {
|
|
|
37
41
|
welcome: values.welcome,
|
|
38
42
|
description: values.description,
|
|
39
43
|
content: values.content,
|
|
44
|
+
highlights: values.highlight,
|
|
45
|
+
badges: values.badge,
|
|
46
|
+
logo: values.logo,
|
|
47
|
+
favicon: values.favicon,
|
|
40
48
|
anonymous: values.anonymous,
|
|
41
49
|
domain: values.domain,
|
|
42
50
|
module: values.module,
|
package/generators/app/index.js
CHANGED
|
@@ -1,23 +1,55 @@
|
|
|
1
1
|
const Generator = require("yeoman-generator");
|
|
2
2
|
const { scaffold } = require("../../lib/scaffold.cjs");
|
|
3
3
|
|
|
4
|
+
// Yeoman and bin/create-fidj.cjs are two doors into the same scaffolder, and
|
|
5
|
+
// they must stay at parity: an input added to one and not the other silently
|
|
6
|
+
// disappears for half the users. test/parity.test.cjs fails when they drift.
|
|
7
|
+
const TEXT_OPTIONS = [
|
|
8
|
+
"app-id",
|
|
9
|
+
"api-endpoint",
|
|
10
|
+
"oidc-issuer",
|
|
11
|
+
"sdk-path",
|
|
12
|
+
"title",
|
|
13
|
+
"welcome",
|
|
14
|
+
"description",
|
|
15
|
+
"content",
|
|
16
|
+
"anonymous",
|
|
17
|
+
"domain",
|
|
18
|
+
"module",
|
|
19
|
+
"module-entry",
|
|
20
|
+
"logo",
|
|
21
|
+
"favicon",
|
|
22
|
+
];
|
|
23
|
+
// Repeatable: --highlight twice yields two cells.
|
|
24
|
+
const LIST_OPTIONS = ["highlight", "badge"];
|
|
25
|
+
const FLAG_OPTIONS = ["local", "replace"];
|
|
26
|
+
|
|
27
|
+
// Yeoman joins a repeated flag into one comma-separated string before any type
|
|
28
|
+
// coercion, and splitting that back is wrong: a highlight body legitimately
|
|
29
|
+
// contains commas ("An install, a fidjId, a few lines"). Read the occurrences
|
|
30
|
+
// from argv, which is exactly what was typed, and fall back to Yeoman's value
|
|
31
|
+
// when the generator is composed programmatically rather than from a command
|
|
32
|
+
// line.
|
|
33
|
+
function repeated(name, fallback, argv = process.argv) {
|
|
34
|
+
const found = [];
|
|
35
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
36
|
+
if (argv[index] === "--" + name && index + 1 < argv.length)
|
|
37
|
+
found.push(argv[index + 1]);
|
|
38
|
+
else if (argv[index].startsWith("--" + name + "="))
|
|
39
|
+
found.push(argv[index].slice(name.length + 3));
|
|
40
|
+
}
|
|
41
|
+
if (found.length) return found;
|
|
42
|
+
if (fallback === undefined) return undefined;
|
|
43
|
+
return Array.isArray(fallback) ? fallback : [fallback];
|
|
44
|
+
}
|
|
45
|
+
|
|
4
46
|
module.exports = class extends Generator {
|
|
5
47
|
constructor(args, opts) {
|
|
6
48
|
super(args, opts);
|
|
7
49
|
this.argument("appname", { type: String, required: false });
|
|
8
|
-
this.option(
|
|
9
|
-
for (const key of
|
|
10
|
-
|
|
11
|
-
"welcome",
|
|
12
|
-
"description",
|
|
13
|
-
"content",
|
|
14
|
-
"domain",
|
|
15
|
-
"sdk-path",
|
|
16
|
-
"anonymous",
|
|
17
|
-
"module",
|
|
18
|
-
"module-entry",
|
|
19
|
-
])
|
|
20
|
-
this.option(key, { type: String });
|
|
50
|
+
for (const key of TEXT_OPTIONS) this.option(key, { type: String });
|
|
51
|
+
for (const key of LIST_OPTIONS) this.option(key, { type: String });
|
|
52
|
+
for (const key of FLAG_OPTIONS) this.option(key, { type: Boolean });
|
|
21
53
|
this.option("api-endpoint", {
|
|
22
54
|
type: String,
|
|
23
55
|
default: "https://api.sandbox.fidj.ovh/v3",
|
|
@@ -55,11 +87,19 @@ module.exports = class extends Generator {
|
|
|
55
87
|
description: this.options.description,
|
|
56
88
|
content: this.options.content,
|
|
57
89
|
anonymous: this.options.anonymous,
|
|
90
|
+
highlights: repeated("highlight", this.options.highlight),
|
|
91
|
+
badges: repeated("badge", this.options.badge),
|
|
92
|
+
logo: this.options.logo,
|
|
93
|
+
favicon: this.options.favicon,
|
|
58
94
|
module: this.options.module,
|
|
59
95
|
moduleEntry: this.options["module-entry"],
|
|
60
96
|
domain: this.options.domain,
|
|
61
|
-
sdkPath: this.options["sdk-path"],
|
|
62
|
-
|
|
97
|
+
sdkPath: this.options["sdk-path"] || process.env.FIDJ_SDK_DIR,
|
|
98
|
+
oidcIssuer: this.options["oidc-issuer"],
|
|
99
|
+
local: this.options.local || process.env.FIDJ_LOCAL === "true",
|
|
100
|
+
replace: this.options.replace,
|
|
101
|
+
appId:
|
|
102
|
+
process.env.FIDJ_APP_ID || this.options["app-id"] || this.answers.appId,
|
|
63
103
|
apiEndpoint: this.options["api-endpoint"],
|
|
64
104
|
});
|
|
65
105
|
this.log(
|
|
@@ -67,3 +107,5 @@ module.exports = class extends Generator {
|
|
|
67
107
|
);
|
|
68
108
|
}
|
|
69
109
|
};
|
|
110
|
+
|
|
111
|
+
module.exports.repeatedOption = repeated;
|
|
@@ -29,7 +29,7 @@ The current unreleased generator requires the matching `@ofidj/node` 3.6.24 SDK.
|
|
|
29
29
|
- `test/server.test.mjs`: fresh HTTP servers verify role changes, revoked/foreign sessions, outage failure, per-user notes and privacy scope.
|
|
30
30
|
- `scripts/build.mjs`: browser and Node builds using [esbuild](https://esbuild.github.io/getting-started/), with a separate TypeScript typecheck.
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Use TDD: add and run a failing behavior test before implementation (red), make it pass (green), then refactor and run `npm test`. Change maintained template source and regenerate to verify that every fresh app receives the fix.
|
|
33
33
|
|
|
34
34
|
## Privacy scope
|
|
35
35
|
|
|
@@ -41,7 +41,7 @@ The browser SDK stores the app’s session in this origin’s local storage. Use
|
|
|
41
41
|
|
|
42
42
|
## Local scenario
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
For the Notes fixture only, `LOCAL_DEMO=true` with the loopback API enables synthetic Alex/Maya/Sam sign-in shortcuts. Content/module entries keep test accounts in developer documentation. These accounts must be seeded by your local Fidj API; the starter does not create accounts or seed data remotely. Alex is the owner, and the owner console can grant Maya Editor access.
|
|
45
45
|
|
|
46
46
|
## Entry flow
|
|
47
47
|
|
|
@@ -71,3 +71,12 @@ Keep `FIDJ_DATA_DIR` outside public assets and generated output. Use one writer
|
|
|
71
71
|
After starting the Notes backend with its server-only adapter secret, run `npm run privacy:check`. The signed check confirms export/erase capabilities and writable storage without accessing user records. Override `FIDJ_APP_URL` for a deployed HTTPS backend. Keep this secret out of browser configuration.
|
|
72
72
|
|
|
73
73
|
Run `npm run privacy:rehearse` for an isolated temporary-store HTTP scenario covering live roles, scoped export, persistence, failure and idempotent erasure. It never points at your running data directory. Groups are managed in the Fidj owner console; direct and group roles are checked live by the backend.
|
|
74
|
+
|
|
75
|
+
## Agreement at sign-in
|
|
76
|
+
|
|
77
|
+
Sign-in and account creation require the unchecked service-agreement checkbox.
|
|
78
|
+
Read the app's current text/version from the dialog before accepting. If the
|
|
79
|
+
agreement cannot load, the form stays blocked. The API records the accepted
|
|
80
|
+
version per app; optional privacy choices are separate. The app owner must replace
|
|
81
|
+
the default demo agreement using the Fidj app's `configurationAsJSON.serviceAgreement`
|
|
82
|
+
(`version` and `text`) before release. No generated-source edit is needed.
|
|
@@ -6,5 +6,9 @@
|
|
|
6
6
|
"localDemo": false,
|
|
7
7
|
"allowAnonymous": true,
|
|
8
8
|
"moduleEntry": "",
|
|
9
|
-
"description": "A space to explore, with an account that puts you in control."
|
|
9
|
+
"description": "A space to explore, with an account that puts you in control.",
|
|
10
|
+
"highlights": [],
|
|
11
|
+
"badges": [],
|
|
12
|
+
"logo": "./fidj-logo.png",
|
|
13
|
+
"favicon": "./fidj-logo.png"
|
|
10
14
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -13,6 +13,9 @@ await Promise.all([
|
|
|
13
13
|
target: "es2022",
|
|
14
14
|
outfile: "dist/public/main.js",
|
|
15
15
|
sourcemap: true,
|
|
16
|
+
// The design system's fonts are copied with public/; esbuild must leave
|
|
17
|
+
// their url()s alone rather than try to resolve them at build time.
|
|
18
|
+
external: ["*.woff2"],
|
|
16
19
|
}),
|
|
17
20
|
build({
|
|
18
21
|
entryPoints: ["server/index.ts"],
|
|
@@ -8,7 +8,7 @@ const escape = (value) =>
|
|
|
8
8
|
);
|
|
9
9
|
export function renderContent(config) {
|
|
10
10
|
// HTML is supplied by the developer at generation time, never by an app visitor.
|
|
11
|
-
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>${escape(config.title)}</title><link rel="icon" href="
|
|
12
|
-
<header class="topbar"><a class="brand" href="#/content"
|
|
11
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>${escape(config.title)}</title><link rel="icon" href="${escape(config.favicon)}"><link rel="stylesheet" href="./main.css"></head><body>
|
|
12
|
+
<header class="topbar"><a class="brand" href="#/content"><img src="${escape(config.logo)}" alt="">${escape(config.title)}</a></header>
|
|
13
13
|
<main><template id="public-content"><section class="card public-content"><h1>${escape(config.welcome)}</h1><div>${config.content}</div></section></template><div id="app" aria-live="polite"></div><footer>Built with Fidj · Your choices belong to this app.</footer></main><script type="module" src="./main.js"></script></body></html>`;
|
|
14
14
|
}
|
|
@@ -241,7 +241,16 @@ export function createApp(
|
|
|
241
241
|
"/main.css": ["main.css", "text/css"],
|
|
242
242
|
"/fidj-logo.png": ["fidj-logo.png", "image/png"],
|
|
243
243
|
};
|
|
244
|
-
|
|
244
|
+
// Beyond the named files above, three directories are served from the
|
|
245
|
+
// build manifest: the assembled application module, the design system's
|
|
246
|
+
// self-hosted fonts, and any logo or favicon the app supplied. The
|
|
247
|
+
// manifest only resolves paths the build actually produced.
|
|
248
|
+
if (
|
|
249
|
+
!files[pathname] &&
|
|
250
|
+
(pathname.startsWith("/module/") ||
|
|
251
|
+
pathname.startsWith("/fonts/") ||
|
|
252
|
+
pathname.startsWith("/brand/"))
|
|
253
|
+
) {
|
|
245
254
|
const manifest = JSON.parse(
|
|
246
255
|
await readFile(join(__dirname, "public-files.json"), "utf8"),
|
|
247
256
|
);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {agreementMarkup, bindAgreement, acceptedAgreement} from "./service-agreement";
|
|
1
2
|
import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
|
|
2
3
|
import config from "../app.config.json";
|
|
3
4
|
import "./style.css";
|
|
@@ -45,6 +46,56 @@ const escape = (value: unknown) =>
|
|
|
45
46
|
);
|
|
46
47
|
const element = <T extends HTMLElement>(id: string) =>
|
|
47
48
|
document.getElementById(id) as T | null;
|
|
49
|
+
function badges() {
|
|
50
|
+
const entries: string[] = config.badges;
|
|
51
|
+
if (!entries?.length) return "";
|
|
52
|
+
return `<footer class="signin-badges">${entries
|
|
53
|
+
.map((entry) => `<span>${escape(entry)}</span>`)
|
|
54
|
+
.join("")}</footer>`;
|
|
55
|
+
}
|
|
56
|
+
function banner() {
|
|
57
|
+
return message
|
|
58
|
+
? `<p role="${failed ? "alert" : "status"}" class="${failed ? "error" : "notice"}">${escape(message)}</p>`
|
|
59
|
+
: "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// One navigation bar for every in-app screen, so signing out stays one click
|
|
63
|
+
// away wherever you are. The sign-in entry and the pre-authentication account
|
|
64
|
+
// screens are full-bleed and carry none.
|
|
65
|
+
function appNav(current: "content" | "privacy" | "account") {
|
|
66
|
+
const tab = (id: string, label: string, selected: boolean) =>
|
|
67
|
+
`<button id="${id}"${selected ? ' class="selected" aria-current="page"' : ""}>${label}</button>`;
|
|
68
|
+
return `<nav class="content-nav" aria-label="App navigation">${tab("content-tab", "Content", current === "content")}${tab("privacy-tab", signedIn ? "My privacy" : "Sign in", current === "privacy")}${signedIn ? tab("account-tab", "My account", current === "account") : ""}${tab("exit", signedIn ? "Sign out" : "Back to sign in", false)}</nav>`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function wireNav() {
|
|
72
|
+
element("content-tab")?.addEventListener("click", () => navigate("content"));
|
|
73
|
+
element("account-tab")?.addEventListener("click", () => navigate("account"));
|
|
74
|
+
element("privacy-tab")?.addEventListener("click", () =>
|
|
75
|
+
navigate(signedIn ? "privacy" : "signin"),
|
|
76
|
+
);
|
|
77
|
+
element("exit")?.addEventListener(
|
|
78
|
+
"click",
|
|
79
|
+
() =>
|
|
80
|
+
void action(async () => {
|
|
81
|
+
if (signedIn) await sdk.logout(true);
|
|
82
|
+
signedIn = false;
|
|
83
|
+
anonymous = false;
|
|
84
|
+
navigate("signin");
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function highlights() {
|
|
90
|
+
const entries: Array<{ heading: string; body: string }> = config.highlights;
|
|
91
|
+
if (!entries?.length) return "";
|
|
92
|
+
return `<div class="signin-highlights">${entries
|
|
93
|
+
.map(
|
|
94
|
+
(entry, index) =>
|
|
95
|
+
`<article><p class="eyebrow">${String(index + 1).padStart(2, "0")}</p><h2>${escape(entry.heading)}</h2><p>${escape(entry.body)}</p></article>`,
|
|
96
|
+
)
|
|
97
|
+
.join("")}</div>`;
|
|
98
|
+
}
|
|
48
99
|
async function request(path: string, method = "GET", data?: unknown) {
|
|
49
100
|
const token = await sdk.fidjGetIdToken();
|
|
50
101
|
const response = await fetch(config.apiEndpoint + path, {
|
|
@@ -169,44 +220,46 @@ function render() {
|
|
|
169
220
|
route = "content";
|
|
170
221
|
if (route === "privacy" && !signedIn) route = "signin";
|
|
171
222
|
window.history.replaceState(null, "", "#/" + route);
|
|
172
|
-
|
|
223
|
+
// My account is a signed-in screen and keeps the app's chrome. Recovery and
|
|
224
|
+
// verification are reached without a session, so they stand alone.
|
|
225
|
+
const standaloneAccount =
|
|
226
|
+
accountRoutes.includes(route) && !(route === "account" && signedIn);
|
|
227
|
+
document.body.classList.toggle(
|
|
228
|
+
"signin-view",
|
|
229
|
+
route === "signin" || standaloneAccount,
|
|
230
|
+
);
|
|
231
|
+
if (standaloneAccount) {
|
|
173
232
|
renderAccount(route);
|
|
174
233
|
return;
|
|
175
234
|
}
|
|
235
|
+
if (route === "account") {
|
|
236
|
+
root.innerHTML = `${appNav("account")}<section class="card content-account">${banner()}${accountForm("account")}</section>`;
|
|
237
|
+
wireNav();
|
|
238
|
+
wireAccount("account");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
176
241
|
if (route === "content" && config.moduleEntry) {
|
|
177
242
|
root.innerHTML = '<p role="status">Opening your app…</p>';
|
|
178
243
|
window.location.assign(config.moduleEntry);
|
|
179
244
|
return;
|
|
180
245
|
}
|
|
181
246
|
if (route === "content") {
|
|
182
|
-
root.innerHTML =
|
|
183
|
-
|
|
184
|
-
navigate("account"),
|
|
185
|
-
);
|
|
186
|
-
element("privacy-tab")!.addEventListener("click", () =>
|
|
187
|
-
navigate(signedIn ? "privacy" : "signin"),
|
|
188
|
-
);
|
|
189
|
-
element("exit")!.addEventListener(
|
|
190
|
-
"click",
|
|
191
|
-
() =>
|
|
192
|
-
void action(async () => {
|
|
193
|
-
if (signedIn) await sdk.logout(true);
|
|
194
|
-
signedIn = false;
|
|
195
|
-
anonymous = false;
|
|
196
|
-
navigate("signin");
|
|
197
|
-
}),
|
|
198
|
-
);
|
|
247
|
+
root.innerHTML = `${appNav("content")}${element<HTMLTemplateElement>("public-content")!.innerHTML}`;
|
|
248
|
+
wireNav();
|
|
199
249
|
return;
|
|
200
250
|
}
|
|
201
|
-
root.innerHTML =
|
|
202
|
-
${
|
|
251
|
+
root.innerHTML = `${route === "signin" ? "" : appNav("privacy")}<section class="${route === "signin" ? "signin-shell" : "card content-account"}">
|
|
252
|
+
${route === "signin" ? "" : banner()}
|
|
203
253
|
${
|
|
204
254
|
route === "signin"
|
|
205
|
-
? `<div class="signin-intro
|
|
206
|
-
<div class="signin-
|
|
207
|
-
</div
|
|
255
|
+
? `<div class="signin-intro${config.highlights?.length ? "" : " is-plain"}"><header class="signin-masthead"><img class="app-mark" src="${escape(config.logo)}" alt=""><strong>${escape(config.title)}</strong></header>
|
|
256
|
+
<div class="signin-identity"><h1>${escape(config.welcome)}</h1><p class="signin-description">${escape(config.description)}</p></div>
|
|
257
|
+
${highlights()}</div>
|
|
258
|
+
<div class="signin-form"><div>${banner()}<h2>Sign in to ${escape(config.title)}</h2><form id="signin"><label for="email">Email</label><input id="email" type="email" placeholder="you@company.com" autocomplete="username" required><div class="field-head"><label for="password">Password</label><a href="#/forgot">Forgot?</a></div><div class="password-field"><input id="password" type="password" placeholder="••••••••••" autocomplete="current-password" required><button type="button" id="reveal" aria-controls="password">Show</button></div>${agreementMarkup()}<button class="primary" type="submit" disabled>Continue</button><button class="secondary" type="submit" name="signup" value="true">Create an account</button></form>${config.allowAnonymous ? `<div class="signin-divider"><span>or explore first</span></div><button class="anonymous-entry" id="anonymous">Enter anonymously <span aria-hidden="true">→</span></button><p class="signin-footnote">No account needed to view the content.</p>` : ""}
|
|
259
|
+
<div class="signin-trust"><p class="signin-trust-head"><img class="signin-logo" src="./fidj-logo.png" alt="Fidj"><strong>Your account, with Fidj</strong></p><p>Signing in creates one Fidj account you keep across every app that uses Fidj.</p><p>You choose what this app may store — and can export or erase it at any moment.</p></div></div>
|
|
260
|
+
${badges()}</div>`
|
|
208
261
|
: `
|
|
209
|
-
<
|
|
262
|
+
<h2>My privacy in ${escape(config.title)}</h2><p>Roles: ${roles.map(escape).join(" · ") || "No assigned roles"}</p><button id="refresh">Refresh access</button>
|
|
210
263
|
<p>These choices apply only to this app.${config.allowAnonymous ? " You can also view the public content by entering anonymously." : ""}</p>
|
|
211
264
|
<p>Service agreement: ${consent.terms ? "Accepted" : "Not recorded"}. ${consent.terms ? "Leaving withdraws this agreement." : 'This generated example uses a demo agreement. <button id="terms">Accept demo agreement</button>'}</p>
|
|
212
265
|
${["analytics", "communications", "optionalData"].map((key, i) => `<label class="toggle"><span>${["Analytics", "Communications", "Optional data"][i]}</span><input type="checkbox" data-purpose="${key}" ${consent[key] ? "checked" : ""}></label>`).join("")}
|
|
@@ -225,26 +278,40 @@ function render() {
|
|
|
225
278
|
<button id="export">Export my app data</button>
|
|
226
279
|
<p>This app stores its session in this browser. The export covers Fidj-held records for this membership. There is no separate app database in this static template.</p>
|
|
227
280
|
${roles.includes("Owner") ? "<p>Resolve app ownership before leaving.</p>" : leaving ? '<p>Confirm departure: your membership and its Fidj-held data will be removed. Your other apps remain available.</p><button id="confirm-leave" class="danger">Confirm leaving this app</button><button id="cancel-leave">Keep my membership</button>' : '<button id="leave" class="danger">Leave this app</button>'}
|
|
228
|
-
<p><a href="${escape(config.dashboardUrl)}/#/my">
|
|
281
|
+
<p class="leaving"><a href="${escape(config.dashboardUrl)}/#/my" target="_blank" rel="noopener">Open Fidj to manage every app you use ↗</a><br><small>Fidj is the account provider behind ${escape(config.title)}. This opens it in a new tab; you stay signed in here.</small></p>`
|
|
229
282
|
}</section>`;
|
|
283
|
+
wireNav();
|
|
284
|
+
element("reveal")?.addEventListener("click", () => {
|
|
285
|
+
const field = element<HTMLInputElement>("password");
|
|
286
|
+
const button = element("reveal");
|
|
287
|
+
if (!field || !button) return;
|
|
288
|
+
const hidden = field.type === "password";
|
|
289
|
+
field.type = hidden ? "text" : "password";
|
|
290
|
+
button.textContent = hidden ? "Hide" : "Show";
|
|
291
|
+
});
|
|
230
292
|
element("anonymous")?.addEventListener("click", () => {
|
|
231
293
|
if (!config.allowAnonymous) return;
|
|
232
294
|
anonymous = true;
|
|
233
295
|
navigate("content");
|
|
234
296
|
});
|
|
235
|
-
element("
|
|
236
|
-
|
|
297
|
+
if (oidc && element("signin")) element("signin")!.innerHTML = agreementMarkup() + '<p>Continue securely with your Fidj account. Your password stays with Fidj.</p><button class="primary" type="submit">Continue with Fidj</button>';
|
|
298
|
+
void bindAgreement(element<HTMLFormElement>("signin"), config.title, config.apiEndpoint, config.appId);
|
|
237
299
|
element<HTMLFormElement>("signin")?.addEventListener("submit", (event) => {
|
|
238
300
|
event.preventDefault();
|
|
301
|
+
const acceptance = acceptedAgreement(event.currentTarget as HTMLFormElement);
|
|
302
|
+
if (!acceptance) return;
|
|
239
303
|
const email = element<HTMLInputElement>("email")?.value || "";
|
|
240
304
|
const password = element<HTMLInputElement>("password")?.value || "";
|
|
241
305
|
const signup = (event.submitter as HTMLButtonElement)?.name === "signup";
|
|
242
306
|
void action(async () => {
|
|
243
307
|
if (oidc) {window.location.assign(await oidc.beginLogin()); return;}
|
|
244
|
-
await sdk.login(email, password, { autoSignup: signup });
|
|
308
|
+
await sdk.login(email, password, { autoSignup: signup, ...acceptance });
|
|
245
309
|
await refresh();
|
|
246
310
|
anonymous = false;
|
|
247
|
-
|
|
311
|
+
// A new account belongs where a returning one lands: inside the app.
|
|
312
|
+
// Sending it to the account card instead dropped people who had just
|
|
313
|
+
// signed up on the shell, one click short of the app they came for.
|
|
314
|
+
navigate("content");
|
|
248
315
|
});
|
|
249
316
|
});
|
|
250
317
|
element("refresh")?.addEventListener("click", () => void action(refresh));
|
|
@@ -264,7 +331,7 @@ function render() {
|
|
|
264
331
|
void action(async () => {
|
|
265
332
|
await request(appPath + "/consents", "PUT", {
|
|
266
333
|
terms: true,
|
|
267
|
-
|
|
334
|
+
cguVersion: "starter-demo-1",
|
|
268
335
|
source: "profile",
|
|
269
336
|
});
|
|
270
337
|
await refresh();
|
|
@@ -327,19 +394,27 @@ function render() {
|
|
|
327
394
|
});
|
|
328
395
|
}
|
|
329
396
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const form =
|
|
335
|
-
route === "forgot"
|
|
397
|
+
// The four account screens. My account is shown inside the app; the recovery
|
|
398
|
+
// and verification ones are reached without a session and stand alone.
|
|
399
|
+
function accountForm(route: string) {
|
|
400
|
+
return route === "forgot"
|
|
336
401
|
? `<h2>Reset your password</h2><p>We’ll email you a link to choose a new password for your shared Fidj account.</p><form id="recovery"><label for="recovery-email">Email address</label><input id="recovery-email" type="email" autocomplete="email" required><button class="primary">Send reset link</button></form>`
|
|
337
402
|
: route === "reset"
|
|
338
403
|
? `<h2>Choose a new password</h2><p>This changes your Fidj password across all your apps and signs out existing sessions.</p>${linkToken ? '<form id="recovery"><label for="new-password">New password</label><input id="new-password" type="password" autocomplete="new-password" minlength="12" required><label for="confirm-password">Confirm password</label><input id="confirm-password" type="password" autocomplete="new-password" minlength="12" required><p>Use at least 12 characters (up to 72 UTF-8 bytes).</p><button class="primary">Save new password</button></form>' : '<p>Request a new link if you no longer have an active reset link.</p><a href="#/forgot">Request a reset link</a>'}`
|
|
339
404
|
: route === "verify"
|
|
340
405
|
? `<h2>${verificationConfirmed ? "Email verified" : "Verify your email"}</h2>${verificationConfirmed ? "<p>Your account is ready. Return to your app to continue.</p>" : "<p>Confirm that this email address belongs to you.</p>"}${verificationConfirmed ? "" : linkToken ? '<form id="recovery"><button class="primary">Confirm email address</button></form>' : "<p>Sign in to your account to request a new verification email.</p>"}`
|
|
341
406
|
: `<h2>My Fidj account</h2><p>Your identity is shared across your apps. Privacy choices remain separate for each app.</p><p id="verification-status">${emailVerified ? "Your email address is verified." : "Your email is not verified yet."}</p><button id="check-verification">Refresh verification status</button>${emailVerified ? "" : '<button id="resend-verification">Send verification email</button>'}<p><a href="#/forgot">Reset my password</a></p><button id="continue-app" class="primary">Continue to ${escape(config.title)}</button>`;
|
|
342
|
-
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function renderAccount(route: string) {
|
|
410
|
+
root.innerHTML = `<section class="signin-shell"><div class="signin-intro is-plain"><header class="signin-masthead"><img class="app-mark" src="${escape(config.logo)}" alt=""><strong>${escape(config.title)}</strong></header>
|
|
411
|
+
<div class="signin-identity"><h1>Your account.<br>Your control.</h1><p class="signin-description">Secure access to the apps you use, with one Fidj identity.</p></div>
|
|
412
|
+
</div>
|
|
413
|
+
<div class="signin-form"><div>${banner()}${accountForm(route)}</div><footer class="signin-badges"><a href="#/signin">Back to sign in</a></footer></div></section>`;
|
|
414
|
+
wireAccount(route);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function wireAccount(route: string) {
|
|
343
418
|
element("continue-app")?.addEventListener("click", () => navigate("content"));
|
|
344
419
|
element("check-verification")?.addEventListener(
|
|
345
420
|
"click",
|