@stacksjs/ts-cloud 0.7.18 → 0.7.20
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/dist/bin/cli.js +697 -571
- package/dist/{chunk-h4pdjzq2.js → chunk-qdhn1s7j.js} +745 -52
- package/dist/{chunk-vpyd42tp.js → chunk-xgnxz5dz.js} +98 -34
- package/dist/deploy/dashboard-auth.d.ts +82 -0
- package/dist/deploy/dashboard-auth.test.d.ts +1 -0
- package/dist/deploy/dashboard-guard.d.ts +40 -0
- package/dist/deploy/dashboard-login-page.d.ts +16 -0
- package/dist/deploy/dashboard-pages.test.d.ts +1 -0
- package/dist/deploy/dashboard-policy.d.ts +37 -0
- package/dist/deploy/dashboard-policy.test.d.ts +1 -0
- package/dist/deploy/dashboard-scope.d.ts +24 -0
- package/dist/deploy/dashboard-scope.test.d.ts +1 -0
- package/dist/deploy/dashboard-session.d.ts +52 -0
- package/dist/deploy/dashboard-users.d.ts +60 -0
- package/dist/deploy/index.js +2 -2
- package/dist/deploy/local-dashboard-server.d.ts +1 -0
- package/dist/drivers/hetzner/client.d.ts +9 -1
- package/dist/drivers/hetzner/config.d.ts +83 -0
- package/dist/drivers/hetzner/config.test.d.ts +1 -0
- package/dist/drivers/hetzner/factory-wiring.test.d.ts +1 -0
- package/dist/drivers/index.js +1 -1
- package/dist/index.js +2 -2
- package/dist/ui/index.html +4 -4
- package/dist/ui/server/actions.html +4 -4
- package/dist/ui/server/activity.html +1 -1
- package/dist/ui/server/backups.html +4 -4
- package/dist/ui/server/database.html +4 -4
- package/dist/ui/server/deployments.html +4 -4
- package/dist/ui/server/diagnostics.html +1 -1
- package/dist/ui/server/firewall.html +4 -4
- package/dist/ui/server/logs.html +4 -4
- package/dist/ui/server/metrics.html +1 -1
- package/dist/ui/server/security.html +1 -1
- package/dist/ui/server/services.html +4 -4
- package/dist/ui/server/sites.html +4 -4
- package/dist/ui/server/ssh-keys.html +4 -4
- package/dist/ui/server/team.html +1214 -0
- package/dist/ui/server/terminal.html +4 -4
- package/dist/ui/server/workers.html +4 -4
- package/dist/ui/serverless/alarms.html +4 -4
- package/dist/ui/serverless/assets.html +4 -4
- package/dist/ui/serverless/cost.html +1 -1
- package/dist/ui/serverless/data.html +4 -4
- package/dist/ui/serverless/deployments.html +4 -4
- package/dist/ui/serverless/firewall.html +1 -1
- package/dist/ui/serverless/functions.html +4 -4
- package/dist/ui/serverless/logs.html +4 -4
- package/dist/ui/serverless/metrics.html +1 -1
- package/dist/ui/serverless/queues.html +4 -4
- package/dist/ui/serverless/scheduler.html +4 -4
- package/dist/ui/serverless/secrets.html +4 -4
- package/dist/ui/serverless/traces.html +4 -4
- package/dist/ui/serverless.html +4 -4
- package/dist/ui-src/pages/partials/head.stx +7 -0
- package/dist/ui-src/pages/partials/nav.stx +43 -6
- package/dist/ui-src/pages/server/deployments.stx +20 -5
- package/dist/ui-src/pages/server/firewall.stx +1 -1
- package/dist/ui-src/pages/server/logs.stx +26 -8
- package/dist/ui-src/pages/server/sites.stx +42 -20
- package/dist/ui-src/pages/server/team.stx +171 -0
- package/package.json +3 -3
|
@@ -46,6 +46,13 @@
|
|
|
46
46
|
.env-opt { padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; color: var(--txt2); }
|
|
47
47
|
.env-opt:hover { color: var(--txt); }
|
|
48
48
|
.env-opt.active { background: var(--okbg); color: var(--ok); }
|
|
49
|
+
.signout { display: flex; }
|
|
50
|
+
.signout button {
|
|
51
|
+
appearance: none; border: 1px solid var(--panel-br); background: rgba(255,255,255,0.035);
|
|
52
|
+
color: var(--txt2); border-radius: 8px; padding: 5px 11px; font: inherit; font-size: 12px;
|
|
53
|
+
font-weight: 600; cursor: pointer; transition: color .12s, border-color .12s;
|
|
54
|
+
}
|
|
55
|
+
.signout button:hover { color: var(--txt); border-color: rgba(255,255,255,0.16); }
|
|
49
56
|
|
|
50
57
|
header.page { padding: 30px 0 22px; display: flex; align-items: flex-start; gap: 20px; flex-wrap: wrap; }
|
|
51
58
|
header.page h1 { font-size: 26px; font-weight: 800; letter-spacing: -0.02em; }
|
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
<script server>
|
|
2
|
-
// Mode-aware, fully server-rendered nav. The deployment mode
|
|
3
|
-
// are injected with the dashboard data; the
|
|
4
|
-
// (`?env=<name>`) the server handles - no
|
|
2
|
+
// Mode- and scope-aware, fully server-rendered nav. The deployment mode,
|
|
3
|
+
// environment list and viewer role are injected with the dashboard data; the
|
|
4
|
+
// environment switcher is plain links (`?env=<name>`) the server handles - no
|
|
5
|
+
// client JavaScript needed.
|
|
6
|
+
//
|
|
7
|
+
// The nav offers only what the viewer can actually reach. A member (a
|
|
8
|
+
// collaborator invited to specific sites) has no box-level access, so the
|
|
9
|
+
// server, database and environment-switching surfaces are not shown - the
|
|
10
|
+
// server would refuse them anyway, and offering a link that 403s is worse than
|
|
11
|
+
// not offering it. This is presentation only: access is enforced server-side.
|
|
5
12
|
let __nav = null
|
|
6
13
|
try { __nav = JSON.parse(process.env.TSCLOUD_DASHBOARD_DATA || '') } catch { __nav = null }
|
|
7
14
|
// Server and serverless are mutually exclusive; the dashboard shows one or the other.
|
|
8
15
|
const navMode = __nav?.mode ?? 'server'
|
|
9
|
-
const
|
|
10
|
-
const
|
|
16
|
+
const isMember = (__nav?.viewer?.role ?? 'admin') === 'member'
|
|
17
|
+
const showServer = navMode === 'server' && !isMember
|
|
18
|
+
const showServerless = navMode === 'serverless' && !isMember
|
|
11
19
|
const navEnvs = __nav?.environments ?? []
|
|
12
20
|
const navEnv = __nav?.environment ?? ''
|
|
21
|
+
// Only the box owner can switch the active environment (it is process-wide).
|
|
22
|
+
const showEnvSwitch = navEnvs.length > 1 && !isMember
|
|
13
23
|
</script>
|
|
14
24
|
<nav>
|
|
15
25
|
<div class="brand"><span class="dot"></span> ts-cloud</div>
|
|
@@ -28,6 +38,23 @@ const navEnv = __nav?.environment ?? ''
|
|
|
28
38
|
<a href="/serverless">Serverless</a>
|
|
29
39
|
@endif
|
|
30
40
|
@endif
|
|
41
|
+
@if (isMember)
|
|
42
|
+
@if ((active ?? '') === 'sites')
|
|
43
|
+
<a href="/server/sites" class="active">Sites</a>
|
|
44
|
+
@else
|
|
45
|
+
<a href="/server/sites">Sites</a>
|
|
46
|
+
@endif
|
|
47
|
+
@if ((active ?? '') === 'deployments')
|
|
48
|
+
<a href="/server/deployments" class="active">Deployments</a>
|
|
49
|
+
@else
|
|
50
|
+
<a href="/server/deployments">Deployments</a>
|
|
51
|
+
@endif
|
|
52
|
+
@if ((active ?? '') === 'logs')
|
|
53
|
+
<a href="/server/logs" class="active">Logs</a>
|
|
54
|
+
@else
|
|
55
|
+
<a href="/server/logs">Logs</a>
|
|
56
|
+
@endif
|
|
57
|
+
@endif
|
|
31
58
|
@if (showServer)
|
|
32
59
|
@if ((active ?? '') === 'database')
|
|
33
60
|
<a href="/server/database" class="active">Databases</a>
|
|
@@ -35,10 +62,17 @@ const navEnv = __nav?.environment ?? ''
|
|
|
35
62
|
<a href="/server/database">Databases</a>
|
|
36
63
|
@endif
|
|
37
64
|
@endif
|
|
65
|
+
@if (!isMember)
|
|
66
|
+
@if ((active ?? '') === 'team')
|
|
67
|
+
<a href="/server/team" class="active">Team</a>
|
|
68
|
+
@else
|
|
69
|
+
<a href="/server/team">Team</a>
|
|
70
|
+
@endif
|
|
71
|
+
@endif
|
|
38
72
|
</div>
|
|
39
73
|
<div class="nav-meta">
|
|
40
74
|
<span>{{ name ?? '' }}</span>
|
|
41
|
-
@if (
|
|
75
|
+
@if (showEnvSwitch)
|
|
42
76
|
<span class="env-switch" title="Switch environment">
|
|
43
77
|
@foreach (navEnvs as e)
|
|
44
78
|
@if (e === navEnv)
|
|
@@ -51,5 +85,8 @@ const navEnv = __nav?.environment ?? ''
|
|
|
51
85
|
@else
|
|
52
86
|
<span class="env">{{ navEnv || env || '' }}</span>
|
|
53
87
|
@endif
|
|
88
|
+
<form method="post" action="/api/logout" class="signout">
|
|
89
|
+
<button type="submit">Sign out</button>
|
|
90
|
+
</form>
|
|
54
91
|
</div>
|
|
55
92
|
</nav>
|
|
@@ -4,6 +4,9 @@ import { renderDeploymentSparkline } from '../../src/charts.ts'
|
|
|
4
4
|
let __d = null
|
|
5
5
|
try { __d = JSON.parse(process.env.TSCLOUD_DASHBOARD_DATA || '') } catch { __d = null }
|
|
6
6
|
const server = __d?.server ?? { name: 'acme-production-app' }
|
|
7
|
+
// Members reach this page for their own sites; box-level CLI hints and the
|
|
8
|
+
// server crumb are not theirs to act on.
|
|
9
|
+
const isMember = (__d?.viewer?.role ?? 'admin') === 'member'
|
|
7
10
|
const initialDeps = __d?.serverDeploymentsDetail ?? []
|
|
8
11
|
const deploymentChart = renderDeploymentSparkline(initialDeps)
|
|
9
12
|
const deploymentsEmptyReason = __d?.deploymentsEmptyReason ?? 'No deployment history has been recorded yet.'
|
|
@@ -49,12 +52,20 @@ async function runOp() {
|
|
|
49
52
|
<html lang="en" data-theme="dark">
|
|
50
53
|
<head>@include('../partials/head', { title: 'ts-cloud - Deployments' })</head>
|
|
51
54
|
<body>
|
|
52
|
-
@include('../partials/nav', { active: 'server', name: server.name, env: 'production' })
|
|
55
|
+
@include('../partials/nav', { active: isMember ? 'deployments' : 'server', name: server.name, env: 'production' })
|
|
53
56
|
<div class="wrap">
|
|
54
|
-
|
|
57
|
+
@if (isMember)
|
|
58
|
+
<div class="crumbs">Deployments</div>
|
|
59
|
+
@else
|
|
60
|
+
<div class="crumbs"><a href="/">Server</a> <span class="sep">/</span> Deployments</div>
|
|
61
|
+
@endif
|
|
55
62
|
<header class="page">
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
@if (isMember)
|
|
64
|
+
<div><h1>Deployments</h1><p>Release history for your sites, newest first.</p></div>
|
|
65
|
+
@else
|
|
66
|
+
<div><h1>Deployments</h1><p>Server release history, newest first.</p></div>
|
|
67
|
+
<div class="cmds header-actions"><span class="accent cmd">cloud server:deploy {{ server.name }}</span></div>
|
|
68
|
+
@endif
|
|
58
69
|
</header>
|
|
59
70
|
<div class="section">
|
|
60
71
|
<div class="panel">
|
|
@@ -98,7 +109,11 @@ async function runOp() {
|
|
|
98
109
|
<div class="empty" @show="deps().length === 0"><strong>No deployments yet</strong><span>{{ deploymentsEmptyReason }}</span></div>
|
|
99
110
|
<div class="compact empty" @show="deps().length > 0 && filtered().length === 0"><strong>No matches</strong><span>Try a different status or site filter.</span></div>
|
|
100
111
|
@include('../partials/op-confirm')
|
|
101
|
-
|
|
112
|
+
@if (isMember)
|
|
113
|
+
<p class="note">Roll back one of your sites to any prior release.</p>
|
|
114
|
+
@else
|
|
115
|
+
<p class="note">Roll back a site to any prior release, or inspect a box with <span class="mono">cloud server:logs {{ server.name }}</span>.</p>
|
|
116
|
+
@endif
|
|
102
117
|
</div>
|
|
103
118
|
</div>
|
|
104
119
|
</div>
|
|
@@ -81,7 +81,7 @@ onMount(() => { load() })
|
|
|
81
81
|
<div class="port-grid">
|
|
82
82
|
<template :for="p in alwaysOpen()">
|
|
83
83
|
<div class="port-card">
|
|
84
|
-
<div class="top"><span class="tag">tcp</span><span class="pill
|
|
84
|
+
<div class="top"><span class="tag">tcp</span><span class="ok pill">reserved</span></div>
|
|
85
85
|
<div class="listen mono">{{ p }}</div>
|
|
86
86
|
<div class="proc">Always allowed by ts-cloud</div>
|
|
87
87
|
</div>
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
let __d = null
|
|
3
3
|
try { __d = JSON.parse(process.env.TSCLOUD_DASHBOARD_DATA || '') } catch { __d = null }
|
|
4
4
|
const server = __d?.server ?? { name: 'stacks-production-app' }
|
|
5
|
+
// Members see only their own sites' units; box-level CLI hints and the server
|
|
6
|
+
// crumb are not theirs to act on.
|
|
7
|
+
const isMember = (__d?.viewer?.role ?? 'admin') === 'member'
|
|
5
8
|
const initialLogs = __d?.serverLogs ?? []
|
|
6
9
|
const emptyReason = __d?.serverLogsEmptyReason ?? 'No server logs are available yet.'
|
|
7
10
|
</script>
|
|
@@ -30,15 +33,26 @@ onMount(() => { refresh(); const id = setInterval(refresh, 8000); return () => c
|
|
|
30
33
|
<html lang="en" data-theme="dark">
|
|
31
34
|
<head>@include('../partials/head', { title: 'ts-cloud - Server logs' })</head>
|
|
32
35
|
<body>
|
|
33
|
-
@include('../partials/nav', { active: 'server', name: server.name, env: 'production' })
|
|
36
|
+
@include('../partials/nav', { active: isMember ? 'logs' : 'server', name: server.name, env: 'production' })
|
|
34
37
|
<div class="wrap">
|
|
35
|
-
|
|
38
|
+
@if (isMember)
|
|
39
|
+
<div class="crumbs">Logs</div>
|
|
40
|
+
@else
|
|
41
|
+
<div class="crumbs"><a href="/">Server</a> <span class="sep">/</span> Logs</div>
|
|
42
|
+
@endif
|
|
36
43
|
<header class="page">
|
|
37
|
-
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
@if (isMember)
|
|
45
|
+
<div>
|
|
46
|
+
<h1>Logs</h1>
|
|
47
|
+
<p>Live journal entries from your sites and their queue workers.</p>
|
|
48
|
+
</div>
|
|
49
|
+
@else
|
|
50
|
+
<div>
|
|
51
|
+
<h1>Logs</h1>
|
|
52
|
+
<p>Live journal entries from every managed service on {{ server.name }}.</p>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="cmds header-actions"><span class="accent cmd">cloud server:logs {{ server.name }}</span></div>
|
|
55
|
+
@endif
|
|
42
56
|
</header>
|
|
43
57
|
|
|
44
58
|
<div class="section">
|
|
@@ -76,7 +90,11 @@ onMount(() => { refresh(); const id = setInterval(refresh, 8000); return () => c
|
|
|
76
90
|
<div class="compact empty" @show="logs().length > 0 && filtered().length === 0"><strong>No matching logs</strong><span>Try a different level or source filter.</span></div>
|
|
77
91
|
<div class="empty" @show="logs().length === 0"><strong>No logs available</strong><span>{{ emptyReason }}</span></div>
|
|
78
92
|
|
|
79
|
-
|
|
93
|
+
@if (isMember)
|
|
94
|
+
<p class="note">Live <span class="mono">journalctl</span> for your sites' services, <b>queue workers</b> and daemons. Auto-refreshes every 8s.</p>
|
|
95
|
+
@else
|
|
96
|
+
<p class="note">Live <span class="mono">journalctl</span> for the web server, every managed service, and each app's service, <b>queue workers</b>, and daemons - plus the backup runner. Auto-refreshes every 8s.</p>
|
|
97
|
+
@endif
|
|
80
98
|
</div>
|
|
81
99
|
</div>
|
|
82
100
|
</div>
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
let __d = null
|
|
3
3
|
try { __d = JSON.parse(process.env.TSCLOUD_DASHBOARD_DATA || '') } catch { __d = null }
|
|
4
4
|
const server = __d?.server ?? { name: 'stacks-production-app' }
|
|
5
|
+
// A member (a collaborator invited to specific sites) has no box-level access,
|
|
6
|
+
// so the box-owner affordances - adding and removing sites - are not offered.
|
|
7
|
+
// Presentation only: the server enforces this regardless of what is rendered.
|
|
8
|
+
const isMember = (__d?.viewer?.role ?? 'admin') === 'member'
|
|
5
9
|
const sites = __d?.sitesDetail ?? [
|
|
6
10
|
{ name: 'main', route: 'stacksjs.com', href: 'https://stacksjs.com', kind: 'stacks', runtime: 'bun', tls: 'https', deploy: 'service', root: '/var/www/main/current', branch: 'main', status: 'live' },
|
|
7
11
|
{ name: 'api', route: 'internal', kind: 'api', runtime: 'bun', tls: 'loopback', deploy: 'service', root: '/var/www/api/current', branch: 'main', status: 'live' },
|
|
@@ -75,29 +79,45 @@ async function saveEdit(name) {
|
|
|
75
79
|
<html lang="en" data-theme="dark">
|
|
76
80
|
<head>@include('../partials/head', { title: 'ts-cloud - Sites' })</head>
|
|
77
81
|
<body>
|
|
78
|
-
@include('../partials/nav', { active: 'server', name: server.name, env: 'production' })
|
|
82
|
+
@include('../partials/nav', { active: isMember ? 'sites' : 'server', name: server.name, env: 'production' })
|
|
79
83
|
<div class="wrap">
|
|
80
|
-
|
|
84
|
+
@if (isMember)
|
|
85
|
+
<div class="crumbs">Sites</div>
|
|
86
|
+
@else
|
|
87
|
+
<div class="crumbs"><a href="/">Server</a> <span class="sep">/</span> Sites</div>
|
|
88
|
+
@endif
|
|
81
89
|
<header class="page">
|
|
82
|
-
|
|
83
|
-
|
|
90
|
+
@if (isMember)
|
|
91
|
+
<div><h1>Sites</h1><p>The sites you have been given access to.</p></div>
|
|
92
|
+
@else
|
|
93
|
+
<div><h1>Sites</h1><p>Server routes served from the box, each deployed as an atomic release.</p></div>
|
|
94
|
+
<div class="cmds header-actions"><span class="accent cmd">cloud site:add <name></span></div>
|
|
95
|
+
@endif
|
|
84
96
|
</header>
|
|
85
|
-
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
<
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
@if (!isMember)
|
|
98
|
+
<div class="section">
|
|
99
|
+
<h2>Add site</h2>
|
|
100
|
+
<div class="panel">
|
|
101
|
+
<form class="form-grid" @submit.prevent="addSite()">
|
|
102
|
+
<div class="field-block"><label>Name</label><input placeholder="docs" :value="addName()" @input="addName.set($event.target.value)"></div>
|
|
103
|
+
<div class="field-block"><label>Root</label><input placeholder="dist/docs" :value="addRoot()" @input="addRoot.set($event.target.value)"></div>
|
|
104
|
+
<div class="field-block"><label>Domain</label><input placeholder="example.com" :value="addDomain()" @input="addDomain.set($event.target.value)"></div>
|
|
105
|
+
<div class="field-block"><label>Path</label><input placeholder="/docs" :value="addPath()" @input="addPath.set($event.target.value)"></div>
|
|
106
|
+
<div class="field-block"><label>Deploy</label><input :value="addDeploy()" @input="addDeploy.set($event.target.value)"></div>
|
|
107
|
+
<div class="field-block"><label>Port</label><input placeholder="3000" :value="addPort()" @input="addPort.set($event.target.value)"></div>
|
|
108
|
+
<div class="field-block wide"><label>Build command</label><input placeholder="bun run build" :value="addBuild()" @input="addBuild.set($event.target.value)"></div>
|
|
109
|
+
<div class="field-block wide"><label>Start command</label><input placeholder="bun run start" :value="addStart()" @input="addStart.set($event.target.value)"></div>
|
|
110
|
+
<div class="form-actions"><button class="btn" type="submit">Add site</button><span class="msg" :class="addMsgKind()">{{ addMsg() }}</span></div>
|
|
111
|
+
</form>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
@endif
|
|
115
|
+
@if (isMember && sites.length === 0)
|
|
116
|
+
<div class="empty">
|
|
117
|
+
<strong>No sites yet</strong>
|
|
118
|
+
<span>You have not been given access to a site on this server. Ask the server owner to invite you to one.</span>
|
|
99
119
|
</div>
|
|
100
|
-
|
|
120
|
+
@endif
|
|
101
121
|
@foreach (sites as site)
|
|
102
122
|
<div class="section">
|
|
103
123
|
<h2>{{ site.name }} <span class="more"><span class="pill {{ tone(site.status) }}">{{ site.status }}</span></span></h2>
|
|
@@ -129,7 +149,9 @@ async function saveEdit(name) {
|
|
|
129
149
|
<div class="row-actions" style="margin-top:12px;">
|
|
130
150
|
<button class="btn sm" type="button" @click="askDeploy('{{ site.name }}')">Deploy</button>
|
|
131
151
|
<button class="btn ghost sm" type="button" @click="toggleEdit('{{ site.name }}')">Edit</button>
|
|
132
|
-
|
|
152
|
+
@if (!isMember)
|
|
153
|
+
<button class="btn danger sm" type="button" @click="askRemove('{{ site.name }}')">Remove</button>
|
|
154
|
+
@endif
|
|
133
155
|
</div>
|
|
134
156
|
<form class="form-grid" style="margin-top:14px;" @show="editing() === '{{ site.name }}'" @submit.prevent="saveEdit('{{ site.name }}')">
|
|
135
157
|
<div class="field-block">
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
<script server>
|
|
2
|
+
let __d = null
|
|
3
|
+
try { __d = JSON.parse(process.env.TSCLOUD_DASHBOARD_DATA || '') } catch { __d = null }
|
|
4
|
+
const server = __d?.server ?? { name: 'acme-production-app' }
|
|
5
|
+
// Only the box owner reaches this page (the server refuses /api/users for
|
|
6
|
+
// anyone else), so the site list here is always the full one.
|
|
7
|
+
const siteNames = (__d?.sitesDetail ?? __d?.sites ?? [{ name: 'main' }, { name: 'docs' }]).map(s => s.name)
|
|
8
|
+
</script>
|
|
9
|
+
<script client>
|
|
10
|
+
// Collaborator management: list, invite, revoke. A member holds a role per site
|
|
11
|
+
// - 'owner' may also change that site's settings, 'collaborator' may view and
|
|
12
|
+
// deploy it. Neither ever reaches the box itself (shell, SSH keys, firewall,
|
|
13
|
+
// databases): those are the box owner's alone, because each of them is root on
|
|
14
|
+
// a server that hosts other people's sites.
|
|
15
|
+
const users = state([])
|
|
16
|
+
const sites = state(siteNames)
|
|
17
|
+
const username = state('')
|
|
18
|
+
const displayName = state('')
|
|
19
|
+
const grants = state({})
|
|
20
|
+
const message = state('')
|
|
21
|
+
const messageKind = state('')
|
|
22
|
+
// Shown once after an invite - the password is generated server-side, hashed,
|
|
23
|
+
// and never retrievable again.
|
|
24
|
+
const issued = state(null)
|
|
25
|
+
|
|
26
|
+
function setMessage(text, kind) { message.set(text); messageKind.set(kind || '') }
|
|
27
|
+
function grantFor(site) { return grants()[site] || 'none' }
|
|
28
|
+
function setGrant(site, role) {
|
|
29
|
+
const next = { ...grants() }
|
|
30
|
+
if (role === 'none') delete next[site]
|
|
31
|
+
else next[site] = role
|
|
32
|
+
grants.set(next)
|
|
33
|
+
}
|
|
34
|
+
function siteList(user) {
|
|
35
|
+
const entries = Object.entries(user.sites || {})
|
|
36
|
+
if (!entries.length) return '-'
|
|
37
|
+
return entries.map(([s, r]) => s + ' (' + r + ')').join(', ')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function load() {
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch('/api/users')
|
|
43
|
+
if (!res.ok) throw new Error('Could not read the team.')
|
|
44
|
+
const data = await res.json()
|
|
45
|
+
users.set(data.users || [])
|
|
46
|
+
} catch (e) { setMessage((e && e.message) || 'Could not read the team.', 'bad') }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function invite() {
|
|
50
|
+
setMessage('Saving...', ''); issued.set(null)
|
|
51
|
+
const payload = { username: username(), name: displayName(), sites: grants() }
|
|
52
|
+
const res = await fetch('/api/users', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
|
|
53
|
+
const data = await res.json().catch(() => ({}))
|
|
54
|
+
if (!res.ok || data.ok === false) { setMessage(data.error || 'Could not save.', 'bad'); return }
|
|
55
|
+
if (data.password) issued.set({ username: data.user.username, password: data.password })
|
|
56
|
+
setMessage(data.password ? 'Invited ' + data.user.username + '.' : 'Updated ' + data.user.username + '.', 'ok')
|
|
57
|
+
username.set(''); displayName.set(''); grants.set({})
|
|
58
|
+
load()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function revoke(name) {
|
|
62
|
+
setMessage('Removing...', '')
|
|
63
|
+
const res = await fetch('/api/users', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: name }) })
|
|
64
|
+
const data = await res.json().catch(() => ({}))
|
|
65
|
+
if (!res.ok || data.ok === false) { setMessage(data.error || 'Could not remove.', 'bad'); return }
|
|
66
|
+
setMessage('Removed ' + name + '.', 'ok')
|
|
67
|
+
load()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
onMount(() => { load() })
|
|
71
|
+
</script>
|
|
72
|
+
<!DOCTYPE html>
|
|
73
|
+
<html lang="en" data-theme="dark">
|
|
74
|
+
<head>@include('../partials/head', { title: 'ts-cloud - Team' })</head>
|
|
75
|
+
<body>
|
|
76
|
+
@include('../partials/nav', { active: 'team', name: server.name, env: 'production' })
|
|
77
|
+
<div class="wrap">
|
|
78
|
+
<div class="crumbs"><a href="/">Server</a> <span class="sep">/</span> Team</div>
|
|
79
|
+
<header class="page">
|
|
80
|
+
<div>
|
|
81
|
+
<h1>Team</h1>
|
|
82
|
+
<p>Invite people to individual sites. They see only the sites you grant them, and never the box.</p>
|
|
83
|
+
</div>
|
|
84
|
+
</header>
|
|
85
|
+
|
|
86
|
+
<div class="section">
|
|
87
|
+
<h2>Invite</h2>
|
|
88
|
+
<div class="panel">
|
|
89
|
+
<form class="form-grid" @submit.prevent="invite()">
|
|
90
|
+
<div class="field-block">
|
|
91
|
+
<label for="team-username">Username</label>
|
|
92
|
+
<input id="team-username" autocomplete="off" placeholder="dana" :value="username()" @input="username.set($event.target.value)" required>
|
|
93
|
+
</div>
|
|
94
|
+
<div class="field-block">
|
|
95
|
+
<label for="team-name">Display name</label>
|
|
96
|
+
<input id="team-name" autocomplete="off" placeholder="Dana Whitfield" :value="displayName()" @input="displayName.set($event.target.value)">
|
|
97
|
+
</div>
|
|
98
|
+
<div class="field-block wide">
|
|
99
|
+
<label>Site access</label>
|
|
100
|
+
<div class="grant-grid">
|
|
101
|
+
<template :for="s in sites()">
|
|
102
|
+
<div class="grant">
|
|
103
|
+
<span class="mono">{{ s }}</span>
|
|
104
|
+
<select :value="grantFor(s)" @change="setGrant(s, $event.target.value)">
|
|
105
|
+
<option value="none">No access</option>
|
|
106
|
+
<option value="collaborator">Collaborator - view and deploy</option>
|
|
107
|
+
<option value="owner">Owner - view, deploy and edit settings</option>
|
|
108
|
+
</select>
|
|
109
|
+
</div>
|
|
110
|
+
</template>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
<div class="form-actions">
|
|
114
|
+
<button class="btn" type="submit">Send invite</button>
|
|
115
|
+
<span class="msg" :class="messageKind()" role="status">{{ message() }}</span>
|
|
116
|
+
</div>
|
|
117
|
+
</form>
|
|
118
|
+
|
|
119
|
+
<div class="issued" @show="!!issued()">
|
|
120
|
+
<strong>Password for {{ issued()?.username }}</strong>
|
|
121
|
+
<code>{{ issued()?.password }}</code>
|
|
122
|
+
<span>Copy it now and send it over a channel you trust. It is stored hashed and cannot be shown again.</span>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div class="section">
|
|
128
|
+
<h2>People</h2>
|
|
129
|
+
<div class="panel">
|
|
130
|
+
<table>
|
|
131
|
+
<thead><tr><th>User</th><th>Role</th><th>Sites</th><th></th></tr></thead>
|
|
132
|
+
<tbody>
|
|
133
|
+
<template :for="u in users()">
|
|
134
|
+
<tr>
|
|
135
|
+
<td><b>{{ u.name }}</b><span class="mono uname">{{ u.username }}</span></td>
|
|
136
|
+
<td><span class="tag">{{ u.role === 'admin' ? 'box owner' : 'member' }}</span></td>
|
|
137
|
+
<td class="mono">{{ u.role === 'admin' ? 'every site' : siteList(u) }}</td>
|
|
138
|
+
<td class="table-actions"><button type="button" class="btn danger sm" @click="revoke(u.username)">Remove</button></td>
|
|
139
|
+
</tr>
|
|
140
|
+
</template>
|
|
141
|
+
</tbody>
|
|
142
|
+
</table>
|
|
143
|
+
<div class="compact empty" @show="users().length === 0"><strong>No one invited yet</strong><span>Invite someone above to give them access to a site.</span></div>
|
|
144
|
+
<p class="note">Box owners reach everything on this server. Members reach only the sites listed here, and never the shell, SSH keys, firewall or databases. Access is checked on every request.</p>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
<style>
|
|
149
|
+
.grant-grid { display: grid; gap: 10px; }
|
|
150
|
+
.grant { display: grid; grid-template-columns: minmax(120px, 0.4fr) 1fr; gap: 12px; align-items: center; }
|
|
151
|
+
.grant > span { color: var(--txt2); font-size: 13px; }
|
|
152
|
+
.grant select {
|
|
153
|
+
width: 100%; background: rgba(255,255,255,0.05); border: 1px solid var(--panel-br); border-radius: 10px;
|
|
154
|
+
padding: 9px 12px; color: var(--txt); font-family: inherit; font-size: 13.5px;
|
|
155
|
+
}
|
|
156
|
+
.grant select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(90,139,224,0.12); }
|
|
157
|
+
.issued {
|
|
158
|
+
display: grid; gap: 7px; margin-top: 16px; padding: 14px 16px;
|
|
159
|
+
border: 1px solid rgba(53,209,155,0.28); background: var(--okbg); border-radius: 12px;
|
|
160
|
+
}
|
|
161
|
+
.uname { margin-left: 8px; color: var(--txt3); }
|
|
162
|
+
.issued strong { font-size: 13px; color: var(--ok); }
|
|
163
|
+
.issued code {
|
|
164
|
+
font-family: var(--mono); font-size: 14px; color: var(--txt); user-select: all;
|
|
165
|
+
background: rgba(0,0,0,0.25); border-radius: 8px; padding: 8px 10px; overflow-wrap: anywhere;
|
|
166
|
+
}
|
|
167
|
+
.issued span { color: var(--txt2); font-size: 12px; line-height: 1.5; }
|
|
168
|
+
@media (max-width: 640px) { .grant { grid-template-columns: 1fr; gap: 6px; } }
|
|
169
|
+
</style>
|
|
170
|
+
</body>
|
|
171
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stacksjs/ts-cloud",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.20",
|
|
5
5
|
"description": "A lightweight, performant infrastructure-as-code library and CLI for deploying both server-based (EC2) and serverless applications.",
|
|
6
6
|
"author": "Chris Breuer <chris@stacksjs.com>",
|
|
7
7
|
"license": "MIT",
|
|
@@ -89,8 +89,8 @@
|
|
|
89
89
|
"test": "bun test"
|
|
90
90
|
},
|
|
91
91
|
"dependencies": {
|
|
92
|
-
"@ts-cloud/aws-types": "0.7.
|
|
93
|
-
"@ts-cloud/core": "0.7.
|
|
92
|
+
"@ts-cloud/aws-types": "0.7.20",
|
|
93
|
+
"@ts-cloud/core": "0.7.20",
|
|
94
94
|
"@stacksjs/ts-xml": "^0.1.0"
|
|
95
95
|
},
|
|
96
96
|
"devDependencies": {
|