feat(api): token management endpoints + Settings UI

- routes/tokens.js: create (returns the full secret once), list (never the secret),
  revoke. Mounted JWT-only via api-surface.js so an API token can never mint, list or
  revoke tokens - no self-escalation.
- Settings "API Tokens" section: create form (name + read/write/full scope), one-time
  secret reveal with copy, token list, revoke; i18n across en/es/fr/de/pt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-12 13:33:56 -05:00 committed by screentinker
parent 73ca3cf258
commit fab4ae909a
9 changed files with 309 additions and 0 deletions

View file

@ -156,6 +156,11 @@ export const api = {
// Device Groups - Playlist
groupAssignPlaylist: (groupId, playlist_id) => request(`/groups/${groupId}/assign-playlist`, { method: 'POST', body: JSON.stringify({ playlist_id }) }),
// API Tokens (personal access tokens, workspace-scoped)
getTokens: () => request('/tokens'),
createToken: (data) => request('/tokens', { method: 'POST', body: JSON.stringify(data) }),
revokeToken: (id) => request('/tokens/' + id, { method: 'DELETE' }),
// Current user
getMe: () => request('/auth/me'),
updateMe: (data) => request('/auth/me', { method: 'PUT', body: JSON.stringify(data) }),

View file

@ -350,6 +350,30 @@ export default {
'settings.title': 'Einstellungen',
'settings.subtitle': 'Serverkonfiguration und Setup-Informationen',
'settings.account': 'Konto',
// API-Tokens
'apitoken.title': 'API-Tokens',
'apitoken.desc': 'Persönliche Zugriffstokens für die öffentliche API, beschränkt auf diesen Arbeitsbereich. Behandeln Sie sie wie Passwörter wer das Token hat, kann hier in Ihrem Namen handeln.',
'apitoken.name_placeholder': 'z. B. Agentur-Integration',
'apitoken.scope_read': 'Nur Lesen',
'apitoken.scope_write': 'Lesen & Schreiben',
'apitoken.scope_full': 'Voll (inkl. Gerätebefehle)',
'apitoken.create': 'Token erstellen',
'apitoken.none': 'Noch keine Tokens.',
'apitoken.col_token': 'Token',
'apitoken.col_name': 'Name',
'apitoken.col_scope': 'Bereich',
'apitoken.col_created': 'Erstellt',
'apitoken.col_last_used': 'Zuletzt verwendet',
'apitoken.never': 'Nie',
'apitoken.revoke': 'Widerrufen',
'apitoken.revoked': 'Widerrufen',
'apitoken.secret_title': 'Kopieren Sie Ihr Token jetzt',
'apitoken.secret_warning': 'Dies ist das einzige Mal, dass das vollständige Token angezeigt wird. Bewahren Sie es sicher auf Sie können es nicht erneut einsehen.',
'apitoken.copy': 'Kopieren',
'apitoken.copied': 'In die Zwischenablage kopiert',
'apitoken.created_toast': 'Token erstellt',
'apitoken.revoked_toast': 'Token widerrufen',
'apitoken.revoke_confirm': 'Dieses Token widerrufen? Jede Integration, die es verwendet, funktioniert sofort nicht mehr.',
'settings.save_profile': 'Profil speichern',
'settings.change_password': 'Passwort ändern',
'settings.password_min_8': 'Muss mindestens 8 Zeichen lang sein.',

View file

@ -386,6 +386,30 @@ export default {
'settings.title': 'Settings',
'settings.subtitle': 'Server configuration and setup information',
'settings.account': 'Account',
// API Tokens
'apitoken.title': 'API Tokens',
'apitoken.desc': 'Personal access tokens for the public API, scoped to this workspace. Treat them like passwords — anyone with the token can act as you here.',
'apitoken.name_placeholder': 'e.g. Agency integration',
'apitoken.scope_read': 'Read only',
'apitoken.scope_write': 'Read & write',
'apitoken.scope_full': 'Full (incl. device commands)',
'apitoken.create': 'Create token',
'apitoken.none': 'No tokens yet.',
'apitoken.col_token': 'Token',
'apitoken.col_name': 'Name',
'apitoken.col_scope': 'Scope',
'apitoken.col_created': 'Created',
'apitoken.col_last_used': 'Last used',
'apitoken.never': 'Never',
'apitoken.revoke': 'Revoke',
'apitoken.revoked': 'Revoked',
'apitoken.secret_title': 'Copy your token now',
'apitoken.secret_warning': "This is the only time the full token is shown. Store it somewhere safe — you won't be able to see it again.",
'apitoken.copy': 'Copy',
'apitoken.copied': 'Copied to clipboard',
'apitoken.created_toast': 'Token created',
'apitoken.revoked_toast': 'Token revoked',
'apitoken.revoke_confirm': 'Revoke this token? Any integration using it stops working immediately.',
'settings.save_profile': 'Save Profile',
'settings.email_alerts': 'Email me when devices go offline',
'settings.change_password': 'Change Password',

View file

@ -349,6 +349,30 @@ export default {
'settings.title': 'Configuración',
'settings.subtitle': 'Configuración del servidor e información de instalación',
'settings.account': 'Cuenta',
// Tokens de API
'apitoken.title': 'Tokens de API',
'apitoken.desc': 'Tokens de acceso personal para la API pública, limitados a este espacio de trabajo. Trátalos como contraseñas: cualquiera que tenga el token puede actuar como tú aquí.',
'apitoken.name_placeholder': 'p. ej. Integración de agencia',
'apitoken.scope_read': 'Solo lectura',
'apitoken.scope_write': 'Lectura y escritura',
'apitoken.scope_full': 'Completo (incl. comandos de dispositivo)',
'apitoken.create': 'Crear token',
'apitoken.none': 'Aún no hay tokens.',
'apitoken.col_token': 'Token',
'apitoken.col_name': 'Nombre',
'apitoken.col_scope': 'Alcance',
'apitoken.col_created': 'Creado',
'apitoken.col_last_used': 'Último uso',
'apitoken.never': 'Nunca',
'apitoken.revoke': 'Revocar',
'apitoken.revoked': 'Revocado',
'apitoken.secret_title': 'Copia tu token ahora',
'apitoken.secret_warning': 'Esta es la única vez que se muestra el token completo. Guárdalo en un lugar seguro: no podrás volver a verlo.',
'apitoken.copy': 'Copiar',
'apitoken.copied': 'Copiado al portapapeles',
'apitoken.created_toast': 'Token creado',
'apitoken.revoked_toast': 'Token revocado',
'apitoken.revoke_confirm': '¿Revocar este token? Cualquier integración que lo use dejará de funcionar de inmediato.',
'settings.save_profile': 'Guardar perfil',
'settings.change_password': 'Cambiar contraseña',
'settings.password_min_8': 'Debe tener al menos 8 caracteres.',

View file

@ -350,6 +350,30 @@ export default {
'settings.title': 'Paramètres',
'settings.subtitle': 'Configuration du serveur et informations d\'installation',
'settings.account': 'Compte',
// Jetons d'API
'apitoken.title': "Jetons d'API",
'apitoken.desc': "Jetons d'accès personnels pour l'API publique, limités à cet espace de travail. Traitez-les comme des mots de passe : toute personne disposant du jeton peut agir en votre nom ici.",
'apitoken.name_placeholder': 'p. ex. Intégration agence',
'apitoken.scope_read': 'Lecture seule',
'apitoken.scope_write': 'Lecture et écriture',
'apitoken.scope_full': 'Complet (cmd. appareils incluses)',
'apitoken.create': 'Créer un jeton',
'apitoken.none': 'Aucun jeton pour le moment.',
'apitoken.col_token': 'Jeton',
'apitoken.col_name': 'Nom',
'apitoken.col_scope': 'Portée',
'apitoken.col_created': 'Créé',
'apitoken.col_last_used': 'Dernière utilisation',
'apitoken.never': 'Jamais',
'apitoken.revoke': 'Révoquer',
'apitoken.revoked': 'Révoqué',
'apitoken.secret_title': 'Copiez votre jeton maintenant',
'apitoken.secret_warning': "C'est la seule fois où le jeton complet est affiché. Conservez-le en lieu sûr : vous ne pourrez plus le revoir.",
'apitoken.copy': 'Copier',
'apitoken.copied': 'Copié dans le presse-papiers',
'apitoken.created_toast': 'Jeton créé',
'apitoken.revoked_toast': 'Jeton révoqué',
'apitoken.revoke_confirm': "Révoquer ce jeton ? Toute intégration qui l'utilise cessera de fonctionner immédiatement.",
'settings.save_profile': 'Enregistrer le profil',
'settings.change_password': 'Changer le mot de passe',
'settings.password_min_8': 'Doit contenir au moins 8 caractères.',

View file

@ -350,6 +350,30 @@ export default {
'settings.title': 'Configurações',
'settings.subtitle': 'Configuração do servidor e informações de instalação',
'settings.account': 'Conta',
// Tokens de API
'apitoken.title': 'Tokens de API',
'apitoken.desc': 'Tokens de acesso pessoal para a API pública, restritos a este espaço de trabalho. Trate-os como senhas — qualquer pessoa com o token pode agir como você aqui.',
'apitoken.name_placeholder': 'ex.: Integração da agência',
'apitoken.scope_read': 'Somente leitura',
'apitoken.scope_write': 'Leitura e escrita',
'apitoken.scope_full': 'Completo (incl. comandos de dispositivo)',
'apitoken.create': 'Criar token',
'apitoken.none': 'Ainda não há tokens.',
'apitoken.col_token': 'Token',
'apitoken.col_name': 'Nome',
'apitoken.col_scope': 'Escopo',
'apitoken.col_created': 'Criado',
'apitoken.col_last_used': 'Último uso',
'apitoken.never': 'Nunca',
'apitoken.revoke': 'Revogar',
'apitoken.revoked': 'Revogado',
'apitoken.secret_title': 'Copie seu token agora',
'apitoken.secret_warning': 'Esta é a única vez que o token completo é exibido. Guarde-o em um lugar seguro — você não poderá vê-lo novamente.',
'apitoken.copy': 'Copiar',
'apitoken.copied': 'Copiado para a área de transferência',
'apitoken.created_toast': 'Token criado',
'apitoken.revoked_toast': 'Token revogado',
'apitoken.revoke_confirm': 'Revogar este token? Qualquer integração que o utilize para de funcionar imediatamente.',
'settings.save_profile': 'Salvar perfil',
'settings.change_password': 'Alterar senha',
'settings.password_min_8': 'Deve ter no mínimo 8 caracteres.',

View file

@ -60,6 +60,28 @@ export async function render(container) {
`}
</div>
<div class="settings-section">
<h3>${t('apitoken.title')}</h3>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:16px">${t('apitoken.desc')}</p>
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;margin-bottom:16px">
<div class="form-group" style="margin-bottom:0;flex:1;min-width:180px">
<label>${t('apitoken.col_name')}</label>
<input type="text" id="tokName" class="input" placeholder="${esc(t('apitoken.name_placeholder'))}">
</div>
<div class="form-group" style="margin-bottom:0;min-width:200px">
<label>${t('apitoken.col_scope')}</label>
<select id="tokScope" class="input" style="background:var(--bg-input)">
<option value="read">${esc(t('apitoken.scope_read'))}</option>
<option value="write">${esc(t('apitoken.scope_write'))}</option>
<option value="full">${esc(t('apitoken.scope_full'))}</option>
</select>
</div>
<button class="btn btn-primary btn-sm" id="createTokenBtn">${t('apitoken.create')}</button>
</div>
<div id="tokenSecretBox" style="display:none"></div>
<div id="tokenList"><p style="color:var(--text-muted);font-size:13px">${t('settings.loading_users')}</p></div>
</div>
${isAdmin ? `
<div class="settings-section">
<h3>${t('settings.license')}</h3>
@ -297,6 +319,110 @@ export async function render(container) {
setLanguage(e.target.value);
});
// API Tokens — available to every user (manages their own, workspace-scoped).
const fmtTokenDate = (ts) => {
if (!ts) return '';
try { return new Date(ts * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); }
catch { return String(ts); }
};
const scopeLabel = (s) => ({
read: t('apitoken.scope_read'),
write: t('apitoken.scope_write'),
full: t('apitoken.scope_full'),
}[s] || s);
async function loadTokens() {
const el = document.getElementById('tokenList');
if (!el) return;
const tokens = await api.getTokens().catch(() => []);
if (!tokens.length) {
el.innerHTML = `<p style="color:var(--text-muted);font-size:13px">${t('apitoken.none')}</p>`;
return;
}
el.innerHTML = `
<div class="table-wrap">
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:560px">
<thead>
<tr style="border-bottom:1px solid var(--border);text-align:left">
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('apitoken.col_token')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('apitoken.col_name')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('apitoken.col_scope')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('apitoken.col_created')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('apitoken.col_last_used')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500"></th>
</tr>
</thead>
<tbody>
${tokens.map(tok => `
<tr style="border-bottom:1px solid var(--border)${tok.revoked_at ? ';opacity:0.55' : ''}">
<td style="padding:10px 12px;font-family:monospace">${esc(tok.prefix)}&hellip;</td>
<td style="padding:10px 12px">${esc(tok.name || '')}</td>
<td style="padding:10px 12px">${esc(scopeLabel(tok.scope))}</td>
<td style="padding:10px 12px">${esc(fmtTokenDate(tok.created_at))}</td>
<td style="padding:10px 12px">${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')}</td>
<td style="padding:10px 12px;white-space:nowrap;text-align:right">
${tok.revoked_at
? `<span style="color:var(--text-muted);font-size:12px">${t('apitoken.revoked')}</span>`
: `<button class="btn btn-secondary btn-sm revoke-token-btn" data-id="${esc(String(tok.id))}">${t('apitoken.revoke')}</button>`}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
el.querySelectorAll('.revoke-token-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(t('apitoken.revoke_confirm'))) return;
try {
await api.revokeToken(btn.dataset.id);
showToast(t('apitoken.revoked_toast'), 'success');
loadTokens();
} catch (err) {
showToast(err.message, 'error');
}
});
});
}
loadTokens();
document.getElementById('createTokenBtn')?.addEventListener('click', async () => {
const name = document.getElementById('tokName').value.trim();
const scope = document.getElementById('tokScope').value;
const btn = document.getElementById('createTokenBtn');
btn.disabled = true;
try {
const r = await api.createToken({ name, scope });
const box = document.getElementById('tokenSecretBox');
box.style.display = 'block';
box.innerHTML = `
<div style="background:var(--bg-secondary);border:1px solid var(--accent);border-radius:var(--radius);padding:16px;margin-bottom:16px">
<h4 style="font-size:14px;margin-bottom:8px">${t('apitoken.secret_title')}</h4>
<p style="color:var(--danger);font-size:12px;margin-bottom:12px"><strong>${t('apitoken.secret_warning')}</strong></p>
<div style="display:flex;gap:8px;align-items:center">
<input type="text" class="input" readonly value="${esc(r.token)}" style="font-family:monospace;flex:1" onclick="this.select()">
<button class="btn btn-secondary btn-sm" id="copyTokenBtn">${t('apitoken.copy')}</button>
</div>
</div>
`;
document.getElementById('copyTokenBtn')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(r.token);
showToast(t('apitoken.copied'), 'success');
} catch { /* clipboard may be unavailable; the field is selectable */ }
});
document.getElementById('tokName').value = '';
showToast(t('apitoken.created_toast'), 'success');
loadTokens();
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
}
});
document.getElementById('saveAcctBtn')?.addEventListener('click', async () => {
const name = document.getElementById('acctName').value.trim();
if (!name) return showToast(t('settings.toast.name_required'), 'error');

View file

@ -45,6 +45,7 @@ const JWT_ONLY_ROUTERS = [
{ path: '/api/white-label', mod: './routes/white-label', tenancy: true },
{ path: '/api/workspaces', mod: './routes/workspaces' },
{ path: '/api/admin', mod: './routes/admin' },
{ path: '/api/tokens', mod: './routes/tokens', tenancy: true },
];
module.exports = { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS };

57
server/routes/tokens.js Normal file
View file

@ -0,0 +1,57 @@
// Public API token management (Phase 1). DASHBOARD-ONLY: this router is mounted
// JWT-only in server.js, so an API token can never manage tokens (no privilege
// self-escalation). A user manages their own tokens, bound to their active workspace.
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const { db } = require('../db/database');
const { generateToken, hashToken, displayPrefix } = require('../middleware/apiToken');
const { accessContext } = require('../lib/tenancy');
const SCOPES = ['read', 'write', 'full'];
// List the caller's tokens in the active workspace. Never returns the secret/hash.
router.get('/', (req, res) => {
if (!req.workspaceId) return res.status(403).json({ error: 'No active workspace' });
const rows = db.prepare(`
SELECT id, prefix, name, scope, workspace_id, created_at, last_used_at, revoked_at
FROM api_tokens WHERE user_id = ? AND workspace_id = ? ORDER BY created_at DESC
`).all(req.user.id, req.workspaceId);
res.json(rows);
});
// Create a token bound to the active workspace. The full secret is returned ONCE.
router.post('/', (req, res) => {
if (!req.workspaceId || !req.workspace) return res.status(403).json({ error: 'No active workspace' });
const name = (req.body.name || '').trim();
const scope = req.body.scope || 'read';
if (!name) return res.status(400).json({ error: 'name is required' });
if (name.length > 100) return res.status(400).json({ error: 'name too long' });
if (!SCOPES.includes(scope)) return res.status(400).json({ error: "scope must be 'read', 'write' or 'full'" });
// The token runs with platform powers stripped (role forced to 'user'), so it must
// bind to a workspace the owner reaches via membership/org - not platform act-as -
// else apiTokenAuth+resolveTenancy would land it in no workspace at use time.
if (!accessContext(req.user.id, 'user', req.workspace)) {
return res.status(400).json({ error: 'You must be a member of this workspace to create a token here' });
}
const secret = generateToken();
const id = crypto.randomUUID();
db.prepare(`
INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now'))
`).run(id, hashToken(secret), displayPrefix(secret), name, req.user.id, req.workspaceId, scope);
// `token` is returned only here, never again.
res.status(201).json({ id, token: secret, prefix: displayPrefix(secret), name, scope, workspace_id: req.workspaceId });
});
// Revoke one of the caller's own tokens (soft delete - takes effect on the next request).
router.delete('/:id', (req, res) => {
const row = db.prepare('SELECT id, revoked_at FROM api_tokens WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!row) return res.status(404).json({ error: 'Token not found' });
if (!row.revoked_at) {
db.prepare("UPDATE api_tokens SET revoked_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
}
res.json({ success: true });
});
module.exports = router;