mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-15 02:33:15 -06:00
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:
parent
73ca3cf258
commit
fab4ae909a
|
|
@ -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) }),
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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)}…</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');
|
||||
|
|
|
|||
|
|
@ -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
57
server/routes/tokens.js
Normal 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;
|
||||
Loading…
Reference in a new issue