Merge pull request #172 from theta42/fix/test-email-sms-and-join-key-ui
fix: test email/SMS senders, all SMS delivery, join-key install UI (v1.30.1)
This commit is contained in:
@@ -1,3 +1,19 @@
|
||||
# v1.30.1
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Test Email always failed with `Email.send is not a function`.** `models/email.js` exports `{Mail}`; the handler required the module and called `.send` on it directly. Every other caller destructures it. The button could never have worked.
|
||||
- **Test SMS failed with `Unexpected token '<', "<!DOCTYPE "...`.** It POSTed to `https://api.voip.ms/v1.0/sms/send` with Basic auth — an endpoint that does not exist. VoIP.ms's REST API is a GET against `https://voip.ms/api/v1/rest.php` with `api_username`/`api_password` and `method=sendSMS`, so the fabricated URL returned an HTML page and `response.json()` threw. It could never have sent anything.
|
||||
- **All SMS delivery was broken, not just the test button.** `models/sms.js` called `PluginInstance.find({…})`, but @simpleworkjs/orm has no `find` — the query method is `list({where})`. It threw "is not a function" on every send, before it could even fall back to the direct VoIP.ms path, so OTP-by-SMS and notifications were dead too.
|
||||
- Both test endpoints now send through the **same senders every real message uses** (`Mail.send`, `SMS.send`). A test that reimplements delivery proves nothing about whether real delivery works — which is exactly how two broken paths went unnoticed.
|
||||
- The SMS credential check no longer demands `conf.voipms` when a messaging plugin is loaded; the plugin supplies its own credentials, and requiring both blocked a working setup from testing itself.
|
||||
- Both endpoints report a failure as a `400` with the underlying reason (`VoIP.ms error: invalid_credentials`, `connect ECONNREFUSED …:587`) instead of an opaque `500`. A misconfiguration is the operator's to fix and the UI should be able to show it.
|
||||
- test: a guard suite that fails the build on any call to a non-existent ORM static (`find`/`findOne`/`findAll`/`where`), on requiring `models/email` without destructuring `{Mail}`, and on any reference to the bogus `api.voip.ms` host.
|
||||
|
||||
### Added
|
||||
|
||||
- **Install Agent offers the join-key flow.** The modal now leads with "Join key" — mint one, copy a single install command, and the host enrolls itself. Pre-registering a specific host moved to a second tab. v1.30.0 shipped join keys in the API and documented the modal as the place to get one, but the modal itself still only did the pre-register flow.
|
||||
|
||||
# v1.30.0
|
||||
|
||||
Adds **join keys**: installing the agent with one key is now all it takes to add a host. Fixes a set of Directory/discovery defects found on a fresh `setup.sh` install.
|
||||
|
||||
@@ -14,7 +14,12 @@ async function send(to, message) {
|
||||
const registry = require('../services/plugin_registry');
|
||||
const pluginSecrets = require('../utils/plugin_secrets');
|
||||
|
||||
const instances = await PluginInstance.find({ category: 'messaging', enabled: true });
|
||||
// @simpleworkjs/orm has no `find` -- the query method is `list({where})`.
|
||||
// `PluginInstance.find(...)` threw "is not a function" on EVERY call into
|
||||
// this sender, so SMS delivery never worked at all: not the test button, not
|
||||
// OTP-by-SMS, not notifications. It failed before it could even fall back to
|
||||
// the direct VoIP.ms path below.
|
||||
const instances = await PluginInstance.list({ where: { category: 'messaging', enabled: true } });
|
||||
if (instances.length > 0) {
|
||||
const inst = instances[0];
|
||||
const manifest = registry.getManifest(inst.pluginType);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.30.0",
|
||||
"version": "1.30.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.30.0",
|
||||
"version": "1.30.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.30.0",
|
||||
"version": "1.30.1",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
|
||||
+40
-31
@@ -136,15 +136,25 @@ router.post('/test-email', async (req, res, next) => {
|
||||
return res.status(400).json({ error: 'Recipient email address is required' });
|
||||
}
|
||||
|
||||
// Use the email model to send the test message
|
||||
const Email = require('../models/email');
|
||||
// Send through the SAME sender every other feature uses (password reset,
|
||||
// invites, OTP-by-email, notifications). A "test" that reimplements
|
||||
// delivery proves nothing about whether real mail works.
|
||||
//
|
||||
// models/email.js exports `{Mail}`; requiring the module and calling
|
||||
// `.send` on it directly -- as this did -- always threw
|
||||
// "Email.send is not a function", so the button could never succeed.
|
||||
const { Mail } = require('../models/email');
|
||||
const testSubject = subject || 'SSO Manager Test Email';
|
||||
const testBody = body || `<p>This is a test email from SSO Manager.</p><p>If you received this, your SMTP configuration is working correctly.</p><p>Sent at: ${new Date().toISOString()}</p>`;
|
||||
|
||||
await Email.send(to, testSubject, testBody);
|
||||
await Mail.send(to, testSubject, testBody);
|
||||
res.json({ success: true, message: `Test email sent to ${to}` });
|
||||
} catch(err) {
|
||||
next(err);
|
||||
// A failed test is almost always a misconfiguration (wrong host, refused
|
||||
// connection, bad credentials) -- the operator's to fix, and something the
|
||||
// UI should be able to show them. Surfacing it as a 400 with the reason
|
||||
// beats an opaque 500 carrying a raw stack-trace name.
|
||||
return res.status(400).json({ error: err.message || 'Failed to send test email' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -156,38 +166,37 @@ router.post('/test-sms', async (req, res, next) => {
|
||||
return res.status(400).json({ error: 'Recipient phone number is required' });
|
||||
}
|
||||
|
||||
// Send through models/sms.js -- the same path every real SMS takes. It
|
||||
// prefers a configured messaging plugin and falls back to VoIP.ms, and it
|
||||
// normalizes the destination to E.164 digits.
|
||||
//
|
||||
// This used to POST to `https://api.voip.ms/v1.0/sms/send` with Basic auth.
|
||||
// No such endpoint exists: VoIP.ms's REST API is a GET against
|
||||
// `https://voip.ms/api/v1/rest.php` with `api_username`/`api_password` and
|
||||
// `method=sendSMS`. The fabricated URL returned an HTML page, so
|
||||
// `response.json()` threw `Unexpected token '<', "<!DOCTYPE "...` and the
|
||||
// button reported that as the failure. It could never have sent anything.
|
||||
const { SMS } = require('../models/sms');
|
||||
const { PluginInstance } = require('../models/plugin_instance');
|
||||
|
||||
// A messaging plugin, when present, supplies its own credentials -- so
|
||||
// requiring conf.voipms unconditionally would block a perfectly working
|
||||
// setup from testing itself.
|
||||
const messagingPlugins = await PluginInstance.list({ where: { category: 'messaging', enabled: true } }).catch(() => []);
|
||||
const voipmsConf = conf.voipms || {};
|
||||
if (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did) {
|
||||
return res.status(400).json({ error: 'VoIP.ms credentials not configured. Please configure username, DID, and password in the SMS tab.' });
|
||||
if (!messagingPlugins.length && (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did)) {
|
||||
return res.status(400).json({ error: 'No messaging plugin is loaded and VoIP.ms credentials are not configured. Set username, DID and password in the SMS tab, or load a messaging plugin.' });
|
||||
}
|
||||
|
||||
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your VoIP.ms configuration is working correctly.`;
|
||||
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your SMS configuration is working correctly.`;
|
||||
|
||||
// VoIP.ms SMS API endpoint
|
||||
const voipmsApiUrl = 'https://api.voip.ms/v1.0';
|
||||
const authHeader = Buffer.from(`${voipmsConf.username}:${voipmsConf.password}`).toString('base64');
|
||||
|
||||
const response = await fetch(`${voipmsApiUrl}/sms/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${authHeader}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
did: voipmsConf.did,
|
||||
to: to,
|
||||
message: testMessage
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.status === 'success') {
|
||||
res.json({ success: true, message: `Test SMS sent to ${to}` });
|
||||
} else {
|
||||
res.status(400).json({ error: `VoIP.ms API error: ${result.message || 'Unknown error'}` });
|
||||
}
|
||||
await SMS.send(to, testMessage);
|
||||
res.json({ success: true, message: `Test SMS sent to ${to}` });
|
||||
} catch(err) {
|
||||
next(err);
|
||||
// The sender rejects with a useful reason (`VoIP.ms error: <status>`, or a
|
||||
// plugin's own error). Surface it as a 400 the UI can display rather than
|
||||
// an opaque 500 -- a misconfiguration is the operator's to fix, not a bug.
|
||||
return res.status(400).json({ error: err.message || 'Failed to send test SMS' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// @simpleworkjs/orm models expose `list`/`get`/`count`/`create` -- there is no
|
||||
// `find`, `findOne`, `findAll` or `where`. Calling one is not a syntax error and
|
||||
// nothing catches it until the line actually runs, so it can sit in a rarely
|
||||
// exercised path indefinitely.
|
||||
//
|
||||
// It did: `models/sms.js` called `PluginInstance.find({...})`, which threw
|
||||
// "is not a function" on EVERY SMS send -- the test button, OTP-by-SMS and
|
||||
// notifications alike -- before it could even reach the VoIP.ms fallback. SMS
|
||||
// delivery had simply never worked.
|
||||
const ORM_MODELS = [
|
||||
'Resource', 'ResourceEdge', 'ResourceGroup', 'AccessRequest', 'Webhook',
|
||||
'PluginInstance', 'SharedSecret', 'SharedSecretGrant', 'VaultAppToken',
|
||||
'Agent', 'AgentJoinKey',
|
||||
];
|
||||
const MISSING_STATICS = ['find', 'findOne', 'findAll', 'findAndCountAll', 'where'];
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const SCAN_DIRS = ['models', 'routes', 'services', 'utils', 'plugins', 'controller', 'middleware'];
|
||||
|
||||
function walk(dir, out = []) {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return out; }
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules') continue;
|
||||
walk(full, out);
|
||||
} else if (entry.name.endsWith('.js')) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Strip comments so a line *describing* the bug (like the one in models/sms.js)
|
||||
// isn't reported as the bug.
|
||||
function stripComments(src) {
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||
}
|
||||
|
||||
test('no source file calls an ORM static that does not exist', () => {
|
||||
const pattern = new RegExp(
|
||||
`\\b(${ORM_MODELS.join('|')})\\s*\\.\\s*(${MISSING_STATICS.join('|')})\\s*\\(`,
|
||||
'g'
|
||||
);
|
||||
|
||||
const offenders = [];
|
||||
for (const dir of SCAN_DIRS) {
|
||||
for (const file of walk(path.join(ROOT, dir))) {
|
||||
const src = stripComments(fs.readFileSync(file, 'utf8'));
|
||||
src.split('\n').forEach((line, i) => {
|
||||
const m = line.match(pattern);
|
||||
if (m) offenders.push(`${path.relative(ROOT, file)}:${i + 1} — ${m.join(', ')}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
// models/email.js exports `{Mail}`, not a bare sender. Requiring the module and
|
||||
// calling `.send` on it -- as routes/api_conf.js's test-email did -- always
|
||||
// threw "Email.send is not a function", so the Test Email button could never
|
||||
// have worked.
|
||||
test('the email module exports Mail.send and callers destructure it', () => {
|
||||
const mod = require('../models/email');
|
||||
expect(typeof mod.Mail).toBe('object');
|
||||
expect(typeof mod.Mail.send).toBe('function');
|
||||
// The bare module has no send() -- this is exactly the mistake to catch.
|
||||
expect(mod.send).toBeUndefined();
|
||||
|
||||
const offenders = [];
|
||||
for (const dir of SCAN_DIRS) {
|
||||
for (const file of walk(path.join(ROOT, dir))) {
|
||||
const src = stripComments(fs.readFileSync(file, 'utf8'));
|
||||
// `X = require('...email')` followed by `X.send(` where X was not
|
||||
// destructured.
|
||||
const assigned = [...src.matchAll(/(?:const|let|var)\s+(\w+)\s*=\s*require\([^)]*models\/email[^)]*\)/g)]
|
||||
.map(m => m[1]);
|
||||
for (const name of assigned) {
|
||||
if (new RegExp(`\\b${name}\\s*\\.\\s*send\\s*\\(`).test(src)) {
|
||||
offenders.push(`${path.relative(ROOT, file)} — ${name}.send(), but the module exports {Mail}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
// The VoIP.ms REST API is a GET against voip.ms/api/v1/rest.php with
|
||||
// api_username/api_password and method=sendSMS. `api.voip.ms/v1.0/sms/send`
|
||||
// (which test-sms used to POST to with Basic auth) does not exist -- it
|
||||
// returned an HTML page, so response.json() threw
|
||||
// `Unexpected token '<', "<!DOCTYPE "...` and the button reported that.
|
||||
test('nothing targets the non-existent api.voip.ms host', () => {
|
||||
const offenders = [];
|
||||
for (const dir of SCAN_DIRS) {
|
||||
for (const file of walk(path.join(ROOT, dir))) {
|
||||
// Comments stripped: the note in routes/api_conf.js explaining this
|
||||
// very bug names the bad host, and describing a mistake is not
|
||||
// making it.
|
||||
const src = stripComments(fs.readFileSync(file, 'utf8'));
|
||||
src.split('\n').forEach((line, i) => {
|
||||
if (line.includes('api.voip.ms')) {
|
||||
offenders.push(`${path.relative(ROOT, file)}:${i + 1}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
+123
-2
@@ -1640,6 +1640,21 @@
|
||||
// public_key must reach the host: without it the agent refuses every
|
||||
// high-risk command. It was never emitted before, which is why signed
|
||||
// commands only ever "worked" while verification was being skipped.
|
||||
// Join-key command. Only a key we just minted can appear here -- the list
|
||||
// endpoint deliberately never returns key values.
|
||||
const joinUrl = ($('#agent-quick-url').val() || window.location.origin).replace(/\/+$/, '');
|
||||
const selectedKeyId = $('#agent-join-key-select').val();
|
||||
let joinCmd;
|
||||
if (mintedJoinKey) {
|
||||
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${mintedJoinKey}"`;
|
||||
} else if (selectedKeyId) {
|
||||
const k = agentJoinKeys.find(x => x.id === selectedKeyId);
|
||||
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${k ? k.keyPrefix : ''}…"\n\n# Paste the full value of this key -- it was only shown when created.\n# If you no longer have it, create a new key above.`;
|
||||
} else {
|
||||
joinCmd = '# Create a join key above, or select one you already have the value for.';
|
||||
}
|
||||
$('#agent-join-command').text(joinCmd);
|
||||
|
||||
const pubKey = (pendingEnrollment && pendingEnrollment.publicKey) || '';
|
||||
const quickCmd = `curl -fsSL ${quickUrl}/resources/theta-agent/install.sh | sh -s -- --url "${quickUrl}" --token "${quickToken}"`
|
||||
+ (pubKey ? ` --public-key "${pubKey}"` : '');
|
||||
@@ -1719,14 +1734,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="agent-mode-join-btn" data-bs-toggle="tab" data-bs-target="#agent-mode-join" type="button" role="tab">
|
||||
<i class="fa-solid fa-key me-1"></i> Join key <span class="badge bg-success ms-1">easiest</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="agent-mode-pre-btn" data-bs-toggle="tab" data-bs-target="#agent-mode-pre" type="button" role="tab">
|
||||
<i class="fa-solid fa-id-badge me-1"></i> Pre-register this host
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content mb-3">
|
||||
<!-- ── Join key: one credential, host enrolls itself ────────────── -->
|
||||
<div class="tab-pane fade show active" id="agent-mode-join" role="tabpanel">
|
||||
<div class="card border-success">
|
||||
<div class="card-header py-2 fw-bold small bg-success-subtle">
|
||||
<i class="fa-solid fa-key me-1"></i> Install with a join key
|
||||
</div>
|
||||
<div class="card-body py-3">
|
||||
<p class="small text-muted mb-3">
|
||||
Run this on any host and it enrolls itself. The SSO issues that host its own
|
||||
token and public key on first connect, and the agent writes both into its
|
||||
<code>agent.yml</code> — nothing to copy back and forth. One key works for as
|
||||
many hosts as you like; each still gets its own revocable identity.
|
||||
</p>
|
||||
<div class="d-flex gap-2 align-items-end mb-3">
|
||||
<div class="flex-grow-1">
|
||||
<label class="form-label small fw-bold mb-1">Existing join keys</label>
|
||||
<select id="agent-join-key-select" class="form-select form-select-sm" onchange="updateAgentCommands()"></select>
|
||||
<div class="form-text small">A key's value is shown only when it is created — mint a new one if you don't have it saved.</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-success" onclick="mintAgentJoinKey()">
|
||||
<i class="fa-solid fa-plus me-1"></i> New join key
|
||||
</button>
|
||||
</div>
|
||||
<div id="agent-join-key-result" style="display:none"></div>
|
||||
|
||||
<label class="form-label small fw-bold mb-1">Run on the target host (as root):</label>
|
||||
<pre class="bg-dark text-light p-3 rounded font-monospace small mb-2 text-wrap text-break" id="agent-join-command" style="user-select: all;"></pre>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button class="btn btn-sm btn-success" id="btn-copy-join" onclick="copyAgentCommand('agent-join-command', 'btn-copy-join')">
|
||||
<i class="fa-solid fa-copy me-1"></i> Copy install command
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Pre-register: bind to a host resource up front ───────────── -->
|
||||
<div class="tab-pane fade" id="agent-mode-pre" role="tabpanel">
|
||||
|
||||
<div class="card border-primary mb-3" id="agent-enroll-card">
|
||||
<div class="card-header py-2 fw-bold small bg-primary-subtle">
|
||||
<i class="fa-solid fa-id-badge me-1"></i> 1. Enroll this host
|
||||
</div>
|
||||
<div class="card-body py-3">
|
||||
<p class="small text-muted mb-3">
|
||||
The SSO issues the agent's token and records it. Tokens it did not issue are rejected,
|
||||
so enroll the host first — the install command below is built from the result.
|
||||
Use this when you want the agent bound to a specific Directory host from the start.
|
||||
The SSO issues the token here and you copy it onto the machine yourself.
|
||||
</p>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
@@ -1870,6 +1938,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /pre-register pane -->
|
||||
</div><!-- /tab-content -->
|
||||
`;
|
||||
|
||||
app.modal.open({
|
||||
@@ -1878,6 +1948,8 @@
|
||||
size: 'lg'
|
||||
});
|
||||
|
||||
loadAgentJoinKeys();
|
||||
|
||||
// Only hosts can carry an agent -- the API rejects anything else, so don't
|
||||
// offer it here.
|
||||
const $sel = $('#agent-enroll-resource').empty();
|
||||
@@ -1898,6 +1970,55 @@
|
||||
updateAgentCommands();
|
||||
}
|
||||
|
||||
// Join keys the operator can reuse. Values are never returned by the list
|
||||
// endpoint -- only a prefix -- so the dropdown identifies a key without being
|
||||
// able to rebuild an install command from it. Minting is the only way to see
|
||||
// a key's value, and only once.
|
||||
var agentJoinKeys = [];
|
||||
var mintedJoinKey = null; // in-memory, for the command shown right now
|
||||
|
||||
function loadAgentJoinKeys() {
|
||||
app.api.get('agent/join-keys', function(err, res) {
|
||||
agentJoinKeys = (res && res.joinKeys ? res.joinKeys : []).filter(k => !k.revoked);
|
||||
const $sel = $('#agent-join-key-select').empty();
|
||||
if (!agentJoinKeys.length) {
|
||||
$sel.append('<option value="">No join keys yet — create one</option>');
|
||||
} else {
|
||||
$sel.append('<option value="">Select a key…</option>');
|
||||
agentJoinKeys.forEach(k => {
|
||||
const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : 'unused';
|
||||
$sel.append($('<option>').val(k.id).text(`${k.label} (${k.keyPrefix}…, ${used})`));
|
||||
});
|
||||
}
|
||||
updateAgentCommands();
|
||||
});
|
||||
}
|
||||
|
||||
async function mintAgentJoinKey() {
|
||||
try {
|
||||
const res = await app.api.post('agent/join-keys', { label: 'ui' });
|
||||
const body = (res && (res.results || res)) || {};
|
||||
if (!body.key) throw new Error(body.message || 'no key returned');
|
||||
mintedJoinKey = body.key;
|
||||
$('#agent-join-key-result').show().html(
|
||||
'<div class="alert alert-success py-2 small mb-3">'
|
||||
+ '<i class="fa-solid fa-circle-check me-1"></i><strong>Join key created.</strong> '
|
||||
+ 'It is shown <strong>once</strong> — only its hash is stored. It is already in the command below.'
|
||||
+ '</div>'
|
||||
+ '<label class="form-label small fw-bold mb-1">Join key</label>'
|
||||
+ '<div class="input-group input-group-sm mb-3">'
|
||||
+ '<input type="text" class="form-control font-monospace" readonly value="' + esc(body.key) + '">'
|
||||
+ '<button class="btn btn-outline-secondary" type="button" id="btn-copy-jk" onclick="copyAgentCommand(\'agent-jk-copy\', \'btn-copy-jk\')"><i class="fa-solid fa-copy"></i></button>'
|
||||
+ '</div>'
|
||||
+ '<span id="agent-jk-copy" class="d-none">' + esc(body.key) + '</span>'
|
||||
);
|
||||
loadAgentJoinKeys();
|
||||
updateAgentCommands();
|
||||
} catch (err) {
|
||||
app.messages.toast('Could not create a join key: ' + (err.message || err), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Mint the token server-side, then reveal the install steps built from it.
|
||||
async function enrollAgent() {
|
||||
const name = ($('#agent-enroll-name').val() || '').trim();
|
||||
|
||||
Reference in New Issue
Block a user