diff --git a/frontend/css/main.css b/frontend/css/main.css
index bbc6c26..86429ce 100644
--- a/frontend/css/main.css
+++ b/frontend/css/main.css
@@ -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;
diff --git a/frontend/index.html b/frontend/index.html
index 5be30e5..747051a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -31,6 +31,7 @@
ScreenTinker
+
-
diff --git a/frontend/js/api.js b/frontend/js/api.js
index d5409e2..778f491 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -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'),
diff --git a/frontend/js/app.js b/frontend/js/app.js
index 4b17160..22c5820 100644
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -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 {}
}
diff --git a/frontend/js/components/workspace-switcher.js b/frontend/js/components/workspace-switcher.js
new file mode 100644
index 0000000..cf0367d
--- /dev/null
+++ b/frontend/js/components/workspace-switcher.js
@@ -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 = `No workspace`;
+ return;
+ }
+
+ if (list.length === 1) {
+ container.classList.remove('open');
+ container.innerHTML = `${esc(list[0].name)}`;
+ 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 = `
+
+
+ `;
+
+ 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]));
+}
diff --git a/frontend/sw-admin.js b/frontend/sw-admin.js
index 94d9ad9..26575ba 100644
--- a/frontend/sw-admin.js
+++ b/frontend/sw-admin.js
@@ -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))
+ );
});
diff --git a/server/routes/auth.js b/server/routes/auth.js
index 23ccc0d..dbbb612 100644
--- a/server/routes/auth.js
+++ b/server/routes/auth.js
@@ -293,14 +293,32 @@ 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
- FROM workspace_members wm
- JOIN workspaces w ON w.id = wm.workspace_id
- JOIN organizations o ON o.id = w.organization_id
- WHERE wm.user_id = ?
- ORDER BY o.name, w.name
- `).all(req.user.id);
+ // 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
+ WHERE wm.user_id = ?
+ ORDER BY o.name, w.name
+ `).all(req.user.id);
const currentOrg = req.organizationId
? db.prepare('SELECT id, name FROM organizations WHERE id = ?').get(req.organizationId)