Merge branch 'feat/admin-plans-with-counts'

This commit is contained in:
ScreenTinker 2026-07-29 19:36:10 -05:00
commit bb016b8313
10 changed files with 217 additions and 3 deletions

View file

@ -242,6 +242,8 @@ export const api = {
adminCreateUser: (data) => request('/admin/users', { method: 'POST', body: JSON.stringify(data) }),
adminCreateOrg: (name) => request('/admin/orgs', { method: 'POST', body: JSON.stringify({ name }) }),
adminListOrgs: () => request('/admin/orgs'),
// Platform-admin view: EVERY plan incl. hidden ones, with subscriber counts.
adminListPlans: () => request('/admin/plans'),
adminDeleteOrg: (id) => request(`/admin/orgs/${id}`, { method: 'DELETE' }),
adminDeleteWorkspace: (id) => request(`/admin/workspaces/${id}`, { method: 'DELETE' }),
aiGetSettings: () => request('/ai/settings'),

View file

@ -865,6 +865,10 @@ export default {
'admin.access_denied_desc': 'Plattform-Admin-Zugriff erforderlich.',
'admin.all_users': 'Alle Benutzer',
'admin.plans': 'Abonnementpläne',
'admin.col.accounts': 'Konten',
'admin.col.screens': 'Bildschirme',
'admin.plan_hidden': 'ausgeblendet',
'admin.plan_orphaned': 'Konten mit einem nicht mehr vorhandenen Tarif',
'admin.system': 'System',
'admin.col.user': 'Benutzer',
'admin.col.auth': 'Auth',

View file

@ -1270,6 +1270,10 @@ export default {
'admin.access_denied_desc': 'Platform admin access required.',
'admin.all_users': 'All Users',
'admin.plans': 'Subscription Plans',
'admin.col.accounts': 'Accounts',
'admin.col.screens': 'Screens',
'admin.plan_hidden': 'hidden',
'admin.plan_orphaned': 'Accounts on a plan that no longer exists',
'admin.system': 'System',
// #15: instance-level default branding
'admin.branding.title': 'Default branding',

View file

@ -895,6 +895,10 @@ export default {
'admin.access_denied_desc': 'Se requiere acceso de administrador de plataforma.',
'admin.all_users': 'Todos los usuarios',
'admin.plans': 'Planes de suscripción',
'admin.col.accounts': 'Cuentas',
'admin.col.screens': 'Pantallas',
'admin.plan_hidden': 'oculto',
'admin.plan_orphaned': 'Cuentas en un plan que ya no existe',
'admin.system': 'Sistema',
'admin.col.user': 'Usuario',
'admin.col.auth': 'Auth',

View file

@ -865,6 +865,10 @@ export default {
'admin.access_denied_desc': 'Accès administrateur plateforme requis.',
'admin.all_users': 'Tous les utilisateurs',
'admin.plans': 'Plans d\'abonnement',
'admin.col.accounts': 'Comptes',
'admin.col.screens': 'Écrans',
'admin.plan_hidden': 'masqué',
'admin.plan_orphaned': "Comptes sur un forfait qui n'existe plus",
'admin.system': 'Système',
'admin.col.user': 'Utilisateur',
'admin.col.auth': 'Auth',

View file

@ -823,6 +823,10 @@ export default {
'admin.access_denied_desc': 'È richiesto l\'accesso come amministratore di piattaforma.',
'admin.all_users': 'Tutti gli Utenti',
'admin.plans': 'Piani di Abbonamento',
'admin.col.accounts': 'Account',
'admin.col.screens': 'Schermi',
'admin.plan_hidden': 'nascosto',
'admin.plan_orphaned': 'Account su un piano che non esiste più',
'admin.system': 'Sistema',
'admin.col.user': 'Utente',
'admin.col.auth': 'Autenticazione',

View file

@ -865,6 +865,10 @@ export default {
'admin.access_denied_desc': 'Acesso de admin da plataforma necessário.',
'admin.all_users': 'Todos os usuários',
'admin.plans': 'Planos de assinatura',
'admin.col.accounts': 'Contas',
'admin.col.screens': 'Ecrãs',
'admin.plan_hidden': 'oculto',
'admin.plan_orphaned': 'Contas num plano que já não existe',
'admin.system': 'Sistema',
'admin.col.user': 'Usuário',
'admin.col.auth': 'Auth',

View file

@ -373,7 +373,10 @@ async function loadStatusDebug() {
async function loadPlans() {
const el = document.getElementById('plansTable');
try {
const plans = await fetch('/api/subscription/plans').then(r => r.json());
// Admin endpoint, not /api/subscription/plans: that one filters `active = 1` because it feeds
// the pricing page, so a deliberately hidden plan (a comped or beta tier) was invisible to the
// operator too. Here we want every plan, plus who is actually on each one.
const { plans, orphaned } = await api.adminListPlans();
el.innerHTML = `
<div class="table-wrap">
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:500px">
@ -383,20 +386,31 @@ async function loadPlans() {
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.storage')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.monthly')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.yearly')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.accounts')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.screens')}</th>
</tr></thead>
<tbody>
${plans.map(p => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px;font-weight:500">${p.display_name}</td>
<tr style="border-bottom:1px solid var(--border)${p.active ? '' : ';opacity:.7'}">
<td style="padding:8px;font-weight:500">${esc(p.display_name)}
<span style="color:var(--text-muted);font-weight:400;font-size:11px">${esc(p.id)}</span>
${p.active ? '' : `<span style="margin-left:6px;font-size:10px;padding:1px 6px;border:1px solid var(--border);border-radius:8px;color:var(--text-muted)">${t('admin.plan_hidden')}</span>`}
</td>
<td style="padding:8px;text-align:right">${p.max_devices === -1 ? t('admin.unlimited') : p.max_devices}</td>
<td style="padding:8px;text-align:right">${p.max_storage_mb === -1 ? t('admin.unlimited') : p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024)+'GB' : p.max_storage_mb+'MB'}</td>
<td style="padding:8px;text-align:right">${p.price_monthly > 0 ? '$'+p.price_monthly : t('admin.free')}</td>
<td style="padding:8px;text-align:right">${p.price_yearly > 0 ? '$'+p.price_yearly : '-'}</td>
<td style="padding:8px;text-align:right${p.user_count ? ';font-weight:500' : ';color:var(--text-muted)'}">${p.user_count}</td>
<td style="padding:8px;text-align:right;color:var(--text-muted)">${p.device_count}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
${(orphaned && orphaned.length) ? `
<p style="margin-top:10px;color:var(--danger);font-size:12px">
${t('admin.plan_orphaned')}: ${orphaned.map(o => `<strong>${esc(o.plan_id)}</strong> (${o.user_count})`).join(', ')}
</p>` : ''}
`;
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
}

View file

@ -432,6 +432,34 @@ router.post('/trigger-update', requirePlatformAdmin, async (req, res) => {
// distinct_accounts is the signal, not rejections. Values are counts only — the identifiers are
// salted-hashed inside the telemetry module and never leave it, so this cannot become a roster
// of a customer's email addresses. Platform-admin only, and in-memory (a restart clears it).
// Platform-admin plan overview. Deliberately NOT the public /api/subscription/plans list, which
// filters `active = 1` because that is what the pricing page renders — so an intentionally hidden
// plan (a comped or beta tier) was invisible to the operator as well as to customers, with no way
// to see it existed or who was on it. This returns EVERY plan plus how many accounts sit on each,
// so a hidden tier is manageable rather than folklore.
router.get('/plans', requirePlatformAdmin, (req, res) => {
const plans = db.prepare(`
SELECT p.*,
(SELECT COUNT(*) FROM users u WHERE u.plan_id = p.id) AS user_count,
(SELECT COUNT(*) FROM organizations o WHERE o.plan_id = p.id) AS org_count,
(SELECT COUNT(*) FROM devices d
JOIN workspaces w ON w.id = d.workspace_id
JOIN organizations o2 ON o2.id = w.organization_id
JOIN users u2 ON u2.id = o2.owner_user_id
WHERE u2.plan_id = p.id) AS device_count
FROM plans p
ORDER BY p.active DESC, p.sort_order ASC
`).all();
// Accounts whose plan_id no longer resolves would otherwise be invisible in a per-plan view —
// they are the ones that actually need attention (a deleted plan leaves them with no entitlements).
const orphaned = db.prepare(`
SELECT u.plan_id, COUNT(*) AS user_count FROM users u
WHERE u.plan_id IS NOT NULL AND u.plan_id NOT IN (SELECT id FROM plans)
GROUP BY u.plan_id
`).all();
res.json({ plans, orphaned });
});
router.get('/limiter-rejections', requirePlatformAdmin, (req, res) => {
const rows = require('../lib/limiter-telemetry').snapshot();
res.json({

View file

@ -0,0 +1,146 @@
'use strict';
// A plan can be hidden from customers by setting active = 0 — that is how a comped or beta tier is
// kept off the pricing page. But the only plan listing was /api/subscription/plans, which filters
// `active = 1` because it FEEDS that pricing page. So a hidden plan was invisible to the operator
// too: no way to see it existed, or who was on it, from the admin screen that is supposed to show
// exactly that.
//
// Two invariants, and they pull in opposite directions, which is why both are pinned here:
// - the PUBLIC list must never leak an inactive plan (that is the whole point of hiding it)
// - the ADMIN list must show every plan, with how many accounts are on each
//
// Isolated in-memory DB injected into the require cache, same approach as admin-users.test.js.
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const Database = require('better-sqlite3');
const express = require('express');
process.env.JWT_SECRET = 'test-secret-admin-plans';
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE plans (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
display_name TEXT NOT NULL,
max_devices INTEGER NOT NULL DEFAULT 2,
max_storage_mb INTEGER NOT NULL DEFAULT 500,
remote_control INTEGER NOT NULL DEFAULT 0,
remote_url INTEGER NOT NULL DEFAULT 0,
priority_support INTEGER NOT NULL DEFAULT 0,
price_monthly REAL NOT NULL DEFAULT 0,
price_yearly REAL NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1
);
-- Columns here are the ones resolveSessionUser selects; a missing one makes requireAuth throw
-- and the request comes back 401, which reads like an auth bug rather than a schema gap.
CREATE TABLE users (
id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL DEFAULT '',
password_hash TEXT, auth_provider TEXT NOT NULL DEFAULT 'local', avatar_url TEXT,
role TEXT NOT NULL DEFAULT 'user', plan_id TEXT,
email_alerts INTEGER DEFAULT 1, must_change_password INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE organizations (
id TEXT PRIMARY KEY, name TEXT NOT NULL, owner_user_id TEXT NOT NULL, plan_id TEXT
);
CREATE TABLE workspaces (
id TEXT PRIMARY KEY, organization_id TEXT NOT NULL, name TEXT NOT NULL
);
CREATE TABLE devices (
id TEXT PRIMARY KEY, name TEXT, workspace_id TEXT
);
INSERT INTO plans (id,name,display_name,max_devices,price_monthly,sort_order,active) VALUES
('free','free','Free',1,0,0,1),
('pro','pro','Pro',15,99,3,1),
('beta','beta','Beta Tester',8,0,9,0);
INSERT INTO users (id,email,role,plan_id) VALUES
('padmin','padmin@t.local','platform_admin','pro'),
('u1','u1@t.local','user','beta'),
('u2','u2@t.local','user','beta'),
('u3','u3@t.local','user','pro');
INSERT INTO organizations (id,name,owner_user_id,plan_id) VALUES ('o1','Org One','u1','beta');
INSERT INTO workspaces (id,organization_id,name) VALUES ('w1','o1','WS One');
INSERT INTO devices (id,name,workspace_id) VALUES ('d1','Screen','w1'), ('d2','Screen 2','w1');
`);
const dbModulePath = require.resolve('../db/database');
require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db } };
const { requireAuth, generateToken } = require('../middleware/auth');
const adminRouter = require('../routes/admin');
const subscriptionRouter = require('../routes/subscription');
// Use the app's own token factory rather than hand-rolling claims — it owns the secret, the
// algorithm and the claim shape, and a test that guesses those tests the guess.
const row = (id) => db.prepare('SELECT id, email, role FROM users WHERE id = ?').get(id);
const adminToken = generateToken(row('padmin'), null);
const userToken = generateToken(row('u1'), null);
const app = express();
app.use(express.json());
app.use('/api/admin', requireAuth, adminRouter);
app.use('/api/subscription', subscriptionRouter);
const server = app.listen(0);
async function get(pathname, token) {
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
const res = await fetch(`http://127.0.0.1:${server.address().port}${pathname}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
let body = null;
try { body = await res.json(); } catch (_) {}
return { status: res.status, body };
}
test('THE POINT: the admin list includes a hidden plan', async () => {
const { status, body } = await get('/api/admin/plans', adminToken);
assert.equal(status, 200);
const ids = body.plans.map(p => p.id);
assert.ok(ids.includes('beta'), `hidden plan missing from the admin view: ${ids.join(',')}`);
assert.equal(body.plans.find(p => p.id === 'beta').active, 0);
});
test('and it reports how many accounts are on each plan', async () => {
const { body } = await get('/api/admin/plans', adminToken);
const by = Object.fromEntries(body.plans.map(p => [p.id, p]));
assert.equal(by.beta.user_count, 2);
assert.equal(by.pro.user_count, 2); // padmin + u3
assert.equal(by.free.user_count, 0);
assert.equal(by.beta.org_count, 1);
assert.equal(by.beta.device_count, 2, 'screens owned by accounts on the plan');
});
test('THE OTHER HALF: the public list must NOT leak a hidden plan', async () => {
// If this ever fails, hiding a comped tier stops working and it appears on the pricing page.
const { status, body } = await get('/api/subscription/plans');
assert.equal(status, 200);
const ids = body.map(p => p.id);
assert.deepEqual(ids.sort(), ['free', 'pro'], `inactive plan leaked: ${ids.join(',')}`);
});
test('visible plans sort before hidden ones, so the list reads naturally', async () => {
const { body } = await get('/api/admin/plans', adminToken);
const firstHidden = body.plans.findIndex(p => !p.active);
const lastVisible = body.plans.reduce((acc, p, i) => (p.active ? i : acc), -1);
assert.ok(firstHidden > lastVisible, 'hidden plans should come after the visible ones');
});
test('a non-platform-admin cannot read the plan overview', async () => {
assert.equal((await get('/api/admin/plans', userToken)).status, 403);
});
test('an unauthenticated caller cannot read it either', async () => {
assert.equal((await get('/api/admin/plans')).status, 401);
});
test.after(() => { server.close(); });