mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
fix(frontend): workspace switcher (Phase 3 MVP) + SW network-first migration + platform_admin accessible_workspaces expansion + static render CSS cleanup. The switcher adds a sidebar dropdown for users who are members of multiple workspaces, renders as static text with a 'Workspace' label for single-workspace users, and muted 'No workspace' for zero. Uses existing /api/auth/me's accessible_workspaces and POST /api/auth/switch-workspace endpoints. Platform admin / superadmin users now see all workspaces in accessible_workspaces (closing the known regression from 88d91b1) via a LEFT JOIN that preserves workspace_role semantics (null = acting-as, role string = direct member). No cap on the list - deliberate for now, revisit at 50+ workspaces. SW fix bumps rd-admin-v1 -> rd-admin-v2 and switches fetch strategy from cache-first to network-first so the server's existing Cache-Control: no-cache + ETag headers actually get respected; preserves offline fallback. Static render CSS drops the bordered-box chrome that was making single-workspace users think the static text was clickable. Includes test fixture user switcher-test@local.test (credentials in fixture SQL header). Surfaced by semetra22 / Discord report about 'screens jumbled up' post-migration; root cause was the missing workspace switcher UI making devices in non-active workspaces appear missing.
This commit is contained in:
parent
bc445a0a7c
commit
0c91390e56
|
|
@ -32,6 +32,65 @@ body {
|
|||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Workspace switcher (Phase 3 MVP). Sits in sidebar-header below the logo.
|
||||
Three render modes via JS: dropdown (>1 ws), static text (1 ws),
|
||||
muted empty state (0 ws). */
|
||||
.workspace-switcher { position: relative; margin-top: 12px; }
|
||||
.workspace-switcher-button {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
width: 100%; padding: 8px 10px;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); color: var(--text-primary);
|
||||
font-size: 13px; cursor: pointer; transition: all var(--transition);
|
||||
}
|
||||
.workspace-switcher-button:hover { border-color: var(--accent); }
|
||||
.workspace-switcher-static {
|
||||
display: block; padding: 4px 2px;
|
||||
color: var(--text-primary); font-size: 13px; font-weight: 500;
|
||||
}
|
||||
.workspace-switcher-static::before {
|
||||
content: 'Workspace';
|
||||
display: block;
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
color: var(--text-muted); margin-bottom: 2px;
|
||||
}
|
||||
.workspace-switcher-empty {
|
||||
display: block; padding: 8px 10px;
|
||||
color: var(--text-muted); font-size: 12px; font-style: italic;
|
||||
}
|
||||
.workspace-switcher-button .chev {
|
||||
flex-shrink: 0; margin-left: 8px; color: var(--text-muted);
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
.workspace-switcher.open .chev { transform: rotate(180deg); }
|
||||
.workspace-switcher-menu {
|
||||
display: none;
|
||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
max-height: 320px; overflow-y: auto; z-index: 100;
|
||||
}
|
||||
.workspace-switcher.open .workspace-switcher-menu { display: block; }
|
||||
.workspace-switcher-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 12px; cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-primary); font-size: 13px;
|
||||
}
|
||||
.workspace-switcher-item:last-child { border-bottom: none; }
|
||||
.workspace-switcher-item:hover { background: var(--bg-input); }
|
||||
.workspace-switcher-item.current { font-weight: 600; }
|
||||
.workspace-switcher-item .check {
|
||||
flex-shrink: 0; color: var(--accent); width: 14px;
|
||||
}
|
||||
.workspace-switcher-item .ws-meta { flex: 1; min-width: 0; }
|
||||
.workspace-switcher-item .ws-name {
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.workspace-switcher-item .ws-org {
|
||||
font-size: 11px; color: var(--text-muted); margin-top: 2px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
flex: 1;
|
||||
padding: 12px 8px;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
</svg>
|
||||
<span>ScreenTinker</span>
|
||||
</div>
|
||||
<div class="workspace-switcher" id="workspaceSwitcher"></div>
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="#/" class="nav-link active" data-view="dashboard">
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ export const api = {
|
|||
// 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 }) }),
|
||||
|
||||
// Admin - Users
|
||||
getUsers: () => request('/auth/users'),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import * as playlists from './views/playlists.js';
|
|||
import { applyBranding } from './branding.js';
|
||||
import { t } from './i18n.js';
|
||||
import { isPlatformAdmin } from './utils.js';
|
||||
import { renderWorkspaceSwitcher } from './components/workspace-switcher.js';
|
||||
|
||||
const app = document.getElementById('app');
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
|
|
@ -94,6 +95,9 @@ async function refreshCurrentUser() {
|
|||
if (!res.ok) return;
|
||||
const fresh = await res.json();
|
||||
localStorage.setItem('user', JSON.stringify(fresh));
|
||||
// Re-render the workspace switcher on every /me refresh - cheap, and keeps
|
||||
// the dropdown in sync if a workspace was added/removed in another tab.
|
||||
renderWorkspaceSwitcher(fresh);
|
||||
window.dispatchEvent(new CustomEvent('user-refreshed', { detail: fresh }));
|
||||
} catch {}
|
||||
}
|
||||
|
|
|
|||
91
frontend/js/components/workspace-switcher.js
Normal file
91
frontend/js/components/workspace-switcher.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { api } from '../api.js';
|
||||
import { showToast } from './toast.js';
|
||||
|
||||
// Render the workspace switcher inside #workspaceSwitcher based on the
|
||||
// /api/auth/me response. Three modes:
|
||||
// - 0 accessible workspaces: muted "No workspace" placeholder
|
||||
// - 1 accessible workspace: workspace name as static text
|
||||
// - >1 accessible workspaces: dropdown button + menu with click-to-switch
|
||||
export function renderWorkspaceSwitcher(me) {
|
||||
const container = document.getElementById('workspaceSwitcher');
|
||||
if (!container) return;
|
||||
|
||||
const list = Array.isArray(me?.accessible_workspaces) ? me.accessible_workspaces : [];
|
||||
const currentId = me?.current_workspace_id || null;
|
||||
|
||||
if (list.length === 0) {
|
||||
container.classList.remove('open');
|
||||
container.innerHTML = `<span class="workspace-switcher-empty">No workspace</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (list.length === 1) {
|
||||
container.classList.remove('open');
|
||||
container.innerHTML = `<span class="workspace-switcher-static">${esc(list[0].name)}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// >1: dropdown. Alpha sort by workspace name for MVP (no recently-used yet).
|
||||
const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const current = sorted.find(w => w.id === currentId) || sorted[0];
|
||||
|
||||
container.innerHTML = `
|
||||
<button class="workspace-switcher-button" type="button" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span class="ws-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(current.name)}</span>
|
||||
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="workspace-switcher-menu" role="listbox">
|
||||
${sorted.map(w => `
|
||||
<div class="workspace-switcher-item ${w.id === currentId ? 'current' : ''}" data-workspace-id="${esc(w.id)}" role="option">
|
||||
<svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" style="${w.id === currentId ? '' : 'visibility:hidden'}">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
<div class="ws-meta">
|
||||
<div class="ws-name">${esc(w.name)}</div>
|
||||
<div class="ws-org">${esc(w.organization_name || '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const button = container.querySelector('.workspace-switcher-button');
|
||||
button.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const opening = !container.classList.contains('open');
|
||||
container.classList.toggle('open');
|
||||
button.setAttribute('aria-expanded', String(opening));
|
||||
});
|
||||
|
||||
container.querySelectorAll('.workspace-switcher-item').forEach(item => {
|
||||
item.addEventListener('click', async () => {
|
||||
const wsId = item.dataset.workspaceId;
|
||||
if (wsId === currentId) { container.classList.remove('open'); return; }
|
||||
try {
|
||||
const resp = await api.switchWorkspace(wsId);
|
||||
if (resp?.token) {
|
||||
localStorage.setItem('token', resp.token);
|
||||
window.location.reload();
|
||||
} else {
|
||||
showToast('Switch returned no token', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message || 'Failed to switch workspace', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Click-outside closes the menu.
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!container.contains(e.target)) {
|
||||
container.classList.remove('open');
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||
}
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
const CACHE = 'rd-admin-v1';
|
||||
// Service worker for the admin SPA. Bumped to v2 to invalidate the cache-first
|
||||
// caches that were shipping stale JS to existing clients (the server already
|
||||
// sends Cache-Control: no-cache + ETag, but the previous SW intercepted before
|
||||
// any of that mattered). Strategy is now network-first with offline fallback.
|
||||
const CACHE = 'rd-admin-v2';
|
||||
|
||||
self.addEventListener('install', e => {
|
||||
e.waitUntil(caches.open(CACHE).then(c => c.addAll([
|
||||
|
|
@ -15,7 +19,18 @@ self.addEventListener('activate', e => {
|
|||
});
|
||||
|
||||
self.addEventListener('fetch', e => {
|
||||
// Network first for API, cache first for static
|
||||
// Don't intercept API or socket.io traffic - those need to hit the network unmediated.
|
||||
if (e.request.url.includes('/api/') || e.request.url.includes('/socket.io/')) return;
|
||||
e.respondWith(caches.match(e.request).then(r => r || fetch(e.request)));
|
||||
// Network-first: respect the server's Cache-Control: no-cache + ETag (304s
|
||||
// stay fast); fall back to cache only when offline. Re-populate the cache
|
||||
// on every successful fetch so the offline fallback stays current.
|
||||
e.respondWith(
|
||||
fetch(e.request)
|
||||
.then(resp => {
|
||||
const copy = resp.clone();
|
||||
caches.open(CACHE).then(c => c.put(e.request, copy)).catch(() => {});
|
||||
return resp;
|
||||
})
|
||||
.catch(() => caches.match(e.request))
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -293,8 +293,26 @@ function getMicrosoftProfile(accessToken) {
|
|||
// roles, and the list of accessible workspaces. Legacy fields (user object at
|
||||
// the top level) are preserved so existing frontend code continues to work.
|
||||
router.get('/me', requireAuth, resolveTenancy, (req, res) => {
|
||||
const accessible = db.prepare(`
|
||||
SELECT w.id, w.name, w.organization_id, o.name AS organization_name, wm.role AS workspace_role
|
||||
// Platform admins see every workspace in the system (via the LEFT JOIN they
|
||||
// still get their own workspace_role for direct memberships; NULL elsewhere,
|
||||
// matching accessContext's actingAs semantics). Regular users see only
|
||||
// workspaces they have a direct workspace_members row in. Role is read from
|
||||
// the signed JWT (not user-supplied), so non-admins cannot reach the admin
|
||||
// branch. No cap on the admin list yet - revisit at 50+ workspaces when
|
||||
// dropdown UX without search starts to degrade.
|
||||
const isPlatformAdmin = req.user.role === 'platform_admin' || req.user.role === 'superadmin';
|
||||
const accessible = isPlatformAdmin
|
||||
? db.prepare(`
|
||||
SELECT w.id, w.name, w.organization_id, o.name AS organization_name,
|
||||
wm.role AS workspace_role
|
||||
FROM workspaces w
|
||||
JOIN organizations o ON o.id = w.organization_id
|
||||
LEFT JOIN workspace_members wm ON wm.workspace_id = w.id AND wm.user_id = ?
|
||||
ORDER BY o.name, w.name
|
||||
`).all(req.user.id)
|
||||
: db.prepare(`
|
||||
SELECT w.id, w.name, w.organization_id, o.name AS organization_name,
|
||||
wm.role AS workspace_role
|
||||
FROM workspace_members wm
|
||||
JOIN workspaces w ON w.id = wm.workspace_id
|
||||
JOIN organizations o ON o.id = w.organization_id
|
||||
|
|
|
|||
Loading…
Reference in a new issue