From fab4ae909ad9fc1859aa34eaac0dd63bacb53b9d Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Fri, 12 Jun 2026 13:33:56 -0500 Subject: [PATCH] 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) --- frontend/js/api.js | 5 ++ frontend/js/i18n/de.js | 24 +++++++ frontend/js/i18n/en.js | 24 +++++++ frontend/js/i18n/es.js | 24 +++++++ frontend/js/i18n/fr.js | 24 +++++++ frontend/js/i18n/pt.js | 24 +++++++ frontend/js/views/settings.js | 126 ++++++++++++++++++++++++++++++++++ server/config/api-surface.js | 1 + server/routes/tokens.js | 57 +++++++++++++++ 9 files changed, 309 insertions(+) create mode 100644 server/routes/tokens.js diff --git a/frontend/js/api.js b/frontend/js/api.js index baa27fc..e0ec520 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -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) }), diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index e40a9c2..d4f680a 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -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.', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 6dbae19..7efdaa0 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -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', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index 0661bd5..f722311 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -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.', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index 3f0cf57..e7e1c48 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -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.', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index c08d463..da40f03 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -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.', diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index 9a460f1..6e0fe02 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -60,6 +60,28 @@ export async function render(container) { `} +
+

${t('apitoken.title')}

+

${t('apitoken.desc')}

+
+
+ + +
+
+ + +
+ +
+ +

${t('settings.loading_users')}

+
+ ${isAdmin ? `

${t('settings.license')}

@@ -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 = `

${t('apitoken.none')}

`; + return; + } + el.innerHTML = ` +
+ + + + + + + + + + + + + ${tokens.map(tok => ` + + + + + + + + + `).join('')} + +
${t('apitoken.col_token')}${t('apitoken.col_name')}${t('apitoken.col_scope')}${t('apitoken.col_created')}${t('apitoken.col_last_used')}
${esc(tok.prefix)}…${esc(tok.name || '')}${esc(scopeLabel(tok.scope))}${esc(fmtTokenDate(tok.created_at))}${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')} + ${tok.revoked_at + ? `${t('apitoken.revoked')}` + : ``} +
+
+ `; + + 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 = ` +
+

${t('apitoken.secret_title')}

+

${t('apitoken.secret_warning')}

+
+ + +
+
+ `; + 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'); diff --git a/server/config/api-surface.js b/server/config/api-surface.js index b32a2f0..9af5c93 100644 --- a/server/config/api-surface.js +++ b/server/config/api-surface.js @@ -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 }; diff --git a/server/routes/tokens.js b/server/routes/tokens.js new file mode 100644 index 0000000..ea0a26d --- /dev/null +++ b/server/routes/tokens.js @@ -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;