@voxgig/build 4.3.0 → 4.11.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/README.md +51 -2
- package/build.ts +20 -0
- package/dist/api/api_gen.d.ts +7 -0
- package/dist/api/api_gen.js +370 -0
- package/dist/api/api_gen.js.map +1 -0
- package/dist/build.d.ts +16 -1
- package/dist/build.js +17 -1
- package/dist/build.js.map +1 -1
- package/dist/doc/doc_gen.d.ts +8 -0
- package/dist/doc/doc_gen.js +397 -0
- package/dist/doc/doc_gen.js.map +1 -0
- package/dist/env/web/web_gen.js +159 -1
- package/dist/env/web/web_gen.js.map +1 -1
- package/dist/shape/ent.js +4 -2
- package/dist/shape/ent.js.map +1 -1
- package/env/web/web_gen.ts +174 -1
- package/package.json +6 -9
- package/tm/web/backend/env/shared/seed.ts.frag +23 -0
- package/tm/web/backend/env/web/api.ts.frag +157 -0
- package/tm/web/backend/env/web/web.ts.frag +37 -4
- package/tm/web/backend/srv/api/api-srv.ts.frag +4 -0
- package/tm/web/backend/srv/api/expose.ts.frag +33 -0
- package/tm/web/backend/srv/api/get_info.ts.frag +6 -0
- package/tm/web/backend/srv/api/on_ent.ts.frag +124 -0
- package/tm/web/backend/srv/auth/apikey_util.ts.frag +10 -0
- package/tm/web/backend/srv/auth/auth-srv.ts.frag +1 -0
- package/tm/web/backend/srv/auth/change_pass.ts.frag +12 -0
- package/tm/web/backend/srv/auth/create_apikey.ts.frag +33 -0
- package/tm/web/backend/srv/auth/get_info.ts.frag +1 -0
- package/tm/web/backend/srv/auth/list_apikey.ts.frag +15 -0
- package/tm/web/backend/srv/auth/load_auth.ts.frag +10 -1
- package/tm/web/backend/srv/auth/remind_pass.ts.frag +35 -0
- package/tm/web/backend/srv/auth/revoke_apikey.ts.frag +23 -0
- package/tm/web/backend/srv/auth/signin_user.ts.frag +12 -2
- package/tm/web/backend/srv/auth/signout_user.ts.frag +11 -1
- package/tm/web/backend/srv/auth/update_user.ts.frag +15 -0
- package/tm/web/backend/srv/auth/user_util.ts.frag +21 -0
- package/tm/web/backend/srv/auth/web_change_pass.ts.frag +16 -0
- package/tm/web/backend/srv/auth/web_create_apikey.ts.frag +15 -0
- package/tm/web/backend/srv/auth/web_list_apikey.ts.frag +11 -0
- package/tm/web/backend/srv/auth/web_load_auth.ts.frag +1 -0
- package/tm/web/backend/srv/auth/web_remind_pass.ts.frag +7 -0
- package/tm/web/backend/srv/auth/web_revoke_apikey.ts.frag +14 -0
- package/tm/web/backend/srv/auth/web_signin_user.ts.frag +1 -0
- package/tm/web/backend/srv/auth/web_signout_user.ts.frag +11 -3
- package/tm/web/backend/srv/auth/web_update_user.ts.frag +11 -0
- package/tm/web/backend/srv/ent/access.ts.frag +179 -0
- package/tm/web/backend/srv/ent/cmd_list.ts.frag +86 -0
- package/tm/web/backend/srv/ent/cmd_load.ts.frag +56 -0
- package/tm/web/backend/srv/ent/cmd_remove.ts.frag +60 -0
- package/tm/web/backend/srv/ent/cmd_save.ts.frag +141 -0
- package/tm/web/backend/srv/ent/ent-srv.ts.frag +4 -0
- package/tm/web/backend/srv/ent/get_info.ts.frag +6 -0
- package/tm/web/backend/srv/ent/web_cmd_list.ts.frag +17 -0
- package/tm/web/backend/srv/ent/web_cmd_load.ts.frag +17 -0
- package/tm/web/backend/srv/ent/web_cmd_remove.ts.frag +17 -0
- package/tm/web/backend/srv/ent/web_cmd_save.ts.frag +17 -0
- package/tm/web/backend/test/unit/env/web/api-router.test.ts.frag +315 -0
- package/tm/web/backend/test/unit/env/web/surface.test.ts.frag +109 -0
- package/tm/web/backend/test/unit/srv/api/api.setup.ts.frag +162 -0
- package/tm/web/backend/test/unit/srv/api/api.test.ts.frag +225 -0
- package/tm/web/backend/test/unit/srv/auth/apikey.test.ts.frag +152 -0
- package/tm/web/backend/test/unit/srv/auth/auth.setup.ts.frag +40 -0
- package/tm/web/backend/test/unit/srv/auth/session.test.ts.frag +189 -0
- package/tm/web/backend/test/unit/srv/ent/ent.setup.ts.frag +53 -0
- package/tm/web/backend/test/unit/srv/ent/proxy.test.ts.frag +109 -0
- package/tm/web/docs/explanation/web-architecture.md.frag +50 -0
- package/tm/web/docs/how-to/add-a-custom-entity-view.md.frag +53 -0
- package/tm/web/docs/how-to/change-the-theme.md.frag +63 -0
- package/tm/web/docs/how-to/customise-the-web-app.md.frag +68 -0
- package/tm/web/docs/how-to/use-the-api.md.frag +53 -0
- package/tm/web/docs/reference/web-app.md.frag +98 -0
- package/tm/web/web/AGENTS.md.frag +52 -0
- package/tm/web/web/e2e/smoke.spec.js.frag +13 -11
- package/tm/web/web/index.html.frag +3 -0
- package/tm/web/web/package.json.frag +4 -1
- package/tm/web/web/playwright.config.js.frag +6 -1
- package/tm/web/web/src/api.js.frag +92 -0
- package/tm/web/web/src/bus.js.frag +46 -23
- package/tm/web/web/src/cmp/admin.js.frag +292 -193
- package/tm/web/web/src/cmp/admin.md.frag +51 -0
- package/tm/web/web/src/cmp/app.js.frag +12 -15
- package/tm/web/web/src/cmp/app.md.frag +28 -0
- package/tm/web/web/src/cmp/auth.js.frag +69 -41
- package/tm/web/web/src/cmp/auth.md.frag +36 -0
- package/tm/web/web/src/cmp/public.js.frag +58 -0
- package/tm/web/web/src/cmp/public.md.frag +26 -0
- package/tm/web/web/src/cmp/settings.js.frag +178 -0
- package/tm/web/web/src/cmp/settings.md.frag +30 -0
- package/tm/web/web/src/cmp/shell.js.frag +231 -0
- package/tm/web/web/src/cmp/shell.md.frag +49 -0
- package/tm/web/web/src/cmp/view/custom-view.js.frag +47 -0
- package/tm/web/web/src/cmp/view/custom-view.md.frag +31 -0
- package/tm/web/web/src/custom.css.frag +8 -0
- package/tm/web/web/src/customise.js.frag +30 -0
- package/tm/web/web/src/hooks.js.frag +79 -0
- package/tm/web/web/src/main.js.frag +17 -4
- package/tm/web/web/src/model.js.frag +162 -0
- package/tm/web/web/src/style.css.frag +135 -21
- package/tm/web/web/src/theme.js.frag +82 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Component: vg-app (`cmp/app.js`)
|
|
2
|
+
|
|
3
|
+
The root router component: mounts the public site or the authenticated
|
|
4
|
+
shell depending on auth state, and re-routes when that state changes.
|
|
5
|
+
|
|
6
|
+
## Structure
|
|
7
|
+
|
|
8
|
+
```mermaid
|
|
9
|
+
flowchart TB
|
|
10
|
+
app[vg-app] -->|signed out| pub[vg-public]
|
|
11
|
+
app -->|signed in| shell[vg-shell]
|
|
12
|
+
pub --> auth[vg-auth]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Messages
|
|
16
|
+
|
|
17
|
+
| Message | Direction | Purpose |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `cmp:auth,load:state` | post | Trigger initial auth load on mount |
|
|
20
|
+
| `cmp:evt,name:auth` (event `auth`) | sub via `onEvent` | Re-route on sign-in / sign-out |
|
|
21
|
+
|
|
22
|
+
## Notes
|
|
23
|
+
|
|
24
|
+
- Mounted once from `index.html`; everything else hangs off it.
|
|
25
|
+
- Re-routing replaces `innerHTML`, tearing down the previous subtree —
|
|
26
|
+
long-lived components must tolerate re-mounts (see the `isConnected`
|
|
27
|
+
guard convention in shell/admin).
|
|
28
|
+
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
// Auth: a seneca plugin holding the signed-in principal, and the
|
|
2
|
-
// <vg-auth>
|
|
3
|
-
//
|
|
1
|
+
// Auth: a seneca plugin holding the signed-in principal (state), and the
|
|
2
|
+
// <vg-auth> login form used on the public site. The signed-in UI lives in
|
|
3
|
+
// the app shell's user menu, not here.
|
|
4
4
|
|
|
5
5
|
import { bus, emit } from '../bus.js'
|
|
6
|
+
import { remindPass } from '../api.js'
|
|
7
|
+
import * as Hooks from '../hooks.js'
|
|
6
8
|
|
|
7
9
|
|
|
8
10
|
bus.use(function auth_cmp() {
|
|
@@ -16,7 +18,7 @@ bus.use(function auth_cmp() {
|
|
|
16
18
|
|
|
17
19
|
// Resolve current auth from the backend (cookie session).
|
|
18
20
|
seneca.add('cmp:auth,load:state', function (msg, reply) {
|
|
19
|
-
this.act('aim:
|
|
21
|
+
this.act('aim:web,on:auth,load:auth', function (err, out) {
|
|
20
22
|
user = (!err && out.ok && 'signedin' === out.state) ? out.user : null
|
|
21
23
|
emit('auth', { user })
|
|
22
24
|
reply({ ok: true, user })
|
|
@@ -24,7 +26,7 @@ bus.use(function auth_cmp() {
|
|
|
24
26
|
})
|
|
25
27
|
|
|
26
28
|
seneca.add('cmp:auth,signin:user', function (msg, reply) {
|
|
27
|
-
this.act('aim:
|
|
29
|
+
this.act('aim:web,on:auth,signin:user', {
|
|
28
30
|
email: msg.email,
|
|
29
31
|
password: msg.password,
|
|
30
32
|
}, function (err, out) {
|
|
@@ -38,7 +40,7 @@ bus.use(function auth_cmp() {
|
|
|
38
40
|
})
|
|
39
41
|
|
|
40
42
|
seneca.add('cmp:auth,signout:user', function (msg, reply) {
|
|
41
|
-
this.act('aim:
|
|
43
|
+
this.act('aim:web,on:auth,signout:user', function () {
|
|
42
44
|
user = null
|
|
43
45
|
emit('auth', { user })
|
|
44
46
|
reply({ ok: true })
|
|
@@ -47,51 +49,77 @@ bus.use(function auth_cmp() {
|
|
|
47
49
|
})
|
|
48
50
|
|
|
49
51
|
|
|
52
|
+
// The public login form (+ a forgot-password flow that never actually
|
|
53
|
+
// emails — the backend logs the reset code).
|
|
50
54
|
class VgAuth extends HTMLElement {
|
|
51
55
|
connectedCallback() {
|
|
56
|
+
this.mode = 'signin'
|
|
52
57
|
this.render()
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
}
|
|
60
|
+
render() {
|
|
61
|
+
if ('forgot' === this.mode) {
|
|
62
|
+
this.renderForgot()
|
|
68
63
|
}
|
|
69
64
|
else {
|
|
70
|
-
this.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
</
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
65
|
+
this.renderSignin()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
renderSignin() {
|
|
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
|
+
<a href="#" class="vg-link" id="vg-forgot">Forgot password?</a>
|
|
77
|
+
${Hooks.html('auth:form:footer', {})}
|
|
78
|
+
<div class="vg-auth-err" id="vg-auth-err"></div>
|
|
79
|
+
</form>`
|
|
80
|
+
this.querySelector('#vg-forgot').onclick = (ev) => {
|
|
81
|
+
ev.preventDefault()
|
|
82
|
+
this.mode = 'forgot'
|
|
83
|
+
this.render()
|
|
84
|
+
}
|
|
85
|
+
this.querySelector('form').onsubmit = async (ev) => {
|
|
86
|
+
ev.preventDefault()
|
|
87
|
+
const fd = new FormData(ev.target)
|
|
88
|
+
const res = await bus.post('cmp:auth,signin:user', {
|
|
89
|
+
email: fd.get('email'),
|
|
90
|
+
password: fd.get('password'),
|
|
91
|
+
})
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
this.querySelector('#vg-auth-err').textContent = 'Sign in failed: ' + res.why
|
|
92
94
|
}
|
|
93
95
|
}
|
|
94
96
|
}
|
|
97
|
+
|
|
98
|
+
renderForgot() {
|
|
99
|
+
this.innerHTML = `
|
|
100
|
+
<form class="vg-auth-form">
|
|
101
|
+
<h2>Reset password</h2>
|
|
102
|
+
<p class="vg-hint">Enter your email and we'll send a reset link.</p>
|
|
103
|
+
<label>Email <input name="email" type="email" required /></label>
|
|
104
|
+
<button type="submit">Send reset link</button>
|
|
105
|
+
<a href="#" class="vg-link" id="vg-back">Back to sign in</a>
|
|
106
|
+
<div class="vg-auth-msg" id="vg-auth-msg"></div>
|
|
107
|
+
</form>`
|
|
108
|
+
this.querySelector('#vg-back').onclick = (ev) => {
|
|
109
|
+
ev.preventDefault()
|
|
110
|
+
this.mode = 'signin'
|
|
111
|
+
this.render()
|
|
112
|
+
}
|
|
113
|
+
this.querySelector('form').onsubmit = async (ev) => {
|
|
114
|
+
ev.preventDefault()
|
|
115
|
+
const fd = new FormData(ev.target)
|
|
116
|
+
await remindPass(fd.get('email'))
|
|
117
|
+
// Always the same message (no user enumeration; nothing is actually sent).
|
|
118
|
+
this.querySelector('#vg-auth-msg').textContent =
|
|
119
|
+
'If that email is registered, a reset link has been sent.'
|
|
120
|
+
}
|
|
121
|
+
}
|
|
95
122
|
}
|
|
96
123
|
|
|
97
124
|
customElements.define('vg-auth', VgAuth)
|
|
125
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Component: vg-auth (`cmp/auth.js`)
|
|
2
|
+
|
|
3
|
+
The sign-in form (with forgot-password), and the client-side `cmp:auth`
|
|
4
|
+
state service other components query.
|
|
5
|
+
|
|
6
|
+
## Message flow
|
|
7
|
+
|
|
8
|
+
```mermaid
|
|
9
|
+
sequenceDiagram
|
|
10
|
+
participant U as User
|
|
11
|
+
participant A as vg-auth
|
|
12
|
+
participant G as gateway (backend)
|
|
13
|
+
U->>A: submit email + password
|
|
14
|
+
A->>G: aim:req,on:auth,signin:user
|
|
15
|
+
G-->>A: { ok, user }
|
|
16
|
+
A->>A: emit 'auth' event (cmp:evt,name:auth)
|
|
17
|
+
Note over A: vg-app re-routes to the shell
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Messages
|
|
21
|
+
|
|
22
|
+
| Message | Direction | Purpose |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `cmp:auth,load:state` | answers | Load the session (`aim:req,on:auth,load:auth`) |
|
|
25
|
+
| `cmp:auth,get:state` | answers | Current `{ user }` for other components |
|
|
26
|
+
| `cmp:auth,signin:user` | answers | Sign in, then emit `auth` |
|
|
27
|
+
| `cmp:auth,signout:user` | answers | Sign out, then emit `auth` |
|
|
28
|
+
| `aim:req,on:auth,remind:pass` | post | Forgot-password (server stub; no email sent) |
|
|
29
|
+
| event `auth` | emit | Auth state changed (the store cache also clears on this) |
|
|
30
|
+
|
|
31
|
+
## Customisation
|
|
32
|
+
|
|
33
|
+
| Hook point | Kind | Effect |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `auth:form:footer` | html | Extra links/markup under the form |
|
|
36
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The public content site (marketing pages) shown to signed-out visitors,
|
|
2
|
+
// with the login form. Static content — the app itself is behind auth.
|
|
3
|
+
|
|
4
|
+
import * as Hooks from '../hooks.js'
|
|
5
|
+
|
|
6
|
+
class VgPublic extends HTMLElement {
|
|
7
|
+
connectedCallback() {
|
|
8
|
+
this.innerHTML = `
|
|
9
|
+
<div class="vg-public">
|
|
10
|
+
<header class="vg-public-nav">
|
|
11
|
+
<span class="vg-brand">📋 Todo</span>
|
|
12
|
+
<nav>
|
|
13
|
+
<a href="#features">Features</a>
|
|
14
|
+
<a href="#about">About</a>
|
|
15
|
+
</nav>
|
|
16
|
+
</header>
|
|
17
|
+
|
|
18
|
+
<section class="vg-hero">
|
|
19
|
+
<div class="vg-hero-copy">
|
|
20
|
+
<h1>Plan together. Ship faster.</h1>
|
|
21
|
+
<p>Organise work into projects, invite your team, and track every
|
|
22
|
+
todo — a simple, collaborative workspace.</p>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="vg-hero-auth">
|
|
25
|
+
<vg-auth></vg-auth>
|
|
26
|
+
</div>
|
|
27
|
+
</section>
|
|
28
|
+
|
|
29
|
+
<section id="features" class="vg-features">
|
|
30
|
+
<h2>Features</h2>
|
|
31
|
+
<div class="vg-feature-grid">
|
|
32
|
+
<div class="vg-feature"><h3>Projects</h3>
|
|
33
|
+
<p>Group work into projects and assign collaborators.</p></div>
|
|
34
|
+
<div class="vg-feature"><h3>Todo lists</h3>
|
|
35
|
+
<p>Break projects into lists and items, done when done.</p></div>
|
|
36
|
+
<div class="vg-feature"><h3>Collaboration</h3>
|
|
37
|
+
<p>Everyone assigned to a project sees and edits its work.</p></div>
|
|
38
|
+
</div>
|
|
39
|
+
</section>
|
|
40
|
+
|
|
41
|
+
<section id="about" class="vg-about">
|
|
42
|
+
<h2>About</h2>
|
|
43
|
+
<p>A demonstration of a model-driven enterprise app: the entire UI
|
|
44
|
+
— navigation, forms and entity relationships — is generated from
|
|
45
|
+
the data model.</p>
|
|
46
|
+
</section>
|
|
47
|
+
|
|
48
|
+
${Hooks.html('public:sections', {})}
|
|
49
|
+
|
|
50
|
+
<footer class="vg-public-footer">
|
|
51
|
+
<span>© Todo. A Voxgig demo.</span>
|
|
52
|
+
</footer>
|
|
53
|
+
</div>`
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
customElements.define('vg-public', VgPublic)
|
|
58
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Component: vg-public (`cmp/public.js`)
|
|
2
|
+
|
|
3
|
+
The public content site shown to signed-out visitors: marketing sections
|
|
4
|
+
(hero, features, about) plus the sign-in form.
|
|
5
|
+
|
|
6
|
+
## Structure
|
|
7
|
+
|
|
8
|
+
```mermaid
|
|
9
|
+
flowchart TB
|
|
10
|
+
pub[vg-public] --> hero[hero / features / about sections]
|
|
11
|
+
pub --> auth[vg-auth - the login form]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Customisation
|
|
15
|
+
|
|
16
|
+
| Hook point | Kind | Effect |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `public:sections` | html | Append extra marketing sections |
|
|
19
|
+
|
|
20
|
+
Static content is meant to be edited directly — this component is
|
|
21
|
+
create-once and project-owned; replace the copy with your product's.
|
|
22
|
+
|
|
23
|
+
## Messages
|
|
24
|
+
|
|
25
|
+
None of its own — sign-in flows through the embedded `vg-auth`.
|
|
26
|
+
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// User settings & security: update profile and change password. Backed by
|
|
2
|
+
// the auth service wrappers (aim:req,on:auth,{update:user,change:pass}).
|
|
3
|
+
|
|
4
|
+
import { bus } from '../bus.js'
|
|
5
|
+
import * as Api from '../api.js'
|
|
6
|
+
import * as Hooks from '../hooks.js'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class VgSettings extends HTMLElement {
|
|
10
|
+
async connectedCallback() {
|
|
11
|
+
const state = await bus.post('cmp:auth,get:state')
|
|
12
|
+
this.user = state.user || {}
|
|
13
|
+
this.apikeys = []
|
|
14
|
+
this.newKey = null
|
|
15
|
+
this.render()
|
|
16
|
+
this.loadKeys()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async loadKeys() {
|
|
20
|
+
const res = await Api.listApikeys()
|
|
21
|
+
this.apikeys = (res.ok && res.items) || []
|
|
22
|
+
if (this.isConnected) {
|
|
23
|
+
this.renderKeys()
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
render() {
|
|
28
|
+
this.innerHTML = `
|
|
29
|
+
<div class="vg-entity vg-settings">
|
|
30
|
+
<h2>Settings & security</h2>
|
|
31
|
+
|
|
32
|
+
<section class="vg-card">
|
|
33
|
+
<h3>Profile</h3>
|
|
34
|
+
<form class="vg-entity-form" id="vg-profile">
|
|
35
|
+
<label>Name <input name="name" type="text" value="${esc(this.user.name)}" /></label>
|
|
36
|
+
<label>Email <input type="email" value="${esc(this.user.email)}" disabled /></label>
|
|
37
|
+
<div class="vg-form-actions"><button type="submit" class="vg-primary">Save profile</button></div>
|
|
38
|
+
<div class="vg-form-msg" id="vg-profile-msg"></div>
|
|
39
|
+
</form>
|
|
40
|
+
</section>
|
|
41
|
+
|
|
42
|
+
<section class="vg-card">
|
|
43
|
+
<h3>Change password</h3>
|
|
44
|
+
<form class="vg-entity-form" id="vg-pass">
|
|
45
|
+
<label>New password <input name="password" type="password" required minlength="8" /></label>
|
|
46
|
+
<label>Confirm <input name="confirm" type="password" required minlength="8" /></label>
|
|
47
|
+
<div class="vg-form-actions"><button type="submit" class="vg-primary">Change password</button></div>
|
|
48
|
+
<div class="vg-form-msg" id="vg-pass-msg"></div>
|
|
49
|
+
</form>
|
|
50
|
+
</section>
|
|
51
|
+
|
|
52
|
+
<section class="vg-card">
|
|
53
|
+
<h3>API keys</h3>
|
|
54
|
+
<p class="vg-muted">Access the REST API (<code>/api/v1/…</code>) with
|
|
55
|
+
<code>Authorization: Bearer <key></code>. A key is shown
|
|
56
|
+
only once, at creation.</p>
|
|
57
|
+
<form class="vg-entity-form vg-apikey-form" id="vg-apikey-create">
|
|
58
|
+
<label>Name <input name="name" type="text" required placeholder="e.g. ci-deploy" /></label>
|
|
59
|
+
<div class="vg-form-actions"><button type="submit" class="vg-primary">Create key</button></div>
|
|
60
|
+
<div class="vg-form-msg" id="vg-apikey-msg"></div>
|
|
61
|
+
</form>
|
|
62
|
+
<div id="vg-apikey-new"></div>
|
|
63
|
+
<div id="vg-apikey-list"></div>
|
|
64
|
+
</section>
|
|
65
|
+
|
|
66
|
+
${Hooks.html('settings:sections', { user: this.user })}
|
|
67
|
+
</div>`
|
|
68
|
+
|
|
69
|
+
this.querySelector('#vg-profile').onsubmit = async (ev) => {
|
|
70
|
+
ev.preventDefault()
|
|
71
|
+
const name = ev.target.querySelector('[name=name]').value
|
|
72
|
+
const res = await Api.updateUser({ name })
|
|
73
|
+
const msg = this.querySelector('#vg-profile-msg')
|
|
74
|
+
if (res.ok) {
|
|
75
|
+
this.user.name = res.user ? res.user.name : name
|
|
76
|
+
msg.textContent = 'Profile saved.'
|
|
77
|
+
msg.className = 'vg-form-msg vg-ok'
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
msg.textContent = 'Save failed: ' + (res.why || '')
|
|
81
|
+
msg.className = 'vg-form-msg vg-err'
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.querySelector('#vg-apikey-create').onsubmit = async (ev) => {
|
|
86
|
+
ev.preventDefault()
|
|
87
|
+
const name = ev.target.querySelector('[name=name]').value.trim()
|
|
88
|
+
const msg = this.querySelector('#vg-apikey-msg')
|
|
89
|
+
const res = await Api.createApikey(name)
|
|
90
|
+
if (res.ok) {
|
|
91
|
+
ev.target.reset()
|
|
92
|
+
this.newKey = res.key
|
|
93
|
+
msg.textContent = ''
|
|
94
|
+
await this.loadKeys()
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
msg.textContent = 'Create failed: ' + (res.why || '')
|
|
98
|
+
msg.className = 'vg-form-msg vg-err'
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
this.renderKeys()
|
|
102
|
+
|
|
103
|
+
this.querySelector('#vg-pass').onsubmit = async (ev) => {
|
|
104
|
+
ev.preventDefault()
|
|
105
|
+
const pw = ev.target.querySelector('[name=password]').value
|
|
106
|
+
const confirm = ev.target.querySelector('[name=confirm]').value
|
|
107
|
+
const msg = this.querySelector('#vg-pass-msg')
|
|
108
|
+
if (pw !== confirm) {
|
|
109
|
+
msg.textContent = 'Passwords do not match.'
|
|
110
|
+
msg.className = 'vg-form-msg vg-err'
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
const res = await Api.changePass(pw)
|
|
114
|
+
if (res.ok) {
|
|
115
|
+
ev.target.reset()
|
|
116
|
+
msg.textContent = 'Password changed.'
|
|
117
|
+
msg.className = 'vg-form-msg vg-ok'
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
msg.textContent = 'Change failed: ' + (res.why || '')
|
|
121
|
+
msg.className = 'vg-form-msg vg-err'
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The API-keys list (re-rendered after create/revoke). The freshly
|
|
127
|
+
// created key is shown ONCE (it is never retrievable again).
|
|
128
|
+
renderKeys() {
|
|
129
|
+
const fresh = this.querySelector('#vg-apikey-new')
|
|
130
|
+
if (fresh) {
|
|
131
|
+
fresh.innerHTML = this.newKey ? `
|
|
132
|
+
<div class="vg-apikey-once">
|
|
133
|
+
<strong>Copy your new key now — it will not be shown again:</strong>
|
|
134
|
+
<code class="vg-apikey-value">${esc(this.newKey)}</code>
|
|
135
|
+
</div>` : ''
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const list = this.querySelector('#vg-apikey-list')
|
|
139
|
+
if (!list) {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
list.innerHTML = this.apikeys.length ? `
|
|
143
|
+
<table class="vg-table vg-apikey-table">
|
|
144
|
+
<thead><tr><th>Name</th><th>Key</th><th>Created</th><th>Status</th><th></th></tr></thead>
|
|
145
|
+
<tbody>
|
|
146
|
+
${this.apikeys.map((k) => `
|
|
147
|
+
<tr data-id="${esc(k.id)}">
|
|
148
|
+
<td>${esc(k.name)}</td>
|
|
149
|
+
<td><code>${esc(k.prefix)}…</code></td>
|
|
150
|
+
<td>${k.t_c ? new Date(k.t_c).toISOString().slice(0, 10) : ''}</td>
|
|
151
|
+
<td>${k.revoked ? 'revoked' : 'active'}</td>
|
|
152
|
+
<td>${k.revoked ? '' :
|
|
153
|
+
`<button type="button" class="vg-apikey-revoke" data-id="${esc(k.id)}">Revoke</button>`}</td>
|
|
154
|
+
</tr>`).join('')}
|
|
155
|
+
</tbody>
|
|
156
|
+
</table>` : '<p class="vg-muted">No API keys yet.</p>'
|
|
157
|
+
|
|
158
|
+
for (const btn of list.querySelectorAll('.vg-apikey-revoke')) {
|
|
159
|
+
btn.onclick = async () => {
|
|
160
|
+
const res = await Api.revokeApikey(btn.dataset.id)
|
|
161
|
+
if (res.ok) {
|
|
162
|
+
this.newKey = null
|
|
163
|
+
await this.loadKeys()
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
function esc(s) {
|
|
172
|
+
return String(null == s ? '' : s).replace(/[&<>"]/g, (c) =>
|
|
173
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
customElements.define('vg-settings', VgSettings)
|
|
178
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Component: vg-settings (`cmp/settings.js`)
|
|
2
|
+
|
|
3
|
+
User settings & security: profile (name/email) and change-password.
|
|
4
|
+
Mounted in the shell's main area from the user menu.
|
|
5
|
+
|
|
6
|
+
## Message flow
|
|
7
|
+
|
|
8
|
+
```mermaid
|
|
9
|
+
sequenceDiagram
|
|
10
|
+
participant S as vg-settings
|
|
11
|
+
participant G as gateway (backend)
|
|
12
|
+
S->>G: aim:req,on:auth,update:user (profile save)
|
|
13
|
+
S->>G: aim:req,on:auth,change:pass (old + new password)
|
|
14
|
+
G-->>S: { ok } | { ok:false, why }
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Messages
|
|
18
|
+
|
|
19
|
+
| Message | Direction | Purpose |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `cmp:auth,get:state` | post | Current user to populate the form |
|
|
22
|
+
| `aim:req,on:auth,update:user` | post (via `api.js`) | Save profile |
|
|
23
|
+
| `aim:req,on:auth,change:pass` | post (via `api.js`) | Change password (min length 8) |
|
|
24
|
+
|
|
25
|
+
## Customisation
|
|
26
|
+
|
|
27
|
+
| Hook point | Kind | Effect |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `settings:sections` | html | Extra settings sections |
|
|
30
|
+
|