Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions apps/e2e/src/generated/test-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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}`
Expand All @@ -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'
Expand Down
59 changes: 59 additions & 0 deletions apps/e2e/src/tests/workspaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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}`, {
Expand Down Expand Up @@ -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`, {});
});
Expand Down Expand Up @@ -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);
Expand All @@ -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);
});
});

Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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';
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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(<AdminAccessPanel open onClose={() => undefined} />);
fireEvent.click(await screen.findByTestId('admin-policy-owners-invite-new'));
await waitFor(() => expect(apiAdminSetOwnersCanInviteNew).toHaveBeenCalledWith(true));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
apiAdminSetUserActive,
apiAdminSetUserPassword,
apiAdminSetMembersCanCreateWorkspaces,
apiAdminSetOwnersCanInviteNew,
apiAdminSetPolicy,
apiAdminSetUserRole,
apiAdminTransferAdmin,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -559,6 +575,18 @@ export const AdminAccessPanel: React.FC<{ open: boolean; onClose: () => void }>
Members can create workspaces
<span className="text-slate-500">· otherwise only admins can; everyone always has their own</span>
</label>
<label className="inline-flex items-center gap-1.5 text-[11px] text-slate-300 basis-full">
<input
type="checkbox"
data-testid="admin-policy-owners-invite-new"
checked={policy.ownersCanInviteNew}
disabled={busy}
onChange={(e) => void changeOwnersInviteNew(e.target.checked)}
className="rounded border-slate-600 bg-slate-900"
/>
Workspace owners can invite new people
<span className="text-slate-500">· inviting an email with no account makes one (as a viewer)</span>
</label>
</div>
)}
<form
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/frontend/features/workspaces/api/workspacesApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,52 @@ export async function apiSetWorkspaceMember(id: string, userId: string, role: Wo
export async function apiRemoveWorkspaceMember(id: string, userId: string): Promise<void> {
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<WorkspaceInvite[]> {
return (await api.get<{ invites: WorkspaceInvite[] }>(`/workspaces/${enc(id)}/invites`)).invites;
}

export async function apiRevokeWorkspaceInvite(id: string, inviteId: string): Promise<void> {
await api.delete(`/workspaces/${enc(id)}/invites/${enc(inviteId)}`, {});
}

export async function apiMyInvites(): Promise<WorkspaceInvite[]> {
return (await api.get<{ invites: WorkspaceInvite[] }>('/workspaces/invites')).invites;
}

export async function apiAcceptInvite(inviteId: string): Promise<string> {
return (await api.post<{ workspaceId: string }>(`/workspaces/invites/${enc(inviteId)}/accept`, {})).workspaceId;
}

export async function apiDeclineInvite(inviteId: string): Promise<void> {
await api.post(`/workspaces/invites/${enc(inviteId)}/decline`, {});
}
Loading
Loading