mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Two halves of the same problem: an account created with a password could never
use single sign-on, and the login page offered a credential before it knew
which one applied.
LINKING. Signing in with a provider never adopts an account that already has a
password -- that is the takeover the login path exists to refuse. The README
promised the way out ("the owner signs in locally and links from Settings") but
nothing had ever been built, so the refusal was a dead end rather than a
redirection. Settings now has a Sign-in method block: an account with a password
can link an instance-wide provider, and one on a provider can unlink back to a
password.
The account being linked comes from the SIGNED TRANSACTION -- the session that
started it -- never from the email in the returned token. That distinction is
the whole feature: taking it from the token would be the same email-keyed
takeover under a friendlier name. The email must still match the account's own,
because login resolves accounts by the asserted address, and one provider
subject may not be linked to two accounts.
Linking DELETES the password rather than keeping it alongside. One credential at
a time, and the confirmation says so in those words, because a password left
behind is a second way in that the user believes they replaced. Unlink therefore
takes the new password up front and writes it in the SAME statement as the
unlink -- never unlink now and set a password after, which leaves an account
briefly, or on failure permanently, with no way in.
Instance-wide providers only. An organization's provider is chosen by a
customer; letting one attach itself to a platform account would hand that
customer whatever the account can do.
IDENTIFIER-FIRST. The password box now appears only after an address has been
submitted, which is what lets the organization lookup happen before a credential
is offered: someone whose company requires its own provider is shown that,
rather than a password box that will be refused. Editing the address returns to
the identifier step so a corrected domain gets a fresh answer.
The per-keystroke lookup is gone with it. It answered for half-typed domains,
changed the form under someone mid-address, and spent a 10/min per-IP budget on
people who had not finished typing -- an office behind one address could exhaust
it without a single sign-in attempt.
Instance-wide providers stay visible at all times now, by decision: the server
refuses them for an SSO-only organization anyway, and hiding them made the page
change shape while typing.
Verified in a real browser, not only by rendering: password hidden -> submit ->
visible and focused -> edit the address -> hidden again, with no page errors.
Four mutations of the linking rules fail the tests (account from the email
instead of the session, keeping the password, allowing org providers, dropping
requireAuth).
285 lines
16 KiB
JavaScript
285 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 }) }),
|
|
setDevicePin: (id, body) => request(`/devices/${id}/settings-pin`, { method: 'POST', body: JSON.stringify(body) }),
|
|
|
|
// #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 }) }),
|
|
clearDevicePlaylist: (device_id) => request(`/devices/${device_id}/playlist`, { method: 'DELETE' }),
|
|
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'),
|
|
// Unlink an instance-wide SSO provider. The new password is required in the same call:
|
|
// the account must never sit between credentials.
|
|
ssoUnlink: (password) => request('/auth/oidc/unlink', { method: 'POST', body: JSON.stringify({ password }) }),
|
|
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) }),
|
|
updateWorkspaceSecuritySettings: (workspaceId, data) => request(`/workspaces/${workspaceId}/security-settings`, { method: 'PUT', 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 })
|
|
}),
|
|
};
|