feat(site): join UI, spoke read-only enforcement, live WAN health, fresh-install guard
Completes the multi-site join layer on top of the v2.2.0 endpoints: - UI (Master Site modal): a fresh install (canJoin) gets a 'Join an Existing Site' form (master URL + stj_ key); a master gets a 'Site Join Keys' manager (mint/revoke/list, key shown once); WAN Sync Health now reflects a live probe. - POST /api/site/ping (Bearer stj_ key, no admin session): lightweight master reachability probe for WAN health (cheap vs /export). - Spoke read-only: directory-write routes (resources/edges/groups/secrets/ grants/driver-action/discovered) reject with 403 pointing at the master. - Fresh-install guard: /api/site/join refuses unless no users beyond the bootstrap admin and no enrolled agents (siteIsFresh), and site-status exposes canJoin so the UI only offers join on a genuinely fresh install. The bootstrap's seeded default resources are NOT the signal (they always exist). - The spoke stores the join key (masterJoinKey) in /config/site.json so WAN health (and a future write-proxy) can reach the master. - Tests: siteIsFresh cases in tests/site_join.test.js.
This commit is contained in:
+38
-53
@@ -6,25 +6,28 @@ copy for local latency and autonomy (see the root `MULTI_SITE_SPEC.md` for the
|
||||
full architecture). This page covers the server endpoints that make a spoke
|
||||
"join" an existing master.
|
||||
|
||||
> Status: **server endpoints only.** The `setup.sh` wiring and the UI that calls
|
||||
> them are the next layer; the join is designed to run during a fresh bring-up
|
||||
> (before the bootstrap seeds local content), so there is nothing local to wipe
|
||||
> when adopting the master's directory.
|
||||
> Status: **server endpoints + UI + setup.sh wiring.** A fresh bring-up can
|
||||
> adopt a master directory via the Directory UI or via `setup.env`, and a
|
||||
> joined spoke is read-only with live WAN health.
|
||||
|
||||
## The flow
|
||||
|
||||
1. On the **master**, an admin mints a **site join key** (`stj_…`, shown once,
|
||||
stored hashed, revocable).
|
||||
2. On the **spoke** (a fresh install), an admin calls `POST /api/site/join`
|
||||
with the master's URL + that key.
|
||||
stored hashed, revocable) — Directory → the Master Site modal → **Site Join Keys**.
|
||||
2. On the **spoke** (a fresh install), either:
|
||||
- **UI**: Directory → the Master Site modal → **Join an Existing Site**, or
|
||||
- **setup.sh**: set `CFG_MASTER_DIRECTORY_URL` + `CFG_MASTER_DIRECTORY_JOIN_KEY`
|
||||
in `setup.env` before the first run.
|
||||
3. The spoke pulls the master's directory export (LDAP tree + resource
|
||||
catalog), imports it, and persists its own spoke role
|
||||
(`isMaster: false`, `masterUrl`, `siteSlug`).
|
||||
(`isMaster: false`, `masterUrl`, `siteSlug`) in `/config/site.json`.
|
||||
|
||||
Joining is allowed only on a **fresh install** (no users beyond the bootstrap
|
||||
admin, no enrolled agents) — the join endpoint enforces this, so a populated
|
||||
directory can never be merged into a master's.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Join key management (admin session)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| :--- | :--- | :--- |
|
||||
| `GET` | `/api/site/join-keys` | List keys (prefix + usage only; never the key) |
|
||||
@@ -32,60 +35,42 @@ full architecture). This page covers the server endpoints that make a spoke
|
||||
| `POST` | `/api/site/join-keys/:id/revoke` | Stop it accepting new joins |
|
||||
| `DELETE` | `/api/site/join-keys/:id` | Remove it |
|
||||
| `GET` | `/api/site/config` | Current role (isMaster, masterUrl, siteSlug) |
|
||||
| `POST` | `/api/site/export` | Master directory export (Bearer `stj_` key) |
|
||||
| `POST` | `/api/site/ping` | Lightweight master reachability probe (Bearer `stj_` key) |
|
||||
| `POST` | `/api/site/join` | Adopt a master directory (admin session) |
|
||||
|
||||
Mint a key:
|
||||
## Behavior after joining (spoke)
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $SESSION_TOKEN" \
|
||||
-X POST https://sso.master.example.com/api/site/join-keys \
|
||||
-H 'Content-Type: application/json' -d '{"label":"staten-island"}'
|
||||
# -> { "joinKey": {...}, "key": "stj_9f2e..." } # show `key` once
|
||||
```
|
||||
- **Read-only**: directory-write requests (resources, edges, groups, secrets,
|
||||
grants, driver actions, discovery merges) are rejected with `403` pointing at
|
||||
the master. Writes must go to the master.
|
||||
- **WAN health**: `site-status` pings the master over the stored site join key
|
||||
and reports `wanConnected`; the Master Site modal shows live Online/Offline.
|
||||
- **Role persists**: `isMaster`/`masterUrl`/`siteSlug` live in `/config/site.json`
|
||||
(the env vars `IS_MASTER`/`MASTER_URL`/`SITE_SLUG` only seed the defaults), so
|
||||
a restart never silently reverts a spoke to master.
|
||||
|
||||
### Export (master side — no admin session)
|
||||
## Deployment (setup.sh)
|
||||
|
||||
`POST /api/site/export` authenticated with a site join key
|
||||
(`Authorization: Bearer stj_…`). Returns the local LDAP tree as an LDIF
|
||||
(`slapcat`), the resource catalog (`Resource` + `ResourceEdge` rows), the site
|
||||
slug, and the LDAP base DN. The spoke's join endpoint calls this.
|
||||
|
||||
### Join (spoke side — admin session)
|
||||
|
||||
`POST /api/site/join` with:
|
||||
|
||||
```json
|
||||
{ "masterUrl": "https://sso.master.example.com", "joinKey": "stj_9f2e..." }
|
||||
```
|
||||
|
||||
The spoke:
|
||||
|
||||
1. **Imports the resource catalog** — resources are upserted by slug (the
|
||||
master is authoritative for the shared catalog) and edges are recreated.
|
||||
2. **Imports the LDAP tree** — the master's LDIF is loaded into the local
|
||||
slapd with `ldapadd -c`, so the spoke keeps its own `cn=admin` / base DN and
|
||||
inherits the master's users/groups.
|
||||
3. **Persists the spoke role** in `/config/site.json` (survives restarts).
|
||||
|
||||
The join is refused if this node is already a spoke (no re-join).
|
||||
|
||||
## Deployment (setup.sh wiring — next layer)
|
||||
|
||||
`setup.env` will carry the intent so the join runs only on a **fresh** bring-up:
|
||||
`setup.env` carries the intent so the join runs only on a **fresh** bring-up:
|
||||
|
||||
```
|
||||
# Multi-site: join an existing (master) deployment instead of seeding a fresh one.
|
||||
# Honored ONLY on first run; re-runs ignore it once ./config/ exists.
|
||||
#CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
|
||||
#CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
|
||||
CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
|
||||
CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
|
||||
```
|
||||
|
||||
The role itself is seeded from the environment (`IS_MASTER`, `MASTER_URL`,
|
||||
`SITE_SLUG`) and overridden by `/config/site.json` once a promote/join writes it.
|
||||
`setup.sh` runs `bootstrap/site-join.js` inside the sso-manager container after
|
||||
the bootstrap; it logs in as the admin and calls `/api/site/join`. A node that
|
||||
already joined reports "already a spoke" and setup continues (idempotent).
|
||||
|
||||
## Security
|
||||
|
||||
- Join keys are single-use-intent credentials: shown once, stored as a SHA-256
|
||||
hash, revocable/expirable — the same model as agent join keys.
|
||||
- The export endpoint never returns admin secrets; it returns the LDAP tree +
|
||||
resource catalog the spoke needs to operate.
|
||||
- Join is admin-gated on the spoke and key-gated on the master.
|
||||
- The export/ping endpoints return only the directory tree/catalog (no admin
|
||||
secrets) and require a valid join key.
|
||||
- Join is admin-gated on the spoke, key-gated on the master, and fresh-install
|
||||
gated on both sides.
|
||||
- The join key is stored on the spoke only so it can reach the master for WAN
|
||||
health (and, in a later layer, write-proxy).
|
||||
|
||||
@@ -252,6 +252,23 @@ router.get('/resources', async (req, res, next) => {
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Spoke read-only enforcement ─────────────────────────────────────────────
|
||||
// On a joined spoke the catalog is a copy of the master's; directory writes
|
||||
// must go to the master (MULTI_SITE_SPEC.md — spoke = read-only catalog). Any
|
||||
// mutating request below this point is rejected on a spoke with a pointer to
|
||||
// the master. (site-status / site-promote live AFTER this middleware and are
|
||||
// not directory writes.)
|
||||
router.use((req, res, next) => {
|
||||
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
|
||||
const cfg = siteConfig.get();
|
||||
if (!cfg.isMaster) {
|
||||
const hint = cfg.masterUrl ? ' Directory writes must go to the master at ' + cfg.masterUrl + '.' : '';
|
||||
return res.status(403).json({ status: 'error', message: 'This node is a spoke (read-only catalog).' + hint });
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
router.post('/resources', async (req, res, next) => {
|
||||
try {
|
||||
if (!req.body.hostId && req.body.parentSlug) {
|
||||
@@ -887,6 +904,30 @@ router.post('/discovered/merge', async (req, res, next) => {
|
||||
// MASTER_URL / SITE_SLUG only seed the defaults. site-promote and the
|
||||
// /api/site/join flow both write to it.
|
||||
const siteConfig = require('../utils/site_config');
|
||||
const { siteIsFresh } = require('../utils/site_join');
|
||||
const { Agent } = require('../models/agent');
|
||||
|
||||
// probeMasterHealth checks whether this (spoke) node can reach its master over
|
||||
// the site join key. The master's /api/site/ping is deliberately lightweight.
|
||||
async function probeMasterHealth(cfg) {
|
||||
if (cfg.isMaster) return true;
|
||||
if (!cfg.masterUrl || !cfg.masterJoinKey) return false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10000);
|
||||
try {
|
||||
const resp = await fetch(String(cfg.masterUrl).replace(/\/+$/, '') + '/api/site/ping', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + cfg.masterJoinKey, 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
signal: controller.signal
|
||||
});
|
||||
return resp.ok;
|
||||
} catch (e) {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/site-status', async (req, res, next) => {
|
||||
try {
|
||||
@@ -895,14 +936,20 @@ router.get('/site-status', async (req, res, next) => {
|
||||
const gateResources = allResources.filter(r => r.metadata && r.metadata.subType === 'wireguard');
|
||||
|
||||
const cfg = siteConfig.get();
|
||||
const wanConnected = await probeMasterHealth(cfg);
|
||||
let canJoin = false;
|
||||
if (cfg.isMaster) {
|
||||
canJoin = await siteIsFresh({ User, Agent }).catch(() => false);
|
||||
}
|
||||
res.json({
|
||||
status: 'ok',
|
||||
config: {
|
||||
isMaster: cfg.isMaster,
|
||||
masterUrl: cfg.masterUrl,
|
||||
siteSlug: cfg.siteSlug,
|
||||
wanConnected: cfg.wanConnected,
|
||||
siteMode: cfg.isMaster ? 'master' : 'spoke'
|
||||
wanConnected,
|
||||
siteMode: cfg.isMaster ? 'master' : 'spoke',
|
||||
canJoin
|
||||
},
|
||||
sitesCount: sites.length,
|
||||
sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
|
||||
|
||||
@@ -25,8 +25,10 @@ const permission = require('../utils/permission');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { Resource, ResourceEdge } = require('../models/resource');
|
||||
const { SiteJoinKey } = require('../models/site_join_key');
|
||||
const User = require('../models/user');
|
||||
const { Agent } = require('../models/agent');
|
||||
const siteConfig = require('../utils/site_config');
|
||||
const { importDirectory, ldapAddArgs, baseDnFrom } = require('../utils/site_join');
|
||||
const { importDirectory, ldapAddArgs, baseDnFrom, siteIsFresh } = require('../utils/site_join');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const router = express.Router();
|
||||
@@ -80,6 +82,19 @@ router.post('/export', async (req, res, next) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Ping (MASTER side, Bearer site-join-key; no admin session) ─────────────
|
||||
// Lightweight reachability probe a spoke uses for WAN-health — deliberately
|
||||
// cheap (no LDAP dump / catalog), unlike /export.
|
||||
router.post('/ping', async (req, res, next) => {
|
||||
try {
|
||||
const auth = req.headers.authorization || '';
|
||||
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||
const key = await SiteJoinKey.authenticate(rawKey);
|
||||
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
|
||||
res.json({ status: 'ok', siteSlug: siteConfig.get().siteSlug, ts: Math.floor(Date.now() / 1000) });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Everything below requires an admin session ──────────────────────────────
|
||||
router.use(middleware.auth);
|
||||
router.use(async (req, res, next) => {
|
||||
@@ -157,6 +172,14 @@ router.post('/join', async (req, res, next) => {
|
||||
if (!cfg.isMaster) {
|
||||
return res.status(400).json({ status: 'error', message: 'this node is already a spoke (re-join is not supported)' });
|
||||
}
|
||||
// Only a fresh install may join — a directory with real users must not be
|
||||
// merged into a master's (that is the destructive case).
|
||||
if (!(await siteIsFresh({ User, Agent }))) {
|
||||
return res.status(409).json({
|
||||
status: 'error',
|
||||
message: 'This directory already has users/agents. Only a fresh install may join a site (re-provision the host to adopt a master directory).'
|
||||
});
|
||||
}
|
||||
|
||||
const base = String(masterUrl).replace(/\/+$/, '');
|
||||
const controller = new AbortController();
|
||||
@@ -201,8 +224,11 @@ router.post('/join', async (req, res, next) => {
|
||||
ldapNote = 'skipped/failed: ' + e.message;
|
||||
}
|
||||
|
||||
// 3. Persist the spoke role (survives restarts).
|
||||
siteConfig.save({ isMaster: false, masterUrl: base, siteSlug: exportData.siteSlug || cfg.siteSlug });
|
||||
// 3. Persist the spoke role (survives restarts). The join key is kept so
|
||||
// the spoke can run WAN-health checks (and, in a later layer, proxy
|
||||
// writes) against the master — it is a spoke-to-master credential, not
|
||||
// a shared secret.
|
||||
siteConfig.save({ isMaster: false, masterUrl: base, siteSlug: exportData.siteSlug || cfg.siteSlug, masterJoinKey: joinKey });
|
||||
|
||||
logAudit('joined', {
|
||||
actor: req.user.uid,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom } = require('../utils/site_join');
|
||||
const { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom, siteIsFresh } = require('../utils/site_join');
|
||||
|
||||
// In-memory model stubs so importDirectory can be exercised without a DB.
|
||||
function makeStore() {
|
||||
@@ -119,3 +119,32 @@ test('baseDnFrom prefers stack.ldapBaseDn and falls back to the bind DN', () =>
|
||||
expect(baseDnFrom({ ldap: { bindDN: 'cn=admin,dc=example,dc=com' } })).toBe('dc=example,dc=com');
|
||||
expect(baseDnFrom({ ldap: { bindDN: 'cn=admin' } })).toBe('');
|
||||
});
|
||||
|
||||
// The fresh-install guard: only no-users-beyond-admin + no-agents may join.
|
||||
test('siteIsFresh is true with only the bootstrap admin and no agents', async () => {
|
||||
const User = { listDetail: async () => [{ uid: 'admin', isServiceAccount: false }] };
|
||||
const Agent = { list: async () => [] };
|
||||
expect(await siteIsFresh({ User, Agent })).toBe(true);
|
||||
});
|
||||
|
||||
test('siteIsFresh is false with a second real user', async () => {
|
||||
const User = { listDetail: async () => [{ uid: 'admin' }, { uid: 'bob' }] };
|
||||
const Agent = { list: async () => [] };
|
||||
expect(await siteIsFresh({ User, Agent })).toBe(false);
|
||||
});
|
||||
|
||||
test('siteIsFresh is false with an enrolled agent', async () => {
|
||||
const User = { listDetail: async () => [{ uid: 'admin' }] };
|
||||
const Agent = { list: async () => [{ id: 'a1' }] };
|
||||
expect(await siteIsFresh({ User, Agent })).toBe(false);
|
||||
});
|
||||
|
||||
test('siteIsFresh ignores service accounts', async () => {
|
||||
const User = { listDetail: async () => [
|
||||
{ uid: 'admin' },
|
||||
{ uid: 'sso-svc', isServiceAccount: true },
|
||||
{ uid: 'ldapclient', isServiceAccount: true }
|
||||
] };
|
||||
const Agent = { list: async () => [] };
|
||||
expect(await siteIsFresh({ User, Agent })).toBe(true);
|
||||
});
|
||||
|
||||
@@ -101,4 +101,25 @@ function baseDnFrom(conf) {
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
module.exports = { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom };
|
||||
// siteIsFresh reports whether this deployment may join a master site
|
||||
// (MULTI_SITE_SPEC.md): no users beyond the bootstrap admin and no enrolled
|
||||
// agents. The bootstrap always seeds a handful of default resources (site →
|
||||
// host → sso/proxy services), so resources are NOT the signal — the operator's
|
||||
// rule is "no users". A directory with real users must never be merged into a
|
||||
// master's; that is the destructive case this guard prevents.
|
||||
async function siteIsFresh({ User, Agent }) {
|
||||
const agents = (Agent && Agent.list ? await Agent.list().catch(() => []) : []);
|
||||
if (agents && agents.length > 0) return false;
|
||||
if (User && typeof User.listDetail === 'function') {
|
||||
try {
|
||||
const users = await User.listDetail();
|
||||
const real = (users || []).filter(u => !u.isServiceAccount);
|
||||
return real.length <= 1; // at most the bootstrap admin
|
||||
} catch (e) {
|
||||
// LDAP unreachable — fall back to the agent-only check.
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom, siteIsFresh };
|
||||
|
||||
+100
-1
@@ -3336,7 +3336,9 @@
|
||||
'<table class="table table-sm text-start mb-0">' +
|
||||
'<tr><th>Local Site Slug:</th><td><code>' + esc(cfg.siteSlug || 'site-default') + '</code></td></tr>' +
|
||||
'<tr><th>Master Authority URL:</th><td>' + (cfg.masterUrl ? ('<code>' + esc(cfg.masterUrl) + '</code>') : '<em>(This Node is Master)</em>') + '</td></tr>' +
|
||||
'<tr><th>WAN Sync Health:</th><td><span class="badge bg-success"><i class="fa-solid fa-check me-1"></i> Online / Operational</span></td></tr>' +
|
||||
'<tr><th>WAN Sync Health:</th><td>' + (res.config.wanConnected === false
|
||||
? '<span class="badge bg-danger"><i class="fa-solid fa-xmark me-1"></i> Offline / Disconnected</span>'
|
||||
: '<span class="badge bg-success"><i class="fa-solid fa-check me-1"></i> Online / Operational</span>') + '</td></tr>' +
|
||||
'<tr><th>Registered Sites:</th><td><span class="badge bg-primary">' + (res.sitesCount || 0) + ' sites</span></td></tr>' +
|
||||
'<tr><th>Theta Gateways:</th><td><span class="badge bg-dark">' + (res.gatewaysCount || 0) + ' active gateways</span></td></tr>' +
|
||||
'</table>' +
|
||||
@@ -3346,6 +3348,38 @@
|
||||
'<i class="fa-solid fa-network-wired me-1"></i> <strong>WireGuard Gateway Mesh & NETMAP</strong>: Inter-site routing operates via <code>theta-gateway</code> subnets (<code>10.x.0.0/16</code>) with default NETMAP shadow translations (<code>10.x.168.0/24 → 192.168.1.0/24</code>).' +
|
||||
'</div>';
|
||||
|
||||
// Fresh install (no users/resources yet): offer to JOIN an existing
|
||||
// master site instead of seeding a new directory.
|
||||
if (isMaster && cfg.canJoin) {
|
||||
html += '<div class="card border-primary shadow-sm mt-3">' +
|
||||
'<div class="card-body">' +
|
||||
'<h6 class="fw-bold"><i class="fa-solid fa-link me-1 text-primary"></i> Join an Existing Site (Spoke)</h6>' +
|
||||
'<p class="small text-muted mb-2">This is a fresh install. Join an existing (master) deployment to run as a read-only spoke of its directory — paste the master URL and a site join key minted there.</p>' +
|
||||
'<div class="row g-2">' +
|
||||
'<div class="col-md-7"><input type="text" id="site-join-url" class="form-control form-control-sm" placeholder="Master Directory URL (e.g. https://sso.master.example.com)"></div>' +
|
||||
'<div class="col-md-5"><input type="text" id="site-join-key" class="form-control form-control-sm font-monospace" placeholder="Site join key (stj_...)"></div>' +
|
||||
'</div>' +
|
||||
'<button class="btn btn-sm btn-primary mt-2" onclick="joinCurrentSiteToMaster()"><i class="fa-solid fa-link me-1"></i> Join Site</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// A master mints the site join keys spokes present when joining.
|
||||
if (isMaster) {
|
||||
html += '<div class="card mt-3">' +
|
||||
'<div class="card-header py-2 fw-bold small"><i class="fa-solid fa-key me-1"></i> Site Join Keys <span class="text-muted">(for spokes to adopt this directory)</span></div>' +
|
||||
'<div class="card-body py-2">' +
|
||||
'<div class="d-flex gap-2 align-items-end mb-2">' +
|
||||
'<div class="flex-grow-1"><input type="text" id="site-join-key-label" class="form-control form-control-sm" placeholder="label (e.g. staten-island)"></div>' +
|
||||
'<button class="btn btn-sm btn-success" onclick="mintSiteJoinKey()"><i class="fa-solid fa-plus me-1"></i> Mint key</button>' +
|
||||
'</div>' +
|
||||
'<div id="site-join-key-result" class="mb-2"></div>' +
|
||||
'<table class="table table-sm table-hover mb-0 small"><thead><tr><th>Label</th><th>Prefix</th><th>Used</th><th>Status</th><th class="text-end"></th></tr></thead><tbody id="site-join-key-tbody"></tbody></table>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
loadSiteJoinKeys();
|
||||
}
|
||||
|
||||
if (!isMaster) {
|
||||
html += '<div class="card border-danger shadow-sm mt-3">' +
|
||||
'<div class="card-body text-center">' +
|
||||
@@ -3383,6 +3417,71 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Site join (spoke adopts a master directory) ───────────────────────────
|
||||
async function joinCurrentSiteToMaster() {
|
||||
const masterUrl = ($('#site-join-url').val() || '').trim();
|
||||
const joinKey = ($('#site-join-key').val() || '').trim();
|
||||
if (!masterUrl || !joinKey) {
|
||||
return app.messages.toast('Enter the master Directory URL and a site join key', 'warning');
|
||||
}
|
||||
const confirmed = await app.messages.confirm(
|
||||
'Join ' + masterUrl + ' as a read-only spoke? This adopts its directory (users, groups, resources).',
|
||||
app.modal.body(), 'warning'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const res = await app.api.post('site/join', { masterUrl, joinKey });
|
||||
app.messages.toast(res.message || 'Joined master site', 'success');
|
||||
app.modal.close();
|
||||
refreshSiteStatus();
|
||||
} catch (e) {
|
||||
app.messages.action('Join failed: ' + (e.message || e), app.modal.body(), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSiteJoinKeys() {
|
||||
try {
|
||||
const res = await app.api.get('site/join-keys');
|
||||
const rows = (res.joinKeys || []).map(k => {
|
||||
const status = k.revoked
|
||||
? '<span class="badge bg-secondary">revoked</span>'
|
||||
: '<span class="badge bg-success">active</span>';
|
||||
const revoke = k.revoked
|
||||
? ''
|
||||
: '<button class="btn btn-sm btn-outline-danger" onclick="revokeSiteJoinKey(\'' + k.id + '\')"><i class="fa-solid fa-ban me-1"></i>Revoke</button>';
|
||||
return '<tr><td>' + esc(k.label) + '</td><td><code>' + esc(k.keyPrefix) + '</code></td><td>' + (k.use_count || 0) + '</td><td>' + status + '</td><td class="text-end">' + revoke + '</td></tr>';
|
||||
}).join('');
|
||||
$('#site-join-key-tbody').html(rows);
|
||||
} catch (e) { console.error('load site join keys:', e); }
|
||||
}
|
||||
|
||||
async function mintSiteJoinKey() {
|
||||
const label = ($('#site-join-key-label').val() || '').trim() || 'default';
|
||||
try {
|
||||
const res = await app.api.post('site/join-keys', { label });
|
||||
$('#site-join-key-result').html(
|
||||
'<div class="alert alert-warning small p-2 mb-0">Key created (shown once, copy it now): <code class="user-select-all">' + res.key + '</code>' +
|
||||
'<button class="btn btn-sm btn-outline-secondary ms-2" onclick="copySiteJoinKey(this)"><i class="fa-solid fa-copy me-1"></i>Copy</button></div>'
|
||||
);
|
||||
loadSiteJoinKeys();
|
||||
} catch (e) { app.messages.toast('Mint failed: ' + (e.message || e), 'danger'); }
|
||||
}
|
||||
|
||||
function copySiteJoinKey(btn) {
|
||||
const code = $(btn).closest('div').find('code').text();
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
$(btn).html('<i class="fa-solid fa-check me-1"></i>Copied!');
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeSiteJoinKey(id) {
|
||||
try {
|
||||
await app.api.post('site/join-keys/' + id + '/revoke', {});
|
||||
loadSiteJoinKeys();
|
||||
} catch (e) { app.messages.toast('Revoke failed: ' + (e.message || e), 'danger'); }
|
||||
}
|
||||
|
||||
$(document).ready(function(){
|
||||
loadDiscoveryResources();
|
||||
loadDiscoveryPlugins();
|
||||
|
||||
Reference in New Issue
Block a user