@voxgig/build 4.2.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build.ts +11 -1
- package/dist/build.d.ts +17 -1
- package/dist/build.js +10 -2
- package/dist/build.js.map +1 -1
- package/dist/env/env_gen.d.ts +1 -0
- package/dist/env/env_gen.js +11 -0
- package/dist/env/env_gen.js.map +1 -1
- package/dist/env/web/web_gen.d.ts +19 -0
- package/dist/env/web/web_gen.js +93 -0
- package/dist/env/web/web_gen.js.map +1 -0
- package/env/env_gen.ts +14 -0
- package/env/web/web_gen.ts +115 -0
- package/package.json +1 -1
- package/tm/web/backend/env/web/web.ts.frag +120 -0
- package/tm/web/backend/srv/auth/auth-srv.ts.frag +4 -0
- package/tm/web/backend/srv/auth/get_info.ts.frag +5 -0
- package/tm/web/backend/srv/auth/load_auth.ts.frag +6 -0
- package/tm/web/backend/srv/auth/signin_user.ts.frag +14 -0
- package/tm/web/backend/srv/auth/signout_user.ts.frag +5 -0
- package/tm/web/backend/srv/auth/web_load_auth.ts.frag +12 -0
- package/tm/web/backend/srv/auth/web_signin_user.ts.frag +25 -0
- package/tm/web/backend/srv/auth/web_signout_user.ts.frag +9 -0
- package/tm/web/web/e2e/smoke.spec.js.frag +21 -0
- package/tm/web/web/index.html.frag +15 -0
- package/tm/web/web/package.json.frag +19 -0
- package/tm/web/web/playwright.config.js.frag +40 -0
- package/tm/web/web/src/bus.js.frag +78 -0
- package/tm/web/web/src/cmp/admin.js.frag +272 -0
- package/tm/web/web/src/cmp/app.js.frag +30 -0
- package/tm/web/web/src/cmp/auth.js.frag +97 -0
- package/tm/web/web/src/main.js.frag +10 -0
- package/tm/web/web/src/style.css.frag +26 -0
package/build.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { srv_handler } from './env/lambda/srv_handler'
|
|
|
18
18
|
import { resources_yml } from './env/lambda/res_yml'
|
|
19
19
|
|
|
20
20
|
import { env_gen, ENV_FILES, ENV_SRC, KINDS as ENV_KINDS } from './env/env_gen'
|
|
21
|
+
import { web_gen, WEB_FILES } from './env/web/web_gen'
|
|
21
22
|
|
|
22
23
|
import {
|
|
23
24
|
generate, empty, TM,
|
|
@@ -42,7 +43,15 @@ const EnvGen = {
|
|
|
42
43
|
env_gen,
|
|
43
44
|
files: ENV_FILES,
|
|
44
45
|
srcfiles: ENV_SRC,
|
|
45
|
-
kinds: ENV_KINDS,
|
|
46
|
+
kinds: [...ENV_KINDS, 'web'],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
// EnvWeb: the experimental web frontend (SPA + backend web runner +
|
|
51
|
+
// auth), generated create-once. See env/web/web_gen.ts.
|
|
52
|
+
const EnvWeb = {
|
|
53
|
+
web_gen,
|
|
54
|
+
files: WEB_FILES,
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
|
|
@@ -59,6 +68,7 @@ const Fragments = {
|
|
|
59
68
|
export {
|
|
60
69
|
EnvLambda,
|
|
61
70
|
EnvGen,
|
|
71
|
+
EnvWeb,
|
|
62
72
|
Fragments,
|
|
63
73
|
|
|
64
74
|
// Building blocks for ejected project templates (src/gen/<name>.ts
|
package/dist/build.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ declare const EnvGen: {
|
|
|
28
28
|
folder: string;
|
|
29
29
|
tm?: string;
|
|
30
30
|
src?: string;
|
|
31
|
+
root?: string;
|
|
31
32
|
}) => Promise<void>;
|
|
32
33
|
files: Record<string, {
|
|
33
34
|
frag: string;
|
|
@@ -40,10 +41,25 @@ declare const EnvGen: {
|
|
|
40
41
|
}[]>;
|
|
41
42
|
kinds: string[];
|
|
42
43
|
};
|
|
44
|
+
declare const EnvWeb: {
|
|
45
|
+
web_gen: (model: any, spec: {
|
|
46
|
+
root: string;
|
|
47
|
+
tm?: string;
|
|
48
|
+
env?: any;
|
|
49
|
+
force?: boolean;
|
|
50
|
+
}) => Promise<{
|
|
51
|
+
created: string[];
|
|
52
|
+
skipped: string[];
|
|
53
|
+
}>;
|
|
54
|
+
files: {
|
|
55
|
+
frag: string;
|
|
56
|
+
out: string;
|
|
57
|
+
}[];
|
|
58
|
+
};
|
|
43
59
|
declare const Fragments: {
|
|
44
60
|
load: typeof loadFragment;
|
|
45
61
|
render: typeof renderFragment;
|
|
46
62
|
list: typeof listFragments;
|
|
47
63
|
folder: string;
|
|
48
64
|
};
|
|
49
|
-
export { EnvLambda, EnvGen, Fragments, generate, empty, TM, loadFragment, renderFragment, MsgMetaShape, CoreConfShape, CloudConfShape, res_dynamo_yml, };
|
|
65
|
+
export { EnvLambda, EnvGen, EnvWeb, Fragments, generate, empty, TM, loadFragment, renderFragment, MsgMetaShape, CoreConfShape, CloudConfShape, res_dynamo_yml, };
|
package/dist/build.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/* Copyright © 2022-2026 Voxgig Ltd, MIT License. */
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
exports.res_dynamo_yml = exports.CloudConfShape = exports.CoreConfShape = exports.MsgMetaShape = exports.renderFragment = exports.loadFragment = exports.TM = exports.empty = exports.generate = exports.Fragments = exports.EnvGen = exports.EnvLambda = void 0;
|
|
4
|
+
exports.res_dynamo_yml = exports.CloudConfShape = exports.CoreConfShape = exports.MsgMetaShape = exports.renderFragment = exports.loadFragment = exports.TM = exports.empty = exports.generate = exports.Fragments = exports.EnvWeb = exports.EnvGen = exports.EnvLambda = void 0;
|
|
5
5
|
// EnvLambda: generation of the Lambda deployment templates, built on the
|
|
6
6
|
// jostraca templating library. The templates live in env/lambda/, one file
|
|
7
7
|
// per output for convenience:
|
|
@@ -18,6 +18,7 @@ const srv_yml_1 = require("./env/lambda/srv_yml");
|
|
|
18
18
|
const srv_handler_1 = require("./env/lambda/srv_handler");
|
|
19
19
|
const res_yml_1 = require("./env/lambda/res_yml");
|
|
20
20
|
const env_gen_1 = require("./env/env_gen");
|
|
21
|
+
const web_gen_1 = require("./env/web/web_gen");
|
|
21
22
|
const generate_1 = require("./env/lambda/generate");
|
|
22
23
|
Object.defineProperty(exports, "generate", { enumerable: true, get: function () { return generate_1.generate; } });
|
|
23
24
|
Object.defineProperty(exports, "empty", { enumerable: true, get: function () { return generate_1.empty; } });
|
|
@@ -43,9 +44,16 @@ const EnvGen = {
|
|
|
43
44
|
env_gen: env_gen_1.env_gen,
|
|
44
45
|
files: env_gen_1.ENV_FILES,
|
|
45
46
|
srcfiles: env_gen_1.ENV_SRC,
|
|
46
|
-
kinds: env_gen_1.KINDS,
|
|
47
|
+
kinds: [...env_gen_1.KINDS, 'web'],
|
|
47
48
|
};
|
|
48
49
|
exports.EnvGen = EnvGen;
|
|
50
|
+
// EnvWeb: the experimental web frontend (SPA + backend web runner +
|
|
51
|
+
// auth), generated create-once. See env/web/web_gen.ts.
|
|
52
|
+
const EnvWeb = {
|
|
53
|
+
web_gen: web_gen_1.web_gen,
|
|
54
|
+
files: web_gen_1.WEB_FILES,
|
|
55
|
+
};
|
|
56
|
+
exports.EnvWeb = EnvWeb;
|
|
49
57
|
// Fragment tooling (used by voxgig-system template list/eject/diff, and
|
|
50
58
|
// available to project code).
|
|
51
59
|
const Fragments = {
|
package/dist/build.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build.js","sourceRoot":"","sources":["../build.ts"],"names":[],"mappings":";AAAA,oDAAoD;;;AAEpD,yEAAyE;AACzE,2EAA2E;AAC3E,8BAA8B;AAC9B,EAAE;AACF,yEAAyE;AACzE,uEAAuE;AACvE,yEAAyE;AACzE,EAAE;AACF,yEAAyE;AACzE,2EAA2E;AAC3E,wEAAwE;AACxE,qCAAqC;AAErC,kDAA8C;AAC9C,0DAAsD;AACtD,kDAAoD;AAEpD,2CAA+E;
|
|
1
|
+
{"version":3,"file":"build.js","sourceRoot":"","sources":["../build.ts"],"names":[],"mappings":";AAAA,oDAAoD;;;AAEpD,yEAAyE;AACzE,2EAA2E;AAC3E,8BAA8B;AAC9B,EAAE;AACF,yEAAyE;AACzE,uEAAuE;AACvE,yEAAyE;AACzE,EAAE;AACF,yEAAyE;AACzE,2EAA2E;AAC3E,wEAAwE;AACxE,qCAAqC;AAErC,kDAA8C;AAC9C,0DAAsD;AACtD,kDAAoD;AAEpD,2CAA+E;AAC/E,+CAAsD;AAEtD,oDAG8B;AAmD5B,yFArDA,mBAAQ,OAqDA;AACR,sFAtDU,gBAAK,OAsDV;AACL,mFAvDiB,aAAE,OAuDjB;AACF,6FAvDA,uBAAY,OAuDA;AACZ,+FAxDc,yBAAc,OAwDd;AArDhB,qCAA0C;AAsDxC,6FAtDO,kBAAY,OAsDP;AArDd,uCAA4D;AAsD1D,8FAtDO,oBAAa,OAsDP;AACb,+FAvDsB,qBAAc,OAuDtB;AAtDhB,yDAAqD;AAuDnD,+FAvDO,+BAAc,OAuDP;AApDhB,MAAM,SAAS,GAAG;IAChB,OAAO,EAAP,iBAAO;IACP,WAAW,EAAX,yBAAW;IACX,aAAa,EAAb,uBAAa;CACd,CAAA;AAgCC,8BAAS;AA7BX,wEAAwE;AACxE,yDAAyD;AACzD,MAAM,MAAM,GAAG;IACb,OAAO,EAAP,iBAAO;IACP,KAAK,EAAE,mBAAS;IAChB,QAAQ,EAAE,iBAAO;IACjB,KAAK,EAAE,CAAC,GAAG,eAAS,EAAE,KAAK,CAAC;CAC7B,CAAA;AAuBC,wBAAM;AApBR,oEAAoE;AACpE,wDAAwD;AACxD,MAAM,MAAM,GAAG;IACb,OAAO,EAAP,iBAAO;IACP,KAAK,EAAE,mBAAS;CACjB,CAAA;AAgBC,wBAAM;AAbR,wEAAwE;AACxE,8BAA8B;AAC9B,MAAM,SAAS,GAAG;IAChB,IAAI,EAAE,uBAAY;IAClB,MAAM,EAAE,yBAAc;IACtB,IAAI,EAAE,wBAAa;IACnB,MAAM,EAAE,iBAAM;CACf,CAAA;AAOC,8BAAS"}
|
package/dist/env/env_gen.d.ts
CHANGED
package/dist/env/env_gen.js
CHANGED
|
@@ -21,6 +21,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
21
21
|
const util_1 = require("@voxgig/util");
|
|
22
22
|
const conf_1 = require("../shape/conf");
|
|
23
23
|
const generate_1 = require("./lambda/generate");
|
|
24
|
+
const web_gen_1 = require("./web/web_gen");
|
|
24
25
|
// The supported environment kinds and the files each generates.
|
|
25
26
|
// `out` may contain $$slots$$ (rendered with the same slot values).
|
|
26
27
|
const ENV_FILES = {
|
|
@@ -97,6 +98,16 @@ const env_gen = async (model, spec) => {
|
|
|
97
98
|
continue;
|
|
98
99
|
}
|
|
99
100
|
const kind = def.kind || name;
|
|
101
|
+
// The 'web' kind is the frontend SPA (+ backend web runner + auth),
|
|
102
|
+
// not a flat deployment-artifact set - delegate to EnvWeb.
|
|
103
|
+
if ('web' === kind) {
|
|
104
|
+
if (null == spec.root) {
|
|
105
|
+
throw new Error('@voxgig/build: env kind "web" needs spec.root ' +
|
|
106
|
+
'(the project root); pass it from the build action');
|
|
107
|
+
}
|
|
108
|
+
await (0, web_gen_1.web_gen)(model, { root: spec.root, tm: spec.tm, env: def });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
100
111
|
const filedefs = ENV_FILES[kind];
|
|
101
112
|
if (null == filedefs) {
|
|
102
113
|
throw new Error('@voxgig/build: unknown environment kind: ' + kind +
|
package/dist/env/env_gen.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"env_gen.js","sourceRoot":"","sources":["../../env/env_gen.ts"],"names":[],"mappings":";AAAA,+CAA+C;;;;;;AAE/C,sEAAsE;AACtE,6DAA6D;AAC7D,EAAE;AACF,uCAAuC;AACvC,wEAAwE;AACxE,EAAE;AACF,sEAAsE;AACtE,mEAAmE;AACnE,kEAAkE;AAClE,qEAAqE;AACrE,sCAAsC;AAEtC,4CAAmB;AACnB,gDAAuB;AAEvB,uCAAuC;AAEvC,wCAA6C;AAE7C,gDAA0E;
|
|
1
|
+
{"version":3,"file":"env_gen.js","sourceRoot":"","sources":["../../env/env_gen.ts"],"names":[],"mappings":";AAAA,+CAA+C;;;;;;AAE/C,sEAAsE;AACtE,6DAA6D;AAC7D,EAAE;AACF,uCAAuC;AACvC,wEAAwE;AACxE,EAAE;AACF,sEAAsE;AACtE,mEAAmE;AACnE,kEAAkE;AAClE,qEAAqE;AACrE,sCAAsC;AAEtC,4CAAmB;AACnB,gDAAuB;AAEvB,uCAAuC;AAEvC,wCAA6C;AAE7C,gDAA0E;AAC1E,2CAAuC;AAGvC,gEAAgE;AAChE,oEAAoE;AACpE,MAAM,SAAS,GAAoD;IACjE,KAAK,EAAE;QACL,EAAE,IAAI,EAAE,mBAAmB,EAAE,GAAG,EAAE,QAAQ,EAAE;QAC5C,EAAE,IAAI,EAAE,sBAAsB,EAAE,GAAG,EAAE,WAAW,EAAE;KACnD;IACD,KAAK,EAAE;QACL,EAAE,IAAI,EAAE,4BAA4B,EAAE,GAAG,EAAE,0BAA0B,EAAE;QACvE,EAAE,IAAI,EAAE,sBAAsB,EAAE,GAAG,EAAE,WAAW,EAAE;QAClD,EAAE,IAAI,EAAE,sBAAsB,EAAE,GAAG,EAAE,WAAW,EAAE;KACnD;IACD,MAAM,EAAE;QACN,EAAE,IAAI,EAAE,wBAAwB,EAAE,GAAG,EAAE,YAAY,EAAE;QACrD,EAAE,IAAI,EAAE,yBAAyB,EAAE,GAAG,EAAE,aAAa,EAAE;QACvD,EAAE,IAAI,EAAE,uBAAuB,EAAE,GAAG,EAAE,WAAW,EAAE;KACpD;IACD,EAAE,EAAE;QACF,EAAE,IAAI,EAAE,yBAAyB,EAAE,GAAG,EAAE,iBAAiB,EAAE;QAC3D,EAAE,IAAI,EAAE,mBAAmB,EAAE,GAAG,EAAE,WAAW,EAAE;KAChD;IACD,GAAG,EAAE;QACH,EAAE,IAAI,EAAE,yBAAyB,EAAE,GAAG,EAAE,gBAAgB,EAAE;QAC1D,EAAE,IAAI,EAAE,oBAAoB,EAAE,GAAG,EAAE,WAAW,EAAE;KACjD;IACD,KAAK,EAAE;QACL,EAAE,IAAI,EAAE,sBAAsB,EAAE,GAAG,EAAE,WAAW,EAAE;QAClD,EAAE,IAAI,EAAE,sBAAsB,EAAE,GAAG,EAAE,WAAW,EAAE;KACnD;IACD,UAAU,EAAE;QACV,EAAE,IAAI,EAAE,+BAA+B,EAAE,GAAG,EAAE,eAAe,EAAE;QAC/D,EAAE,IAAI,EAAE,2BAA2B,EAAE,GAAG,EAAE,WAAW,EAAE;KACxD;CACF,CAAA;AAqHC,8BAAS;AAnHX,uEAAuE;AACvE,sEAAsE;AACtE,uEAAuE;AACvE,sEAAsE;AACtE,mDAAmD;AACnD,MAAM,OAAO,GAAiE;IAC5E,KAAK,EAAE,EAAE,EAAE,2CAA2C;IACtD,KAAK,EAAE;QACL,EAAE,IAAI,EAAE,sBAAsB,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,EAAE;KAChE;IACD,MAAM,EAAE;QACN,EAAE,IAAI,EAAE,sBAAsB,EAAE,GAAG,EAAE,QAAQ,EAAE,GAAG,EAAE,WAAW,EAAE;KAClE;IACD,EAAE,EAAE;QACF,EAAE,IAAI,EAAE,sBAAsB,EAAE,GAAG,EAAE,IAAI,EAAE,GAAG,EAAE,OAAO,EAAE;KAC1D;IACD,GAAG,EAAE;QACH,sDAAsD;QACtD,EAAE,IAAI,EAAE,0BAA0B,EAAE,GAAG,EAAE,QAAQ,EAAE,GAAG,EAAE,WAAW,EAAE;KACtE;IACD,KAAK,EAAE;QACL,EAAE,IAAI,EAAE,qBAAqB,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,EAAE;KAC/D;IACD,UAAU,EAAE;QACV,EAAE,IAAI,EAAE,2BAA2B,EAAE,GAAG,EAAE,YAAY,EAAE,GAAG,EAAE,WAAW,EAAE;KAC3E;CACF,CAAA;AA0FC,0BAAO;AAxFT,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;AAyFlC,sBAAK;AAtFP,MAAM,OAAO,GAAG,KAAK,EAAE,KAAU,EAAE,IAOlC,EAAE,EAAE;;IAEH,MAAM,IAAI,GAAG,IAAA,oBAAa,EAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAChD,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,GAAG,IAAI,EAAE,CAAA;IAEjC,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QACzC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAA;QACrB,MAAM,GAAG,GAAQ,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;QAE/B,IAAI,KAAK,KAAK,GAAG,CAAC,MAAM,EAAE,CAAC;YACzB,SAAQ;QACV,CAAC;QAED,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,IAAI,IAAI,CAAA;QAE7B,oEAAoE;QACpE,2DAA2D;QAC3D,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;YACnB,IAAI,IAAI,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC;gBACtB,MAAM,IAAI,KAAK,CAAC,gDAAgD;oBAC9D,mDAAmD,CAAC,CAAA;YACxD,CAAC;YACD,MAAM,IAAA,iBAAO,EAAC,KAAK,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,CAAA;YAChE,SAAQ;QACV,CAAC;QACD,MAAM,QAAQ,GAAG,SAAS,CAAC,IAAI,CAAC,CAAA;QAChC,IAAI,IAAI,IAAI,QAAQ,EAAE,CAAC;YACrB,MAAM,IAAI,KAAK,CAAC,2CAA2C,GAAG,IAAI;gBAChE,QAAQ,GAAG,IAAI,GAAG,iBAAiB,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,GAAG,CAAC,CAAA;QACjE,CAAC;QAED,gEAAgE;QAChE,8DAA8D;QAC9D,+BAA+B;QAC/B,MAAM,KAAK,GAAwB;YACjC,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,IAAI,EAAE,IAAA,eAAQ,EAAC,IAAI,CAAC,IAAI,CAAC;YACzB,GAAG,EAAE,IAAI;YACT,IAAI,EAAE,CAAA,MAAA,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,0CAAE,OAAO,KAAI,IAAI;YAC3C,IAAI,EAAE,GAAG,CAAC,IAAI,IAAI,IAAI;YACtB,MAAM,EAAE,GAAG,CAAC,MAAM,IAAI,WAAW;YACjC,KAAK,EAAE,GAAG,CAAC,KAAK,IAAI,KAAK;SAC1B,CAAA;QACD,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;YACnC,IAAI,QAAQ,KAAK,OAAO,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;gBACvD,KAAK,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,GAAG,CAAC,CAAA;YACvB,CAAC;QACH,CAAC;QAED,gDAAgD;QAChD,KAAK,CAAC,GAAG,GAAG,IAAI,CAAA;QAEhB,MAAM,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;YAClC,IAAI,EAAE,IAAA,yBAAc,EAAC,EAAE,CAAC,GAAG,EAAE,KAAK,CAAC;YACnC,OAAO,EAAE,IAAA,yBAAc,EAAC,IAAA,uBAAY,EAAC,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,KAAK,CAAC;SACnE,CAAC,CAAC,CAAA;QAEH,MAAM,IAAA,mBAAQ,EAAC,IAAI,CAAC,MAAM,GAAG,GAAG,GAAG,IAAI,EAAE,KAAK,CAAC,CAAA;QAE/C,qEAAqE;QACrE,IAAI,IAAI,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC;YACrB,KAAK,MAAM,EAAE,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC;gBACvC,MAAM,IAAI,GAAG,cAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,EAAE,IAAA,yBAAc,EAAC,EAAE,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAA;gBACvE,IAAI,CAAC,YAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;oBACzB,MAAM,OAAO,GACX,IAAA,yBAAc,EAAC,IAAA,uBAAY,EAAC,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,KAAK,CAAC,CAAA;oBAC3D,YAAE,CAAC,SAAS,CAAC,cAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;oBACrD,YAAE,CAAC,aAAa,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;gBACjC,CAAC;YACH,CAAC;QACH,CAAC;IACH,CAAC;AACH,CAAC,CAAA;AAIC,0BAAO"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
declare const WEB_FILES: {
|
|
2
|
+
frag: string;
|
|
3
|
+
out: string;
|
|
4
|
+
}[];
|
|
5
|
+
declare const DEFAULT_USERS: {
|
|
6
|
+
name: string;
|
|
7
|
+
email: string;
|
|
8
|
+
password: string;
|
|
9
|
+
}[];
|
|
10
|
+
declare const web_gen: (model: any, spec: {
|
|
11
|
+
root: string;
|
|
12
|
+
tm?: string;
|
|
13
|
+
env?: any;
|
|
14
|
+
force?: boolean;
|
|
15
|
+
}) => Promise<{
|
|
16
|
+
created: string[];
|
|
17
|
+
skipped: string[];
|
|
18
|
+
}>;
|
|
19
|
+
export { web_gen, WEB_FILES, DEFAULT_USERS, };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/* Copyright © 2026 Voxgig Ltd, MIT License. */
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.DEFAULT_USERS = exports.WEB_FILES = exports.web_gen = void 0;
|
|
8
|
+
// EnvWeb: generate the experimental web frontend - a Single Page App built
|
|
9
|
+
// from web components on a client-side Seneca service bus (no React/Vue) -
|
|
10
|
+
// plus the backend web runner and auth service it needs.
|
|
11
|
+
//
|
|
12
|
+
// Everything EnvWeb generates is CREATE-ONCE: a project owns its frontend
|
|
13
|
+
// after generation (the SPA is a starting point users heavily customize).
|
|
14
|
+
// Fragments live in @voxgig/build tm/web/ and are project-shadowable via
|
|
15
|
+
// spec.tm (the project's tm/web folder), like every other template.
|
|
16
|
+
//
|
|
17
|
+
// The entity admin is model-driven at RUNTIME (it fetches the compiled
|
|
18
|
+
// model.json), so the generated SPA adapts to any model with no
|
|
19
|
+
// per-entity code generation.
|
|
20
|
+
const fs_1 = __importDefault(require("fs"));
|
|
21
|
+
const path_1 = __importDefault(require("path"));
|
|
22
|
+
const util_1 = require("@voxgig/util");
|
|
23
|
+
const conf_1 = require("../../shape/conf");
|
|
24
|
+
const generate_1 = require("../lambda/generate");
|
|
25
|
+
// File manifest: fragment (relative to tm/web) -> output path (relative to
|
|
26
|
+
// the project root, which holds backend/ and web/). $$slots$$ in the
|
|
27
|
+
// output path are rendered too.
|
|
28
|
+
const WEB_FILES = [
|
|
29
|
+
// Frontend SPA (web/)
|
|
30
|
+
{ frag: 'web/package.json.frag', out: 'web/package.json' },
|
|
31
|
+
{ frag: 'web/index.html.frag', out: 'web/index.html' },
|
|
32
|
+
{ frag: 'web/playwright.config.js.frag', out: 'web/playwright.config.js' },
|
|
33
|
+
{ frag: 'web/src/main.js.frag', out: 'web/src/main.js' },
|
|
34
|
+
{ frag: 'web/src/bus.js.frag', out: 'web/src/bus.js' },
|
|
35
|
+
{ frag: 'web/src/style.css.frag', out: 'web/src/style.css' },
|
|
36
|
+
{ frag: 'web/src/cmp/app.js.frag', out: 'web/src/cmp/app.js' },
|
|
37
|
+
{ frag: 'web/src/cmp/auth.js.frag', out: 'web/src/cmp/auth.js' },
|
|
38
|
+
{ frag: 'web/src/cmp/admin.js.frag', out: 'web/src/cmp/admin.js' },
|
|
39
|
+
{ frag: 'web/e2e/smoke.spec.js.frag', out: 'web/e2e/smoke.spec.js' },
|
|
40
|
+
// Backend web runner + auth service (backend/src/...)
|
|
41
|
+
{ frag: 'backend/env/web/web.ts.frag', out: 'backend/src/env/web/web.ts' },
|
|
42
|
+
{ frag: 'backend/srv/auth/auth-srv.ts.frag', out: 'backend/src/srv/auth/auth-srv.ts' },
|
|
43
|
+
{ frag: 'backend/srv/auth/get_info.ts.frag', out: 'backend/src/srv/auth/get_info.ts' },
|
|
44
|
+
{ frag: 'backend/srv/auth/signin_user.ts.frag', out: 'backend/src/srv/auth/signin_user.ts' },
|
|
45
|
+
{ frag: 'backend/srv/auth/signout_user.ts.frag', out: 'backend/src/srv/auth/signout_user.ts' },
|
|
46
|
+
{ frag: 'backend/srv/auth/load_auth.ts.frag', out: 'backend/src/srv/auth/load_auth.ts' },
|
|
47
|
+
{ frag: 'backend/srv/auth/web_signin_user.ts.frag', out: 'backend/src/srv/auth/web_signin_user.ts' },
|
|
48
|
+
{ frag: 'backend/srv/auth/web_signout_user.ts.frag', out: 'backend/src/srv/auth/web_signout_user.ts' },
|
|
49
|
+
{ frag: 'backend/srv/auth/web_load_auth.ts.frag', out: 'backend/src/srv/auth/web_load_auth.ts' },
|
|
50
|
+
];
|
|
51
|
+
exports.WEB_FILES = WEB_FILES;
|
|
52
|
+
// Default seeded users (local testing only), rendered into the runner.
|
|
53
|
+
const DEFAULT_USERS = [
|
|
54
|
+
{ name: 'Alice Example', email: 'alice@example.com', password: 'alice-pass-01' },
|
|
55
|
+
{ name: 'Bob Example', email: 'bob@example.com', password: 'bob-pass-01' },
|
|
56
|
+
];
|
|
57
|
+
exports.DEFAULT_USERS = DEFAULT_USERS;
|
|
58
|
+
// web_gen(model, spec): generate the frontend + backend web pieces into
|
|
59
|
+
// the project. spec.root is the project root (parent of backend/); files
|
|
60
|
+
// that already exist are left untouched (create-once).
|
|
61
|
+
const web_gen = async (model, spec) => {
|
|
62
|
+
const core = (0, conf_1.CoreConfShape)(model.main.conf.core);
|
|
63
|
+
const envdef = spec.env || {};
|
|
64
|
+
const users = envdef.users || DEFAULT_USERS;
|
|
65
|
+
const seed = users[0] || DEFAULT_USERS[0];
|
|
66
|
+
const port = envdef.port ||
|
|
67
|
+
(model.main.conf.port && model.main.conf.port.backend) || 8080;
|
|
68
|
+
const slots = {
|
|
69
|
+
name: core.name,
|
|
70
|
+
Name: (0, util_1.camelify)(core.name),
|
|
71
|
+
users: JSON.stringify(users, null, 2),
|
|
72
|
+
e2eport: envdef.e2eport || (port + 10),
|
|
73
|
+
seedEmail: seed.email,
|
|
74
|
+
seedPassword: seed.password,
|
|
75
|
+
};
|
|
76
|
+
const created = [];
|
|
77
|
+
const skipped = [];
|
|
78
|
+
for (const fd of WEB_FILES) {
|
|
79
|
+
const outrel = (0, generate_1.renderFragment)(fd.out, slots);
|
|
80
|
+
const dest = path_1.default.join(spec.root, outrel);
|
|
81
|
+
if (fs_1.default.existsSync(dest) && !spec.force) {
|
|
82
|
+
skipped.push(outrel);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const content = (0, generate_1.renderFragment)((0, generate_1.loadFragment)(fd.frag, spec, 'web'), slots);
|
|
86
|
+
fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true });
|
|
87
|
+
fs_1.default.writeFileSync(dest, content);
|
|
88
|
+
created.push(outrel);
|
|
89
|
+
}
|
|
90
|
+
return { created: created.sort(), skipped: skipped.sort() };
|
|
91
|
+
};
|
|
92
|
+
exports.web_gen = web_gen;
|
|
93
|
+
//# sourceMappingURL=web_gen.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"web_gen.js","sourceRoot":"","sources":["../../../env/web/web_gen.ts"],"names":[],"mappings":"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"}
|
package/env/env_gen.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { camelify } from '@voxgig/util'
|
|
|
20
20
|
import { CoreConfShape } from '../shape/conf'
|
|
21
21
|
|
|
22
22
|
import { generate, loadFragment, renderFragment } from './lambda/generate'
|
|
23
|
+
import { web_gen } from './web/web_gen'
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
// The supported environment kinds and the files each generates.
|
|
@@ -93,6 +94,8 @@ const env_gen = async (model: any, spec: {
|
|
|
93
94
|
tm?: string // project tm/env folder (fragment shadowing)
|
|
94
95
|
src?: string // project src/env folder: runtime entries generated
|
|
95
96
|
// here ONCE (existing files are never overwritten)
|
|
97
|
+
root?: string // project root (holds backend/ and web/) - required
|
|
98
|
+
// for the 'web' kind (full frontend generation)
|
|
96
99
|
}) => {
|
|
97
100
|
|
|
98
101
|
const core = CoreConfShape(model.main.conf.core)
|
|
@@ -107,6 +110,17 @@ const env_gen = async (model: any, spec: {
|
|
|
107
110
|
}
|
|
108
111
|
|
|
109
112
|
const kind = def.kind || name
|
|
113
|
+
|
|
114
|
+
// The 'web' kind is the frontend SPA (+ backend web runner + auth),
|
|
115
|
+
// not a flat deployment-artifact set - delegate to EnvWeb.
|
|
116
|
+
if ('web' === kind) {
|
|
117
|
+
if (null == spec.root) {
|
|
118
|
+
throw new Error('@voxgig/build: env kind "web" needs spec.root ' +
|
|
119
|
+
'(the project root); pass it from the build action')
|
|
120
|
+
}
|
|
121
|
+
await web_gen(model, { root: spec.root, tm: spec.tm, env: def })
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
110
124
|
const filedefs = ENV_FILES[kind]
|
|
111
125
|
if (null == filedefs) {
|
|
112
126
|
throw new Error('@voxgig/build: unknown environment kind: ' + kind +
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/* Copyright © 2026 Voxgig Ltd, MIT License. */
|
|
2
|
+
|
|
3
|
+
// EnvWeb: generate the experimental web frontend - a Single Page App built
|
|
4
|
+
// from web components on a client-side Seneca service bus (no React/Vue) -
|
|
5
|
+
// plus the backend web runner and auth service it needs.
|
|
6
|
+
//
|
|
7
|
+
// Everything EnvWeb generates is CREATE-ONCE: a project owns its frontend
|
|
8
|
+
// after generation (the SPA is a starting point users heavily customize).
|
|
9
|
+
// Fragments live in @voxgig/build tm/web/ and are project-shadowable via
|
|
10
|
+
// spec.tm (the project's tm/web folder), like every other template.
|
|
11
|
+
//
|
|
12
|
+
// The entity admin is model-driven at RUNTIME (it fetches the compiled
|
|
13
|
+
// model.json), so the generated SPA adapts to any model with no
|
|
14
|
+
// per-entity code generation.
|
|
15
|
+
|
|
16
|
+
import Fs from 'fs'
|
|
17
|
+
import Path from 'path'
|
|
18
|
+
|
|
19
|
+
import { camelify } from '@voxgig/util'
|
|
20
|
+
|
|
21
|
+
import { CoreConfShape } from '../../shape/conf'
|
|
22
|
+
|
|
23
|
+
import { loadFragment, renderFragment } from '../lambda/generate'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
// File manifest: fragment (relative to tm/web) -> output path (relative to
|
|
27
|
+
// the project root, which holds backend/ and web/). $$slots$$ in the
|
|
28
|
+
// output path are rendered too.
|
|
29
|
+
const WEB_FILES: { frag: string, out: string }[] = [
|
|
30
|
+
// Frontend SPA (web/)
|
|
31
|
+
{ frag: 'web/package.json.frag', out: 'web/package.json' },
|
|
32
|
+
{ frag: 'web/index.html.frag', out: 'web/index.html' },
|
|
33
|
+
{ frag: 'web/playwright.config.js.frag', out: 'web/playwright.config.js' },
|
|
34
|
+
{ frag: 'web/src/main.js.frag', out: 'web/src/main.js' },
|
|
35
|
+
{ frag: 'web/src/bus.js.frag', out: 'web/src/bus.js' },
|
|
36
|
+
{ frag: 'web/src/style.css.frag', out: 'web/src/style.css' },
|
|
37
|
+
{ frag: 'web/src/cmp/app.js.frag', out: 'web/src/cmp/app.js' },
|
|
38
|
+
{ frag: 'web/src/cmp/auth.js.frag', out: 'web/src/cmp/auth.js' },
|
|
39
|
+
{ frag: 'web/src/cmp/admin.js.frag', out: 'web/src/cmp/admin.js' },
|
|
40
|
+
{ frag: 'web/e2e/smoke.spec.js.frag', out: 'web/e2e/smoke.spec.js' },
|
|
41
|
+
|
|
42
|
+
// Backend web runner + auth service (backend/src/...)
|
|
43
|
+
{ frag: 'backend/env/web/web.ts.frag', out: 'backend/src/env/web/web.ts' },
|
|
44
|
+
{ frag: 'backend/srv/auth/auth-srv.ts.frag', out: 'backend/src/srv/auth/auth-srv.ts' },
|
|
45
|
+
{ frag: 'backend/srv/auth/get_info.ts.frag', out: 'backend/src/srv/auth/get_info.ts' },
|
|
46
|
+
{ frag: 'backend/srv/auth/signin_user.ts.frag', out: 'backend/src/srv/auth/signin_user.ts' },
|
|
47
|
+
{ frag: 'backend/srv/auth/signout_user.ts.frag', out: 'backend/src/srv/auth/signout_user.ts' },
|
|
48
|
+
{ frag: 'backend/srv/auth/load_auth.ts.frag', out: 'backend/src/srv/auth/load_auth.ts' },
|
|
49
|
+
{ frag: 'backend/srv/auth/web_signin_user.ts.frag', out: 'backend/src/srv/auth/web_signin_user.ts' },
|
|
50
|
+
{ frag: 'backend/srv/auth/web_signout_user.ts.frag', out: 'backend/src/srv/auth/web_signout_user.ts' },
|
|
51
|
+
{ frag: 'backend/srv/auth/web_load_auth.ts.frag', out: 'backend/src/srv/auth/web_load_auth.ts' },
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
// Default seeded users (local testing only), rendered into the runner.
|
|
56
|
+
const DEFAULT_USERS = [
|
|
57
|
+
{ name: 'Alice Example', email: 'alice@example.com', password: 'alice-pass-01' },
|
|
58
|
+
{ name: 'Bob Example', email: 'bob@example.com', password: 'bob-pass-01' },
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
// web_gen(model, spec): generate the frontend + backend web pieces into
|
|
63
|
+
// the project. spec.root is the project root (parent of backend/); files
|
|
64
|
+
// that already exist are left untouched (create-once).
|
|
65
|
+
const web_gen = async (model: any, spec: {
|
|
66
|
+
root: string // project root (holds backend/ and web/)
|
|
67
|
+
tm?: string // project tm/web folder (fragment shadowing)
|
|
68
|
+
env?: any // the model env entry (config: port, users)
|
|
69
|
+
force?: boolean // overwrite existing files (default: create-once)
|
|
70
|
+
}) => {
|
|
71
|
+
|
|
72
|
+
const core = CoreConfShape(model.main.conf.core)
|
|
73
|
+
const envdef = spec.env || {}
|
|
74
|
+
|
|
75
|
+
const users = envdef.users || DEFAULT_USERS
|
|
76
|
+
const seed = users[0] || DEFAULT_USERS[0]
|
|
77
|
+
const port = envdef.port ||
|
|
78
|
+
(model.main.conf.port && model.main.conf.port.backend) || 8080
|
|
79
|
+
|
|
80
|
+
const slots: Record<string, any> = {
|
|
81
|
+
name: core.name,
|
|
82
|
+
Name: camelify(core.name),
|
|
83
|
+
users: JSON.stringify(users, null, 2),
|
|
84
|
+
e2eport: envdef.e2eport || (port + 10),
|
|
85
|
+
seedEmail: seed.email,
|
|
86
|
+
seedPassword: seed.password,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const created: string[] = []
|
|
90
|
+
const skipped: string[] = []
|
|
91
|
+
|
|
92
|
+
for (const fd of WEB_FILES) {
|
|
93
|
+
const outrel = renderFragment(fd.out, slots)
|
|
94
|
+
const dest = Path.join(spec.root, outrel)
|
|
95
|
+
|
|
96
|
+
if (Fs.existsSync(dest) && !spec.force) {
|
|
97
|
+
skipped.push(outrel)
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const content = renderFragment(loadFragment(fd.frag, spec, 'web'), slots)
|
|
102
|
+
Fs.mkdirSync(Path.dirname(dest), { recursive: true })
|
|
103
|
+
Fs.writeFileSync(dest, content)
|
|
104
|
+
created.push(outrel)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { created: created.sort(), skipped: skipped.sort() }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
export {
|
|
112
|
+
web_gen,
|
|
113
|
+
WEB_FILES,
|
|
114
|
+
DEFAULT_USERS,
|
|
115
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
|
|
2
|
+
// Local web runner: the local in-memory backend plus an HTTP layer for
|
|
3
|
+
// the SPA - express serving web/dist, the model, and a single seneca
|
|
4
|
+
// gateway endpoint (/seneca) that the browser-side Seneca bus posts
|
|
5
|
+
// messages to (seneca-browser fetch transport). Cookie auth via
|
|
6
|
+
// @seneca/gateway-auth (express_cookie): signin sets the todo-auth
|
|
7
|
+
// cookie, after which the gateway attaches the principal so @seneca/owner
|
|
8
|
+
// scopes todos per user.
|
|
9
|
+
//
|
|
10
|
+
// Predefined users are seeded at startup (local testing only).
|
|
11
|
+
|
|
12
|
+
import Path from 'node:path'
|
|
13
|
+
|
|
14
|
+
import Express from 'express'
|
|
15
|
+
import CookieParser from 'cookie-parser'
|
|
16
|
+
|
|
17
|
+
import Seneca from 'seneca'
|
|
18
|
+
import { Local } from '@voxgig/system'
|
|
19
|
+
|
|
20
|
+
import { basic, base } from '../shared/basic'
|
|
21
|
+
|
|
22
|
+
import Pkg from '../../../package.json'
|
|
23
|
+
import Model from '../../../model/model.json'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
const PORT = parseInt(process.env.PORT || '', 10) ||
|
|
27
|
+
(Model as any).main.conf.port.backend
|
|
28
|
+
|
|
29
|
+
// Predefined users seeded at startup (local testing only). Edit freely.
|
|
30
|
+
const USERS = $$users$$
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
run()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async function run() {
|
|
37
|
+
const { deep } = Seneca.util
|
|
38
|
+
|
|
39
|
+
const seneca = Seneca(deep(base.seneca, { tag: '$$name$$-web' }))
|
|
40
|
+
|
|
41
|
+
seneca.context.model = Model
|
|
42
|
+
seneca.context.env = 'local'
|
|
43
|
+
seneca.context.stage = 'local'
|
|
44
|
+
seneca.context.srvname = 'all'
|
|
45
|
+
seneca.context.pkg = Pkg
|
|
46
|
+
|
|
47
|
+
seneca.test()
|
|
48
|
+
|
|
49
|
+
basic(seneca)
|
|
50
|
+
|
|
51
|
+
seneca
|
|
52
|
+
.use('gateway', {
|
|
53
|
+
// Allow auth + every service the model declares (aim:<srv>).
|
|
54
|
+
allow: Object.keys((Model as any).main.srv).reduce(
|
|
55
|
+
(a: any, n: string) => (a['aim:' + n] = true, a),
|
|
56
|
+
{ 'aim:req,on:auth': true }),
|
|
57
|
+
})
|
|
58
|
+
.use('gateway-express', {
|
|
59
|
+
// gateway-express sets/clears the auth cookie when a result carries
|
|
60
|
+
// gateway$.auth (see web_signin_user / web_signout_user). Cookie
|
|
61
|
+
// attributes use the plugin defaults (httpOnly, sameSite).
|
|
62
|
+
auth: {
|
|
63
|
+
token: {
|
|
64
|
+
name: '$$name$$-auth',
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
.use('gateway-auth', {
|
|
69
|
+
spec: {
|
|
70
|
+
express_cookie: {
|
|
71
|
+
active: true,
|
|
72
|
+
token: {
|
|
73
|
+
name: '$$name$$-auth',
|
|
74
|
+
},
|
|
75
|
+
user: {
|
|
76
|
+
auth: true,
|
|
77
|
+
require: false,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
seneca.use(Local, {
|
|
84
|
+
srv: {
|
|
85
|
+
folder: __dirname + '/../../../dist/srv',
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
await seneca.ready()
|
|
90
|
+
|
|
91
|
+
// Seed the predefined users (idempotent per boot: in-memory store).
|
|
92
|
+
for (const u of USERS) {
|
|
93
|
+
const res = await seneca.post('sys:user,register:user', u)
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
console.log('SEED-USER-FAILED', u.email, res.why)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const app = Express()
|
|
100
|
+
// web/ is a sibling of backend/; from dist/env/local go up to the
|
|
101
|
+
// project root, then web/dist.
|
|
102
|
+
const webdist = Path.join(
|
|
103
|
+
__dirname, '..', '..', '..', '..', 'web', 'dist')
|
|
104
|
+
|
|
105
|
+
app
|
|
106
|
+
.use(Express.json())
|
|
107
|
+
.use(new (CookieParser as any)())
|
|
108
|
+
.post('/seneca', seneca.export('gateway-express/handler'))
|
|
109
|
+
.get('/model.json',
|
|
110
|
+
(_req: any, res: any) => res.sendFile(
|
|
111
|
+
Path.join(__dirname, '..', '..', '..', 'model', 'model.json')))
|
|
112
|
+
.use(Express.static(webdist))
|
|
113
|
+
.listen(PORT)
|
|
114
|
+
|
|
115
|
+
console.log('$$name$$-web started', {
|
|
116
|
+
port: PORT,
|
|
117
|
+
users: USERS.map((u) => u.email),
|
|
118
|
+
version: Pkg.version,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Sign a user in via @seneca/user login (email + password).
|
|
2
|
+
module.exports = function make_signin_user() {
|
|
3
|
+
return async function signin_user(this: any, msg: any) {
|
|
4
|
+
const seneca = this
|
|
5
|
+
|
|
6
|
+
const out = await seneca.post('sys:user,login:user', {
|
|
7
|
+
email: msg.email,
|
|
8
|
+
password: msg.password,
|
|
9
|
+
fields: ['id', 'email', 'name', 'handle'],
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
return out
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Current auth state, from the principal the gateway resolved (cookie).
|
|
2
|
+
module.exports = function make_web_load_auth() {
|
|
3
|
+
return async function web_load_auth(this: any, _msg: any, meta: any) {
|
|
4
|
+
const user = meta.custom?.principal?.user
|
|
5
|
+
const out: any = { ok: true, state: 'signedout' }
|
|
6
|
+
if (user) {
|
|
7
|
+
out.state = 'signedin'
|
|
8
|
+
out.user = { id: user.id, email: user.email, handle: user.handle }
|
|
9
|
+
}
|
|
10
|
+
return out
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Gateway signin: on success the gateway$ auth token result field makes
|
|
2
|
+
// gateway-auth (express_cookie) set the auth cookie.
|
|
3
|
+
module.exports = function make_web_signin_user() {
|
|
4
|
+
return async function web_signin_user(this: any, msg: any) {
|
|
5
|
+
const res = await this.post('aim:auth,signin:user', {
|
|
6
|
+
email: msg.email,
|
|
7
|
+
password: msg.password,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
const out: any = { ok: res.ok }
|
|
11
|
+
if (res.ok) {
|
|
12
|
+
out.gateway$ = { auth: { token: res.login.token } }
|
|
13
|
+
out.user = {
|
|
14
|
+
id: res.user.id,
|
|
15
|
+
email: res.user.email,
|
|
16
|
+
name: res.user.name,
|
|
17
|
+
handle: res.user.handle,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
out.why = res.why
|
|
22
|
+
}
|
|
23
|
+
return out
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module.exports = function make_web_signout_user() {
|
|
2
|
+
return async function web_signout_user(this: any, _msg: any, meta: any) {
|
|
3
|
+
const token = meta.custom?.principal?.token
|
|
4
|
+
if (token) {
|
|
5
|
+
await this.post('aim:auth,signout:user', { token })
|
|
6
|
+
}
|
|
7
|
+
return { ok: true, gateway$: { auth: { remove: true } } }
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
// Generic smoke test: the SPA boots, a seeded user can sign in, and the
|
|
4
|
+
// model-driven entity admin renders with at least one entity. Add
|
|
5
|
+
// project-specific CRUD flows alongside this file.
|
|
6
|
+
|
|
7
|
+
test('sign in and load the entity admin', async ({ page }) => {
|
|
8
|
+
await page.goto('/')
|
|
9
|
+
|
|
10
|
+
await expect(page.locator('vg-auth h2')).toHaveText('Sign in')
|
|
11
|
+
|
|
12
|
+
await page.fill('input[name=email]', '$$seedEmail$$')
|
|
13
|
+
await page.fill('input[name=password]', '$$seedPassword$$')
|
|
14
|
+
await page.click('vg-auth button[type=submit]')
|
|
15
|
+
|
|
16
|
+
await expect(page.locator('.vg-auth-bar b')).toHaveText('$$seedEmail$$')
|
|
17
|
+
await expect(page.locator('vg-entity-admin nav a')).not.toHaveCount(0)
|
|
18
|
+
|
|
19
|
+
// The first entity's list loads (count line rendered).
|
|
20
|
+
await expect(page.locator('#vg-count')).toBeVisible()
|
|
21
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>$$Name$$</title>
|
|
7
|
+
<script src="/seneca-browser.js"></script>
|
|
8
|
+
<script src="/seneca-browser-store.js"></script>
|
|
9
|
+
<script src="/seneca-browser-debug.js"></script>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<vg-app></vg-app>
|
|
13
|
+
<script type="module" src="/src/main.js"></script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "$$name$$-web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "$$Name$$ SPA: web components on a Seneca service bus",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"postinstall": "node -e \"const fs=require('fs');fs.mkdirSync('public',{recursive:true});for(const p of ['seneca-browser','@voxgig/seneca-browser-store','@voxgig/seneca-browser-debug'])fs.copyFileSync(require.resolve(p),'public/'+p.replace('@voxgig/','')+'.js')\"",
|
|
8
|
+
"dev": "vite",
|
|
9
|
+
"build": "vite build",
|
|
10
|
+
"e2e": "playwright test"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@playwright/test": "^1.58.2",
|
|
14
|
+
"@voxgig/seneca-browser-debug": "^0.1.0",
|
|
15
|
+
"@voxgig/seneca-browser-store": "^0.1.0",
|
|
16
|
+
"seneca-browser": "^8.0.0-rc4",
|
|
17
|
+
"vite": "^7.3.4"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Headless-chrome E2E for the $$Name$$ SPA. The backend web runner (which
|
|
2
|
+
// serves the built SPA and the /seneca gateway) is started as the test
|
|
3
|
+
// webServer; Playwright waits for it, then drives the SPA.
|
|
4
|
+
import { defineConfig, devices } from '@playwright/test'
|
|
5
|
+
|
|
6
|
+
const PORT = $$e2eport$$
|
|
7
|
+
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
testDir: './e2e',
|
|
10
|
+
timeout: 30000,
|
|
11
|
+
fullyParallel: false,
|
|
12
|
+
// One worker: all specs share a single backend web runner with one
|
|
13
|
+
// in-memory store and the same seeded users, so they must run serially to
|
|
14
|
+
// avoid cross-spec contention on shared state.
|
|
15
|
+
workers: 1,
|
|
16
|
+
reporter: [['list']],
|
|
17
|
+
use: {
|
|
18
|
+
baseURL: `http://localhost:${PORT}`,
|
|
19
|
+
trace: 'off',
|
|
20
|
+
},
|
|
21
|
+
projects: [
|
|
22
|
+
{
|
|
23
|
+
name: 'chromium',
|
|
24
|
+
use: {
|
|
25
|
+
...devices['Desktop Chrome'],
|
|
26
|
+
// Self-contained chromium (no system chrome / no desktop): install
|
|
27
|
+
// with `npx playwright install chromium` (no --with-deps).
|
|
28
|
+
launchOptions: { args: ['--no-sandbox'] },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
webServer: {
|
|
33
|
+
command: `npm run build && cd ../backend && PORT=${PORT} node dist/env/web/web.js`,
|
|
34
|
+
url: `http://localhost:${PORT}/`,
|
|
35
|
+
timeout: 60000,
|
|
36
|
+
reuseExistingServer: false,
|
|
37
|
+
stdout: 'ignore',
|
|
38
|
+
stderr: 'pipe',
|
|
39
|
+
},
|
|
40
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The browser-side Seneca service bus. All component state and all
|
|
2
|
+
// backend communication flow through messages on this bus:
|
|
3
|
+
//
|
|
4
|
+
// aim:* -> the backend gateway (/seneca) via the
|
|
5
|
+
// seneca-browser fetch transport (cookie auth)
|
|
6
|
+
// cmp:* -> local component plugins (state)
|
|
7
|
+
//
|
|
8
|
+
// Cross-component notification uses the tiny `evt` plugin below:
|
|
9
|
+
// components post cmp:bus,emit:<name> and subscribe with onEvent().
|
|
10
|
+
|
|
11
|
+
/* global Seneca, SenecaBrowserStore, SenecaBrowserDebug */
|
|
12
|
+
|
|
13
|
+
const bus = Seneca({
|
|
14
|
+
legacy: false,
|
|
15
|
+
timeout: 8888,
|
|
16
|
+
plugin: {
|
|
17
|
+
browser: {
|
|
18
|
+
endpoint: '/seneca',
|
|
19
|
+
fetch: {
|
|
20
|
+
credentials: 'same-origin',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
.test()
|
|
26
|
+
.client({ type: 'browser', pin: 'aim:*' })
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
// Transparent state cache (Redux-like): caches aim:* query results and
|
|
30
|
+
// invalidates on writes, so repeated reads are served without a round-trip.
|
|
31
|
+
// Registered AFTER .client() so the aim:* pin actions exist to be wrapped.
|
|
32
|
+
if ('undefined' !== typeof SenecaBrowserStore) {
|
|
33
|
+
bus.use(SenecaBrowserStore, { pin: 'aim:*' })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// In-window devtools: a resizable/hideable pop-up showing live Seneca
|
|
37
|
+
// message flows, config, and (via the store plugin above) the cache tree.
|
|
38
|
+
// aim:* messages are the remote (backend) calls. Loaded as a global script
|
|
39
|
+
// in index.html; guarded so a missing bundle never breaks the app.
|
|
40
|
+
if ('undefined' !== typeof SenecaBrowserDebug) {
|
|
41
|
+
bus.use(SenecaBrowserDebug, { remotePins: 'aim:*' })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
// Event fanout, unified on Seneca messages: cross-component events use the
|
|
46
|
+
// bus's own sub/act. Unlike a plain `add` (single handler), `sub` allows
|
|
47
|
+
// many observers per pattern - so it IS the pub/sub primitive. A no-op sink
|
|
48
|
+
// action lets `act` resolve; observers subscribe via `sub`. This is the same
|
|
49
|
+
// mechanism reactivity uses (see @seneca/browser-store's changed:* events).
|
|
50
|
+
bus.add('cmp:evt', function (msg, reply) {
|
|
51
|
+
reply()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
function onEvent(name, fn) {
|
|
55
|
+
bus.sub('cmp:evt,name:' + name, function (msg) {
|
|
56
|
+
fn(msg.data || {})
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function emit(name, data) {
|
|
61
|
+
bus.act('cmp:evt,name:' + name, { data })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
// Avoid cross-user staleness: drop all cached queries whenever auth changes
|
|
66
|
+
// (sign in / sign out), since cached results are principal-scoped.
|
|
67
|
+
if ('undefined' !== typeof SenecaBrowserStore) {
|
|
68
|
+
onEvent('auth', function () {
|
|
69
|
+
bus.act('sys:browser-store,clear:store', function () {})
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
export {
|
|
75
|
+
bus,
|
|
76
|
+
onEvent,
|
|
77
|
+
emit,
|
|
78
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Entity admin: a django-admin-style generic CRUD UI, driven entirely by
|
|
2
|
+
// the compiled model (fetched from /model.json). The entity_admin plugin
|
|
3
|
+
// owns the model + per-entity message mapping (state); the
|
|
4
|
+
// <vg-entity-admin> component renders nav / list / form and talks to the
|
|
5
|
+
// plugin (and through it, the backend) only via bus messages.
|
|
6
|
+
|
|
7
|
+
import { bus } from '../bus.js'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
// Fields managed by the system, not the form.
|
|
11
|
+
const SYS_FIELDS = ['id', 'owner_id', 't_c', 't_m', 't_mh', 't_ch']
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
bus.use(function entity_admin() {
|
|
15
|
+
const seneca = this
|
|
16
|
+
|
|
17
|
+
let model = null
|
|
18
|
+
|
|
19
|
+
// Entities from the model: [{ zone, name, canon, fields }]
|
|
20
|
+
function entities() {
|
|
21
|
+
const out = []
|
|
22
|
+
const ent = (model && model.main.ent) || {}
|
|
23
|
+
for (const zone of Object.keys(ent)) {
|
|
24
|
+
if ('sys' === zone) {
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
for (const name of Object.keys(ent[zone])) {
|
|
28
|
+
out.push({
|
|
29
|
+
zone,
|
|
30
|
+
name,
|
|
31
|
+
canon: zone + '/' + name,
|
|
32
|
+
fields: ent[zone][name].field || {},
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Message mapping convention: entity zone/name -> aim:<zone>,<verb>:<name>.
|
|
40
|
+
function msgFor(canon, verb) {
|
|
41
|
+
const [zone, name] = canon.split('/')
|
|
42
|
+
return { aim: zone, [verb]: name }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
seneca.add('cmp:entity_admin,load:model', function (msg, reply) {
|
|
46
|
+
if (model) {
|
|
47
|
+
return reply({ ok: true, entities: entities() })
|
|
48
|
+
}
|
|
49
|
+
fetch('/model.json').then((r) => r.json()).then((m) => {
|
|
50
|
+
model = m
|
|
51
|
+
reply({ ok: true, entities: entities() })
|
|
52
|
+
}).catch((e) => reply({ ok: false, why: String(e) }))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
seneca.add('cmp:entity_admin,list:item', function (msg, reply) {
|
|
56
|
+
this.act(msgFor(msg.canon, 'list'), function (err, out) {
|
|
57
|
+
reply(err ? { ok: false, why: err.message } : out)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Save/remove just pass through to the backend. The UI refresh is NOT
|
|
62
|
+
// triggered here: the store plugin emits a sys:browser-store,changed:group
|
|
63
|
+
// message on the write, and <vg-entity-admin> re-lists reactively in
|
|
64
|
+
// response (see connectedCallback). This decouples "who mutated" from
|
|
65
|
+
// "who must refresh".
|
|
66
|
+
seneca.add('cmp:entity_admin,save:item', function (msg, reply) {
|
|
67
|
+
this.act({ ...msgFor(msg.canon, 'save'), item: msg.item },
|
|
68
|
+
function (err, out) {
|
|
69
|
+
reply(err ? { ok: false, why: err.message } : out)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
seneca.add('cmp:entity_admin,remove:item', function (msg, reply) {
|
|
74
|
+
this.act({ ...msgFor(msg.canon, 'remove'), id: msg.id },
|
|
75
|
+
function (err, out) {
|
|
76
|
+
reply(err ? { ok: false, why: err.message } : out)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
function inputFor(fname, fdef, value) {
|
|
83
|
+
const kind = (fdef && fdef.kind) || 'String'
|
|
84
|
+
const val = null == value ? '' : value
|
|
85
|
+
if ('Boolean' === kind) {
|
|
86
|
+
return `<input name="${fname}" type="checkbox" ${true === value ? 'checked' : ''} />`
|
|
87
|
+
}
|
|
88
|
+
if ('Number' === kind) {
|
|
89
|
+
return `<input name="${fname}" type="number" value="${val}" />`
|
|
90
|
+
}
|
|
91
|
+
return `<input name="${fname}" type="text" value="${String(val).replace(/"/g, '"')}" />`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class VgEntityAdmin extends HTMLElement {
|
|
96
|
+
constructor() {
|
|
97
|
+
super()
|
|
98
|
+
this.canon = null
|
|
99
|
+
this.editing = null // item being edited, or {} for new
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
connectedCallback() {
|
|
103
|
+
// Reactivity via Seneca messages: re-list whenever the store signals that
|
|
104
|
+
// this entity's data changed - regardless of which component wrote it.
|
|
105
|
+
// `sub` is Seneca's native fan-out (many observers per pattern), so this
|
|
106
|
+
// is the same messaging primitive used everywhere else.
|
|
107
|
+
bus.sub('sys:browser-store,changed:group', (msg) => {
|
|
108
|
+
if (this.isConnected && msg.group === this.canon) {
|
|
109
|
+
this.renderList()
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
bus.sub('sys:browser-store,changed:all', () => {
|
|
113
|
+
if (this.isConnected && this.canon) {
|
|
114
|
+
this.renderList()
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
this.render()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async render() {
|
|
121
|
+
const res = await bus.post('cmp:entity_admin,load:model')
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
this.innerHTML = `<p>model load failed: ${res.why}</p>`
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
this.entities = res.entities
|
|
127
|
+
if (null == this.canon && 0 < res.entities.length) {
|
|
128
|
+
this.canon = res.entities[0].canon
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const nav = res.entities.map((e) =>
|
|
132
|
+
`<a href="#" class="vg-nav ${e.canon === this.canon ? 'sel' : ''}"
|
|
133
|
+
data-canon="${e.canon}">${e.canon}</a>`).join(' ')
|
|
134
|
+
|
|
135
|
+
this.innerHTML = `
|
|
136
|
+
<div class="vg-admin">
|
|
137
|
+
<nav>${nav}</nav>
|
|
138
|
+
<div id="vg-list"></div>
|
|
139
|
+
<div id="vg-form"></div>
|
|
140
|
+
</div>`
|
|
141
|
+
|
|
142
|
+
for (const a of this.querySelectorAll('.vg-nav')) {
|
|
143
|
+
a.onclick = (ev) => {
|
|
144
|
+
ev.preventDefault()
|
|
145
|
+
this.canon = a.dataset.canon
|
|
146
|
+
this.editing = null
|
|
147
|
+
this.render()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
await this.renderList()
|
|
152
|
+
this.renderForm()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
entdef() {
|
|
156
|
+
return this.entities.find((e) => e.canon === this.canon)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async renderList() {
|
|
160
|
+
// May be invoked reactively (store change event) before the first full
|
|
161
|
+
// render has built the DOM - skip until the list container exists.
|
|
162
|
+
if (!this.entities || !this.querySelector('#vg-list')) {
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
const out = await bus.post('cmp:entity_admin,list:item',
|
|
166
|
+
{ canon: this.canon })
|
|
167
|
+
const items = (out.ok && out.list) || []
|
|
168
|
+
const fields = Object.keys(this.entdef().fields)
|
|
169
|
+
.filter((f) => !SYS_FIELDS.includes(f))
|
|
170
|
+
|
|
171
|
+
const head = fields.map((f) => `<th>${f}</th>`).join('')
|
|
172
|
+
const rows = items.map((item) => `
|
|
173
|
+
<tr data-id="${item.id}">
|
|
174
|
+
${fields.map((f) => `<td>${null == item[f] ? '' : item[f]}</td>`).join('')}
|
|
175
|
+
<td>
|
|
176
|
+
<button class="vg-edit" data-id="${item.id}">edit</button>
|
|
177
|
+
<button class="vg-del" data-id="${item.id}">delete</button>
|
|
178
|
+
</td>
|
|
179
|
+
</tr>`).join('')
|
|
180
|
+
|
|
181
|
+
this.querySelector('#vg-list').innerHTML = `
|
|
182
|
+
<h2>${this.canon} <button id="vg-new">new</button></h2>
|
|
183
|
+
<table class="vg-table">
|
|
184
|
+
<thead><tr>${head}<th></th></tr></thead>
|
|
185
|
+
<tbody>${rows}</tbody>
|
|
186
|
+
</table>
|
|
187
|
+
<p id="vg-count">${items.length} item${1 === items.length ? '' : 's'}</p>`
|
|
188
|
+
|
|
189
|
+
this.items = items
|
|
190
|
+
this.querySelector('#vg-new').onclick = () => {
|
|
191
|
+
this.editing = {}
|
|
192
|
+
this.renderForm()
|
|
193
|
+
}
|
|
194
|
+
for (const b of this.querySelectorAll('.vg-edit')) {
|
|
195
|
+
b.onclick = () => {
|
|
196
|
+
this.editing = this.items.find((i) => i.id === b.dataset.id)
|
|
197
|
+
this.renderForm()
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const b of this.querySelectorAll('.vg-del')) {
|
|
201
|
+
b.onclick = async () => {
|
|
202
|
+
await bus.post('cmp:entity_admin,remove:item',
|
|
203
|
+
{ canon: this.canon, id: b.dataset.id })
|
|
204
|
+
// The list refresh comes reactively from the store's changed event.
|
|
205
|
+
this.editing = null
|
|
206
|
+
this.renderForm()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
renderForm() {
|
|
212
|
+
const box = this.querySelector('#vg-form')
|
|
213
|
+
if (null == this.editing) {
|
|
214
|
+
box.innerHTML = ''
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const def = this.entdef()
|
|
219
|
+
const fields = Object.keys(def.fields)
|
|
220
|
+
.filter((f) => !SYS_FIELDS.includes(f))
|
|
221
|
+
|
|
222
|
+
box.innerHTML = `
|
|
223
|
+
<form class="vg-entity-form">
|
|
224
|
+
<h3>${this.editing.id ? 'Edit' : 'New'} ${this.canon}</h3>
|
|
225
|
+
${fields.map((f) => `
|
|
226
|
+
<label>${(def.fields[f] && def.fields[f].label) || f}
|
|
227
|
+
${inputFor(f, def.fields[f], this.editing[f])}
|
|
228
|
+
</label>`).join('')}
|
|
229
|
+
<button type="submit">Save</button>
|
|
230
|
+
<button type="button" id="vg-cancel">Cancel</button>
|
|
231
|
+
<div id="vg-form-err"></div>
|
|
232
|
+
</form>`
|
|
233
|
+
|
|
234
|
+
box.querySelector('#vg-cancel').onclick = () => {
|
|
235
|
+
this.editing = null
|
|
236
|
+
this.renderForm()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
box.querySelector('form').onsubmit = async (ev) => {
|
|
240
|
+
ev.preventDefault()
|
|
241
|
+
const item = { ...(this.editing.id ? { id: this.editing.id } : {}) }
|
|
242
|
+
for (const f of fields) {
|
|
243
|
+
const input = box.querySelector(`[name="${f}"]`)
|
|
244
|
+
const kind = (def.fields[f] && def.fields[f].kind) || 'String'
|
|
245
|
+
if ('Boolean' === kind) {
|
|
246
|
+
item[f] = input.checked
|
|
247
|
+
}
|
|
248
|
+
else if ('' === input.value) {
|
|
249
|
+
// Blank optional field: leave unset (don't send an empty string;
|
|
250
|
+
// an optional/Skip field rejects '').
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
item[f] = 'Number' === kind ? Number(input.value) : input.value
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const res = await bus.post('cmp:entity_admin,save:item',
|
|
258
|
+
{ canon: this.canon, item })
|
|
259
|
+
if (!res.ok) {
|
|
260
|
+
box.querySelector('#vg-form-err').textContent =
|
|
261
|
+
'save failed: ' + (res.why || '')
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
// The list refresh comes reactively from the store's changed event.
|
|
265
|
+
this.editing = null
|
|
266
|
+
this.renderForm()
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
customElements.define('vg-entity-admin', VgEntityAdmin)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// App shell: shows the auth component always; the entity admin only when
|
|
2
|
+
// signed in. Reacts to bus auth events.
|
|
3
|
+
|
|
4
|
+
import { bus, onEvent } from '../bus.js'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class VgApp extends HTMLElement {
|
|
8
|
+
connectedCallback() {
|
|
9
|
+
this.innerHTML = `
|
|
10
|
+
<header><h1>$$Name$$</h1><vg-auth></vg-auth></header>
|
|
11
|
+
<main id="vg-main"></main>`
|
|
12
|
+
|
|
13
|
+
onEvent('auth', ({ user }) => this.setMain(user))
|
|
14
|
+
|
|
15
|
+
// Resolve the cookie session on load.
|
|
16
|
+
bus.post('cmp:auth,load:state')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
setMain(user) {
|
|
20
|
+
const main = this.querySelector('#vg-main')
|
|
21
|
+
if (user) {
|
|
22
|
+
main.innerHTML = '<vg-entity-admin></vg-entity-admin>'
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
main.innerHTML = '<p class="vg-hint">Sign in to manage your data.</p>'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
customElements.define('vg-app', VgApp)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Auth: a seneca plugin holding the signed-in principal, and the
|
|
2
|
+
// <vg-auth> web component (sign-in form / signed-in bar) driving it
|
|
3
|
+
// purely through bus messages.
|
|
4
|
+
|
|
5
|
+
import { bus, emit } from '../bus.js'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
bus.use(function auth_cmp() {
|
|
9
|
+
const seneca = this
|
|
10
|
+
|
|
11
|
+
let user = null
|
|
12
|
+
|
|
13
|
+
seneca.add('cmp:auth,get:state', function (msg, reply) {
|
|
14
|
+
reply({ ok: true, user })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// Resolve current auth from the backend (cookie session).
|
|
18
|
+
seneca.add('cmp:auth,load:state', function (msg, reply) {
|
|
19
|
+
this.act('aim:req,on:auth,load:auth', function (err, out) {
|
|
20
|
+
user = (!err && out.ok && 'signedin' === out.state) ? out.user : null
|
|
21
|
+
emit('auth', { user })
|
|
22
|
+
reply({ ok: true, user })
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
seneca.add('cmp:auth,signin:user', function (msg, reply) {
|
|
27
|
+
this.act('aim:req,on:auth,signin:user', {
|
|
28
|
+
email: msg.email,
|
|
29
|
+
password: msg.password,
|
|
30
|
+
}, function (err, out) {
|
|
31
|
+
if (err || !out.ok) {
|
|
32
|
+
return reply({ ok: false, why: (out && out.why) || 'signin-failed' })
|
|
33
|
+
}
|
|
34
|
+
user = out.user
|
|
35
|
+
emit('auth', { user })
|
|
36
|
+
reply({ ok: true, user })
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
seneca.add('cmp:auth,signout:user', function (msg, reply) {
|
|
41
|
+
this.act('aim:req,on:auth,signout:user', function () {
|
|
42
|
+
user = null
|
|
43
|
+
emit('auth', { user })
|
|
44
|
+
reply({ ok: true })
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class VgAuth extends HTMLElement {
|
|
51
|
+
connectedCallback() {
|
|
52
|
+
this.render()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async render() {
|
|
56
|
+
const state = await bus.post('cmp:auth,get:state')
|
|
57
|
+
|
|
58
|
+
if (state.user) {
|
|
59
|
+
this.innerHTML = `
|
|
60
|
+
<div class="vg-auth-bar">
|
|
61
|
+
<span>Signed in: <b>${state.user.email}</b></span>
|
|
62
|
+
<button id="vg-signout">Sign out</button>
|
|
63
|
+
</div>`
|
|
64
|
+
this.querySelector('#vg-signout').onclick = async () => {
|
|
65
|
+
await bus.post('cmp:auth,signout:user')
|
|
66
|
+
this.render()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
this.innerHTML = `
|
|
71
|
+
<form class="vg-auth-form">
|
|
72
|
+
<h2>Sign in</h2>
|
|
73
|
+
<label>Email <input name="email" type="email" required /></label>
|
|
74
|
+
<label>Password <input name="password" type="password" required /></label>
|
|
75
|
+
<button type="submit">Sign in</button>
|
|
76
|
+
<div class="vg-auth-err" id="vg-auth-err"></div>
|
|
77
|
+
</form>`
|
|
78
|
+
this.querySelector('form').onsubmit = async (ev) => {
|
|
79
|
+
ev.preventDefault()
|
|
80
|
+
const fd = new FormData(ev.target)
|
|
81
|
+
const res = await bus.post('cmp:auth,signin:user', {
|
|
82
|
+
email: fd.get('email'),
|
|
83
|
+
password: fd.get('password'),
|
|
84
|
+
})
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
this.querySelector('#vg-auth-err').textContent =
|
|
87
|
+
'Sign in failed: ' + res.why
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
this.render()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
customElements.define('vg-auth', VgAuth)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Todo SPA entry: web components on a Seneca service bus (experimental).
|
|
2
|
+
// No framework: each component is a custom element backed by a seneca
|
|
3
|
+
// plugin (state), and all data flows are bus messages - aim:* messages
|
|
4
|
+
// travel to the backend gateway via the seneca-browser fetch transport.
|
|
5
|
+
|
|
6
|
+
import './style.css'
|
|
7
|
+
import './bus.js'
|
|
8
|
+
import './cmp/auth.js'
|
|
9
|
+
import './cmp/admin.js'
|
|
10
|
+
import './cmp/app.js'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
* { box-sizing: border-box; }
|
|
2
|
+
body { font: 15px/1.5 system-ui, sans-serif; margin: 0; color: #1c2733; }
|
|
3
|
+
header { display: flex; align-items: center; justify-content: space-between;
|
|
4
|
+
padding: 0.6rem 1.2rem; border-bottom: 2px solid #e3e8ee; }
|
|
5
|
+
header h1 { font-size: 1.2rem; margin: 0; }
|
|
6
|
+
main { padding: 1.2rem; max-width: 60rem; }
|
|
7
|
+
|
|
8
|
+
.vg-auth-bar { display: flex; gap: 0.8rem; align-items: center; }
|
|
9
|
+
.vg-auth-form { max-width: 22rem; display: grid; gap: 0.6rem; }
|
|
10
|
+
.vg-auth-form label { display: grid; gap: 0.2rem; }
|
|
11
|
+
.vg-auth-err { color: #b3261e; min-height: 1.2em; }
|
|
12
|
+
|
|
13
|
+
.vg-admin nav { margin-bottom: 1rem; }
|
|
14
|
+
.vg-admin nav a { margin-right: 0.8rem; text-decoration: none; }
|
|
15
|
+
.vg-admin nav a.sel { font-weight: 700; border-bottom: 2px solid #1c2733; }
|
|
16
|
+
|
|
17
|
+
.vg-table { border-collapse: collapse; width: 100%; }
|
|
18
|
+
.vg-table th, .vg-table td { text-align: left; padding: 0.35rem 0.6rem;
|
|
19
|
+
border-bottom: 1px solid #e3e8ee; }
|
|
20
|
+
|
|
21
|
+
.vg-entity-form { margin-top: 1.2rem; max-width: 26rem; display: grid;
|
|
22
|
+
gap: 0.6rem; padding: 1rem; border: 1px solid #e3e8ee; border-radius: 6px; }
|
|
23
|
+
.vg-entity-form label { display: grid; gap: 0.2rem; }
|
|
24
|
+
|
|
25
|
+
button { cursor: pointer; padding: 0.25rem 0.7rem; }
|
|
26
|
+
.vg-hint { color: #5b6b7b; }
|