diff --git a/apps/e2e/scripts/run-all.mjs b/apps/e2e/scripts/run-all.mjs index 71cfbd652..f9c879d89 100644 --- a/apps/e2e/scripts/run-all.mjs +++ b/apps/e2e/scripts/run-all.mjs @@ -107,8 +107,8 @@ const ALWAYS = [ }, { key: 'workspaces', - file: 'src/tests/workspaces.test.ts', - label: 'Workspaces: create, switch, settings', + file: 'src/tests/workspaces.test.ts src/tests/workspaces-sharing-limits.test.ts', + label: 'Workspaces: create, switch, settings, sharing limits', }, { key: 'sql-editor', diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts index 72075c972..8123afc03 100644 --- a/apps/e2e/src/generated/test-ids.ts +++ b/apps/e2e/src/generated/test-ids.ts @@ -639,6 +639,8 @@ export const TestIds = { adminWorkspaceMembers: (id: string | number) => `admin-workspace-members-${id}`, /** button · Open */ adminWorkspaceOpen: (id: string | number) => `admin-workspace-open-${id}`, + /** button · Restore */ + adminWorkspaceRestore: (id: string | number) => `admin-workspace-restore-${id}`, /** div */ adminWorkspaces: 'admin-workspaces', /** p */ @@ -3539,6 +3541,8 @@ export const TestIds = { WorkspaceSettingsDialog: { /** li */ workspaceInvite: (id: string | number) => `workspace-invite-${id}`, + /** p */ + workspaceInviteCeiling: 'workspace-invite-ceiling', /** input · teammate@company.com */ workspaceInviteEmail: 'workspace-invite-email', /** form */ @@ -3742,6 +3746,7 @@ export type TestId = | `admin-workspace-join-${string}` | `admin-workspace-members-${string}` | `admin-workspace-open-${string}` + | `admin-workspace-restore-${string}` | 'admin-workspaces' | 'admin-workspaces-error' | 'admin-workspaces-list' @@ -5203,6 +5208,7 @@ export type TestId = | `workflow-variables-environment-${string}` | 'workflow-view' | `workspace-invite-${string}` + | 'workspace-invite-ceiling' | 'workspace-invite-email' | 'workspace-invite-form' | 'workspace-invite-new-account' diff --git a/apps/e2e/src/helpers/accounts.ts b/apps/e2e/src/helpers/accounts.ts new file mode 100644 index 000000000..de84e15e0 --- /dev/null +++ b/apps/e2e/src/helpers/accounts.ts @@ -0,0 +1,52 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Calling the API directly, and acting as a second account in a browser. + * + * The calls go to the API port, not through the Vite proxy (see app-session.ts). + */ +import type { Page } from 'playwright'; +import { buildDriver, BASE_URL } from './driver.js'; +import { byTestId } from './test-ids.js'; + +export const API_URL = process.env.E2E_API_URL ?? 'http://127.0.0.1:3210'; + +export interface ApiResult { + status: number; + json: any; +} + +/** One request to `/api` as the session in `cookie`. A body that is not JSON reads as null. */ +export async function apiCall(method: string, path: string, body: unknown, cookie: string): Promise { + const res = await fetch(`${API_URL}/api${path}`, { + method, + headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), cookie }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + return { status: res.status, json: await res.json().catch(() => null) }; +} + +/** Signs `email` in and returns its session cookie as `name=value`. */ +export async function signInAs(email: string, password: string): Promise { + const res = await fetch(`${API_URL}/api/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + if (res.status !== 200) throw new Error(`Signing in as ${email} failed: HTTP ${res.status}`); + return (res.headers.get('set-cookie') ?? '').split(';')[0]!; +} + +/** A browser signed in with `cookie`, past onboarding. Close it with quitDriver. */ +export async function browserAs(cookie: string): Promise { + const page = await buildDriver({ signedIn: false }); + const [name, ...rest] = cookie.split('='); + await page.context().addCookies([{ name: name!, value: rest.join('='), url: BASE_URL }]); + await page.goto(BASE_URL); + await page.waitForSelector(`${byTestId('onboarding-skip')}, ${byTestId('profile-menu-trigger')}`, { timeout: 30_000 }); + const skip = page.locator(byTestId('onboarding-skip')); + if (await skip.count()) await skip.click(); + return page; +} diff --git a/apps/e2e/src/tests/workspaces-sharing-limits.test.ts b/apps/e2e/src/tests/workspaces-sharing-limits.test.ts new file mode 100644 index 000000000..d6f9ced54 --- /dev/null +++ b/apps/e2e/src/tests/workspaces-sharing-limits.test.ts @@ -0,0 +1,139 @@ +/** + * Sharing a workspace lends no more than the sharer holds, and an archived + * workspace can come back — in a real browser against the running server. + * + * A personal workspace's account runs who is in it whatever its own role, so + * before the ceiling a viewer account could invite a colleague as owner, and + * the colleague got DDL, DML and GRANT on the viewer's saved connections. + * + * Runs as the suites' admin plus two throwaway viewer accounts, and leaves the + * admin in its own workspace, where the other suites expect it. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import type { Page } from 'playwright'; +import { buildDriver, quitDriver, clickWhen, waitFor, BASE_URL } from '../helpers/driver.js'; +import { apiCall, browserAs, signInAs } from '../helpers/accounts.js'; +import { sessionCookie } from '../helpers/app-session.js'; +import { saveScreenshot } from '../helpers/screenshot.js'; +import { byTestId } from '../helpers/test-ids.js'; + +const RUN = Date.now().toString(36); +const SHARER_EMAIL = `e2e-wssharer-${RUN}@foxschema.test`; +const PEER_EMAIL = `e2e-wspeer-${RUN}@foxschema.test`; + +let admin = ''; +let adminPersonalId = ''; +let sharer = { id: '', cookie: '' }; +let peer = { id: '', cookie: '' }; +const created: string[] = []; +const pages: Page[] = []; + +const api = (method: string, path: string, body?: unknown, cookie = admin) => apiCall(method, path, body, cookie); + +/** A viewer account, signed in: its id and session cookie. */ +async function viewerAccount(email: string): Promise<{ id: string; cookie: string }> { + const password = `ws-${randomBytes(8).toString('hex')}`; + const added = await api('POST', '/admin/users', { email, password, role: 'viewer' }); + expect(added.status, JSON.stringify(added.json)).toBe(200); + return { id: added.json.user.id, cookie: await signInAs(email, password) }; +} + +/** A browser as `cookie`, closed after the suite. */ +async function browserFor(cookie: string): Promise { + const page = await browserAs(cookie); + pages.push(page); + return page; +} + +beforeAll(async () => { + admin = await sessionCookie(); + const list = await api('GET', '/workspaces'); + expect(list.status, JSON.stringify(list.json)).toBe(200); + adminPersonalId = list.json.workspaces.find((w: { personal: boolean }) => w.personal).id; + await api('POST', `/workspaces/${adminPersonalId}/select`, {}); + [sharer, peer] = await Promise.all([viewerAccount(SHARER_EMAIL), viewerAccount(PEER_EMAIL)]); +}, 120_000); + +afterAll(async () => { + for (const page of pages) await quitDriver(page); + for (const id of [sharer.id, peer.id]) if (id) await api('PUT', `/admin/users/${id}/active`, { active: false }); + for (const id of created) await api('POST', `/workspaces/${id}/archive`, {}); + await api('POST', `/workspaces/${adminPersonalId}/select`, {}); +}); + +describe('workspace sharing limits', () => { + it('a viewer sharing its own workspace is offered viewer, and the server refuses more', async () => { + const own = (await api('GET', '/workspaces', undefined, sharer.cookie)).json.workspaces.find( + (w: { personal: boolean }) => w.personal + ); + expect(own.role).toBe('viewer'); + + // The settings dialog offers what the server will accept, and says why. + const page = await browserFor(sharer.cookie); + await clickWhen(page, byTestId('workspace-rail-btn')); + await clickWhen(page, byTestId('workspace-menu-settings')); + await waitFor(page, byTestId('workspace-invite-role'), 15_000); + const offered = await page.evaluate(() => + [...(document.querySelector('[data-testid="workspace-invite-role"]') as HTMLSelectElement).options].map((o) => [ + o.value, + !o.disabled, + ]) + ); + expect(offered).toEqual([ + ['viewer', true], + ['editor', false], + ['owner', false], + ]); + expect(await page.locator(byTestId('workspace-invite-ceiling')).innerText()).toMatch(/up to viewer/); + await saveScreenshot(page, 'workspaces-sharing-ceiling'); + + // The server refuses the same, whatever the client sends. + for (const role of ['owner', 'editor']) { + const refused = await api('POST', `/workspaces/${own.id}/invites`, { email: PEER_EMAIL, role }, sharer.cookie); + expect(refused.status, `${role}: ${JSON.stringify(refused.json)}`).toBe(403); + } + const sent = await api('POST', `/workspaces/${own.id}/invites`, { email: PEER_EMAIL, role: 'viewer' }, sharer.cookie); + expect(sent.status, JSON.stringify(sent.json)).toBe(200); + + // The colleague joins as a viewer and acts as one: it can read, not write. + const accepted = await api('POST', `/workspaces/invites/${sent.json.invite.id}/accept`, {}, peer.cookie); + expect(accepted.status, JSON.stringify(accepted.json)).toBe(200); + expect((await api('POST', `/workspaces/${own.id}/select`, {}, peer.cookie)).status).toBe(200); + const me = (await api('GET', '/auth/me', undefined, peer.cookie)).json; + expect(me.workspace).toMatchObject({ id: own.id, role: 'viewer' }); + for (const p of ['editor.dml', 'editor.ddl', 'editor.grant', 'schema.migrate']) { + expect(me.user.permissions, p).not.toContain(p); + } + + // Nor can the sharer raise it afterwards. + const raised = await api('PUT', `/workspaces/${own.id}/members/${peer.id}`, { role: 'owner' }, sharer.cookie); + expect(raised.status, JSON.stringify(raised.json)).toBe(403); + }, 120_000); + + it('an admin restores an archived workspace from Access control', async () => { + const made = await api('POST', '/workspaces', { name: `e2e-restore-${RUN}` }); + expect(made.status, JSON.stringify(made.json)).toBe(200); + const id = made.json.workspace.id as string; + created.push(id); + expect((await api('POST', `/workspaces/${id}/archive`, {})).status).toBe(200); + expect((await api('GET', '/workspaces')).json.workspaces.some((w: { id: string }) => w.id === id)).toBe(false); + + const page = await buildDriver(); + pages.push(page); + await page.goto(BASE_URL); + await clickWhen(page, byTestId('profile-menu-trigger')); + await clickWhen(page, byTestId('profile-access-control')); + await clickWhen(page, byTestId('admin-tab-workspaces')); + await clickWhen(page, byTestId('admin-workspaces-show-archived')); + await clickWhen(page, byTestId(`admin-workspace-restore-${id}`)); + // Back: the row offers Members again instead of Restore. + await waitFor(page, byTestId(`admin-workspace-members-${id}`), 15_000); + expect(await page.locator(byTestId(`admin-workspace-restore-${id}`)).count()).toBe(0); + await saveScreenshot(page, 'workspaces-admin-restored'); + + expect((await api('GET', '/workspaces')).json.workspaces.find((w: { id: string }) => w.id === id)).toMatchObject({ + role: 'owner', + }); + }, 120_000); +}); diff --git a/apps/web/src/frontend/features/admin/components/AdminWorkspacesPanel.test.tsx b/apps/web/src/frontend/features/admin/components/AdminWorkspacesPanel.test.tsx index 74d25b558..fca3ad3cc 100644 --- a/apps/web/src/frontend/features/admin/components/AdminWorkspacesPanel.test.tsx +++ b/apps/web/src/frontend/features/admin/components/AdminWorkspacesPanel.test.tsx @@ -12,6 +12,7 @@ const apiAdminListWorkspaces = vi.fn(); const apiArchiveWorkspace = vi.fn(); const apiSelectWorkspace = vi.fn(); const apiSetWorkspaceMember = vi.fn(); +const apiRestoreWorkspace = vi.fn(); vi.mock('@/features/workspaces', () => ({ loadWorkspaceSettingsDialog: async () => ({ default: () => null }), @@ -19,6 +20,7 @@ vi.mock('@/features/workspaces', () => ({ apiArchiveWorkspace: (...a: unknown[]) => apiArchiveWorkspace(...a), apiSelectWorkspace: (...a: unknown[]) => apiSelectWorkspace(...a), apiSetWorkspaceMember: (...a: unknown[]) => apiSetWorkspaceMember(...a), + apiRestoreWorkspace: (...a: unknown[]) => apiRestoreWorkspace(...a), })); import { AdminWorkspacesPanel } from './AdminWorkspacesPanel'; @@ -28,7 +30,7 @@ const personal = { ...shared, id: 'ben-own', name: "ben's workspace", personalOw const joined = { ...shared, id: 'ops', name: 'Ops', adminIsMember: true }; beforeEach(() => { - for (const f of [apiAdminListWorkspaces, apiArchiveWorkspace, apiSelectWorkspace, apiSetWorkspaceMember]) f.mockReset(); + for (const f of [apiAdminListWorkspaces, apiArchiveWorkspace, apiSelectWorkspace, apiSetWorkspaceMember, apiRestoreWorkspace]) f.mockReset(); apiAdminListWorkspaces.mockResolvedValue([shared, personal, joined]); useAuthStore.setState({ user: { id: 'admin', email: 'admin@x.com', onboardingCompleted: true, role: 'admin', permissions: [] }, @@ -81,4 +83,18 @@ describe('AdminWorkspacesPanel', () => { fireEvent.click(screen.getByTestId('admin-workspaces-show-archived')); await waitFor(() => expect(apiAdminListWorkspaces).toHaveBeenLastCalledWith(true)); }); + + it('restores an archived workspace, and offers nothing else on it', async () => { + apiRestoreWorkspace.mockResolvedValue(undefined); + apiAdminListWorkspaces.mockResolvedValue([{ ...shared, archived: true }]); + render(); + await screen.findByTestId('admin-workspace-restore-books'); + for (const gone of ['join', 'open', 'members', 'archive']) { + expect(screen.queryByTestId(`admin-workspace-${gone}-books`), gone).toBeNull(); + } + fireEvent.click(screen.getByTestId('admin-workspace-restore-books')); + await waitFor(() => expect(apiRestoreWorkspace).toHaveBeenCalledWith('books')); + // And reads the list again, so the row shows it is back. + await waitFor(() => expect(apiAdminListWorkspaces).toHaveBeenCalledTimes(2)); + }); }); diff --git a/apps/web/src/frontend/features/admin/components/AdminWorkspacesPanel.tsx b/apps/web/src/frontend/features/admin/components/AdminWorkspacesPanel.tsx index b7dc753f0..7e34cb442 100644 --- a/apps/web/src/frontend/features/admin/components/AdminWorkspacesPanel.tsx +++ b/apps/web/src/frontend/features/admin/components/AdminWorkspacesPanel.tsx @@ -8,7 +8,8 @@ * * An admin opens one only by joining it, and joining puts the admin in its * member list, so nobody's workspace is looked into silently. From here an - * admin can also run its members (hand ownership on) and archive it. + * admin can also run its members (hand ownership on), archive it, and bring an + * archived one back. */ import React, { lazy, useCallback, useEffect, useState } from 'react'; import { Layers, Loader2 } from 'lucide-react'; @@ -17,6 +18,7 @@ import { MountWhenOpened } from '@/shared/components/MountWhenOpened'; import { apiAdminListWorkspaces, apiArchiveWorkspace, + apiRestoreWorkspace, apiSelectWorkspace, apiSetWorkspaceMember, loadWorkspaceSettingsDialog, @@ -101,10 +103,22 @@ export const AdminWorkspacesPanel: React.FC<{ reload?: () => void }> = ({ reload {w.memberCount} {w.memberCount === 1 ? 'member' : 'members'}

- {w.adminIsMember ? ( + {w.adminIsMember && ( + you’re in it + )} + {w.archived ? ( + + ) : ( <> - you’re in it - {!w.archived && ( + {w.adminIsMember ? ( + ) : ( + )} - - ) : ( - !w.archived && ( - ) - )} - {!w.archived && ( - - )} - {!w.archived && !w.personalOwner && ( - + {!w.personalOwner && ( + + )} + )} ))} diff --git a/apps/web/src/frontend/features/workspaces/api/workspacesApi.ts b/apps/web/src/frontend/features/workspaces/api/workspacesApi.ts index 634186bd3..1e26bfec8 100644 --- a/apps/web/src/frontend/features/workspaces/api/workspacesApi.ts +++ b/apps/web/src/frontend/features/workspaces/api/workspacesApi.ts @@ -7,9 +7,10 @@ * switching is `select` and a reload — every request after it, from any * tab, acts there. */ +import type { WorkspaceRole } from '@foxschema/shared'; import { api } from '@/shared/api/client'; -export type WorkspaceRole = 'viewer' | 'editor' | 'owner'; +export type { WorkspaceRole }; export interface WorkspaceItem { id: string; @@ -101,6 +102,11 @@ export async function apiArchiveWorkspace(id: string): Promise { await api.post(`/workspaces/${enc(id)}/archive`, {}); } +/** Admins: bring an archived workspace back, members and data as they were. */ +export async function apiRestoreWorkspace(id: string): Promise { + await api.post(`/workspaces/${enc(id)}/restore`, {}); +} + export async function apiWorkspaceMembers(id: string): Promise { return (await api.get<{ members: WorkspaceMember[] }>(`/workspaces/${enc(id)}/members`)).members; } diff --git a/apps/web/src/frontend/features/workspaces/components/WorkspaceRailButton.test.tsx b/apps/web/src/frontend/features/workspaces/components/WorkspaceRailButton.test.tsx index 27509e58d..068ca4dec 100644 --- a/apps/web/src/frontend/features/workspaces/components/WorkspaceRailButton.test.tsx +++ b/apps/web/src/frontend/features/workspaces/components/WorkspaceRailButton.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { useAuthStore } from '@/platform/authStore'; const apiListWorkspaces = vi.fn(); @@ -60,6 +60,14 @@ describe('WorkspaceRailButton', () => { expect(screen.getByText('One workspace at a time')).toBeTruthy(); }); + it('closes its menu on Escape', async () => { + render(); + fireEvent.click(await screen.findByTestId('workspace-rail-btn')); + await screen.findByTestId('workspace-rail-menu'); + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByTestId('workspace-rail-menu')).toBeNull()); + }); + it('marks the button when an invitation is waiting', async () => { apiMyInvites.mockResolvedValue([{ id: 'inv1' }]); render(); diff --git a/apps/web/src/frontend/features/workspaces/components/WorkspaceRailButton.tsx b/apps/web/src/frontend/features/workspaces/components/WorkspaceRailButton.tsx index 3147c9501..f36f9c8f7 100644 --- a/apps/web/src/frontend/features/workspaces/components/WorkspaceRailButton.tsx +++ b/apps/web/src/frontend/features/workspaces/components/WorkspaceRailButton.tsx @@ -74,8 +74,16 @@ export function WorkspaceRailButton({ if (anchorRef.current?.contains(target) || popoverRef.current?.contains(target)) return; setOpen(false); }; + // Escape closes it too, unless something inside used the key first. + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !event.defaultPrevented) setOpen(false); + }; document.addEventListener('mousedown', onClick); - return () => document.removeEventListener('mousedown', onClick); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onClick); + document.removeEventListener('keydown', onKey); + }; }, [open, anchorRef, popoverRef]); if (launch) return null; diff --git a/apps/web/src/frontend/features/workspaces/components/WorkspaceSettingsDialog.test.tsx b/apps/web/src/frontend/features/workspaces/components/WorkspaceSettingsDialog.test.tsx new file mode 100644 index 000000000..80982725e --- /dev/null +++ b/apps/web/src/frontend/features/workspaces/components/WorkspaceSettingsDialog.test.tsx @@ -0,0 +1,121 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * The roles the dialog offers are the roles the server accepts: nobody gives + * more than they can do, so a viewer sharing its own workspace is offered + * viewer and nothing above it. + */ +import React from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useAuthStore } from '@/platform/authStore'; + +const apiListWorkspaces = vi.fn(); +const apiWorkspaceMembers = vi.fn(); +const apiWorkspaceInvites = vi.fn(); + +vi.mock('../api/workspacesApi', () => ({ + apiListWorkspaces: (...a: unknown[]) => apiListWorkspaces(...a), + apiWorkspaceMembers: (...a: unknown[]) => apiWorkspaceMembers(...a), + apiWorkspaceInvites: (...a: unknown[]) => apiWorkspaceInvites(...a), + apiArchiveWorkspace: vi.fn(), + apiInviteToWorkspace: vi.fn(), + apiRevokeWorkspaceInvite: vi.fn(), + apiUpdateWorkspace: vi.fn(), + apiRemoveWorkspaceMember: vi.fn(), + apiRenameWorkspace: vi.fn(), + apiSetWorkspaceMember: vi.fn(), +})); + +import { WorkspaceSettingsDialog } from './WorkspaceSettingsDialog'; + +const personal = (role: string) => ({ + id: 'own', + name: "ana's workspace", + visibility: 'private', + joinRole: 'viewer', + personal: true, + role, + memberCount: 2, +}); +const team = { id: 'team', name: 'Data team', visibility: 'private', joinRole: 'viewer', personal: false, role: 'owner', memberCount: 2 }; +const guest = { userId: 'bo', email: 'bo@example.com', role: 'viewer', personalOwner: false, addedBy: null, createdAt: '' }; +const self = (role: string) => ({ userId: 'ana', email: 'ana@example.com', role, personalOwner: true, addedBy: null, createdAt: '' }); + +function signIn(role: string) { + useAuthStore.setState({ + user: { id: 'ana', email: 'ana@example.com', onboardingCompleted: true, role, permissions: [] }, + status: 'ready', + } as never); +} + +/** Opens the dialog as an account with `accountRole`, on `workspace` with these members. */ +async function openAs(accountRole: string, workspace: object & { id: string }, members: object[], onClose = () => undefined) { + signIn(accountRole); + apiListWorkspaces.mockResolvedValue({ currentId: workspace.id, workspaces: [workspace], canCreate: false }); + apiWorkspaceMembers.mockResolvedValue(members); + const view = render(); + await screen.findByTestId('workspace-member-role-bo'); + return view; +} + +/** The roles a select offers, and which of them can be picked. */ +function options(testId: string) { + return [...(screen.getByTestId(testId) as HTMLSelectElement).options].map((o) => ({ role: o.value, enabled: !o.disabled })); +} + +beforeEach(() => { + apiListWorkspaces.mockReset(); + apiWorkspaceMembers.mockReset(); + apiWorkspaceInvites.mockReset().mockResolvedValue([]); +}); + +describe('WorkspaceSettingsDialog · roles on offer', () => { + it('a viewer sharing its own workspace is offered viewer, and told why not more', async () => { + await openAs('viewer', personal('viewer'), [self('viewer'), guest]); + const expected = [ + { role: 'viewer', enabled: true }, + { role: 'editor', enabled: false }, + { role: 'owner', enabled: false }, + ]; + expect(options('workspace-invite-role')).toEqual(expected); + expect(options('workspace-member-role-bo')).toEqual(expected); + expect(screen.getByTestId('workspace-invite-ceiling').textContent).toMatch(/up to viewer/); + }); + + it('an editor account offers up to editor in its own workspace', async () => { + await openAs('editor', personal('editor'), [self('editor'), guest]); + expect(options('workspace-invite-role').filter((o) => o.enabled).map((o) => o.role)).toEqual(['viewer', 'editor']); + }); + + it('an owner of a shared workspace, and an admin anywhere, are offered every role', async () => { + const { unmount } = await openAs('editor', team, [{ ...self('owner'), personalOwner: false }, guest]); + expect(options('workspace-invite-role').every((o) => o.enabled)).toBe(true); + expect(screen.queryByTestId('workspace-invite-ceiling')).toBeNull(); + unmount(); + + await openAs('admin', personal('viewer'), [self('owner'), guest]); + expect(options('workspace-invite-role').every((o) => o.enabled)).toBe(true); + }); +}); + +describe('WorkspaceSettingsDialog · closing', () => { + it('Escape closes it', async () => { + const onClose = vi.fn(); + await openAs('editor', team, [guest], onClose); + fireEvent.keyDown(window, { key: 'Escape' }); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); + + it('Escape calls the onClose of the latest render', async () => { + const first = vi.fn(); + const latest = vi.fn(); + const { rerender } = await openAs('editor', team, [guest], first); + rerender(); + fireEvent.keyDown(window, { key: 'Escape' }); + await waitFor(() => expect(latest).toHaveBeenCalledTimes(1)); + expect(first).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/frontend/features/workspaces/components/WorkspaceSettingsDialog.tsx b/apps/web/src/frontend/features/workspaces/components/WorkspaceSettingsDialog.tsx index 3b2895350..738f9e7f5 100644 --- a/apps/web/src/frontend/features/workspaces/components/WorkspaceSettingsDialog.tsx +++ b/apps/web/src/frontend/features/workspaces/components/WorkspaceSettingsDialog.tsx @@ -7,9 +7,10 @@ * it and archiving it. What is editable follows the caller's role in this * workspace; the server enforces the same. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Layers, X } from 'lucide-react'; +import { WORKSPACE_ROLES, workspaceGrantCeiling, workspaceRoleAtMost } from '@foxschema/shared'; import { useAuthStore } from '@/platform/authStore'; import { apiArchiveWorkspace, @@ -29,8 +30,6 @@ import { type WorkspaceRole, } from '../api/workspacesApi'; -const ROLES: WorkspaceRole[] = ['viewer', 'editor', 'owner']; - export const WorkspaceSettingsDialog: React.FC<{ workspaceId: string; /** Its name when the caller is not a member (an admin managing it), so it is not in their list. */ @@ -66,6 +65,17 @@ export const WorkspaceSettingsDialog: React.FC<{ load().catch((e: unknown) => setError(e instanceof Error ? e.message : 'Could not load the workspace')); }, [load]); + // Registered once: both callers pass a fresh onClose on every render. + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !e.defaultPrevented) onCloseRef.current(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + const run = async (action: () => Promise, done?: string) => { setBusy(true); setError(null); @@ -81,9 +91,24 @@ export const WorkspaceSettingsDialog: React.FC<{ } }; - const isOwner = workspace?.role === 'owner' || me?.role === 'admin'; + /** + * The highest role the caller can give here: any for an admin, otherwise its + * own. In a personal workspace that is the account's role, so a viewer shares + * it as viewer. The same rule the server enforces (workspaceGrantCeiling). + */ + const grantCeiling = workspaceGrantCeiling(me?.role ?? '', workspace?.role ?? null); + const isOwner = grantCeiling === 'owner'; /** Who may invite and change roles: owners, admins, and everyone in their own workspace. */ const managesMembers = isOwner || workspace?.personal === true; + const ceilingHint = `You can give up to ${grantCeiling} here: nobody gives more than they can do themselves.`; + const roleOptions = WORKSPACE_ROLES.map((r) => { + const allowed = workspaceRoleAtMost(r, grantCeiling); + return ( + + ); + }); return createPortal(
setInviteRole(e.target.value as WorkspaceRole)} className="bg-slate-950 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-100" > - {ROLES.map((r) => ( - - ))} + {roleOptions}