screentinker/frontend/js/api.js
ScreenTinker caa9fd0f40 feat(workspaces): mutation UI for members (slice 2B)
Completes P2 user-management. Adds the full admin surface for managing
workspace membership: invite modal, role change, member remove, cancel
pending invite. All admin-gated client-side via can_admin from /me,
server-gated via canAdminWorkspace.

Component additions:
- NEW workspace-members-invite-modal.js (~115 LOC). Mirrors
  workspace-rename-modal.js pattern (imperative open + listeners + close
  + esc/click-outside/enter). Two key differences: onSuccess callback
  instead of window.location.reload (allows targeted re-render of
  pending-invites section), and mapError callback so the parent's
  mapMutationError is the single regex-to-i18n source of truth (instead
  of duplicating in the modal).
- workspace-members.js: header invite button (can_admin gated), per-row
  affordances (role select + remove on direct members, cancel on invited
  rows, none on via_org rows), exported mapMutationError mapper,
  re-render on both success AND error for role-select to resync state
  when the server rejects.
- 4 api.js helpers (inviteWorkspaceMember, cancelWorkspaceInvite,
  updateWorkspaceMemberRole, removeWorkspaceMember).
- 24 i18n keys under members.modal.*, members.button.*,
  members.confirm.*, members.error.*, members.success.*
- CSS for .member-actions family (action buttons + role select + hover
  states).

UX decisions:
- Direct-member rows: role <select> replaces role text in same column;
  remove button right of detail
- via_org rows: no actions cell (server would 403; UI respects boundary)
- Invited rows: cancel button only (handoff rule was over-broad -
  cancel-invite IS a valid mutation on invited rows, refined during 2B
  survey)
- Role select fires on change, no Save button (matches teams.js pattern;
  mitigations for accidental clicks noted in handoff if reports come in)
- Mutations re-fetch + re-render rather than optimistic updates -
  simpler, no state-drift bugs, endpoints respond fast
- /invites endpoint skipped entirely when !can_admin (saves a request;
  server still enforces)

Verification: 21/21 Playwright assertions PASS across 6 cases (invite
happy path, invite collision, role change, remove member, last-admin
block, cancel invite). Test infrastructure stashed at
~/Documents/screentinker-2b-playwright-2026-05.py.

Closes P2 (user-management feature). Slice 1+3 backend landed c4fbd2b,
2A read-only view landed 8db171d, 2C accept-invite handler landed
399af54, 2B mutation UI landed here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:45:34 -05:00

189 lines
8.6 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'),
getDevice: (id) => request(`/devices/${id}`),
updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteDevice: (id) => request(`/devices/${id}`, { method: 'DELETE' }),
// Provisioning
pairDevice: (pairing_code, name) => request('/provision/pair', {
method: 'POST',
body: JSON.stringify({ pairing_code, name })
}),
// Content
getContent: (folderId) => {
if (folderId === undefined) return request('/content');
const q = folderId === null ? 'root' : encodeURIComponent(folderId);
return request(`/content?folder_id=${q}`);
},
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 })
}),
// 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' }),
uploadContent: async (file, onProgress) => {
const formData = new FormData();
formData.append('file', file);
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'),
// Device Groups
getGroups: () => request('/groups'),
createGroup: (name, color) => request('/groups', { method: 'POST', body: JSON.stringify({ name, color }) }),
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' }),
reorderPlaylistItems: (id, order) => request(`/playlists/${id}/items/reorder`, { method: 'POST', body: JSON.stringify({ order }) }),
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 }) }),
// 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 - 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 })
}),
};