mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
The admin plan table read /api/subscription/plans, which filters `active = 1` because that endpoint feeds the public pricing page. So the one screen meant to show the operator what plans exist could not show a hidden one — a comped or beta tier was invisible to us as well as to customers, with no way to see it existed or who was on it. Found immediately after creating exactly such a plan. GET /api/admin/plans (platform-admin only) returns every plan plus, per plan, the number of accounts, organisations and screens on it. Visible plans sort first so the list still reads like the pricing ladder, with hidden ones after and badged. The public endpoint is deliberately untouched: hiding a plan has to keep working, and the test pins BOTH directions because they pull against each other — the admin list must include an inactive plan, and the public list must never leak one. Counts are the point, not decoration: "how many people are on what plan" is the question you actually ask of this screen, and it was answerable only by hand in SQLite. Also carries a warning for accounts whose plan no longer resolves. Both users.plan_id and organizations.plan_id are FK-enforced to plans.id and there is no delete-plan route, so this should be unreachable — but migrations here do rebuild tables with foreign keys off (the tenant-cascade one rebuilt thirteen), and that is exactly how a row would be orphaned. Six lines for a state that would otherwise be silent. Strings added to en/de/es/fr/it/pt. Not hi: it has no admin translations at all, lookup falls back to English, and four Hindi strings among forty English ones would read worse than consistent English.
279 lines
16 KiB
JavaScript
279 lines
16 KiB
JavaScript
const API_BASE = '/api';
|
|
|
|
function getAuthHeaders() {
|
|
const token = localStorage.getItem('token');
|
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
}
|
|
|
|
async function request(url, options = {}) {
|
|
const res = await fetch(API_BASE + url, {
|
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders(), ...options.headers },
|
|
...options,
|
|
});
|
|
if (res.status === 401) {
|
|
// Token expired or invalid - redirect to login
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
window.location.hash = '#/login';
|
|
window.location.reload();
|
|
throw new Error('Session expired');
|
|
}
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
throw new Error(err.error || 'Request failed');
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export const api = {
|
|
// Devices
|
|
getDevices: () => request('/devices'),
|
|
reorderDevices: (order) => request('/devices/reorder', { method: 'POST', body: JSON.stringify({ order }) }),
|
|
getDevice: (id) => request(`/devices/${id}`),
|
|
getDeviceOwnerQR: () => request('/provision/device-owner-qr'), // #161: device-owner provisioning
|
|
updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deleteDevice: (id) => request(`/devices/${id}`, { method: 'DELETE' }),
|
|
// #146 Item D: operator block/unblock — refuses the device at its next register with
|
|
// no restart. Server enforces via the SNAT-safe identity chain (deviceSocket).
|
|
blockDevice: (id) => request(`/devices/${id}/block`, { method: 'POST' }),
|
|
unblockDevice: (id) => request(`/devices/${id}/unblock`, { method: 'POST' }),
|
|
// #150: fingerprint-keyed settings snapshots of previously-removed devices (this workspace),
|
|
// and the re-adopt action that applies a snapshot onto a newly-paired device.
|
|
getRemovedDevices: () => request('/devices/removed'),
|
|
reAdoptDevice: (id, fingerprint) => request(`/devices/${id}/re-adopt`, { method: 'POST', body: JSON.stringify({ fingerprint }) }),
|
|
|
|
// #109 PiP overlay: push/clear a floating overlay on a device or group. `id` may be a
|
|
// device id OR a group id (the server resolves + expands). Needs full scope (no-op for JWT).
|
|
sendPip: (id, opts) => request('/pip', { method: 'POST', body: JSON.stringify({ device_id: id, ...opts }) }),
|
|
clearPip: (id, pipId) => request('/pip/clear', { method: 'POST', body: JSON.stringify({ device_id: id, pip_id: pipId || undefined }) }),
|
|
|
|
// Provisioning
|
|
pairDevice: (pairing_code, name) => request('/provision/pair', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ pairing_code, name })
|
|
}),
|
|
|
|
// Content
|
|
getContent: (folderId, includeExpired = false, opts = {}) => {
|
|
const p = new URLSearchParams();
|
|
// #214: a text search spans the whole workspace, so folder_id is only sent when
|
|
// NOT searching (the server also ignores folder_id when q is present, but keeping
|
|
// the client in sync avoids a misleading URL).
|
|
const searching = opts.q && opts.q.trim();
|
|
if (!searching && folderId !== undefined) p.set('folder_id', folderId === null ? 'root' : folderId);
|
|
if (includeExpired) p.set('include_expired', '1');
|
|
if (searching) p.set('q', opts.q.trim());
|
|
if (opts.type && opts.type !== 'all') p.set('type', opts.type);
|
|
if (opts.sort) p.set('sort', opts.sort);
|
|
const qs = p.toString();
|
|
return request(`/content${qs ? '?' + qs : ''}`);
|
|
},
|
|
getContentItem: (id) => request(`/content/${id}`),
|
|
deleteContent: (id) => request(`/content/${id}`, { method: 'DELETE' }),
|
|
updateContent: (id, data) => request(`/content/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
moveContent: (id, folderId) => request(`/content/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ folder_id: folderId })
|
|
}),
|
|
// #213: batch operations — the whole batch succeeds or fails atomically server-side.
|
|
batchDeleteContent: (ids) => request('/content/batch/delete', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids })
|
|
}),
|
|
batchMoveContent: (ids, folderId) => request('/content/batch/move', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids, folder_id: folderId || null })
|
|
}),
|
|
|
|
// Folders
|
|
getFolders: () => request('/folders'),
|
|
createFolder: (name, parentId) => request('/folders', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, parent_id: parentId || null })
|
|
}),
|
|
renameFolder: (id, name) => request(`/folders/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ name })
|
|
}),
|
|
moveFolder: (id, parentId) => request(`/folders/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ parent_id: parentId || null })
|
|
}),
|
|
deleteFolder: (id) => request(`/folders/${id}`, { method: 'DELETE' }),
|
|
// #212: accepts a single File or an array/FileList of Files. All go up in one request
|
|
// under the `files` field (the server also still accepts the legacy `file` field).
|
|
// onProgress reports aggregate percent across the whole batch. Resolves to the content
|
|
// object for a single file, or an array of them for a batch.
|
|
uploadContent: async (file, onProgress, folderId) => {
|
|
const files = (file instanceof FileList || Array.isArray(file)) ? Array.from(file) : [file];
|
|
const formData = new FormData();
|
|
for (const f of files) formData.append('files', f);
|
|
if (folderId) formData.append('folder_id', folderId);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', `${API_BASE}/content`);
|
|
const token = localStorage.getItem('token');
|
|
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
|
if (onProgress) {
|
|
xhr.upload.onprogress = (e) => {
|
|
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
|
|
};
|
|
}
|
|
xhr.onload = () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
resolve(JSON.parse(xhr.responseText));
|
|
} else {
|
|
reject(new Error('Upload failed'));
|
|
}
|
|
};
|
|
xhr.onerror = () => reject(new Error('Upload failed'));
|
|
xhr.send(formData);
|
|
});
|
|
},
|
|
|
|
addRemoteContent: (url, name, mime_type) => request('/content/remote', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ url, name, mime_type })
|
|
}),
|
|
|
|
addYoutubeContent: (url, name) => request('/content/youtube', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ url, name })
|
|
}),
|
|
|
|
// Assignments
|
|
getAssignments: (deviceId) => request(`/assignments/device/${deviceId}`),
|
|
addAssignment: (deviceId, data) => request(`/assignments/device/${deviceId}`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
updateAssignment: (id, data) => request(`/assignments/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deleteAssignment: (id) => request(`/assignments/${id}`, { method: 'DELETE' }),
|
|
reorderAssignments: (deviceId, order) => request(`/assignments/device/${deviceId}/reorder`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ order })
|
|
}),
|
|
|
|
// Widgets
|
|
getWidgets: () => request('/widgets'),
|
|
getWidget: (id) => request('/widgets/' + id),
|
|
|
|
// Device Groups
|
|
getGroups: () => request('/groups'),
|
|
createGroup: (name, color) => request('/groups', { method: 'POST', body: JSON.stringify({ name, color }) }),
|
|
updateGroup: (id, data) => request(`/groups/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
resyncGroup: (id) => request(`/groups/${id}/resync`, { method: 'POST' }),
|
|
deleteGroup: (id) => request(`/groups/${id}`, { method: 'DELETE' }),
|
|
getGroupDevices: (id) => request(`/groups/${id}/devices`),
|
|
addDeviceToGroup: (groupId, device_id) => request(`/groups/${groupId}/devices`, { method: 'POST', body: JSON.stringify({ device_id }) }),
|
|
removeDeviceFromGroup: (groupId, deviceId) => request(`/groups/${groupId}/devices/${deviceId}`, { method: 'DELETE' }),
|
|
sendGroupCommand: (groupId, type, payload) => request(`/groups/${groupId}/command`, { method: 'POST', body: JSON.stringify({ type, payload }) }),
|
|
|
|
// Video walls
|
|
getWalls: () => request('/walls'),
|
|
createWall: (data) => request('/walls', { method: 'POST', body: JSON.stringify(data) }),
|
|
setWallDevices: (id, devices) => request(`/walls/${id}/devices`, { method: 'PUT', body: JSON.stringify({ devices }) }),
|
|
updateWall: (id, data) => request(`/walls/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deleteWall: (id) => request(`/walls/${id}`, { method: 'DELETE' }),
|
|
|
|
// Playlists
|
|
getPlaylists: () => request('/playlists'),
|
|
createPlaylist: (name, description) => request('/playlists', { method: 'POST', body: JSON.stringify({ name, description }) }),
|
|
getPlaylist: (id) => request(`/playlists/${id}`),
|
|
updatePlaylist: (id, data) => request(`/playlists/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deletePlaylist: (id) => request(`/playlists/${id}`, { method: 'DELETE' }),
|
|
getPlaylistItems: (id) => request(`/playlists/${id}/items`),
|
|
addPlaylistItem: (id, data) => request(`/playlists/${id}/items`, { method: 'POST', body: JSON.stringify(data) }),
|
|
updatePlaylistItem: (id, itemId, data) => request(`/playlists/${id}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deletePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}`, { method: 'DELETE' }),
|
|
duplicatePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}/duplicate`, { method: 'POST' }),
|
|
reorderPlaylistItems: (id, order) => request(`/playlists/${id}/items/reorder`, { method: 'POST', body: JSON.stringify({ order }) }),
|
|
// #74/#75 per-item schedule blocks
|
|
getItemSchedules: (id, itemId) => request(`/playlists/${id}/items/${itemId}/schedules`),
|
|
setItemSchedules: (id, itemId, blocks) => request(`/playlists/${id}/items/${itemId}/schedules`, { method: 'PUT', body: JSON.stringify({ blocks }) }),
|
|
assignPlaylistToDevice: (playlistId, device_id) => request(`/playlists/${playlistId}/assign`, { method: 'POST', body: JSON.stringify({ device_id }) }),
|
|
publishPlaylist: (id) => request(`/playlists/${id}/publish`, { method: 'POST' }),
|
|
discardPlaylistDraft: (id) => request(`/playlists/${id}/discard`, { method: 'POST' }),
|
|
|
|
// 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' }),
|
|
setTokenTargets: (id, target_playlist_ids) => request('/tokens/' + id + '/targets', { method: 'PUT', body: JSON.stringify({ target_playlist_ids }) }), // #73: re-designate agency token playlists
|
|
setTokenUploadFolder: (id, upload_folder_id) => request('/tokens/' + id + '/upload-folder', { method: 'PUT', body: JSON.stringify({ upload_folder_id }) }), // #158: rebind agency token upload folder (null = root)
|
|
|
|
// TOTP 2FA (#100) — opt-in per-user, local accounts only. See routes/auth.js.
|
|
totpStatus: () => request('/auth/totp/status'),
|
|
totpSetup: () => request('/auth/totp/setup', { method: 'POST' }),
|
|
totpEnable: (code) => request('/auth/totp/enable', { method: 'POST', body: JSON.stringify({ code }) }),
|
|
totpDisable: (code) => request('/auth/totp/disable', { method: 'POST', body: JSON.stringify({ code }) }),
|
|
totpRegenRecovery: (code) => request('/auth/totp/recovery-codes/regenerate', { method: 'POST', body: JSON.stringify({ code }) }),
|
|
|
|
// Email verification (signup). Resend is generic (never reveals whether the address exists).
|
|
resendVerification: (email) => request('/auth/resend-verification', { method: 'POST', body: JSON.stringify({ email }) }),
|
|
|
|
// Current user
|
|
getMe: () => request('/auth/me'),
|
|
updateMe: (data) => request('/auth/me', { method: 'PUT', body: JSON.stringify(data) }),
|
|
switchWorkspace: (workspaceId) => request('/auth/switch-workspace', { method: 'POST', body: JSON.stringify({ workspace_id: workspaceId }) }),
|
|
renameWorkspace: (id, data) => request(`/workspaces/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
|
|
// Workspace members + invites (slice 2A read-only)
|
|
getWorkspaceMembers: (id) => request(`/workspaces/${id}/members`),
|
|
getWorkspaceInvites: (id) => request(`/workspaces/${id}/invites`),
|
|
|
|
// Workspace member/invite mutations (slice 2B). All admin-only server-side
|
|
// (canAdminWorkspace gate). Server returns translated English error messages
|
|
// mapped to i18n keys via mapMutationError() in workspace-members.js.
|
|
inviteWorkspaceMember: (workspaceId, data) => request(`/workspaces/${workspaceId}/invites`, { method: 'POST', body: JSON.stringify(data) }),
|
|
cancelWorkspaceInvite: (workspaceId, inviteId) => request(`/workspaces/${workspaceId}/invites/${inviteId}`, { method: 'DELETE' }),
|
|
updateWorkspaceMemberRole: (workspaceId, userId, role) => request(`/workspaces/${workspaceId}/members/${userId}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
|
removeWorkspaceMember: (workspaceId, userId) => request(`/workspaces/${workspaceId}/members/${userId}`, { method: 'DELETE' }),
|
|
|
|
// Slice 2C - accept a workspace invite by id (post-auth flow)
|
|
acceptInvite: (inviteId) => request(`/auth/accept-invite/${inviteId}`, { method: 'POST' }),
|
|
|
|
// Admin-provisioned user creation (#10). data: { email, name, password,
|
|
// workspaceId, role, mustChangePassword }
|
|
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'),
|
|
aiSaveSettings: (data) => request('/ai/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
|
aiGenerateDesign: (prompt) => request('/ai/generate-design', { method: 'POST', body: JSON.stringify({ prompt }) }),
|
|
aiListModels: (base_url, api_key) => request('/ai/models', { method: 'POST', body: JSON.stringify({ base_url, api_key }) }),
|
|
|
|
// Instance-level default branding (#15, platform admin).
|
|
adminGetBranding: () => request('/admin/branding'),
|
|
adminSetBranding: (data) => request('/admin/branding', { method: 'PUT', body: JSON.stringify(data) }),
|
|
// #146: toggle the /api/status debug block exposure (platform-admin only).
|
|
adminGetStatusDebug: () => request('/admin/status-debug'),
|
|
adminSetStatusDebug: (enabled) => request('/admin/status-debug', { method: 'PUT', body: JSON.stringify({ enabled }) }),
|
|
|
|
// Per-user workspace membership management (platform Users page modal).
|
|
adminGetUserWorkspaces: (id) => request(`/admin/users/${id}/workspaces`),
|
|
adminAddUserWorkspace: (id, workspaceId, role) => request(`/admin/users/${id}/workspaces`, { method: 'POST', body: JSON.stringify({ workspaceId, role }) }),
|
|
adminSetUserWorkspaceRole: (id, workspaceId, role) => request(`/admin/users/${id}/workspaces/${workspaceId}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
|
adminRemoveUserWorkspace: (id, workspaceId) => request(`/admin/users/${id}/workspaces/${workspaceId}`, { method: 'DELETE' }),
|
|
|
|
// Admin - Users
|
|
getUsers: () => request('/auth/users'),
|
|
deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }),
|
|
resetUserPassword: (id, password) => request(`/auth/users/${id}/password`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ password }),
|
|
}),
|
|
assignPlan: (user_id, plan_id) => request('/subscription/assign', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ user_id, plan_id })
|
|
}),
|
|
};
|