diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts index 5f3e33bf6..ee7f93f3c 100644 --- a/apps/e2e/src/generated/test-ids.ts +++ b/apps/e2e/src/generated/test-ids.ts @@ -575,6 +575,8 @@ export const TestIds = { adminPolicyHint: 'admin-policy-hint', /** input */ adminPolicyMembersCreate: 'admin-policy-members-create', + /** input */ + adminPolicyOwnersInviteNew: 'admin-policy-owners-invite-new', /** p */ adminRolesHint: 'admin-roles-hint', /** button · Save password */ @@ -3413,12 +3415,38 @@ export const TestIds = { workspaceMenuCreateSubmit: 'workspace-menu-create-submit', /** p */ workspaceMenuError: 'workspace-menu-error', + /** div */ + workspaceMenuInvite: (id: string | number) => `workspace-menu-invite-${id}`, + /** button · Accept */ + workspaceMenuInviteAccept: (id: string | number) => `workspace-menu-invite-accept-${id}`, + /** button · Decline */ + workspaceMenuInviteDecline: (id: string | number) => `workspace-menu-invite-decline-${id}`, + /** div */ + workspaceMenuInvites: 'workspace-menu-invites', /** button */ workspaceMenuItem: (id: string | number) => `workspace-menu-item-${id}`, /** button · Workspace settings */ workspaceMenuSettings: 'workspace-menu-settings', }, WorkspaceSettingsDialog: { + /** li */ + workspaceInvite: (id: string | number) => `workspace-invite-${id}`, + /** input · teammate@company.com */ + workspaceInviteEmail: 'workspace-invite-email', + /** form */ + workspaceInviteForm: 'workspace-invite-form', + /** div */ + workspaceInviteNewAccount: 'workspace-invite-new-account', + /** span */ + workspaceInviteNewAccountCode: 'workspace-invite-new-account-code', + /** button · Revoke */ + workspaceInviteRevoke: (id: string | number) => `workspace-invite-revoke-${id}`, + /** select · Role for the invite */ + workspaceInviteRole: 'workspace-invite-role', + /** button · Invite */ + workspaceInviteSubmit: 'workspace-invite-submit', + /** ul */ + workspaceInvites: 'workspace-invites', /** li */ workspaceMember: (userId: string | number) => `workspace-member-${userId}`, /** button · Remove */ @@ -3568,6 +3596,7 @@ export type TestId = | `admin-policy-${string}` | 'admin-policy-hint' | 'admin-policy-members-create' + | 'admin-policy-owners-invite-new' | 'admin-roles-hint' | 'admin-save-password' | 'admin-save-role-perms' @@ -5019,6 +5048,15 @@ export type TestId = | `workflow-trigger-settings-workflow-${string}` | `workflow-variables-environment-${string}` | 'workflow-view' + | `workspace-invite-${string}` + | 'workspace-invite-email' + | 'workspace-invite-form' + | 'workspace-invite-new-account' + | 'workspace-invite-new-account-code' + | `workspace-invite-revoke-${string}` + | 'workspace-invite-role' + | 'workspace-invite-submit' + | 'workspace-invites' | `workspace-member-${string}` | `workspace-member-remove-${string}` | `workspace-member-role-${string}` @@ -5028,6 +5066,10 @@ export type TestId = | 'workspace-menu-create-name' | 'workspace-menu-create-submit' | 'workspace-menu-error' + | `workspace-menu-invite-${string}` + | `workspace-menu-invite-accept-${string}` + | `workspace-menu-invite-decline-${string}` + | 'workspace-menu-invites' | `workspace-menu-item-${string}` | 'workspace-menu-settings' | 'workspace-settings' diff --git a/apps/e2e/src/tests/workspaces.test.ts b/apps/e2e/src/tests/workspaces.test.ts index 02cb5ce60..39521c68e 100644 --- a/apps/e2e/src/tests/workspaces.test.ts +++ b/apps/e2e/src/tests/workspaces.test.ts @@ -6,6 +6,7 @@ * leftover "current workspace" would hide every one of them. */ 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 { sessionCookie } from '../helpers/app-session.js'; @@ -20,6 +21,11 @@ let admin = ''; let driver: Page; let personalId = ''; const created: string[] = []; +const pages: Page[] = []; +const GUEST_EMAIL = `e2e-wsguest-${RUN}@foxschema.test`; +const GUEST_PASSWORD = `guest-${randomBytes(8).toString('hex')}`; +let guestId = ''; +let sharedConnectionId = ''; async function api(method: string, path: string, body?: unknown, cookie = admin) { const res = await fetch(`${API_URL}/api${path}`, { @@ -48,6 +54,8 @@ beforeAll(async () => { afterAll(async () => { if (driver) await quitDriver(driver); + for (const page of pages) await quitDriver(page); + if (guestId) await api('PUT', `/admin/users/${guestId}/active`, { active: false }); await api('POST', `/workspaces/${personalId}/select`, {}); for (const id of created) await api('POST', `/workspaces/${id}/archive`, {}); }); @@ -82,6 +90,7 @@ describe('workspaces', () => { expect(saved.status, JSON.stringify(saved.json)).toBe(200); const inTeam = (await api('GET', '/connections')).json.connections.map((c: { id: string }) => c.id); expect(inTeam).toEqual([saved.json.connection.id]); + sharedConnectionId = saved.json.connection.id; await api('POST', `/workspaces/${personalId}/select`, {}); const inPersonal = (await api('GET', '/connections')).json.connections.map((c: { id: string }) => c.id); @@ -108,4 +117,54 @@ describe('workspaces', () => { expect(res.status).toBe(401); // Signed in but not a member is covered by the server tests; the id answers 404 there. }); + + it('an invited account accepts from its own menu, joins with the invited role, and sees the shared connection', async () => { + const teamId = created[0]!; + const added = await api('POST', '/admin/users', { email: GUEST_EMAIL, password: GUEST_PASSWORD, role: 'viewer' }); + expect(added.status, JSON.stringify(added.json)).toBe(200); + guestId = added.json.user.id; + + // The owner invites through Workspace settings. + await api('POST', `/workspaces/${teamId}/select`, {}); + await openMenu(); + await clickWhen(driver, byTestId('workspace-menu-settings')); + await driver.fill(byTestId('workspace-invite-email'), GUEST_EMAIL); + await driver.selectOption(byTestId('workspace-invite-role'), 'editor'); + await clickWhen(driver, byTestId('workspace-invite-submit')); + await waitFor(driver, `${byTestId('workspace-invites')} li`, 10_000); + await saveScreenshot(driver, 'workspaces-invite-pending'); + await api('POST', `/workspaces/${personalId}/select`, {}); + + // Not a member until it accepts. + const login = await fetch(`${API_URL}/api/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: GUEST_EMAIL, password: GUEST_PASSWORD }), + }); + expect(login.status).toBe(200); + const guestCookie = (login.headers.get('set-cookie') ?? '').split(';')[0]!; + expect((await api('POST', `/workspaces/${teamId}/select`, {}, guestCookie)).status).toBe(404); + + const guest = await buildDriver({ signedIn: false }); + pages.push(guest); + const [name, ...rest] = guestCookie.split('='); + await guest.context().addCookies([{ name: name!, value: rest.join('='), url: BASE_URL }]); + await guest.goto(BASE_URL); + // A first sign-in shows onboarding: wait for it or the app, and skip it. + await guest.waitForSelector(`${byTestId('onboarding-skip')}, ${byTestId('profile-menu-trigger')}`, { timeout: 30_000 }); + const skip = guest.locator(byTestId('onboarding-skip')); + if (await skip.count()) await skip.click(); + await clickWhen(guest, byTestId('profile-menu-trigger')); + const inviteRow = guest.locator(`${byTestId('workspace-menu-invites')} [data-testid^="workspace-menu-invite-accept-"]`); + await inviteRow.waitFor({ timeout: 15_000 }); + await saveScreenshot(guest, 'workspaces-invite-received'); + await Promise.all([guest.waitForEvent('load'), inviteRow.click()]); + + const mine = await api('GET', '/workspaces', undefined, guestCookie); + expect(mine.json.currentId).toBe(teamId); + expect(mine.json.workspaces.find((w: { id: string }) => w.id === teamId).role).toBe('editor'); + const conns = (await api('GET', '/connections', undefined, guestCookie)).json.connections.map((c: { id: string }) => c.id); + expect(conns).toContain(sharedConnectionId); + }); }); + diff --git a/apps/web/src/frontend/features/admin/components/AdminAccessPanel.test.tsx b/apps/web/src/frontend/features/admin/components/AdminAccessPanel.test.tsx index 0df5fbef3..4a36b9331 100644 --- a/apps/web/src/frontend/features/admin/components/AdminAccessPanel.test.tsx +++ b/apps/web/src/frontend/features/admin/components/AdminAccessPanel.test.tsx @@ -21,6 +21,7 @@ const apiAdminGetPolicy = vi.fn(); const apiAdminSetPolicy = vi.fn(); const apiAdminTransferAdmin = vi.fn(); const apiAdminSetMembersCanCreateWorkspaces = vi.fn(); +const apiAdminSetOwnersCanInviteNew = vi.fn(); vi.mock('@/shared/api/authApi', () => ({ apiAdminCreateUser: (...args: unknown[]) => apiAdminCreateUser(...args), @@ -35,6 +36,7 @@ vi.mock('@/shared/api/authApi', () => ({ apiAdminSetPolicy: (...args: unknown[]) => apiAdminSetPolicy(...args), apiAdminTransferAdmin: (...args: unknown[]) => apiAdminTransferAdmin(...args), apiAdminSetMembersCanCreateWorkspaces: (...args: unknown[]) => apiAdminSetMembersCanCreateWorkspaces(...args), + apiAdminSetOwnersCanInviteNew: (...args: unknown[]) => apiAdminSetOwnersCanInviteNew(...args), })); import { AdminAccessPanel } from './AdminAccessPanel'; @@ -62,7 +64,7 @@ beforeEach(() => { apiAdminTransferAdmin.mockReset(); apiAdminListUsers.mockResolvedValue({ users: [localUser] }); - apiAdminGetPolicy.mockResolvedValue({ adminPolicy: 'several', source: 'app', activeAdmins: [localUser.email], membersCanCreateWorkspaces: false }); + apiAdminGetPolicy.mockResolvedValue({ adminPolicy: 'several', source: 'app', activeAdmins: [localUser.email], membersCanCreateWorkspaces: false, ownersCanInviteNew: false }); apiAdminRolePermissions.mockResolvedValue({ matrix: { viewer: [...DEFAULT_ROLE_PERMISSIONS.viewer], @@ -438,3 +440,12 @@ describe('AdminAccessPanel — who creates workspaces', () => { expect(await screen.findByText(/everyone can now create workspaces/i)).toBeTruthy(); }); }); + +describe('AdminAccessPanel — who invites new people', () => { + it('lets the admin let workspace owners invite emails with no account', async () => { + apiAdminSetOwnersCanInviteNew.mockResolvedValue(undefined); + render( undefined} />); + fireEvent.click(await screen.findByTestId('admin-policy-owners-invite-new')); + await waitFor(() => expect(apiAdminSetOwnersCanInviteNew).toHaveBeenCalledWith(true)); + }); +}); diff --git a/apps/web/src/frontend/features/admin/components/AdminAccessPanel.tsx b/apps/web/src/frontend/features/admin/components/AdminAccessPanel.tsx index 5198cf4fd..fcec104d1 100644 --- a/apps/web/src/frontend/features/admin/components/AdminAccessPanel.tsx +++ b/apps/web/src/frontend/features/admin/components/AdminAccessPanel.tsx @@ -19,6 +19,7 @@ import { apiAdminSetUserActive, apiAdminSetUserPassword, apiAdminSetMembersCanCreateWorkspaces, + apiAdminSetOwnersCanInviteNew, apiAdminSetPolicy, apiAdminSetUserRole, apiAdminTransferAdmin, @@ -273,6 +274,21 @@ export const AdminAccessPanel: React.FC<{ open: boolean; onClose: () => void }> } }; + const changeOwnersInviteNew = async (on: boolean) => { + setBusy(true); + setError(null); + setSavedMsg(null); + try { + await apiAdminSetOwnersCanInviteNew(on); + setSavedMsg(on ? 'Workspace owners can now invite new people.' : 'Only admins add new people now.'); + await load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Could not change who invites new people'); + } finally { + setBusy(false); + } + }; + /** Hand the admin role over; the signed-in admin becomes an owner. */ const transferAdmin = async (user: AdminUserRow) => { if (!window.confirm(`Make ${user.email} the admin? You become an owner and lose admin access.`)) return; @@ -559,6 +575,18 @@ export const AdminAccessPanel: React.FC<{ open: boolean; onClose: () => void }> Members can create workspaces · otherwise only admins can; everyone always has their own + )}
{ await api.delete(`/workspaces/${enc(id)}/members/${enc(userId)}`, {}); } + +// --- Invites ----------------------------------------------------------------- + +export interface WorkspaceInvite { + id: string; + workspaceId: string; + workspaceName: string; + email: string; + role: WorkspaceRole; + invitedBy: string; + createdAt: string; + expiresAt: string; +} + +/** When inviting made the account: how its code went out, and the code itself for an admin. */ +export interface NewAccountCode { + code?: string; + link?: string; + expiresAt: string; + delivery: 'email' | 'log' | 'failed'; +} + +export async function apiInviteToWorkspace( + id: string, + email: string, + role: WorkspaceRole +): Promise<{ invite: WorkspaceInvite; newAccount?: NewAccountCode }> { + return api.post(`/workspaces/${enc(id)}/invites`, { email, role }); +} + +export async function apiWorkspaceInvites(id: string): Promise { + return (await api.get<{ invites: WorkspaceInvite[] }>(`/workspaces/${enc(id)}/invites`)).invites; +} + +export async function apiRevokeWorkspaceInvite(id: string, inviteId: string): Promise { + await api.delete(`/workspaces/${enc(id)}/invites/${enc(inviteId)}`, {}); +} + +export async function apiMyInvites(): Promise { + return (await api.get<{ invites: WorkspaceInvite[] }>('/workspaces/invites')).invites; +} + +export async function apiAcceptInvite(inviteId: string): Promise { + return (await api.post<{ workspaceId: string }>(`/workspaces/invites/${enc(inviteId)}/accept`, {})).workspaceId; +} + +export async function apiDeclineInvite(inviteId: string): Promise { + await api.post(`/workspaces/invites/${enc(inviteId)}/decline`, {}); +} diff --git a/apps/web/src/frontend/features/workspaces/components/WorkspaceMenu.test.tsx b/apps/web/src/frontend/features/workspaces/components/WorkspaceMenu.test.tsx index ec7051ae7..45f9d3980 100644 --- a/apps/web/src/frontend/features/workspaces/components/WorkspaceMenu.test.tsx +++ b/apps/web/src/frontend/features/workspaces/components/WorkspaceMenu.test.tsx @@ -16,6 +16,12 @@ const apiSetWorkspaceMember = vi.fn(); const apiRemoveWorkspaceMember = vi.fn(); const apiRenameWorkspace = vi.fn(); const apiArchiveWorkspace = vi.fn(); +const apiMyInvites = vi.fn(); +const apiAcceptInvite = vi.fn(); +const apiDeclineInvite = vi.fn(); +const apiInviteToWorkspace = vi.fn(); +const apiWorkspaceInvites = vi.fn(); +const apiRevokeWorkspaceInvite = vi.fn(); vi.mock('../api/workspacesApi', () => ({ apiListWorkspaces: (...a: unknown[]) => apiListWorkspaces(...a), @@ -26,6 +32,12 @@ vi.mock('../api/workspacesApi', () => ({ apiRemoveWorkspaceMember: (...a: unknown[]) => apiRemoveWorkspaceMember(...a), apiRenameWorkspace: (...a: unknown[]) => apiRenameWorkspace(...a), apiArchiveWorkspace: (...a: unknown[]) => apiArchiveWorkspace(...a), + apiMyInvites: (...a: unknown[]) => apiMyInvites(...a), + apiAcceptInvite: (...a: unknown[]) => apiAcceptInvite(...a), + apiDeclineInvite: (...a: unknown[]) => apiDeclineInvite(...a), + apiInviteToWorkspace: (...a: unknown[]) => apiInviteToWorkspace(...a), + apiWorkspaceInvites: (...a: unknown[]) => apiWorkspaceInvites(...a), + apiRevokeWorkspaceInvite: (...a: unknown[]) => apiRevokeWorkspaceInvite(...a), })); import { WorkspaceMenu } from './WorkspaceMenu'; @@ -35,7 +47,9 @@ const own = { id: 'own', name: "ana's workspace", visibility: 'private', persona const team = { id: 'team', name: 'Data team', visibility: 'private', personal: false, role: 'owner', memberCount: 2 }; beforeEach(() => { - for (const f of [apiListWorkspaces, apiCreateWorkspace, apiSelectWorkspace, apiWorkspaceMembers, apiSetWorkspaceMember, apiRemoveWorkspaceMember, apiRenameWorkspace, apiArchiveWorkspace]) f.mockReset(); + for (const f of [apiListWorkspaces, apiCreateWorkspace, apiSelectWorkspace, apiWorkspaceMembers, apiSetWorkspaceMember, apiRemoveWorkspaceMember, apiRenameWorkspace, apiArchiveWorkspace, apiMyInvites, apiAcceptInvite, apiDeclineInvite, apiInviteToWorkspace, apiWorkspaceInvites, apiRevokeWorkspaceInvite]) f.mockReset(); + apiMyInvites.mockResolvedValue([]); + apiWorkspaceInvites.mockResolvedValue([]); apiListWorkspaces.mockResolvedValue({ currentId: 'own', workspaces: [own, team], canCreate: false }); useAuthStore.setState({ user: { id: 'ana', email: 'ana@example.com', onboardingCompleted: true, role: 'editor', permissions: [] }, @@ -129,3 +143,57 @@ describe('WorkspaceSettingsDialog', () => { expect(screen.queryByTestId('workspace-settings-archive')).toBeNull(); }); }); + +describe('invitations', () => { + const invite = { id: 'inv1', workspaceId: 'team', workspaceName: 'Data team', email: 'ana@example.com', role: 'editor', invitedBy: 'boss@example.com', createdAt: '', expiresAt: '' }; + + it('the menu shows invitations; accepting joins and goes there', async () => { + apiMyInvites.mockResolvedValue([invite]); + apiAcceptInvite.mockResolvedValue('team'); + apiSelectWorkspace.mockResolvedValue(undefined); + const reload = vi.fn(); + render( undefined} reload={reload} />); + expect((await screen.findByTestId('workspace-menu-invite-inv1')).textContent).toMatch(/data team.*editor/i); + fireEvent.click(screen.getByTestId('workspace-menu-invite-accept-inv1')); + await waitFor(() => expect(apiAcceptInvite).toHaveBeenCalledWith('inv1')); + await waitFor(() => expect(apiSelectWorkspace).toHaveBeenCalledWith('team')); + expect(reload).toHaveBeenCalled(); + }); + + it('declining drops the invitation without joining', async () => { + apiMyInvites.mockResolvedValue([invite]); + apiDeclineInvite.mockResolvedValue(undefined); + render( undefined} reload={vi.fn()} />); + fireEvent.click(await screen.findByTestId('workspace-menu-invite-decline-inv1')); + await waitFor(() => expect(screen.queryByTestId('workspace-menu-invite-inv1')).toBeNull()); + expect(apiAcceptInvite).not.toHaveBeenCalled(); + }); + + it('settings: inviting an email with no account shows the code to pass on', async () => { + apiWorkspaceMembers.mockResolvedValue([]); + apiInviteToWorkspace.mockResolvedValue({ invite, newAccount: { code: 'ABCD-EFGH', link: '', expiresAt: '', delivery: 'log' } }); + render( undefined} reload={vi.fn()} />); + fireEvent.change(await screen.findByTestId('workspace-invite-email'), { target: { value: 'New@Example.com' } }); + fireEvent.change(screen.getByTestId('workspace-invite-role'), { target: { value: 'editor' } }); + fireEvent.submit(screen.getByTestId('workspace-invite-form')); + await waitFor(() => expect(apiInviteToWorkspace).toHaveBeenCalledWith('team', 'new@example.com', 'editor')); + expect((await screen.findByTestId('workspace-invite-new-account-code')).textContent).toBe('ABCD-EFGH'); + }); + + it('settings: everyone may invite into their own workspace, even as a viewer there', async () => { + apiListWorkspaces.mockResolvedValue({ currentId: 'own', workspaces: [{ ...own, role: 'viewer' }], canCreate: false }); + apiWorkspaceMembers.mockResolvedValue([]); + render( undefined} reload={vi.fn()} />); + expect(await screen.findByTestId('workspace-invite-form')).toBeTruthy(); + }); + + it('settings: a viewer in a shared workspace cannot invite', async () => { + apiListWorkspaces.mockResolvedValue({ currentId: 'team', workspaces: [{ ...team, role: 'viewer' }], canCreate: false }); + apiWorkspaceMembers.mockResolvedValue([]); + render( undefined} reload={vi.fn()} />); + await screen.findByTestId('workspace-settings-members'); + await waitFor(() => expect(screen.getByTestId('workspace-settings-name')).toBeTruthy()); + expect(screen.queryByTestId('workspace-invite-form')).toBeNull(); + }); +}); + diff --git a/apps/web/src/frontend/features/workspaces/components/WorkspaceMenu.tsx b/apps/web/src/frontend/features/workspaces/components/WorkspaceMenu.tsx index a93ea27f9..28dff88fb 100644 --- a/apps/web/src/frontend/features/workspaces/components/WorkspaceMenu.tsx +++ b/apps/web/src/frontend/features/workspaces/components/WorkspaceMenu.tsx @@ -10,9 +10,13 @@ import React, { useEffect, useState } from 'react'; import { Check, Layers, Plus, Settings2 } from 'lucide-react'; import { + apiAcceptInvite, apiCreateWorkspace, + apiDeclineInvite, apiListWorkspaces, + apiMyInvites, apiSelectWorkspace, + type WorkspaceInvite, type WorkspaceList, } from '../api/workspacesApi'; @@ -26,9 +30,13 @@ export const WorkspaceMenu: React.FC<{ const [creating, setCreating] = useState(false); const [name, setName] = useState(''); const [busy, setBusy] = useState(false); + const [invites, setInvites] = useState([]); useEffect(() => { let alive = true; + apiMyInvites() + .then((i) => alive && setInvites(i)) + .catch(() => undefined); apiListWorkspaces() .then((l) => alive && setList(l)) .catch((e: unknown) => alive && setError(e instanceof Error ? e.message : 'Could not load workspaces')); @@ -59,6 +67,24 @@ export const WorkspaceMenu: React.FC<{ } }; + /** Accepting joins and goes there; declining just drops it. */ + const answer = async (invite: WorkspaceInvite, accept: boolean) => { + setBusy(true); + setError(null); + try { + if (accept) { + await apiSelectWorkspace(await apiAcceptInvite(invite.id)); + reload(); + return; + } + await apiDeclineInvite(invite.id); + setInvites((prev) => prev.filter((i) => i.id !== invite.id)); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Could not answer the invite'); + } + setBusy(false); + }; + const create = async (e: React.FormEvent) => { e.preventDefault(); setBusy(true); @@ -75,6 +101,40 @@ export const WorkspaceMenu: React.FC<{ return (
+ {invites.length > 0 && ( +
+

Invitations

+ {invites.map((i) => ( +
+

+ {i.workspaceName} + · {i.role} +

+

from {i.invitedBy}

+
+ + +
+
+ ))} +
+ )}

Workspace

    {list.workspaces.map((w) => ( diff --git a/apps/web/src/frontend/features/workspaces/components/WorkspaceSettingsDialog.tsx b/apps/web/src/frontend/features/workspaces/components/WorkspaceSettingsDialog.tsx index be7796862..9ef0f662e 100644 --- a/apps/web/src/frontend/features/workspaces/components/WorkspaceSettingsDialog.tsx +++ b/apps/web/src/frontend/features/workspaces/components/WorkspaceSettingsDialog.tsx @@ -13,11 +13,16 @@ import { Layers, X } from 'lucide-react'; import { useAuthStore } from '@/app/store/authStore'; import { apiArchiveWorkspace, + apiInviteToWorkspace, apiListWorkspaces, + apiRevokeWorkspaceInvite, + apiWorkspaceInvites, apiRemoveWorkspaceMember, apiRenameWorkspace, apiSetWorkspaceMember, apiWorkspaceMembers, + type NewAccountCode, + type WorkspaceInvite, type WorkspaceItem, type WorkspaceMember, type WorkspaceRole, @@ -38,6 +43,11 @@ export const WorkspaceSettingsDialog: React.FC<{ const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [saved, setSaved] = useState(null); + const [invites, setInvites] = useState([]); + const [inviteEmail, setInviteEmail] = useState(''); + const [inviteRole, setInviteRole] = useState('viewer'); + /** The account code to pass on, when an invite just made the account. */ + const [newAccount, setNewAccount] = useState<(NewAccountCode & { email: string }) | null>(null); const load = useCallback(async () => { const [list, people] = await Promise.all([apiListWorkspaces(), apiWorkspaceMembers(workspaceId)]); @@ -45,6 +55,8 @@ export const WorkspaceSettingsDialog: React.FC<{ setWorkspace(ws); setName(ws?.name ?? ''); setMembers(people); + // Pending invites are for those who run the members; others get a 403 and see none. + setInvites(await apiWorkspaceInvites(workspaceId).catch(() => [])); }, [workspaceId]); useEffect(() => { @@ -67,6 +79,8 @@ export const WorkspaceSettingsDialog: React.FC<{ }; const isOwner = workspace?.role === 'owner' || me?.role === 'admin'; + /** Who may invite and change roles: owners, admins, and everyone in their own workspace. */ + const managesMembers = isOwner || workspace?.personal === true; return createPortal(
    + {managesMembers && ( +
    + { + e.preventDefault(); + const email = inviteEmail.trim().toLowerCase(); + void run(async () => { + const { newAccount: made } = await apiInviteToWorkspace(workspaceId, email, inviteRole); + setNewAccount(made ? { ...made, email } : null); + setInviteEmail(''); + }, `Invited ${email}. They join when they accept.`); + }} + className="flex flex-wrap items-end gap-2" + > + + + + + {newAccount && ( +
    + {newAccount.delivery === 'email' + ? `${newAccount.email} had no account, so one was made and its invite emailed.` + : newAccount.code + ? `${newAccount.email} had no account, so one was made. Pass this one-time code on yourself:` + : `${newAccount.email} had no account, so one was made, but email is not set up. Ask an admin to pass its invite on (App users → Resend invite).`} + {newAccount.delivery !== 'email' && newAccount.code && ( + + {newAccount.code} + + )} +
    + )} + {invites.length > 0 && ( +
      + {invites.map((i) => ( +
    • + {i.email} + {i.role} · invited + +
    • + ))} +
    + )} +
    + )} +

    Members · {members.length} @@ -148,7 +247,7 @@ export const WorkspaceSettingsDialog: React.FC<{ {m.email} {self && (you)} - {isOwner && !m.personalOwner ? ( + {managesMembers && !m.personalOwner ? (