From 26a3b97e0620c342ed195020ba9473aa3e560fd0 Mon Sep 17 00:00:00 2001 From: MichaelAndish Date: Sat, 4 Jul 2026 22:42:56 +0200 Subject: [PATCH 1/4] feat: enhance roles and permissions management in database and frontend - Updated database migration scripts to improve the roles and permissions structure, including new indices for better query performance. - Refactored SQL seed files to align with the updated permissions model, introducing a global role catalog and ensuring consistency across the application. - Added new role and permission mappings in the seed files to support the updated RBAC system, enhancing user access control. - Introduced new frontend components and tests for managing workspace roles and permissions, improving user experience and functionality. This commit enhances the overall roles and permissions handling, ensuring better alignment between the database and frontend components. --- .../20260318000000_init_gateway.up.sql | 57 ++++++----- database/seeds/001_seed_permissions.sql | 97 ++++++++++++------- database/seeds/004_demo_workspace.sql | 14 +-- frontend/src/core/auth/authorization.test.ts | 38 ++++++++ frontend/src/core/auth/authorization.ts | 32 ++++++ frontend/src/core/auth/can.test.tsx | 51 ++++++++++ frontend/src/core/auth/can.tsx | 57 +++++++++++ frontend/src/core/auth/index.ts | 20 ++++ frontend/src/core/auth/permissions.ts | 83 ++++++++++++++++ .../auth/workspace-authorization-context.tsx | 41 ++++++++ .../api-key-row/api-key-row.test.tsx | 27 +++++- .../components/api-key-row/api-key-row.tsx | 48 +++++---- .../settings/pages/settings.page.test.tsx | 31 +++++- .../features/settings/pages/settings.page.tsx | 43 +++++--- .../workspace-layout/workspace-layout.tsx | 8 +- internal/core/domain/permission.go | 28 +++++- internal/core/domain/permission_test.go | 16 +++ internal/core/service/portal_service.go | 16 +-- .../service/portal_service_extended_test.go | 16 +++ 19 files changed, 602 insertions(+), 121 deletions(-) create mode 100644 frontend/src/core/auth/authorization.test.ts create mode 100644 frontend/src/core/auth/authorization.ts create mode 100644 frontend/src/core/auth/can.test.tsx create mode 100644 frontend/src/core/auth/can.tsx create mode 100644 frontend/src/core/auth/index.ts create mode 100644 frontend/src/core/auth/permissions.ts create mode 100644 frontend/src/core/auth/workspace-authorization-context.tsx create mode 100644 internal/core/domain/permission_test.go diff --git a/database/migrations/20260318000000_init_gateway.up.sql b/database/migrations/20260318000000_init_gateway.up.sql index 07207d64..bdd658ff 100644 --- a/database/migrations/20260318000000_init_gateway.up.sql +++ b/database/migrations/20260318000000_init_gateway.up.sql @@ -67,20 +67,21 @@ CREATE INDEX idx_workspaces_active_list WHERE status = 'active'; -- ============================================================================== --- 3. ROLES TABLE (RBAC) +-- 3. ROLES TABLE (wpd-gogate RBAC — global role catalog) -- ============================================================================== CREATE TABLE roles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, - guard_name TEXT NOT NULL DEFAULT 'msg_web', - display_name TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + guard_name TEXT NOT NULL DEFAULT 'msg_web', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT roles_name_guard_unique UNIQUE (name, guard_name) ); +CREATE INDEX idx_roles_guard_name ON roles (guard_name); + CREATE TRIGGER trg_roles_set_updated_at BEFORE UPDATE ON roles FOR EACH ROW EXECUTE FUNCTION set_updated_at(); @@ -393,25 +394,27 @@ CREATE INDEX idx_workspace_access_audits_actor_user_id WHERE actor_user_id IS NOT NULL; -- ============================================================================== --- 16. PERMISSIONS TABLE (RBAC Engine) +-- 16. PERMISSIONS TABLE (wpd-gogate RBAC) -- ============================================================================== CREATE TABLE permissions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, - guard_name TEXT NOT NULL DEFAULT 'msg_web', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + guard_name TEXT NOT NULL DEFAULT 'msg_web', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT permissions_name_guard_unique UNIQUE (name, guard_name) ); +CREATE INDEX idx_permissions_guard_name ON permissions (guard_name); + CREATE TRIGGER trg_permissions_set_updated_at BEFORE UPDATE ON permissions FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- ============================================================================== --- 17. ROLE_HAS_PERMISSIONS JOIN TABLE (RBAC Engine) +-- 17. ROLE_HAS_PERMISSIONS (wpd-gogate RBAC) -- ============================================================================== CREATE TABLE role_has_permissions ( @@ -421,16 +424,18 @@ CREATE TABLE role_has_permissions ( PRIMARY KEY (permission_id, role_id) ); +CREATE INDEX idx_role_has_permissions_role_id ON role_has_permissions (role_id); + -- ============================================================================== --- 18. MODEL_HAS_ROLES TABLE (Polymorphic RBAC mappings) +-- 18. MODEL_HAS_ROLES (wpd-gogate polymorphic role assignments; team_id = workspace) -- ============================================================================== CREATE TABLE model_has_roles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - model_type TEXT NOT NULL, - model_id UUID NOT NULL, - team_id UUID REFERENCES workspaces(id) ON DELETE CASCADE, + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + model_type TEXT NOT NULL, + model_id UUID NOT NULL, + team_id UUID REFERENCES workspaces(id) ON DELETE CASCADE, CONSTRAINT model_has_roles_unique UNIQUE NULLS NOT DISTINCT (role_id, model_id, model_type, team_id) ); @@ -438,15 +443,15 @@ CREATE TABLE model_has_roles ( CREATE INDEX idx_model_has_roles_lookup ON model_has_roles (model_id, model_type, team_id); -- ============================================================================== --- 19. MODEL_HAS_PERMISSIONS TABLE (Polymorphic RBAC mappings) +-- 19. MODEL_HAS_PERMISSIONS (wpd-gogate direct permission overrides; team_id = workspace) -- ============================================================================== CREATE TABLE model_has_permissions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, - model_type TEXT NOT NULL, - model_id UUID NOT NULL, - team_id UUID REFERENCES workspaces(id) ON DELETE CASCADE, + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + model_type TEXT NOT NULL, + model_id UUID NOT NULL, + team_id UUID REFERENCES workspaces(id) ON DELETE CASCADE, CONSTRAINT model_has_permissions_unique UNIQUE NULLS NOT DISTINCT (permission_id, model_id, model_type, team_id) ); diff --git a/database/seeds/001_seed_permissions.sql b/database/seeds/001_seed_permissions.sql index d13a1eb7..da67b4fb 100644 --- a/database/seeds/001_seed_permissions.sql +++ b/database/seeds/001_seed_permissions.sql @@ -1,56 +1,85 @@ --- Default RBAC Permissions & Role Bindings for WPD Message Gateway +-- Default RBAC roles, permissions, and mappings for WPD Message Gateway. +-- Schema and assignment model follow wpd-gogate (global roles; workspace scope via team_id). +-- Guard name must match domain.RBACGuardName and gogate.DefaultGuardName in wire.go. --- 1. Insert default roles -INSERT INTO roles (id, name, display_name, guard_name) +-- 1. Global workspace roles (available to every workspace via model_has_roles.team_id) +INSERT INTO roles (name, guard_name) VALUES - ('00000000-0000-0000-0000-000000000020', 'admin', 'Admin', 'msg_web'), - ('00000000-0000-0000-0000-000000000021', 'member', 'Member', 'msg_web') + ('admin', 'msg_web'), + ('member', 'msg_web'), + ('viewer', 'msg_web') ON CONFLICT (name, guard_name) DO NOTHING; --- 2. Insert permissions -INSERT INTO permissions (id, name, guard_name) +-- 2. Portal permissions +INSERT INTO permissions (name, guard_name) VALUES - (gen_random_uuid(), 'workspaces.read', 'msg_web'), - (gen_random_uuid(), 'workspaces.write', 'msg_web'), - (gen_random_uuid(), 'members.read', 'msg_web'), - (gen_random_uuid(), 'members.write', 'msg_web'), - (gen_random_uuid(), 'apikeys.read', 'msg_web'), - (gen_random_uuid(), 'apikeys.write', 'msg_web'), - (gen_random_uuid(), 'logs.read', 'msg_web'), - (gen_random_uuid(), 'integrations.read', 'msg_web'), - (gen_random_uuid(), 'integrations.write', 'msg_web'), - (gen_random_uuid(), 'templates.read', 'msg_web'), - (gen_random_uuid(), 'templates.write', 'msg_web'), - (gen_random_uuid(), 'settings.read', 'msg_web'), - (gen_random_uuid(), 'settings.write', 'msg_web'), - (gen_random_uuid(), 'invitations.read', 'msg_web'), - (gen_random_uuid(), 'invitations.write', 'msg_web'), - (gen_random_uuid(), 'inbox.write', 'msg_web') + ('workspaces.read', 'msg_web'), + ('workspaces.write', 'msg_web'), + ('members.read', 'msg_web'), + ('members.write', 'msg_web'), + ('apikeys.read', 'msg_web'), + ('apikeys.write', 'msg_web'), + ('logs.read', 'msg_web'), + ('integrations.read', 'msg_web'), + ('integrations.write', 'msg_web'), + ('templates.read', 'msg_web'), + ('templates.write', 'msg_web'), + ('settings.read', 'msg_web'), + ('settings.write', 'msg_web'), + ('invitations.read', 'msg_web'), + ('invitations.write', 'msg_web'), + ('inbox.write', 'msg_web') ON CONFLICT (name, guard_name) DO NOTHING; --- 3. Bind permissions to 'admin' role -INSERT INTO role_has_permissions (permission_id, role_id) -SELECT p.id, r.id -FROM permissions p -CROSS JOIN roles r -WHERE r.name = 'admin' +-- 3. Admin — full access +INSERT INTO role_has_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +CROSS JOIN permissions p +WHERE r.name = 'admin' AND r.guard_name = 'msg_web' AND p.guard_name = 'msg_web' ON CONFLICT (permission_id, role_id) DO NOTHING; --- 4. Bind permissions to 'member' role (read-only + inbox.write) -INSERT INTO role_has_permissions (permission_id, role_id) -SELECT p.id, r.id -FROM permissions p -CROSS JOIN roles r +-- 4. Member — manage workspace resources; cannot change workspace, members, or invitations +INSERT INTO role_has_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +CROSS JOIN permissions p WHERE r.name = 'member' + AND r.guard_name = 'msg_web' + AND p.guard_name = 'msg_web' AND p.name IN ( 'workspaces.read', 'members.read', 'apikeys.read', + 'apikeys.write', 'logs.read', 'integrations.read', + 'integrations.write', 'templates.read', + 'templates.write', 'settings.read', + 'settings.write', 'invitations.read', 'inbox.write' ) ON CONFLICT (permission_id, role_id) DO NOTHING; + +-- 5. Viewer — read-only +INSERT INTO role_has_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +CROSS JOIN permissions p +WHERE r.name = 'viewer' + AND r.guard_name = 'msg_web' + AND p.guard_name = 'msg_web' + AND p.name IN ( + 'workspaces.read', + 'members.read', + 'apikeys.read', + 'logs.read', + 'integrations.read', + 'templates.read', + 'settings.read', + 'invitations.read' + ) +ON CONFLICT (permission_id, role_id) DO NOTHING; diff --git a/database/seeds/004_demo_workspace.sql b/database/seeds/004_demo_workspace.sql index a346f1ff..b3316c2f 100644 --- a/database/seeds/004_demo_workspace.sql +++ b/database/seeds/004_demo_workspace.sql @@ -47,21 +47,23 @@ ON CONFLICT (id) DO UPDATE SET is_private = EXCLUDED.is_private; INSERT INTO workspace_members (workspace_id, user_id, role_id) -VALUES ( +SELECT '00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000010', - '00000000-0000-0000-0000-000000000020' -) + r.id +FROM roles r +WHERE r.name = 'admin' AND r.guard_name = 'msg_web' ON CONFLICT (workspace_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id; INSERT INTO model_has_roles (role_id, model_type, model_id, team_id) -VALUES ( - '00000000-0000-0000-0000-000000000020', +SELECT + r.id, 'users', '00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000001' -) +FROM roles r +WHERE r.name = 'admin' AND r.guard_name = 'msg_web' ON CONFLICT (role_id, model_id, model_type, team_id) DO NOTHING; INSERT INTO workspace_channels (workspace_id, channel_type, enabled) diff --git a/frontend/src/core/auth/authorization.test.ts b/frontend/src/core/auth/authorization.test.ts new file mode 100644 index 00000000..8bd65d5f --- /dev/null +++ b/frontend/src/core/auth/authorization.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest" + +import { Permission, Role, hasRolePermission } from "./permissions" +import { createWorkspaceAuthorization } from "./authorization" + +describe("createWorkspaceAuthorization", () => { + it("checks role and permissions for the active workspace", () => { + const auth = createWorkspaceAuthorization({ + role: Role.Member, + permissions: [Permission.IntegrationsRead, Permission.IntegrationsWrite], + }) + + expect(auth.hasRole(Role.Member)).toBe(true) + expect(auth.hasRole(Role.Admin)).toBe(false) + expect(auth.can(Permission.IntegrationsWrite)).toBe(true) + expect(auth.can(Permission.MembersWrite)).toBe(false) + expect(auth.canAny(Permission.MembersWrite, Permission.IntegrationsRead)).toBe(true) + expect(auth.canAll(Permission.IntegrationsRead, Permission.IntegrationsWrite)).toBe(true) + expect(auth.canAll(Permission.IntegrationsRead, Permission.MembersWrite)).toBe(false) + }) + + it("denies everything when scope is empty", () => { + const auth = createWorkspaceAuthorization({}) + + expect(auth.hasRole(Role.Admin)).toBe(false) + expect(auth.can(Permission.SettingsRead)).toBe(false) + }) +}) + +describe("hasRolePermission", () => { + it("matches seeded role matrices", () => { + expect(hasRolePermission(Role.Admin, Permission.MembersWrite)).toBe(true) + expect(hasRolePermission(Role.Member, Permission.MembersWrite)).toBe(false) + expect(hasRolePermission(Role.Member, Permission.IntegrationsWrite)).toBe(true) + expect(hasRolePermission(Role.Viewer, Permission.SettingsRead)).toBe(true) + expect(hasRolePermission(Role.Viewer, Permission.SettingsWrite)).toBe(false) + }) +}) diff --git a/frontend/src/core/auth/authorization.ts b/frontend/src/core/auth/authorization.ts new file mode 100644 index 00000000..8673338f --- /dev/null +++ b/frontend/src/core/auth/authorization.ts @@ -0,0 +1,32 @@ +export type WorkspaceAuthorizationScope = { + role?: string + permissions?: string[] +} + +export type WorkspaceAuthorization = { + role: string + permissions: readonly string[] + /** Current user holds one of the given workspace roles. */ + hasRole: (...roles: string[]) => boolean + /** Current user has the permission (resolved from API / wpd-gogate). */ + can: (permission: string) => boolean + canAny: (...permissions: string[]) => boolean + canAll: (...permissions: string[]) => boolean +} + +export function createWorkspaceAuthorization( + scope: WorkspaceAuthorizationScope, +): WorkspaceAuthorization { + const role = scope.role ?? "" + const permissions = scope.permissions ?? [] + const permissionSet = new Set(permissions) + + return { + role, + permissions, + hasRole: (...roles: string[]) => role !== "" && roles.includes(role), + can: (permission: string) => permissionSet.has(permission), + canAny: (...requested) => requested.some((permission) => permissionSet.has(permission)), + canAll: (...requested) => requested.every((permission) => permissionSet.has(permission)), + } +} diff --git a/frontend/src/core/auth/can.test.tsx b/frontend/src/core/auth/can.test.tsx new file mode 100644 index 00000000..31a83ea4 --- /dev/null +++ b/frontend/src/core/auth/can.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { Permission, Role } from "./permissions" +import { Can } from "./can" +import { WorkspaceAuthorizationProvider } from "./workspace-authorization-context" + +function renderWithAuth( + ui: React.ReactNode, + scope: { role?: string; permissions?: string[] }, +) { + return render( + {ui}, + ) +} + +describe("Can", () => { + it("renders children when permission is granted", () => { + renderWithAuth( + + + , + { role: Role.Member, permissions: [Permission.APIKeysWrite] }, + ) + + expect(screen.getByRole("button", { name: "Generate key" })).toBeInTheDocument() + }) + + it("renders fallback when permission is missing", () => { + renderWithAuth( + Read only}> + + , + { role: Role.Viewer, permissions: [Permission.MembersRead] }, + ) + + expect(screen.queryByRole("button", { name: "Remove member" })).not.toBeInTheDocument() + expect(screen.getByText("Read only")).toBeInTheDocument() + }) + + it("renders children when role matches", () => { + renderWithAuth( + +

Admin panel

+
, + { role: Role.Admin, permissions: [] }, + ) + + expect(screen.getByText("Admin panel")).toBeInTheDocument() + }) +}) diff --git a/frontend/src/core/auth/can.tsx b/frontend/src/core/auth/can.tsx new file mode 100644 index 00000000..173688ac --- /dev/null +++ b/frontend/src/core/auth/can.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from "react" + +import { useWorkspaceAuthorization } from "./workspace-authorization-context" + +type CanBaseProps = { + children: ReactNode + fallback?: ReactNode +} + +type CanByPermission = CanBaseProps & { + permission: string + anyPermission?: never + role?: never + anyRole?: never +} + +type CanByAnyPermission = CanBaseProps & { + anyPermission: readonly string[] + permission?: never + role?: never + anyRole?: never +} + +type CanByRole = CanBaseProps & { + role: string + anyRole?: never + permission?: never + anyPermission?: never +} + +type CanByAnyRole = CanBaseProps & { + anyRole: readonly string[] + role?: never + permission?: never + anyPermission?: never +} + +export type CanProps = CanByPermission | CanByAnyPermission | CanByRole | CanByAnyRole + +export function Can(props: CanProps) { + const auth = useWorkspaceAuthorization() + const { children, fallback = null } = props + + let allowed = false + + if ("permission" in props && props.permission) { + allowed = auth.can(props.permission) + } else if ("anyPermission" in props && props.anyPermission) { + allowed = auth.canAny(...props.anyPermission) + } else if ("role" in props && props.role) { + allowed = auth.hasRole(props.role) + } else if ("anyRole" in props && props.anyRole) { + allowed = auth.hasRole(...props.anyRole) + } + + return allowed ? children : fallback +} diff --git a/frontend/src/core/auth/index.ts b/frontend/src/core/auth/index.ts new file mode 100644 index 00000000..c15ab2a8 --- /dev/null +++ b/frontend/src/core/auth/index.ts @@ -0,0 +1,20 @@ +export { Can, type CanProps } from "./can" +export { + createWorkspaceAuthorization, + type WorkspaceAuthorization, + type WorkspaceAuthorizationScope, +} from "./authorization" +export { + AllPermissions, + Permission, + Role, + WorkspaceRoles, + hasRolePermission, + isWorkspaceRole, + type PermissionName, + type WorkspaceRole, +} from "./permissions" +export { + useWorkspaceAuthorization, + WorkspaceAuthorizationProvider, +} from "./workspace-authorization-context" diff --git a/frontend/src/core/auth/permissions.ts b/frontend/src/core/auth/permissions.ts new file mode 100644 index 00000000..017b82f2 --- /dev/null +++ b/frontend/src/core/auth/permissions.ts @@ -0,0 +1,83 @@ +/** Mirrors internal/core/domain/permission.go — keep in sync with backend RBAC seeds. */ + +export const Role = { + Admin: "admin", + Member: "member", + Viewer: "viewer", +} as const + +export type WorkspaceRole = (typeof Role)[keyof typeof Role] + +export const WorkspaceRoles: readonly WorkspaceRole[] = [ + Role.Admin, + Role.Member, + Role.Viewer, +] + +export const Permission = { + WorkspacesRead: "workspaces.read", + WorkspacesWrite: "workspaces.write", + MembersRead: "members.read", + MembersWrite: "members.write", + APIKeysRead: "apikeys.read", + APIKeysWrite: "apikeys.write", + LogsRead: "logs.read", + IntegrationsRead: "integrations.read", + IntegrationsWrite: "integrations.write", + TemplatesRead: "templates.read", + TemplatesWrite: "templates.write", + SettingsRead: "settings.read", + SettingsWrite: "settings.write", + InvitationsRead: "invitations.read", + InvitationsWrite: "invitations.write", + InboxWrite: "inbox.write", +} as const + +export type PermissionName = (typeof Permission)[keyof typeof Permission] + +export const AllPermissions: readonly PermissionName[] = Object.values(Permission) + +const memberPermissions = new Set([ + Permission.WorkspacesRead, + Permission.MembersRead, + Permission.APIKeysRead, + Permission.APIKeysWrite, + Permission.LogsRead, + Permission.IntegrationsRead, + Permission.IntegrationsWrite, + Permission.TemplatesRead, + Permission.TemplatesWrite, + Permission.SettingsRead, + Permission.SettingsWrite, + Permission.InvitationsRead, + Permission.InboxWrite, +]) + +const viewerPermissions = new Set([ + Permission.WorkspacesRead, + Permission.MembersRead, + Permission.APIKeysRead, + Permission.LogsRead, + Permission.IntegrationsRead, + Permission.TemplatesRead, + Permission.SettingsRead, + Permission.InvitationsRead, +]) + +/** Static role → permission matrix (wpd-gogate HasRolePermission equivalent). */ +export function hasRolePermission(role: string, permission: string): boolean { + if (role === Role.Admin) { + return (AllPermissions as readonly string[]).includes(permission) + } + if (role === Role.Member) { + return memberPermissions.has(permission as PermissionName) + } + if (role === Role.Viewer) { + return viewerPermissions.has(permission as PermissionName) + } + return false +} + +export function isWorkspaceRole(role: string): role is WorkspaceRole { + return (WorkspaceRoles as readonly string[]).includes(role) +} diff --git a/frontend/src/core/auth/workspace-authorization-context.tsx b/frontend/src/core/auth/workspace-authorization-context.tsx new file mode 100644 index 00000000..a71809f6 --- /dev/null +++ b/frontend/src/core/auth/workspace-authorization-context.tsx @@ -0,0 +1,41 @@ +/* eslint-disable react-refresh/only-export-components */ +import { createContext, useContext, useMemo, type ReactNode } from "react" + +import { + createWorkspaceAuthorization, + type WorkspaceAuthorization, + type WorkspaceAuthorizationScope, +} from "./authorization" + +const WorkspaceAuthorizationContext = createContext(null) + +type WorkspaceAuthorizationProviderProps = WorkspaceAuthorizationScope & { + children: ReactNode +} + +export function WorkspaceAuthorizationProvider({ + role, + permissions, + children, +}: WorkspaceAuthorizationProviderProps) { + const value = useMemo( + () => createWorkspaceAuthorization({ role, permissions }), + [role, permissions], + ) + + return ( + + {children} + + ) +} + +export function useWorkspaceAuthorization(): WorkspaceAuthorization { + const context = useContext(WorkspaceAuthorizationContext) + if (!context) { + throw new Error( + "useWorkspaceAuthorization must be used within WorkspaceAuthorizationProvider", + ) + } + return context +} diff --git a/frontend/src/features/settings/components/api-key-row/api-key-row.test.tsx b/frontend/src/features/settings/components/api-key-row/api-key-row.test.tsx index cb8834c3..a7845232 100644 --- a/frontend/src/features/settings/components/api-key-row/api-key-row.test.tsx +++ b/frontend/src/features/settings/components/api-key-row/api-key-row.test.tsx @@ -2,6 +2,8 @@ import { render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { describe, expect, it, vi } from "vitest" +import { Permission, Role, WorkspaceAuthorizationProvider } from "@/core/auth" + import { ApiKeyRow } from "./api-key-row" const sampleKey = { @@ -14,9 +16,20 @@ const sampleKey = { last_used_at: null, } +function renderRow( + props: React.ComponentProps, + permissions: string[] = [Permission.APIKeysWrite], +) { + return render( + + + , + ) +} + describe("ApiKeyRow", () => { it("renders key details and action buttons", () => { - render() + renderRow({ apiKey: sampleKey, onRegenerate: vi.fn(), onDelete: vi.fn() }) expect(screen.getByText("Production")).toBeInTheDocument() expect(screen.getByText(/Last used: Never/i)).toBeInTheDocument() @@ -24,12 +37,22 @@ describe("ApiKeyRow", () => { expect(screen.getByRole("button", { name: /delete/i })).toBeInTheDocument() }) + it("hides action buttons for read-only users", () => { + renderRow( + { apiKey: sampleKey, onRegenerate: vi.fn(), onDelete: vi.fn() }, + [Permission.APIKeysRead], + ) + + expect(screen.queryByRole("button", { name: /regenerate/i })).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: /delete/i })).not.toBeInTheDocument() + }) + it("calls regenerate and delete handlers", async () => { const user = userEvent.setup() const onRegenerate = vi.fn() const onDelete = vi.fn() - render() + renderRow({ apiKey: sampleKey, onRegenerate, onDelete }) await user.click(screen.getByRole("button", { name: /regenerate/i })) await user.click(screen.getByRole("button", { name: /delete/i })) diff --git a/frontend/src/features/settings/components/api-key-row/api-key-row.tsx b/frontend/src/features/settings/components/api-key-row/api-key-row.tsx index 657ab4f2..3fe93248 100644 --- a/frontend/src/features/settings/components/api-key-row/api-key-row.tsx +++ b/frontend/src/features/settings/components/api-key-row/api-key-row.tsx @@ -1,5 +1,6 @@ import { Button } from "@/components/ui/button" import { Icon } from "@/components/ui/icon" +import { Permission, useWorkspaceAuthorization } from "@/core/auth" import type { ApiKey } from "../../settings.types" @@ -18,6 +19,9 @@ function formatLastUsed(value?: string | null): string { } export function ApiKeyRow({ apiKey, onRegenerate, onDelete, isBusy }: ApiKeyRowProps) { + const { can } = useWorkspaceAuthorization() + const canManageKeys = can(Permission.APIKeysWrite) + return (
@@ -30,26 +34,30 @@ export function ApiKeyRow({ apiKey, onRegenerate, onDelete, isBusy }: ApiKeyRowP

- - + {canManageKeys ? ( + <> + + + + ) : null}
) diff --git a/frontend/src/features/settings/pages/settings.page.test.tsx b/frontend/src/features/settings/pages/settings.page.test.tsx index d9b254af..8cbb7f2f 100644 --- a/frontend/src/features/settings/pages/settings.page.test.tsx +++ b/frontend/src/features/settings/pages/settings.page.test.tsx @@ -2,6 +2,8 @@ import { render, screen } from "@testing-library/react" import { MemoryRouter, Route, Routes } from "react-router-dom" import { describe, expect, it, vi } from "vitest" +import { Permission, Role, WorkspaceAuthorizationProvider } from "@/core/auth" + import { useSettings } from "../hooks/use-settings.hook" import { SettingsPage } from "./settings.page" @@ -21,12 +23,14 @@ const sampleApiKey = { last_used_at: null, } -function renderSettingsPage(tab = "developer") { +function renderSettingsPage(tab = "developer", permissions = [Permission.APIKeysWrite]) { return render( - - } /> - + + + } /> + + , ) } @@ -71,4 +75,23 @@ describe("SettingsPage", () => { expect(screen.getByText("Production")).toBeInTheDocument() expect(screen.getByRole("button", { name: /generate key/i })).toBeInTheDocument() }) + + it("hides generate key action for read-only users", () => { + mockedUseSettings.mockReturnValue({ + settings: {}, + apiKeys: [sampleApiKey], + messageDispatchConfig: { mode: "memory", storeMessageContent: false }, + isLoading: false, + error: null, + reload: vi.fn(), + saveSettings: vi.fn(), + addApiKey: vi.fn(), + removeApiKey: vi.fn(), + rotateApiKey: vi.fn(), + }) + + renderSettingsPage("developer", [Permission.APIKeysRead]) + + expect(screen.queryByRole("button", { name: /generate key/i })).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/features/settings/pages/settings.page.tsx b/frontend/src/features/settings/pages/settings.page.tsx index 6e1f5cba..03e9fe27 100644 --- a/frontend/src/features/settings/pages/settings.page.tsx +++ b/frontend/src/features/settings/pages/settings.page.tsx @@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input" import { Spinner } from "@/components/ui/spinner" import { PageHeader } from "@/shared/components/page-header" import { cn } from "@/lib/utils" +import { Can, Permission, Role, useWorkspaceAuthorization } from "@/core/auth" import { ApiKeyCreateDialog } from "../components/api-key-create-dialog" import { ApiKeyRow } from "../components/api-key-row" @@ -31,6 +32,8 @@ interface GeneralSettingsPanelProps { } function GeneralSettingsPanel({ settings, onSave }: GeneralSettingsPanelProps) { + const { can } = useWorkspaceAuthorization() + const canEditSettings = can(Permission.SettingsWrite) const [ownerEmail, setOwnerEmail] = useState(settings.owner_email ?? "") const [pinCode, setPinCode] = useState(settings.pin_code ?? "") const [showPin, setShowPin] = useState(false) @@ -57,6 +60,7 @@ function GeneralSettingsPanel({ settings, onSave }: GeneralSettingsPanelProps) { value={ownerEmail} onChange={(ev) => setOwnerEmail(ev.target.value)} placeholder="owner@company.com" + disabled={!canEditSettings} /> @@ -72,6 +76,7 @@ function GeneralSettingsPanel({ settings, onSave }: GeneralSettingsPanelProps) { onChange={(ev) => setPinCode(ev.target.value)} placeholder="••••••" className="pr-10" + disabled={!canEditSettings} /> + {canEditSettings ? ( + + ) : null} ) } @@ -290,15 +297,17 @@ export function SettingsPage() { -
-

Danger zone

-

- Permanently delete this workspace and all associated data. -

- -
+ +
+

Danger zone

+

+ Permanently delete this workspace and all associated data. +

+ +
+
@@ -309,10 +318,12 @@ export function SettingsPage() { Manage keys used to authenticate gateway requests.

- + + +
diff --git a/frontend/src/features/workspaces/layouts/workspace-layout/workspace-layout.tsx b/frontend/src/features/workspaces/layouts/workspace-layout/workspace-layout.tsx index e7432aac..f8475c04 100644 --- a/frontend/src/features/workspaces/layouts/workspace-layout/workspace-layout.tsx +++ b/frontend/src/features/workspaces/layouts/workspace-layout/workspace-layout.tsx @@ -6,6 +6,7 @@ import { SidebarNav } from "@/components/ui/sidebar-nav" import { cn } from "@/lib/utils" import { ROUTES } from "@/core/router/routes" import { fetchUserProfile, setToken, type UserProfile } from "@/core/api/client" +import { WorkspaceAuthorizationProvider } from "@/core/auth" import { AppFooter } from "@/shared/components/app-footer" import { MessageGatewayLogo } from "@/shared/components/message-gateway-logo" import { ThemeToggle } from "@/shared/components/theme-toggle" @@ -47,7 +48,11 @@ export function WorkspaceLayout() { return ( -
+ +
@@ -158,5 +163,6 @@ export function WorkspaceLayout() {
+ ) } diff --git a/internal/core/domain/permission.go b/internal/core/domain/permission.go index 0312d12d..c6ff592e 100644 --- a/internal/core/domain/permission.go +++ b/internal/core/domain/permission.go @@ -1,11 +1,15 @@ package domain -// Roles in the RBAC system. +// Roles in the RBAC system (global catalog; scoped per workspace via wpd-gogate team_id). const ( RoleAdmin = "admin" RoleMember = "member" + RoleViewer = "viewer" ) +// WorkspaceRoles lists assignable workspace roles seeded for every workspace. +var WorkspaceRoles = []string{RoleAdmin, RoleMember, RoleViewer} + // RBACGuardName is the guard_name used for portal roles and permissions in this project. const RBACGuardName = "msg_web" @@ -45,3 +49,25 @@ const ( // Inbox PermissionInboxWrite = "inbox.write" ) + +// ReadPermissions are granted to viewer role and public workspace guests. +var ReadPermissions = []string{ + PermissionWorkspacesRead, + PermissionMembersRead, + PermissionAPIKeysRead, + PermissionLogsRead, + PermissionIntegrationsRead, + PermissionTemplatesRead, + PermissionSettingsRead, + PermissionInvitationsRead, +} + +// IsWorkspaceRole reports whether name is one of the seeded workspace roles. +func IsWorkspaceRole(name string) bool { + for _, role := range WorkspaceRoles { + if role == name { + return true + } + } + return false +} diff --git a/internal/core/domain/permission_test.go b/internal/core/domain/permission_test.go new file mode 100644 index 00000000..420804a5 --- /dev/null +++ b/internal/core/domain/permission_test.go @@ -0,0 +1,16 @@ +package domain + +import "testing" + +func TestIsWorkspaceRole(t *testing.T) { + t.Parallel() + + for _, role := range WorkspaceRoles { + if !IsWorkspaceRole(role) { + t.Fatalf("IsWorkspaceRole(%q) = false, want true", role) + } + } + if IsWorkspaceRole("superadmin") { + t.Fatal("IsWorkspaceRole(superadmin) = true, want false") + } +} diff --git a/internal/core/service/portal_service.go b/internal/core/service/portal_service.go index 1d2edbeb..a91563b5 100644 --- a/internal/core/service/portal_service.go +++ b/internal/core/service/portal_service.go @@ -222,17 +222,8 @@ func (s *PortalService) ListWorkspaces(ctx context.Context, userID string) ([]do continue } if w.Visibility == "public" { - w.Role = "viewer" - w.Permissions = []string{ - domain.PermissionWorkspacesRead, - domain.PermissionMembersRead, - domain.PermissionAPIKeysRead, - domain.PermissionLogsRead, - domain.PermissionIntegrationsRead, - domain.PermissionTemplatesRead, - domain.PermissionSettingsRead, - domain.PermissionInvitationsRead, - } + w.Role = domain.RoleViewer + w.Permissions = append([]string(nil), domain.ReadPermissions...) } } @@ -586,6 +577,9 @@ func (s *PortalService) ListInvitations(ctx context.Context, workspaceID string) // CreateInvitation generates a secure invitation token, stores its hash, and persists the // invitation. The returned plaintext token must be sent to the invitee and is never stored. func (s *PortalService) CreateInvitation(ctx context.Context, inv *domain.Invitation) (rawToken string, err error) { + if !domain.IsWorkspaceRole(inv.Role) { + return "", fmt.Errorf("invalid role %q: %w", inv.Role, port.ErrInvalidInput) + } // Generate a cryptographically random token from two UUIDs. rawToken = uuid.NewString() + uuid.NewString() sum := sha256.Sum256([]byte(rawToken)) diff --git a/internal/core/service/portal_service_extended_test.go b/internal/core/service/portal_service_extended_test.go index 2ac0ae7d..969e836c 100644 --- a/internal/core/service/portal_service_extended_test.go +++ b/internal/core/service/portal_service_extended_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "testing" "time" @@ -139,6 +140,21 @@ func TestPortalService_CreateInvitation(t *testing.T) { } } +func TestPortalService_CreateInvitation_rejectsInvalidRole(t *testing.T) { + t.Parallel() + + svc := NewPortalService(PortalDeps{Invitations: &fakeInvitationRepo{}}) + inv := svc.NewPendingInvitation("ws-1", "user@example.com", "owner") + + _, err := svc.CreateInvitation(context.Background(), inv) + if err == nil { + t.Fatal("expected error for invalid role") + } + if !errors.Is(err, port.ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput, got %v", err) + } +} + func TestPortalService_APIKeys(t *testing.T) { t.Parallel() From fc14476f6a27f18a92bbbf885d6b3563dcc933fd Mon Sep 17 00:00:00 2001 From: MichaelAndish Date: Sat, 4 Jul 2026 23:00:31 +0200 Subject: [PATCH 2/4] feat: enhance roles and permissions management in backend and frontend - Updated the roles and permissions structure in the backend documentation to clarify access levels for admin, member, and viewer roles. - Introduced new public guest permissions for authenticated users in public workspaces, ensuring proper access control. - Refactored the frontend authorization logic to utilize the new permissions model, improving UI gating and security. - Enhanced tests for public guest permissions and role checks to ensure correct behavior in various scenarios. This commit improves the overall roles and permissions handling, ensuring better alignment between backend logic and frontend implementation. --- docs/backend/architecture.md | 11 +- docs/backend/usage.md | 14 ++- docs/frontend/architecture.md | 23 +++- frontend/src/core/auth/index.ts | 2 + frontend/src/core/auth/permissions.ts | 10 ++ .../settings/pages/settings.page.test.tsx | 2 +- internal/app/wire.go | 3 +- internal/core/domain/permission.go | 19 +++- internal/core/domain/permission_test.go | 14 +++ internal/core/port/portal.go | 5 + internal/core/service/portal_service.go | 103 ++++++++++-------- .../infrastructure/authgate/gate_adapter.go | 26 +++-- .../authgate/gate_adapter_test.go | 15 ++- internal/presentation/middleware/rbac.go | 6 +- internal/presentation/middleware/rbac_test.go | 46 ++++++++ 15 files changed, 230 insertions(+), 69 deletions(-) diff --git a/docs/backend/architecture.md b/docs/backend/architecture.md index 674c7670..f3d5c032 100644 --- a/docs/backend/architecture.md +++ b/docs/backend/architecture.md @@ -185,10 +185,13 @@ Portal accounts use **email + password** (passwords are stored hashed). All mana The core service layer decouples from `wpd-gogate` by depending on the `port.AuthorizationGate` interface. The implementation adapter lives in `internal/infrastructure/authgate/gate_adapter.go`. -- **admin**: Full read/write access to settings, integrations, templates, API keys, and member removal. Assigned automatically to the creator of a workspace. -- **member**: Read-only access to workspaces, settings, templates, API keys, and logs, plus inbox mutations (`inbox.write`). +- **admin**: Full access to all portal permissions. Assigned automatically to the workspace creator. +- **member**: Manage workspace resources (integrations, templates, settings, API keys, inbox) but cannot modify workspace metadata, manage members, or send invitations. See `database/seeds/001_seed_permissions.sql` for the exact permission matrix. +- **viewer**: Read-only access to workspace resources (`*.read` permissions). Assigned explicitly via membership or invitation. -Public workspaces (`is_private = false`) are dynamically accessible to any authenticated user as a `"viewer"` with read-only permissions (i.e. `*.read` operations are bypassed and approved automatically without requiring explicit membership or Casbin checks). All write operations on public workspaces remain strictly restricted to workspace admins. +Roles are **global** in the `roles` table (wpd-gogate schema) and scoped per workspace through `workspace_members.role_id` and `model_has_roles.team_id`. + +**Public workspace guests** (authenticated users who are not members of a public workspace) receive a narrower guest set — `workspaces.read` and `templates.read` only — via `domain.PublicGuestPermissions`. The RBAC middleware bypasses gogate checks only for those permissions; all other routes still require membership and role-based permissions. Write operations always require explicit membership with sufficient permissions. ### Send API Auth (Machine-to-Machine) @@ -336,7 +339,7 @@ Every entity in the system is scoped to a workspace: ```text Workspace "myapp" -├── Members (users with roles: admin, member) +├── Members (users with roles: admin, member, viewer) ├── API Keys (for machine-to-machine sends) ├── Integrations (provider credentials per channel) │ ├── email: mailgun { api_key: "...", domain: "..." } diff --git a/docs/backend/usage.md b/docs/backend/usage.md index 06b68b6e..42785711 100644 --- a/docs/backend/usage.md +++ b/docs/backend/usage.md @@ -350,12 +350,16 @@ Additional workspace endpoints (create, API keys, settings, templates, members, Workspace-scoped routes require a valid portal JWT and are governed by a Role-Based Access Control (RBAC) middleware backed by `wpd-gogate`. -Roles and permissions are defined in [permission.go](../../internal/core/domain/permission.go): -- **admin**: Full read/write access. The creator of a workspace is automatically assigned this role (as `admin` in both members repository and `gogate`). -- **member**: Read-only access to workspaces, members, API keys, logs, integrations, templates, settings, and invitations, plus inbox mutations (`inbox.write`). +Roles and permissions are defined in [permission.go](../../internal/core/domain/permission.go) and seeded by `database/seeds/001_seed_permissions.sql`: -To bootstrap roles and permissions, make sure you run the database seeds: -1. Run `database/seeds/001_seed_permissions.sql` to populate roles (`admin`, `member`), default permissions, and role-to-permission mappings. +- **admin**: Full read/write access. The workspace creator is assigned this role automatically (in both `workspace_members` and wpd-gogate `model_has_roles`). +- **member**: Manage integrations, templates, settings, API keys, and inbox content. Cannot change workspace metadata, manage members, or manage invitations. +- **viewer**: Read-only (`*.read` permissions). Used for invited/read-only members. + +**Public workspace guests** (non-members on `visibility = public` workspaces) receive only `workspaces.read` and `templates.read` (`PublicGuestPermissions`). The portal UI uses the `role` and `permissions` fields returned by `GET /api/v1/workspaces` for conditional rendering via `frontend/src/core/auth/`. + +To bootstrap roles and permissions, run the database seeds: +1. Run `database/seeds/001_seed_permissions.sql` to populate roles (`admin`, `member`, `viewer`), permissions, and role-to-permission mappings. 2. Run `database/seeds/002_seed_providers.sql` to seed the provider catalog (memory, mailgun, etc.). 3. Run `database/seeds/003_seed_mailgun_config.sql` to seed Mailgun Portal config field metadata. 4. Run `database/seeds/004_demo_workspace.sql` (optional) — demo user (`demo@weprodev.com` / `secret`), workspace, admin role, memory integration, API key (`demo-client-id` / `demo-secret`). diff --git a/docs/frontend/architecture.md b/docs/frontend/architecture.md index 21430b9a..a69e1084 100644 --- a/docs/frontend/architecture.md +++ b/docs/frontend/architecture.md @@ -60,7 +60,28 @@ ROUTES.workspace.overview(workspaceId) ## Auth -Session guards are deferred. Pages are reachable without login; JWT is attached by `core/api/client` when present. When auth ships, add a guard in `core/router/router.tsx` only. +Portal routes under `/workspaces/:wid/*` require a JWT (`ProtectedRoute` in `core/router/protected-route.tsx`). The API client in `core/api/client.ts` attaches the token to requests. + +### Workspace authorization (UI) + +Inside `WorkspaceLayout`, `WorkspaceAuthorizationProvider` exposes the active workspace `role` and resolved `permissions[]` from `GET /api/v1/workspaces`. Use this for **UI gating only** — the backend enforces permissions via wpd-gogate middleware on every route. + +```tsx +import { Can, Permission, Role, useWorkspaceAuthorization } from "@/core/auth" + +// Declarative + + + + +// Imperative +const { can, hasRole } = useWorkspaceAuthorization() +if (can(Permission.SettingsWrite)) { /* ... */ } +``` + +Constants mirror `internal/core/domain/permission.go`. Static role matrices (e.g. invitation role picker) can use `hasRolePermission()` from `core/auth/permissions.ts`. + +**Never rely on UI checks for security** — they only hide controls; unauthorized API calls still return 403. ## Adding a feature diff --git a/frontend/src/core/auth/index.ts b/frontend/src/core/auth/index.ts index c15ab2a8..d1ebfd8c 100644 --- a/frontend/src/core/auth/index.ts +++ b/frontend/src/core/auth/index.ts @@ -10,7 +10,9 @@ export { Role, WorkspaceRoles, hasRolePermission, + isPublicGuestPermission, isWorkspaceRole, + PublicGuestPermissions, type PermissionName, type WorkspaceRole, } from "./permissions" diff --git a/frontend/src/core/auth/permissions.ts b/frontend/src/core/auth/permissions.ts index 017b82f2..6f77d0d2 100644 --- a/frontend/src/core/auth/permissions.ts +++ b/frontend/src/core/auth/permissions.ts @@ -64,6 +64,16 @@ const viewerPermissions = new Set([ Permission.InvitationsRead, ]) +/** Non-member access on public workspaces — mirrors domain.PublicGuestPermissions. */ +export const PublicGuestPermissions: readonly PermissionName[] = [ + Permission.WorkspacesRead, + Permission.TemplatesRead, +] + +export function isPublicGuestPermission(permission: string): boolean { + return (PublicGuestPermissions as readonly string[]).includes(permission) +} + /** Static role → permission matrix (wpd-gogate HasRolePermission equivalent). */ export function hasRolePermission(role: string, permission: string): boolean { if (role === Role.Admin) { diff --git a/frontend/src/features/settings/pages/settings.page.test.tsx b/frontend/src/features/settings/pages/settings.page.test.tsx index 8cbb7f2f..6205b531 100644 --- a/frontend/src/features/settings/pages/settings.page.test.tsx +++ b/frontend/src/features/settings/pages/settings.page.test.tsx @@ -23,7 +23,7 @@ const sampleApiKey = { last_used_at: null, } -function renderSettingsPage(tab = "developer", permissions = [Permission.APIKeysWrite]) { +function renderSettingsPage(tab = "developer", permissions: string[] = [Permission.APIKeysWrite]) { return render( diff --git a/internal/app/wire.go b/internal/app/wire.go index da385f32..4d9ae223 100644 --- a/internal/app/wire.go +++ b/internal/app/wire.go @@ -74,7 +74,7 @@ func Wire(cfg *Config, sysLogger *pkglogger.Logger) (*Application, error) { if err := gateEngine.LoadPolicy(startCtx); err != nil { return nil, fmt.Errorf("load RBAC policies: %w", err) } - authGate := authgate.NewGoGateAdapter(gateEngine, pgClient.GetDB(startCtx)) + authGate := authgate.NewGoGateAdapter(gateEngine, pgClient, gateCfg) // ── Encryption service ────────────────────────────────────────────────── encKey := cfg.EncryptionKey @@ -127,6 +127,7 @@ func Wire(cfg *Config, sysLogger *pkglogger.Logger) (*Application, error) { JWTSecret: jwtSecret, JWTTTL: jwtTTL, Gate: authGate, + TxManager: pgClient, }) // ── Inbox: payload-backed reads, in-memory writes for SSE ─────────────── diff --git a/internal/core/domain/permission.go b/internal/core/domain/permission.go index c6ff592e..3f7c0699 100644 --- a/internal/core/domain/permission.go +++ b/internal/core/domain/permission.go @@ -50,7 +50,7 @@ const ( PermissionInboxWrite = "inbox.write" ) -// ReadPermissions are granted to viewer role and public workspace guests. +// ReadPermissions are granted to the viewer workspace role (full read-only member). var ReadPermissions = []string{ PermissionWorkspacesRead, PermissionMembersRead, @@ -62,6 +62,23 @@ var ReadPermissions = []string{ PermissionInvitationsRead, } +// PublicGuestPermissions are the only permissions non-members receive on public workspaces. +// Narrower than viewer role: guests must not read members, API keys, logs, or settings. +var PublicGuestPermissions = []string{ + PermissionWorkspacesRead, + PermissionTemplatesRead, +} + +// IsPublicGuestPermission reports whether permission is allowed for public workspace guests. +func IsPublicGuestPermission(name string) bool { + for _, permission := range PublicGuestPermissions { + if permission == name { + return true + } + } + return false +} + // IsWorkspaceRole reports whether name is one of the seeded workspace roles. func IsWorkspaceRole(name string) bool { for _, role := range WorkspaceRoles { diff --git a/internal/core/domain/permission_test.go b/internal/core/domain/permission_test.go index 420804a5..7a46a72c 100644 --- a/internal/core/domain/permission_test.go +++ b/internal/core/domain/permission_test.go @@ -14,3 +14,17 @@ func TestIsWorkspaceRole(t *testing.T) { t.Fatal("IsWorkspaceRole(superadmin) = true, want false") } } + +func TestIsPublicGuestPermission(t *testing.T) { + t.Parallel() + + if !IsPublicGuestPermission(PermissionWorkspacesRead) { + t.Fatal("expected workspaces.read for public guests") + } + if !IsPublicGuestPermission(PermissionTemplatesRead) { + t.Fatal("expected templates.read for public guests") + } + if IsPublicGuestPermission(PermissionLogsRead) { + t.Fatal("logs.read must not be granted to public guests") + } +} diff --git a/internal/core/port/portal.go b/internal/core/port/portal.go index afee1645..10bc4a66 100644 --- a/internal/core/port/portal.go +++ b/internal/core/port/portal.go @@ -46,3 +46,8 @@ type AuthorizationGate interface { GetAllPermissions(ctx context.Context, modelType, modelID, teamID string) ([]string, error) GetPermissionsForTeams(ctx context.Context, modelType, modelID string, teamIDs []string) (map[string][]string, error) } + +// TransactionManager executes a function inside a database transaction. +type TransactionManager interface { + RunInTransaction(ctx context.Context, fn func(ctx context.Context) error) error +} diff --git a/internal/core/service/portal_service.go b/internal/core/service/portal_service.go index a91563b5..098675dd 100644 --- a/internal/core/service/portal_service.go +++ b/internal/core/service/portal_service.go @@ -39,6 +39,7 @@ type PortalDeps struct { JWTSecret string JWTTTL time.Duration Gate port.AuthorizationGate + TxManager port.TransactionManager } // PortalService handles portal authentication and workspace-scoped operations. @@ -53,6 +54,7 @@ type PortalService struct { invites port.InvitationRepository settings port.WorkspaceSettingsRepository gate port.AuthorizationGate + txManager port.TransactionManager // jwtSecret and jwtTTL are unexported; accessed only within this package. jwtSecret string @@ -65,6 +67,10 @@ func NewPortalService(deps PortalDeps) *PortalService { if deps.JWTTTL <= 0 { deps.JWTTTL = 24 * time.Hour } + txM := deps.TxManager + if txM == nil { + txM = &nopTxManager{} + } return &PortalService{ users: deps.Users, members: deps.Members, @@ -76,11 +82,18 @@ func NewPortalService(deps PortalDeps) *PortalService { invites: deps.Invitations, settings: deps.Settings, gate: deps.Gate, + txManager: txM, jwtSecret: deps.JWTSecret, jwtTTL: deps.JWTTTL, } } +type nopTxManager struct{} + +func (m *nopTxManager) RunInTransaction(ctx context.Context, fn func(ctx context.Context) error) error { + return fn(ctx) +} + func (s *PortalService) UserByID(ctx context.Context, id string) (*domain.User, error) { u, err := s.users.GetByID(ctx, id) if err != nil { @@ -124,30 +137,29 @@ func (s *PortalService) CreateWorkspace(ctx context.Context, userID, name, slug, slog.ErrorContext(ctx, "workspace creation failed: user not found", "error", err, "user_id", userID) return nil, err } - w := &domain.Workspace{ - Name: name, - Slug: slug, - AdminEmail: u.Email, - Status: "active", - Visibility: "private", - IconKey: strings.TrimSpace(iconKey), - } - if err := s.workspaces.Create(ctx, w, userID); err != nil { - slog.ErrorContext(ctx, "workspace creation failed: database error", "error", err, "user_id", userID) - return nil, err - } - if err := s.members.Add(ctx, w.ID, userID, domain.RoleAdmin); err != nil { - slog.ErrorContext(ctx, "workspace creation failed: failed to add member", "error", err, "workspace_id", w.ID, "user_id", userID) - if delErr := s.workspaces.Delete(ctx, w.ID); delErr != nil { - slog.WarnContext(ctx, "failed to roll back workspace after member add error", "error", delErr, "workspace_id", w.ID) + var w *domain.Workspace + err = s.txManager.RunInTransaction(ctx, func(txCtx context.Context) error { + w = &domain.Workspace{ + Name: name, + Slug: slug, + AdminEmail: u.Email, + Status: "active", + Visibility: "private", + IconKey: strings.TrimSpace(iconKey), } - return nil, err - } - if err := s.gate.AssignRole(ctx, "users", userID, w.ID, domain.RoleAdmin); err != nil { - slog.ErrorContext(ctx, "workspace creation failed: failed to assign role", "error", err, "workspace_id", w.ID, "user_id", userID) - if delErr := s.workspaces.Delete(ctx, w.ID); delErr != nil { - slog.WarnContext(ctx, "failed to roll back workspace after role assign error", "error", delErr, "workspace_id", w.ID) + if err := s.workspaces.Create(txCtx, w, userID); err != nil { + return err + } + if err := s.members.Add(txCtx, w.ID, userID, domain.RoleAdmin); err != nil { + return err + } + if err := s.gate.AssignRole(txCtx, "users", userID, w.ID, domain.RoleAdmin); err != nil { + return err } + return nil + }) + if err != nil { + slog.ErrorContext(ctx, "workspace creation failed", "error", err, "user_id", userID) return nil, err } slog.InfoContext(ctx, "workspace created successfully", "workspace_id", w.ID, "user_id", userID) @@ -170,12 +182,17 @@ func (s *PortalService) JoinWorkspaceWithPIN(ctx context.Context, userID, slug, slog.WarnContext(ctx, "join workspace with PIN failed: invalid PIN", "workspace_id", ws.ID, "user_id", userID) return errors.New("invalid PIN") } - if err := s.members.Add(ctx, ws.ID, userID, domain.RoleMember); err != nil { - slog.ErrorContext(ctx, "join workspace with PIN failed: failed to add member", "error", err, "workspace_id", ws.ID, "user_id", userID) - return err - } - if err := s.gate.AssignRole(ctx, "users", userID, ws.ID, domain.RoleMember); err != nil { - slog.ErrorContext(ctx, "join workspace with PIN failed: failed to assign role", "error", err, "workspace_id", ws.ID, "user_id", userID) + err = s.txManager.RunInTransaction(ctx, func(txCtx context.Context) error { + if err := s.members.Add(txCtx, ws.ID, userID, domain.RoleMember); err != nil { + return err + } + if err := s.gate.AssignRole(txCtx, "users", userID, ws.ID, domain.RoleMember); err != nil { + return err + } + return nil + }) + if err != nil { + slog.ErrorContext(ctx, "join workspace with PIN failed: database error", "error", err, "workspace_id", ws.ID, "user_id", userID) return err } slog.InfoContext(ctx, "joined workspace successfully with PIN", "workspace_id", ws.ID, "user_id", userID) @@ -223,7 +240,7 @@ func (s *PortalService) ListWorkspaces(ctx context.Context, userID string) ([]do } if w.Visibility == "public" { w.Role = domain.RoleViewer - w.Permissions = append([]string(nil), domain.ReadPermissions...) + w.Permissions = append([]string(nil), domain.PublicGuestPermissions...) } } @@ -277,26 +294,26 @@ func (s *PortalService) ListMembers(ctx context.Context, workspaceID string) ([] return s.members.ListMembers(ctx, workspaceID) } -// RemoveMember removes a user from a workspace and revokes their RBAC role. -// If the member has no role record in the RBAC gate (ErrNotFound), the gate removal -// is skipped safely. Any other gate error aborts the operation before DB removal. func (s *PortalService) RemoveMember(ctx context.Context, workspaceID, userID string) error { // Fetch current role to remove the exact RBAC assignment. - // ErrNotFound means the member was never assigned a role in the gate — skip silently. role, err := s.members.GetRole(ctx, workspaceID, userID) - switch { - case errors.Is(err, port.ErrNotFound): - // No membership record; nothing to remove from RBAC gate. - case err != nil: - // Unexpected infrastructure error — abort before touching the gate. - return fmt.Errorf("wpd-message-gateway: get member role: %w", err) - default: - if gErr := s.gate.RemoveRole(ctx, "users", userID, workspaceID, role); gErr != nil { - return fmt.Errorf("wpd-message-gateway: remove role %s: %w", role, gErr) + if err != nil { + if errors.Is(err, port.ErrNotFound) { + // No membership record; nothing to remove. + return nil } + return fmt.Errorf("wpd-message-gateway: get member role: %w", err) } - return s.members.Remove(ctx, workspaceID, userID) + return s.txManager.RunInTransaction(ctx, func(txCtx context.Context) error { + if err := s.gate.RemoveRole(txCtx, "users", userID, workspaceID, role); err != nil { + return fmt.Errorf("remove role %s: %w", role, err) + } + if err := s.members.Remove(txCtx, workspaceID, userID); err != nil { + return err + } + return nil + }) } func (s *PortalService) ListAPIKeys(ctx context.Context, workspaceID string) ([]domain.APIKey, error) { diff --git a/internal/infrastructure/authgate/gate_adapter.go b/internal/infrastructure/authgate/gate_adapter.go index 7b4b98b6..532c2dab 100644 --- a/internal/infrastructure/authgate/gate_adapter.go +++ b/internal/infrastructure/authgate/gate_adapter.go @@ -12,29 +12,39 @@ import ( "github.com/weprodev/wpd-message-gateway/internal/core/port" ) +// DBProvider resolves the active DB handle (pool or transaction) from context. +type DBProvider interface { + GetDB(ctx context.Context) pgsql.DBTX +} + type GoGateAdapter struct { gate *gogate.Gate - db pgsql.DBTX + db DBProvider + cfg gogate.Config +} + +func NewGoGateAdapter(gate *gogate.Gate, db DBProvider, cfg gogate.Config) port.AuthorizationGate { + return &GoGateAdapter{gate: gate, db: db, cfg: cfg} } -func NewGoGateAdapter(gate *gogate.Gate, db pgsql.DBTX) port.AuthorizationGate { - return &GoGateAdapter{gate: gate, db: db} +func (a *GoGateAdapter) txGate(ctx context.Context) *gogate.Gate { + return gogate.NewGate(a.db.GetDB(ctx), &a.cfg) } func (a *GoGateAdapter) AssignRole(ctx context.Context, modelType, modelID, teamID, roleName string) error { - return a.gate.Model(modelType, modelID, teamID).AssignRole(ctx, roleName, "") + return a.txGate(ctx).Model(modelType, modelID, teamID).AssignRole(ctx, roleName, "") } func (a *GoGateAdapter) RemoveRole(ctx context.Context, modelType, modelID, teamID, roleName string) error { - return a.gate.Model(modelType, modelID, teamID).RemoveRole(ctx, roleName, "") + return a.txGate(ctx).Model(modelType, modelID, teamID).RemoveRole(ctx, roleName, "") } func (a *GoGateAdapter) GetRoleNames(ctx context.Context, modelType, modelID, teamID string) ([]string, error) { - return a.gate.Model(modelType, modelID, teamID).GetRoleNames(ctx) + return a.txGate(ctx).Model(modelType, modelID, teamID).GetRoleNames(ctx) } func (a *GoGateAdapter) GetAllPermissions(ctx context.Context, modelType, modelID, teamID string) ([]string, error) { - return a.gate.Model(modelType, modelID, teamID).GetAllPermissions(ctx) + return a.txGate(ctx).Model(modelType, modelID, teamID).GetAllPermissions(ctx) } func (a *GoGateAdapter) GetPermissionsForTeams( @@ -63,7 +73,7 @@ func (a *GoGateAdapter) GetPermissionsForTeams( ORDER BY team_id, permission ` - rows, err := a.db.QueryContext(ctx, query, modelType, modelID, pq.Array(teamIDs)) + rows, err := a.db.GetDB(ctx).QueryContext(ctx, query, modelType, modelID, pq.Array(teamIDs)) if err != nil { return nil, err } diff --git a/internal/infrastructure/authgate/gate_adapter_test.go b/internal/infrastructure/authgate/gate_adapter_test.go index 71bc922f..6a571610 100644 --- a/internal/infrastructure/authgate/gate_adapter_test.go +++ b/internal/infrastructure/authgate/gate_adapter_test.go @@ -5,8 +5,19 @@ import ( "testing" "github.com/DATA-DOG/go-sqlmock" + gogate "github.com/weprodev/wpd-gogate" + + "github.com/weprodev/go-pkg/pgsql" ) +type staticDBProvider struct { + db pgsql.DBTX +} + +func (s staticDBProvider) GetDB(context.Context) pgsql.DBTX { + return s.db +} + func TestGoGateAdapter_GetPermissionsForTeams_emptyInput(t *testing.T) { t.Parallel() @@ -16,7 +27,7 @@ func TestGoGateAdapter_GetPermissionsForTeams_emptyInput(t *testing.T) { } t.Cleanup(func() { _ = db.Close() }) - adapter := NewGoGateAdapter(nil, db) + adapter := NewGoGateAdapter(nil, staticDBProvider{db: db}, gogate.DefaultConfig()) out, err := adapter.GetPermissionsForTeams(context.Background(), "User", "user-1", nil) if err != nil { t.Fatalf("GetPermissionsForTeams: %v", err) @@ -44,7 +55,7 @@ func TestGoGateAdapter_GetPermissionsForTeams_dedupesPermissions(t *testing.T) { WithArgs("User", "user-1", sqlmock.AnyArg()). WillReturnRows(rows) - adapter := NewGoGateAdapter(nil, db) + adapter := NewGoGateAdapter(nil, staticDBProvider{db: db}, gogate.DefaultConfig()) out, err := adapter.GetPermissionsForTeams(context.Background(), "User", "user-1", []string{"team-1"}) if err != nil { t.Fatalf("GetPermissionsForTeams: %v", err) diff --git a/internal/presentation/middleware/rbac.go b/internal/presentation/middleware/rbac.go index 39e7dab1..58e7fa95 100644 --- a/internal/presentation/middleware/rbac.go +++ b/internal/presentation/middleware/rbac.go @@ -3,11 +3,11 @@ package middleware import ( "log/slog" "net/http" - "strings" "github.com/labstack/echo/v4" gogate "github.com/weprodev/wpd-gogate" + "github.com/weprodev/wpd-message-gateway/internal/core/domain" "github.com/weprodev/wpd-message-gateway/internal/core/port" ) @@ -34,8 +34,8 @@ func RequirePermission(gate *gogate.Gate, workspaceRepo port.WorkspaceRepository return echo.NewHTTPError(http.StatusNotFound, "Workspace not found") } - // If workspace is public and it is a read-only request, allow access - if ws.Visibility == "public" && strings.HasSuffix(permissionName, ".read") { + // Public workspaces: guest-safe read permissions only (non-members). + if ws.Visibility == "public" && domain.IsPublicGuestPermission(permissionName) { slog.InfoContext(c.Request().Context(), "RBAC check bypassed: public workspace read access", "user_id", uid, "workspace_id", wid, "permission", permissionName) return next(c) } diff --git a/internal/presentation/middleware/rbac_test.go b/internal/presentation/middleware/rbac_test.go index e4ea9ce8..9885ca62 100644 --- a/internal/presentation/middleware/rbac_test.go +++ b/internal/presentation/middleware/rbac_test.go @@ -160,4 +160,50 @@ func TestRequirePermission(t *testing.T) { t.Errorf("expected 200 OK, got %d", rec.Code) } }) + + t.Run("Public Workspace Denies Sensitive Read", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/workspaces/ws-1/logs", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("wid") + c.SetParamValues("ws-1") + + ctx := context.WithValue(req.Context(), PortalUserIDKey, "user-123") + c.SetRequest(req.WithContext(ctx)) + + wsRepo := &mockWorkspaceRepository{ + GetByIDFunc: func(ctx context.Context, id string) (*domain.Workspace, error) { + return &domain.Workspace{ + ID: id, + Visibility: "public", + }, nil + }, + } + + mock.ExpectQuery(`SELECT 'role' AS type, r.name AS value FROM model_has_roles mhr JOIN roles r ON r.id = mhr.role_id WHERE mhr.model_type = \$1 AND mhr.model_id = \$2 AND mhr.team_id = \$5 AND r.guard_name = \$3 UNION ALL SELECT 'permission' AS type, p.name AS value FROM model_has_permissions mhp JOIN permissions p ON p.id = mhp.permission_id WHERE mhp.model_type = \$1 AND mhp.model_id = \$2 AND mhp.team_id = \$5 AND p.name = \$4 AND p.guard_name = \$3`). + WithArgs("users", "user-123", "msg_web", domain.PermissionLogsRead, "ws-1"). + WillReturnRows(sqlmock.NewRows([]string{"type", "value"})) + + mw := RequirePermission(gate, wsRepo, domain.PermissionLogsRead) + handler := mw(func(c echo.Context) error { + return c.String(http.StatusOK, "OK") + }) + + err := handler(c) + if err == nil { + t.Fatal("expected handler to fail with forbidden error") + } + + he, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected HTTPError, got %T", err) + } + if he.Code != http.StatusForbidden { + t.Errorf("expected 403 Forbidden, got %d", he.Code) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } + }) } From f5205ee9fbe9528f672db67ec6e1f7d73342b64e Mon Sep 17 00:00:00 2001 From: MichaelAndish Date: Sun, 5 Jul 2026 00:01:29 +0200 Subject: [PATCH 3/4] feat: update demo account information and enhance database seeding - Revised demo account details in the Makefile and Readme to include multiple roles (admin, member, viewer) with a unified password. - Updated SQL seed files to reflect the new demo account structure, ensuring consistent role assignments and UUID management. - Enhanced documentation to clarify the demo accounts and their respective roles within the application. This commit improves the clarity and functionality of demo account management, ensuring better alignment between documentation and implementation. --- Makefile | 11 +- Readme.md | 11 +- database/seeds/004_demo_workspace.sql | 90 +++++++--- docs/backend/architecture.md | 2 +- docs/backend/portal-inbox.md | 4 +- docs/backend/usage.md | 10 +- docs/frontend/architecture.md | 5 +- frontend/src/core/auth/authorization.test.ts | 4 + .../src/features/auth/{ => api}/auth.api.ts | 0 .../features/auth/pages/login.page.test.tsx | 4 +- .../src/features/auth/pages/login.page.tsx | 2 +- .../auth/pages/register.page.test.tsx | 4 +- .../src/features/auth/pages/register.page.tsx | 2 +- .../inbox/{ => api}/get-started.api.ts | 0 .../src/features/inbox/{ => api}/inbox.api.ts | 4 +- .../email-content/email-content.stories.tsx | 15 ++ .../email-content/email-content.test.tsx | 20 ++- .../email-content/email-content.tsx | 25 +-- .../hooks/use-get-started-context.hook.ts | 2 +- .../inbox/hooks/use-inbox-emails.hook.test.ts | 4 +- .../inbox/hooks/use-inbox-emails.hook.ts | 2 +- .../inbox/hooks/use-inbox-logs.hook.ts | 2 +- .../inbox/pages/email-inbox.page.test.tsx | 34 +++- .../features/inbox/pages/email-inbox.page.tsx | 2 +- .../inbox/pages/email-templates.page.test.tsx | 46 ++++- .../inbox/pages/email-templates.page.tsx | 33 ++-- .../{ => api}/integrations.api.ts | 2 +- .../integration-row.stories.tsx | 13 ++ .../integration-row/integration-row.test.tsx | 70 +++++--- .../integration-row/integration-row.tsx | 47 +++-- .../hooks/use-integrations.hook.ts | 4 +- .../use-provider-config-fields.hook.test.ts | 4 +- .../hooks/use-provider-config-fields.hook.ts | 2 +- .../pages/integrations.page.test.tsx | 12 +- .../integrations/pages/integrations.page.tsx | 15 +- .../settings/{ => api}/settings.api.ts | 4 +- .../features/settings/api/team.api.test.ts | 31 ++++ .../src/features/settings/api/team.api.ts | 63 +++++++ .../components/invitation-row/index.ts | 1 + .../invitation-row/invitation-row.stories.tsx | 35 ++++ .../invitation-row/invitation-row.tsx | 25 +++ .../invitation-token-dialog/index.ts | 1 + .../invitation-token-dialog.stories.tsx | 34 ++++ .../invitation-token-dialog.tsx | 70 ++++++++ .../components/invite-member-dialog/index.ts | 1 + .../invite-member-dialog.stories.tsx | 26 +++ .../invite-member-dialog.test.tsx | 60 +++++++ .../invite-member-dialog.tsx | 154 ++++++++++++++++ .../radio-option/radio-option.test.tsx | 21 +++ .../components/radio-option/radio-option.tsx | 18 +- .../components/team-management-panel/index.ts | 1 + .../team-management-panel.stories.tsx | 167 ++++++++++++++++++ .../team-management-panel.tsx | 146 +++++++++++++++ .../components/team-member-row/index.ts | 1 + .../team-member-row.stories.tsx | 53 ++++++ .../team-member-row/team-member-row.test.tsx | 66 +++++++ .../team-member-row/team-member-row.tsx | 52 ++++++ .../settings/hooks/use-settings.hook.ts | 2 +- .../hooks/use-team-management.hook.ts | 95 ++++++++++ .../settings/pages/settings.page.test.tsx | 22 +++ .../features/settings/pages/settings.page.tsx | 28 ++- frontend/src/features/settings/team.types.ts | 30 ++++ .../src/features/settings/team.utils.test.ts | 39 ++++ frontend/src/features/settings/team.utils.ts | 56 ++++++ .../workspaces/{ => api}/workspaces.api.ts | 2 +- .../workspaces/hooks/use-workspaces.hook.ts | 2 +- internal/core/domain/permission.go | 35 ++++ internal/core/domain/permission_test.go | 66 ++++++- internal/core/port/portal.go | 2 + internal/core/service/portal_service.go | 24 ++- .../service/portal_service_extended_test.go | 81 ++++++++- .../postgres/invitation_repository.go | 18 ++ .../postgres/invitation_repository_test.go | 19 ++ .../postgres/workspace_member_repository.go | 16 ++ .../workspace_member_repository_test.go | 19 ++ .../handler/portal_workspace_handler.go | 7 + .../handler/portal_workspace_handler_test.go | 58 +++++- .../presentation/portal_write_routes_test.go | 48 +++++ tests/bruno/Portal/00_Auth/Login Viewer.bru | 27 +++ .../RBAC/Invite Existing Member Forbidden.bru | 28 +++ .../RBAC/Viewer Create API Key Forbidden.bru | 27 +++ .../RBAC/Viewer Create Template Forbidden.bru | 31 ++++ .../RBAC/Viewer Delete Email Forbidden.bru | 21 +++ .../bruno/Portal/RBAC/Viewer Get Settings.bru | 21 +++ .../RBAC/Viewer Patch Settings Forbidden.bru | 27 +++ .../Viewer Upsert Integration Forbidden.bru | 31 ++++ .../Portal/Workspaces/Create Invitation.bru | 33 ++++ .../Portal/Workspaces/List Invitations.bru | 22 +++ .../bruno/Portal/Workspaces/List Members.bru | 28 +++ 89 files changed, 2331 insertions(+), 171 deletions(-) rename frontend/src/features/auth/{ => api}/auth.api.ts (100%) rename frontend/src/features/inbox/{ => api}/get-started.api.ts (100%) rename frontend/src/features/inbox/{ => api}/inbox.api.ts (98%) rename frontend/src/features/integrations/{ => api}/integrations.api.ts (98%) rename frontend/src/features/settings/{ => api}/settings.api.ts (96%) create mode 100644 frontend/src/features/settings/api/team.api.test.ts create mode 100644 frontend/src/features/settings/api/team.api.ts create mode 100644 frontend/src/features/settings/components/invitation-row/index.ts create mode 100644 frontend/src/features/settings/components/invitation-row/invitation-row.stories.tsx create mode 100644 frontend/src/features/settings/components/invitation-row/invitation-row.tsx create mode 100644 frontend/src/features/settings/components/invitation-token-dialog/index.ts create mode 100644 frontend/src/features/settings/components/invitation-token-dialog/invitation-token-dialog.stories.tsx create mode 100644 frontend/src/features/settings/components/invitation-token-dialog/invitation-token-dialog.tsx create mode 100644 frontend/src/features/settings/components/invite-member-dialog/index.ts create mode 100644 frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.stories.tsx create mode 100644 frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.test.tsx create mode 100644 frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.tsx create mode 100644 frontend/src/features/settings/components/team-management-panel/index.ts create mode 100644 frontend/src/features/settings/components/team-management-panel/team-management-panel.stories.tsx create mode 100644 frontend/src/features/settings/components/team-management-panel/team-management-panel.tsx create mode 100644 frontend/src/features/settings/components/team-member-row/index.ts create mode 100644 frontend/src/features/settings/components/team-member-row/team-member-row.stories.tsx create mode 100644 frontend/src/features/settings/components/team-member-row/team-member-row.test.tsx create mode 100644 frontend/src/features/settings/components/team-member-row/team-member-row.tsx create mode 100644 frontend/src/features/settings/hooks/use-team-management.hook.ts create mode 100644 frontend/src/features/settings/team.types.ts create mode 100644 frontend/src/features/settings/team.utils.test.ts create mode 100644 frontend/src/features/settings/team.utils.ts rename frontend/src/features/workspaces/{ => api}/workspaces.api.ts (90%) create mode 100644 internal/presentation/portal_write_routes_test.go create mode 100644 tests/bruno/Portal/00_Auth/Login Viewer.bru create mode 100644 tests/bruno/Portal/RBAC/Invite Existing Member Forbidden.bru create mode 100644 tests/bruno/Portal/RBAC/Viewer Create API Key Forbidden.bru create mode 100644 tests/bruno/Portal/RBAC/Viewer Create Template Forbidden.bru create mode 100644 tests/bruno/Portal/RBAC/Viewer Delete Email Forbidden.bru create mode 100644 tests/bruno/Portal/RBAC/Viewer Get Settings.bru create mode 100644 tests/bruno/Portal/RBAC/Viewer Patch Settings Forbidden.bru create mode 100644 tests/bruno/Portal/RBAC/Viewer Upsert Integration Forbidden.bru create mode 100644 tests/bruno/Portal/Workspaces/Create Invitation.bru create mode 100644 tests/bruno/Portal/Workspaces/List Invitations.bru create mode 100644 tests/bruno/Portal/Workspaces/List Members.bru diff --git a/Makefile b/Makefile index 5822ca87..7218a097 100644 --- a/Makefile +++ b/Makefile @@ -308,9 +308,10 @@ dev: docker-check @printf " │ $(BOLD)Gateway API:$(RESET) http://localhost:10101 │\n" @printf " │ $(BOLD)Portal UI:$(RESET) http://localhost:10104 │\n" @printf " │ │\n" - @printf " │ $(BOLD)Demo Account$(RESET) │\n" - @printf " │ Email: demo@weprodev.com │\n" - @printf " │ Password: secret │\n" + @printf " │ $(BOLD)Demo accounts$(RESET) (password: secret) │\n" + @printf " │ admin: demo@weprodev.com (workspace admin) │\n" + @printf " │ member: member@weprodev.com (workspace member) │\n" + @printf " │ viewer: viewer@weprodev.com (workspace viewer) │\n" @printf " │ │\n" @printf " ╰───────────────────────────────────────────────────╯\n" @printf "\n" @@ -329,7 +330,7 @@ db-init: docker-check @docker compose exec -T -e POSTGRES_USER=gateway -e POSTGRES_DB=gateway db \ bash /docker-entrypoint-initdb.d/init-db.sh @docker compose restart gateway - @printf "$(GREEN)✅ Database ready — gateway will apply seeds on startup (demo@weprodev.com / secret)$(RESET)\n" + @printf "$(GREEN)✅ Database ready — gateway will apply seeds on startup (demo accounts: password secret)$(RESET)\n" @printf "\n" ## Re-apply SQL seeds by restarting the gateway (seeds run on every startup) @@ -337,7 +338,7 @@ seed-demo: docker-check @printf "\n" @printf "$(BOLD)$(CYAN)🌱 Re-applying database seeds (gateway restart)...$(RESET)\n" @docker compose restart gateway - @printf "$(GREEN)✅ Seeds applied on gateway startup (demo@weprodev.com / secret)$(RESET)\n" + @printf "$(GREEN)✅ Seeds applied on gateway startup (demo accounts: password secret)$(RESET)\n" @printf "\n" diff --git a/Readme.md b/Readme.md index 84c00018..7938b00b 100644 --- a/Readme.md +++ b/Readme.md @@ -100,13 +100,18 @@ make dev | Portal UI | http://localhost:10104 | | Gateway API | http://localhost:10101 | -### Demo account +### Demo accounts -Seeded on first DB init. Reset anytime: `make seed-demo`. +Seeded on first DB init. Reset anytime: `make seed-demo`. All portal passwords are `secret`. + +| Role | Email | Demo workspace role | +|------|-------|---------------------| +| Admin | `demo@weprodev.com` | admin | +| Member | `member@weprodev.com` | member | +| Viewer | `viewer@weprodev.com` | viewer | | | | |--|--| -| Portal | `demo@weprodev.com` / `secret` | | API key | `demo-client-id` / `demo-secret` | | Workspace ID | `00000000-0000-0000-0000-000000000001` | diff --git a/database/seeds/004_demo_workspace.sql b/database/seeds/004_demo_workspace.sql index b3316c2f..1fed1bd8 100644 --- a/database/seeds/004_demo_workspace.sql +++ b/database/seeds/004_demo_workspace.sql @@ -1,26 +1,60 @@ -- Demo workspace (idempotent — safe to re-run via init-db.sh, run_migrations.sh, or make seed-demo). --- Portal: demo@weprodev.com / secret --- API key: demo-client-id / demo-secret -- Requires 001_seed_permissions.sql and 002_seed_providers.sql first. +-- +-- Portal accounts (password for all: secret) +-- demo@weprodev.com → admin on Demo Workspace +-- member@weprodev.com → member on Demo Workspace +-- viewer@weprodev.com → viewer on Demo Workspace +-- +-- API key: demo-client-id / demo-secret +-- Workspace: demo (00000000-0000-0000-0000-000000000001) + +-- Fixed demo UUIDs +-- workspace : 00000000-0000-0000-0000-000000000001 +-- admin user: 00000000-0000-0000-0000-000000000010 +-- member : 00000000-0000-0000-0000-000000000011 +-- viewer : 00000000-0000-0000-0000-000000000012 --- Drop conflicting rows so fixed demo UUIDs stay stable after manual sign-up. +-- bcrypt hash for password "secret" (cost 14) +-- Remove conflicting sign-ups so fixed UUIDs stay stable. DELETE FROM users -WHERE email = 'demo@weprodev.com' - AND id <> '00000000-0000-0000-0000-000000000010'; +WHERE email IN ('demo@weprodev.com', 'member@weprodev.com', 'viewer@weprodev.com') + AND id NOT IN ( + '00000000-0000-0000-0000-000000000010', + '00000000-0000-0000-0000-000000000011', + '00000000-0000-0000-0000-000000000012' + ); DELETE FROM workspaces WHERE slug = 'demo' AND id <> '00000000-0000-0000-0000-000000000001'; INSERT INTO users (id, email, password_hash, first_name, last_name, email_verified) -VALUES ( - '00000000-0000-0000-0000-000000000010', - 'demo@weprodev.com', - '$2a$14$fBL/4GbbqSCMTVOYotuUa.Qx0DwnRMpkZaOHxkD3h1X4gMESUhjD.', - 'Demo', - 'User', - true -) +VALUES + ( + '00000000-0000-0000-0000-000000000010', + 'demo@weprodev.com', + '$2a$14$fBL/4GbbqSCMTVOYotuUa.Qx0DwnRMpkZaOHxkD3h1X4gMESUhjD.', + 'Demo', + 'Admin', + true + ), + ( + '00000000-0000-0000-0000-000000000011', + 'member@weprodev.com', + '$2a$14$fBL/4GbbqSCMTVOYotuUa.Qx0DwnRMpkZaOHxkD3h1X4gMESUhjD.', + 'Demo', + 'Member', + true + ), + ( + '00000000-0000-0000-0000-000000000012', + 'viewer@weprodev.com', + '$2a$14$fBL/4GbbqSCMTVOYotuUa.Qx0DwnRMpkZaOHxkD3h1X4gMESUhjD.', + 'Demo', + 'Viewer', + true + ) ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, password_hash = EXCLUDED.password_hash, @@ -46,24 +80,28 @@ ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, is_private = EXCLUDED.is_private; +-- Workspace membership + wpd-gogate role assignments (admin, member, viewer) INSERT INTO workspace_members (workspace_id, user_id, role_id) -SELECT - '00000000-0000-0000-0000-000000000001', - '00000000-0000-0000-0000-000000000010', - r.id -FROM roles r -WHERE r.name = 'admin' AND r.guard_name = 'msg_web' +SELECT '00000000-0000-0000-0000-000000000001', seed.user_id, r.id +FROM ( + VALUES + ('00000000-0000-0000-0000-000000000010'::uuid, 'admin'), + ('00000000-0000-0000-0000-000000000011'::uuid, 'member'), + ('00000000-0000-0000-0000-000000000012'::uuid, 'viewer') +) AS seed(user_id, role_name) +JOIN roles r ON r.name = seed.role_name AND r.guard_name = 'msg_web' ON CONFLICT (workspace_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id; INSERT INTO model_has_roles (role_id, model_type, model_id, team_id) -SELECT - r.id, - 'users', - '00000000-0000-0000-0000-000000000010', - '00000000-0000-0000-0000-000000000001' -FROM roles r -WHERE r.name = 'admin' AND r.guard_name = 'msg_web' +SELECT r.id, 'users', seed.user_id, '00000000-0000-0000-0000-000000000001' +FROM ( + VALUES + ('00000000-0000-0000-0000-000000000010'::uuid, 'admin'), + ('00000000-0000-0000-0000-000000000011'::uuid, 'member'), + ('00000000-0000-0000-0000-000000000012'::uuid, 'viewer') +) AS seed(user_id, role_name) +JOIN roles r ON r.name = seed.role_name AND r.guard_name = 'msg_web' ON CONFLICT (role_id, model_id, model_type, team_id) DO NOTHING; INSERT INTO workspace_channels (workspace_id, channel_type, enabled) diff --git a/docs/backend/architecture.md b/docs/backend/architecture.md index f3d5c032..113adb94 100644 --- a/docs/backend/architecture.md +++ b/docs/backend/architecture.md @@ -245,7 +245,7 @@ The React Portal (`frontend/`) includes: | **Integrations** | Connect and manage providers | | **Settings** | General, API keys, message dispatch | -**API-only today:** team invites/members UI (placeholder), SMS/push/chat captured-message browsers (email inbox only), internal inbox ingest. +**API-only today:** SMS/push/chat captured-message browsers (email inbox only), internal inbox ingest. --- diff --git a/docs/backend/portal-inbox.md b/docs/backend/portal-inbox.md index 61bd14d2..8277ecac 100644 --- a/docs/backend/portal-inbox.md +++ b/docs/backend/portal-inbox.md @@ -16,10 +16,10 @@ The Portal is the web UI and REST surface for the WPD Message Gateway when runni | **Email inbox** | Browse captured outbound emails with live SSE updates | | **Email templates** | Create and manage reusable email layouts | | **Integrations** | Connect, activate, deactivate, or remove messaging providers | -| **Settings** | General workspace settings, API keys, message dispatch mode | +| **Settings** | General workspace settings, API keys, team management, message dispatch mode | | **Get started** | Curl examples for sending via the public gateway API | -Team member management is a settings placeholder. SMS, push, and chat show **request logs** in the UI; captured-message browsing for those channels is API-only. +Team management (members and role-based invitations) lives under **Settings → Team Management**. SMS, push, and chat show **request logs** in the UI; captured-message browsing for those channels is API-only. ### REST API (server) diff --git a/docs/backend/usage.md b/docs/backend/usage.md index 42785711..6c0b28da 100644 --- a/docs/backend/usage.md +++ b/docs/backend/usage.md @@ -153,7 +153,7 @@ The Portal UI supports: Use the **Get started** dialog on the logs pages for curl examples. Send traffic through the public gateway API (`POST /v1/email`, `POST /v1/sms`, etc.) with a workspace API key. -For local dev with PostgreSQL, run migrations then apply seeds in order (see `database/init-db.sh`): `001_seed_permissions.sql`, `002_seed_providers.sql`, `003_seed_mailgun_config.sql`, and `004_demo_workspace.sql` (optional demo data: `demo@weprodev.com` / `secret`). +For local dev with PostgreSQL, run migrations then apply seeds in order (see `database/init-db.sh`): `001_seed_permissions.sql`, `002_seed_providers.sql`, `003_seed_mailgun_config.sql`, and `004_demo_workspace.sql` (optional demo data: `demo@weprodev.com`, `member@weprodev.com`, `viewer@weprodev.com` — password `secret` for all). ### Sending via HTTP @@ -342,9 +342,13 @@ Routes registered in `internal/presentation/router.go`. **Portal UI pages exist | Method | Endpoint | Portal UI | Description | |--------|----------|:---------:|-------------| | GET | `/api/v1/workspaces` | ✓ | List workspaces for the logged-in user | +| GET | `/api/v1/workspaces/:wid/members` | ✓ | List workspace members and roles (`members.read`) | +| DELETE | `/api/v1/workspaces/:wid/members/:userId` | ✓ | Remove a member (`members.write`) | +| GET | `/api/v1/workspaces/:wid/invitations` | ✓ | List pending invitations (`invitations.read`) | +| POST | `/api/v1/workspaces/:wid/invitations` | ✓ | Invite a user with role (`invitations.write`; body: `email`, `role`) | | GET | `/api/v1/workspaces/:wid/logs` | ✓ | Message request logs (optionally filter by `channel`) | -Additional workspace endpoints (create, API keys, settings, templates, members, inbox) are implemented in `internal/presentation/router.go` and covered in [Portal inbox](./portal-inbox.md) and [E2E testing](./e2e-testing.md). +Additional workspace endpoints (create, API keys, settings, templates, inbox) are implemented in `internal/presentation/router.go` and covered in [Portal inbox](./portal-inbox.md) and [E2E testing](./e2e-testing.md). ### Workspace access (portal JWT routes & RBAC) @@ -362,7 +366,7 @@ To bootstrap roles and permissions, run the database seeds: 1. Run `database/seeds/001_seed_permissions.sql` to populate roles (`admin`, `member`, `viewer`), permissions, and role-to-permission mappings. 2. Run `database/seeds/002_seed_providers.sql` to seed the provider catalog (memory, mailgun, etc.). 3. Run `database/seeds/003_seed_mailgun_config.sql` to seed Mailgun Portal config field metadata. -4. Run `database/seeds/004_demo_workspace.sql` (optional) — demo user (`demo@weprodev.com` / `secret`), workspace, admin role, memory integration, API key (`demo-client-id` / `demo-secret`). +4. Run `database/seeds/004_demo_workspace.sql` (optional) — demo workspace with three portal users (`demo@weprodev.com`, `member@weprodev.com`, `viewer@weprodev.com`; password `secret`), admin/member/viewer roles, memory integration, API key (`demo-client-id` / `demo-secret`). ### Dual Authorization Models diff --git a/docs/frontend/architecture.md b/docs/frontend/architecture.md index a69e1084..3f58410b 100644 --- a/docs/frontend/architecture.md +++ b/docs/frontend/architecture.md @@ -32,10 +32,11 @@ Route composition lives in **`core/router/router.tsx`** only. ``` features/workspaces/ ├── index.ts # public barrel +├── api/ # HTTP clients for this feature +│ └── *.api.ts ├── pages/ ├── layouts/ # domain shells (not shared/) ├── hooks/use-*.hook.ts -├── *.api.ts └── *.types.ts ``` @@ -85,7 +86,7 @@ Constants mirror `internal/core/domain/permission.go`. Static role matrices (e.g ## Adding a feature -1. Create `features//` with pages, hooks, `*.api.ts`, `*.types.ts` +1. Create `features//` with pages, hooks, `api/*.api.ts`, `*.types.ts` 2. Export public API from `index.ts` 3. Extend `core/router/routes.ts` and `core/router/router.tsx` 4. Colocate Storybook stories diff --git a/frontend/src/core/auth/authorization.test.ts b/frontend/src/core/auth/authorization.test.ts index 8bd65d5f..b38ac4e6 100644 --- a/frontend/src/core/auth/authorization.test.ts +++ b/frontend/src/core/auth/authorization.test.ts @@ -34,5 +34,9 @@ describe("hasRolePermission", () => { expect(hasRolePermission(Role.Member, Permission.IntegrationsWrite)).toBe(true) expect(hasRolePermission(Role.Viewer, Permission.SettingsRead)).toBe(true) expect(hasRolePermission(Role.Viewer, Permission.SettingsWrite)).toBe(false) + expect(hasRolePermission(Role.Viewer, Permission.IntegrationsWrite)).toBe(false) + expect(hasRolePermission(Role.Viewer, Permission.TemplatesWrite)).toBe(false) + expect(hasRolePermission(Role.Viewer, Permission.InboxWrite)).toBe(false) + expect(hasRolePermission(Role.Viewer, Permission.APIKeysWrite)).toBe(false) }) }) diff --git a/frontend/src/features/auth/auth.api.ts b/frontend/src/features/auth/api/auth.api.ts similarity index 100% rename from frontend/src/features/auth/auth.api.ts rename to frontend/src/features/auth/api/auth.api.ts diff --git a/frontend/src/features/auth/pages/login.page.test.tsx b/frontend/src/features/auth/pages/login.page.test.tsx index 93725813..aee6218f 100644 --- a/frontend/src/features/auth/pages/login.page.test.tsx +++ b/frontend/src/features/auth/pages/login.page.test.tsx @@ -3,10 +3,10 @@ import userEvent from "@testing-library/user-event" import { MemoryRouter } from "react-router-dom" import { describe, expect, it, vi } from "vitest" -import { signInWithPassword } from "../auth.api" +import { signInWithPassword } from "../api/auth.api" import { LoginPage } from "./login.page" -vi.mock("../auth.api", () => ({ +vi.mock("../api/auth.api", () => ({ signInWithPassword: vi.fn(), })) diff --git a/frontend/src/features/auth/pages/login.page.tsx b/frontend/src/features/auth/pages/login.page.tsx index e0bac2aa..959ce59b 100644 --- a/frontend/src/features/auth/pages/login.page.tsx +++ b/frontend/src/features/auth/pages/login.page.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button" import { Icon } from "@/components/ui/icon" import { Input } from "@/components/ui/input" import { AuthCard } from "@/shared/components/auth-card" -import { signInWithPassword } from "../auth.api" +import { signInWithPassword } from "../api/auth.api" export function LoginPage() { const navigate = useNavigate() diff --git a/frontend/src/features/auth/pages/register.page.test.tsx b/frontend/src/features/auth/pages/register.page.test.tsx index 24a41ee8..63221902 100644 --- a/frontend/src/features/auth/pages/register.page.test.tsx +++ b/frontend/src/features/auth/pages/register.page.test.tsx @@ -3,10 +3,10 @@ import userEvent from "@testing-library/user-event" import { MemoryRouter } from "react-router-dom" import { describe, expect, it, vi } from "vitest" -import { registerAccount } from "../auth.api" +import { registerAccount } from "../api/auth.api" import { RegisterPage } from "./register.page" -vi.mock("../auth.api", () => ({ +vi.mock("../api/auth.api", () => ({ registerAccount: vi.fn(), })) diff --git a/frontend/src/features/auth/pages/register.page.tsx b/frontend/src/features/auth/pages/register.page.tsx index 5d244cad..55aee439 100644 --- a/frontend/src/features/auth/pages/register.page.tsx +++ b/frontend/src/features/auth/pages/register.page.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button" import { Icon } from "@/components/ui/icon" import { Input } from "@/components/ui/input" import { AuthCard } from "@/shared/components/auth-card" -import { registerAccount } from "../auth.api" +import { registerAccount } from "../api/auth.api" export function RegisterPage() { const navigate = useNavigate() diff --git a/frontend/src/features/inbox/get-started.api.ts b/frontend/src/features/inbox/api/get-started.api.ts similarity index 100% rename from frontend/src/features/inbox/get-started.api.ts rename to frontend/src/features/inbox/api/get-started.api.ts diff --git a/frontend/src/features/inbox/inbox.api.ts b/frontend/src/features/inbox/api/inbox.api.ts similarity index 98% rename from frontend/src/features/inbox/inbox.api.ts rename to frontend/src/features/inbox/api/inbox.api.ts index dec23822..2c38df90 100644 --- a/frontend/src/features/inbox/inbox.api.ts +++ b/frontend/src/features/inbox/api/inbox.api.ts @@ -1,6 +1,6 @@ import { apiFetch, getToken } from "@/core/api/client" -import type { EmailTemplate, InboxEmailPage, LogRow, StoredEmail } from "./inbox.types" -import { parseLogsResponse } from "./logs.schema" +import type { EmailTemplate, InboxEmailPage, LogRow, StoredEmail } from "../inbox.types" +import { parseLogsResponse } from "../logs.schema" async function parseApiError(res: Response, fallback: string): Promise { try { diff --git a/frontend/src/features/inbox/components/email-content/email-content.stories.tsx b/frontend/src/features/inbox/components/email-content/email-content.stories.tsx index ee6910e0..f937c9e8 100644 --- a/frontend/src/features/inbox/components/email-content/email-content.stories.tsx +++ b/frontend/src/features/inbox/components/email-content/email-content.stories.tsx @@ -1,4 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite" + +import { Permission, Role, WorkspaceAuthorizationProvider } from "@/core/auth" + import { EmailContent } from "./email-content" const mockEmail = { @@ -20,6 +23,18 @@ const meta = { title: "Features/Inbox/EmailContent", component: EmailContent, tags: ["autodocs"], + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], } satisfies Meta export default meta diff --git a/frontend/src/features/inbox/components/email-content/email-content.test.tsx b/frontend/src/features/inbox/components/email-content/email-content.test.tsx index b95f89c2..85d2b25f 100644 --- a/frontend/src/features/inbox/components/email-content/email-content.test.tsx +++ b/frontend/src/features/inbox/components/email-content/email-content.test.tsx @@ -1,6 +1,8 @@ import { render, screen } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" +import { Permission, Role, WorkspaceAuthorizationProvider } from "@/core/auth" + import { EmailContent } from "./email-content" const mockEmail = { @@ -18,11 +20,27 @@ const mockEmail = { }, } +function renderContent( + permissions: string[] = [Permission.InboxWrite], +) { + return render( + + + , + ) +} + describe("EmailContent Component", () => { it("renders correctly with metadata headers", () => { - render() + renderContent() expect(screen.getByText("Welcome to Message Gateway!")).toBeInTheDocument() expect(screen.getAllByText(/John Doe/).length).toBeGreaterThan(0) expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument() }) + + it("hides delete action for read-only users", () => { + renderContent([Permission.LogsRead]) + + expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/features/inbox/components/email-content/email-content.tsx b/frontend/src/features/inbox/components/email-content/email-content.tsx index cd91dc97..8422ba39 100644 --- a/frontend/src/features/inbox/components/email-content/email-content.tsx +++ b/frontend/src/features/inbox/components/email-content/email-content.tsx @@ -1,6 +1,7 @@ import type { StoredEmail } from "../../inbox.types" import { Button } from "@/components/ui/button" import { Icon } from "@/components/ui/icon" +import { Permission, useWorkspaceAuthorization } from "@/core/auth" interface EmailContentProps { message: StoredEmail @@ -25,6 +26,8 @@ function formatFullTime(dateStr: string) { } export function EmailContent({ message, onDelete, isDeleting = false }: EmailContentProps) { + const { can } = useWorkspaceAuthorization() + const canDeleteMessages = can(Permission.InboxWrite) && Boolean(onDelete) const sender = message.email.from_name ? `${message.email.from_name} <${message.email.from}>` : message.email.from || "Unknown sender" @@ -52,16 +55,18 @@ export function EmailContent({ message, onDelete, isDeleting = false }: EmailCon

- + {canDeleteMessages ? ( + + ) : null}
diff --git a/frontend/src/features/inbox/hooks/use-get-started-context.hook.ts b/frontend/src/features/inbox/hooks/use-get-started-context.hook.ts index fa4aaec1..6799b1bb 100644 --- a/frontend/src/features/inbox/hooks/use-get-started-context.hook.ts +++ b/frontend/src/features/inbox/hooks/use-get-started-context.hook.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react" -import { fetchGetStartedContext, type GetStartedContext } from "../get-started.api" +import { fetchGetStartedContext, type GetStartedContext } from "../api/get-started.api" export function useGetStartedContext(workspaceId: string | undefined, enabled: boolean) { const [context, setContext] = useState(null) diff --git a/frontend/src/features/inbox/hooks/use-inbox-emails.hook.test.ts b/frontend/src/features/inbox/hooks/use-inbox-emails.hook.test.ts index 5766548b..9f4c2c41 100644 --- a/frontend/src/features/inbox/hooks/use-inbox-emails.hook.test.ts +++ b/frontend/src/features/inbox/hooks/use-inbox-emails.hook.test.ts @@ -1,10 +1,10 @@ import { act, renderHook, waitFor } from "@testing-library/react" import { describe, expect, it, vi, beforeEach } from "vitest" -import { fetchInboxEmailById, fetchInboxEmails } from "../inbox.api" +import { fetchInboxEmailById, fetchInboxEmails } from "../api/inbox.api" import { useInboxEmails } from "./use-inbox-emails.hook" -vi.mock("../inbox.api", () => ({ +vi.mock("../api/inbox.api", () => ({ fetchInboxEmails: vi.fn(), fetchInboxEmailById: vi.fn(), })) diff --git a/frontend/src/features/inbox/hooks/use-inbox-emails.hook.ts b/frontend/src/features/inbox/hooks/use-inbox-emails.hook.ts index 62baf64e..75096c01 100644 --- a/frontend/src/features/inbox/hooks/use-inbox-emails.hook.ts +++ b/frontend/src/features/inbox/hooks/use-inbox-emails.hook.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react" -import { fetchInboxEmailById, fetchInboxEmails } from "../inbox.api" +import { fetchInboxEmailById, fetchInboxEmails } from "../api/inbox.api" import type { StoredEmail } from "../inbox.types" const PAGE_SIZE = 50 diff --git a/frontend/src/features/inbox/hooks/use-inbox-logs.hook.ts b/frontend/src/features/inbox/hooks/use-inbox-logs.hook.ts index 1d305e0e..5e76ec37 100644 --- a/frontend/src/features/inbox/hooks/use-inbox-logs.hook.ts +++ b/frontend/src/features/inbox/hooks/use-inbox-logs.hook.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react" -import { fetchLogs } from "../inbox.api" +import { fetchLogs } from "../api/inbox.api" import type { LogRow } from "../inbox.types" export function useInboxLogs(workspaceId: string | undefined, channel?: string) { diff --git a/frontend/src/features/inbox/pages/email-inbox.page.test.tsx b/frontend/src/features/inbox/pages/email-inbox.page.test.tsx index 4ff5d57c..ff30b0cb 100644 --- a/frontend/src/features/inbox/pages/email-inbox.page.test.tsx +++ b/frontend/src/features/inbox/pages/email-inbox.page.test.tsx @@ -2,6 +2,8 @@ import { render, screen } from "@testing-library/react" import { MemoryRouter, Route, Routes } from "react-router-dom" import { describe, expect, it, vi } from "vitest" +import { Permission, Role, WorkspaceAuthorizationProvider } from "@/core/auth" + import { useInboxEmails } from "../hooks/use-inbox-emails.hook" import { EmailInboxPage } from "./email-inbox.page" @@ -24,12 +26,14 @@ const sampleEmail = { }, } -function renderPage() { +function renderPage(permissions: string[] = [Permission.LogsRead, Permission.InboxWrite]) { return render( - - } /> - + + + } /> + + , ) } @@ -79,6 +83,28 @@ describe("EmailInboxPage", () => { expect(screen.getAllByText("Hello").length).toBeGreaterThan(0) }) + it("hides delete action for read-only users", () => { + mockedUseInboxEmails.mockReturnValue({ + messages: [sampleEmail], + selectedMessageId: "email-1", + setSelectedMessageId: vi.fn(), + isLoading: false, + isLoadingMore: false, + error: null, + nextCursor: undefined, + hasMore: false, + reload: vi.fn(), + loadMore: vi.fn(), + upsertMessage: vi.fn(), + removeMessage: vi.fn(), + clearMessages: vi.fn(), + }) + + renderPage([Permission.LogsRead]) + + expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument() + }) + it("shows error message when hook reports failure", () => { mockedUseInboxEmails.mockReturnValue({ messages: [], diff --git a/frontend/src/features/inbox/pages/email-inbox.page.tsx b/frontend/src/features/inbox/pages/email-inbox.page.tsx index 2807393c..4e0aaa34 100644 --- a/frontend/src/features/inbox/pages/email-inbox.page.tsx +++ b/frontend/src/features/inbox/pages/email-inbox.page.tsx @@ -9,7 +9,7 @@ import { buildInboxEventsUrl, deleteInboxEmail, fetchInboxEmailById, -} from "../inbox.api" +} from "../api/inbox.api" import { EmailList } from "../components/email-list" import { EmailContent } from "../components/email-content" import { useInboxEmails } from "../hooks/use-inbox-emails.hook" diff --git a/frontend/src/features/inbox/pages/email-templates.page.test.tsx b/frontend/src/features/inbox/pages/email-templates.page.test.tsx index 9495fa95..731ee51c 100644 --- a/frontend/src/features/inbox/pages/email-templates.page.test.tsx +++ b/frontend/src/features/inbox/pages/email-templates.page.test.tsx @@ -2,10 +2,12 @@ import { render, screen, waitFor } from "@testing-library/react" import { MemoryRouter, Route, Routes } from "react-router-dom" import { describe, expect, it, vi } from "vitest" -import { fetchEmailTemplates } from "../inbox.api" +import { Permission, Role, WorkspaceAuthorizationProvider } from "@/core/auth" + +import { fetchEmailTemplates } from "../api/inbox.api" import { EmailTemplatesPage } from "./email-templates.page" -vi.mock("../inbox.api", () => ({ +vi.mock("../api/inbox.api", () => ({ fetchEmailTemplates: vi.fn(), createEmailTemplate: vi.fn(), deleteEmailTemplate: vi.fn(), @@ -13,12 +15,14 @@ vi.mock("../inbox.api", () => ({ const mockedFetchTemplates = vi.mocked(fetchEmailTemplates) -function renderPage() { +function renderPage(permissions: string[] = [Permission.TemplatesRead, Permission.TemplatesWrite]) { return render( - - } /> - + + + } /> + + , ) } @@ -60,4 +64,34 @@ describe("EmailTemplatesPage", () => { }) expect(screen.getByText("welcome")).toBeInTheDocument() }) + + it("hides template write actions for read-only users", async () => { + mockedFetchTemplates.mockResolvedValue({ + ok: true, + items: [ + { + id: "tpl-1", + workspace_id: "ws-1", + name: "Welcome", + unique_key: "welcome", + channel_type: "email", + category: "transactional", + subject: "Welcome!", + content_html: "

Hi

", + is_active: true, + is_default: false, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + ], + }) + + renderPage([Permission.TemplatesRead]) + + await waitFor(() => { + expect(screen.getByText("Welcome")).toBeInTheDocument() + }) + expect(screen.queryByRole("button", { name: /create template/i })).not.toBeInTheDocument() + expect(screen.queryByTitle("Delete Template")).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/features/inbox/pages/email-templates.page.tsx b/frontend/src/features/inbox/pages/email-templates.page.tsx index 64a66b71..61d3a505 100644 --- a/frontend/src/features/inbox/pages/email-templates.page.tsx +++ b/frontend/src/features/inbox/pages/email-templates.page.tsx @@ -17,11 +17,12 @@ import { TableCell, } from "@/components/ui/table" import { ROUTES } from "@/core/router/routes" +import { Permission, useWorkspaceAuthorization } from "@/core/auth" import { createEmailTemplate, deleteEmailTemplate, fetchEmailTemplates, -} from "../inbox.api" +} from "../api/inbox.api" import type { EmailTemplate } from "../inbox.types" type TabType = "templates" | "headers" | "footers" @@ -42,6 +43,8 @@ function getCategoryColor(category: string) { export function EmailTemplatesPage() { const { wid } = useParams<{ wid: string }>() const navigate = useNavigate() + const { can } = useWorkspaceAuthorization() + const canManageTemplates = can(Permission.TemplatesWrite) const [activeTab, setActiveTab] = useState("templates") const [templates, setTemplates] = useState([]) @@ -212,12 +215,12 @@ export function EmailTemplatesPage() { className="w-64 shadow-xs shrink-0" /> - {activeTab === "templates" && ( + {activeTab === "templates" && canManageTemplates ? ( - )} + ) : null}
{/* Table Content */} @@ -250,12 +253,12 @@ export function EmailTemplatesPage() {

{searchQuery ? "No templates match your search criteria." : "Create your first reusable email template template."}

- {!searchQuery && ( + {!searchQuery && canManageTemplates ? ( - )} + ) : null}
) : (
@@ -297,14 +300,18 @@ export function EmailTemplatesPage() { - + {canManageTemplates ? ( + + ) : ( + + )} ))} diff --git a/frontend/src/features/integrations/integrations.api.ts b/frontend/src/features/integrations/api/integrations.api.ts similarity index 98% rename from frontend/src/features/integrations/integrations.api.ts rename to frontend/src/features/integrations/api/integrations.api.ts index 8a56b13a..3e97941b 100644 --- a/frontend/src/features/integrations/integrations.api.ts +++ b/frontend/src/features/integrations/api/integrations.api.ts @@ -1,6 +1,6 @@ import { apiFetch } from "@/core/api/client" -import type { Integration, IntegrationActionResult, IntegrationChannel, IntegrationStatus } from "./integrations.types" +import type { Integration, IntegrationActionResult, IntegrationChannel, IntegrationStatus } from "../integrations.types" export async function listIntegrations(workspaceId: string): Promise { const res = await apiFetch(`/api/v1/workspaces/${workspaceId}/integrations`) diff --git a/frontend/src/features/integrations/components/integration-row/integration-row.stories.tsx b/frontend/src/features/integrations/components/integration-row/integration-row.stories.tsx index 1e24a175..088d7075 100644 --- a/frontend/src/features/integrations/components/integration-row/integration-row.stories.tsx +++ b/frontend/src/features/integrations/components/integration-row/integration-row.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite" +import { Permission, Role, WorkspaceAuthorizationProvider } from "@/core/auth" import type { IntegrationViewModel } from "@/features/integrations/integrations.types" import { IntegrationRow } from "./integration-row" @@ -36,6 +37,18 @@ const meta = { title: "Features/Integrations/IntegrationRow", component: IntegrationRow, parameters: { layout: "padded" }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], } satisfies Meta export default meta diff --git a/frontend/src/features/integrations/components/integration-row/integration-row.test.tsx b/frontend/src/features/integrations/components/integration-row/integration-row.test.tsx index 1fd13b42..fb48313d 100644 --- a/frontend/src/features/integrations/components/integration-row/integration-row.test.tsx +++ b/frontend/src/features/integrations/components/integration-row/integration-row.test.tsx @@ -2,7 +2,9 @@ import { render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { describe, expect, it, vi } from "vitest" +import { Permission, Role, WorkspaceAuthorizationProvider } from "@/core/auth" import type { IntegrationViewModel } from "@/features/integrations/integrations.types" + import { IntegrationRow } from "./integration-row" const mailgunProvider: IntegrationViewModel = { @@ -16,19 +18,28 @@ const mailgunProvider: IntegrationViewModel = { isDeactivated: false, } +function renderRow( + props: React.ComponentProps, + permissions: string[] = [Permission.IntegrationsWrite], +) { + return render( + + + , + ) +} + describe("IntegrationRow", () => { it("shows connect action for available providers", async () => { const user = userEvent.setup() const onConnect = vi.fn() - render( - , - ) + renderRow({ + provider: mailgunProvider, + onConnect, + onActivate: vi.fn(), + onDisconnect: vi.fn(), + }) await user.click(screen.getByRole("button", { name: /connect/i })) expect(onConnect).toHaveBeenCalledWith(mailgunProvider) @@ -39,30 +50,41 @@ describe("IntegrationRow", () => { const onDisconnect = vi.fn() const connected = { ...mailgunProvider, isConnected: true } - render( - , - ) + renderRow({ + provider: connected, + onConnect: vi.fn(), + onActivate: vi.fn(), + onDisconnect, + }) expect(screen.getByText("Connected")).toBeInTheDocument() await user.click(screen.getByRole("button", { name: /disconnect/i })) expect(onDisconnect).toHaveBeenCalledWith(connected) }) - it("shows coming soon badge for unavailable providers", () => { - render( - , + it("hides management actions for read-only users", () => { + renderRow( + { + provider: mailgunProvider, + onConnect: vi.fn(), + onActivate: vi.fn(), + onDisconnect: vi.fn(), + }, + [Permission.IntegrationsRead], ) + expect(screen.getByText("Not connected")).toBeInTheDocument() + expect(screen.queryByRole("button", { name: /connect/i })).not.toBeInTheDocument() + }) + + it("shows coming soon badge for unavailable providers", () => { + renderRow({ + provider: { ...mailgunProvider, isAvailable: false, isComingSoon: true }, + onConnect: vi.fn(), + onActivate: vi.fn(), + onDisconnect: vi.fn(), + }) + expect(screen.getByText("Coming soon")).toBeInTheDocument() expect(screen.queryByRole("button", { name: /connect/i })).not.toBeInTheDocument() }) diff --git a/frontend/src/features/integrations/components/integration-row/integration-row.tsx b/frontend/src/features/integrations/components/integration-row/integration-row.tsx index 10ace676..1bddea4f 100644 --- a/frontend/src/features/integrations/components/integration-row/integration-row.tsx +++ b/frontend/src/features/integrations/components/integration-row/integration-row.tsx @@ -1,5 +1,6 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { Permission, useWorkspaceAuthorization } from "@/core/auth" import { cn } from "@/lib/utils" import { IntegrationProviderIcon } from "@/features/integrations/components/integration-provider-icon" @@ -14,6 +15,8 @@ interface IntegrationRowProps { } export function IntegrationRow({ provider, onConnect, onActivate, onDisconnect, isBusy }: IntegrationRowProps) { + const { can } = useWorkspaceAuthorization() + const canManageIntegrations = can(Permission.IntegrationsWrite) const isDisabled = !provider.isAvailable || isBusy return ( @@ -41,30 +44,34 @@ export function IntegrationRow({ provider, onConnect, onActivate, onDisconnect, ) : provider.isConnected ? ( <> Connected - + {canManageIntegrations ? ( + + ) : null} ) : provider.isDeactivated ? ( <> Deactivated - + {canManageIntegrations ? ( + + ) : null} - ) : ( + ) : canManageIntegrations ? ( + ) : ( + Not connected )}
diff --git a/frontend/src/features/integrations/hooks/use-integrations.hook.ts b/frontend/src/features/integrations/hooks/use-integrations.hook.ts index b09dda4b..41817e0a 100644 --- a/frontend/src/features/integrations/hooks/use-integrations.hook.ts +++ b/frontend/src/features/integrations/hooks/use-integrations.hook.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from "react" -import { deleteIntegration, listIntegrations, upsertIntegration, fetchProviders } from "@/features/integrations/integrations.api" -import type { BackendProvider } from "@/features/integrations/integrations.api" +import { deleteIntegration, listIntegrations, upsertIntegration, fetchProviders } from "@/features/integrations/api/integrations.api" +import type { BackendProvider } from "@/features/integrations/api/integrations.api" import { INTEGRATION_STATUS, type Integration, diff --git a/frontend/src/features/integrations/hooks/use-provider-config-fields.hook.test.ts b/frontend/src/features/integrations/hooks/use-provider-config-fields.hook.test.ts index 694419f9..0a9dd694 100644 --- a/frontend/src/features/integrations/hooks/use-provider-config-fields.hook.test.ts +++ b/frontend/src/features/integrations/hooks/use-provider-config-fields.hook.test.ts @@ -1,11 +1,11 @@ import { renderHook, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -vi.mock("@/features/integrations/integrations.api", () => ({ +vi.mock("@/features/integrations/api/integrations.api", () => ({ fetchProviderConfigFields: vi.fn(), })) -import { fetchProviderConfigFields } from "@/features/integrations/integrations.api" +import { fetchProviderConfigFields } from "@/features/integrations/api/integrations.api" import { useProviderConfigFields } from "./use-provider-config-fields.hook" diff --git a/frontend/src/features/integrations/hooks/use-provider-config-fields.hook.ts b/frontend/src/features/integrations/hooks/use-provider-config-fields.hook.ts index dd9060fe..235522bb 100644 --- a/frontend/src/features/integrations/hooks/use-provider-config-fields.hook.ts +++ b/frontend/src/features/integrations/hooks/use-provider-config-fields.hook.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react" -import { fetchProviderConfigFields, type ProviderConfigField } from "@/features/integrations/integrations.api" +import { fetchProviderConfigFields, type ProviderConfigField } from "@/features/integrations/api/integrations.api" export function useProviderConfigFields(workspaceId: string, providerId: string | undefined, enabled: boolean) { const [fields, setFields] = useState([]) diff --git a/frontend/src/features/integrations/pages/integrations.page.test.tsx b/frontend/src/features/integrations/pages/integrations.page.test.tsx index 7ad643e9..fb3d4d8f 100644 --- a/frontend/src/features/integrations/pages/integrations.page.test.tsx +++ b/frontend/src/features/integrations/pages/integrations.page.test.tsx @@ -2,6 +2,8 @@ import { render, screen } from "@testing-library/react" import { MemoryRouter, Route, Routes } from "react-router-dom" import { describe, expect, it, vi } from "vitest" +import { Permission, Role, WorkspaceAuthorizationProvider } from "@/core/auth" + import { useIntegrations } from "../hooks/use-integrations.hook" import { IntegrationsPage } from "./integrations.page" @@ -13,12 +15,14 @@ vi.mock("../hooks/use-integrations.hook", () => ({ const mockedUseIntegrations = vi.mocked(useIntegrations) -function renderPage() { +function renderPage(permissions: string[] = [Permission.IntegrationsRead, Permission.IntegrationsWrite]) { return render( - - } /> - + + + } /> + + , ) } diff --git a/frontend/src/features/integrations/pages/integrations.page.tsx b/frontend/src/features/integrations/pages/integrations.page.tsx index b3cfb5b8..c3570205 100644 --- a/frontend/src/features/integrations/pages/integrations.page.tsx +++ b/frontend/src/features/integrations/pages/integrations.page.tsx @@ -5,6 +5,7 @@ import { useState } from "react" import { PageHeader } from "@/shared/components/page-header" import { Button } from "@/components/ui/button" import { Icon } from "@/components/ui/icon" +import { Can, Permission } from "@/core/auth" import { cn } from "@/lib/utils" import { ConnectModal } from "@/features/integrations/components/connect-modal" @@ -129,12 +130,14 @@ export function IntegrationsPage() {

{tab === "connected" && ( - + + + )} ) : ( diff --git a/frontend/src/features/settings/settings.api.ts b/frontend/src/features/settings/api/settings.api.ts similarity index 96% rename from frontend/src/features/settings/settings.api.ts rename to frontend/src/features/settings/api/settings.api.ts index 1fcbfb6e..828df04f 100644 --- a/frontend/src/features/settings/settings.api.ts +++ b/frontend/src/features/settings/api/settings.api.ts @@ -1,8 +1,8 @@ import { fetchWorkspaceApiKeys } from "@/core/api/workspace-api-keys" import { apiFetch } from "@/core/api/client" -import { parseWorkspaceSettings } from "./settings.schema" -import type { ApiKey, WorkspaceSettings, WorkspaceSettingsPatch } from "./settings.types" +import { parseWorkspaceSettings } from "../settings.schema" +import type { ApiKey, WorkspaceSettings, WorkspaceSettingsPatch } from "../settings.types" async function readApiErrorMessage(res: Response, fallback: string): Promise { try { diff --git a/frontend/src/features/settings/api/team.api.test.ts b/frontend/src/features/settings/api/team.api.test.ts new file mode 100644 index 00000000..7b9b1da4 --- /dev/null +++ b/frontend/src/features/settings/api/team.api.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest" + +import { listInvitations, listMembers } from "./team.api" + +vi.mock("@/core/api/client", () => ({ + apiFetch: vi.fn(), +})) + +import { apiFetch } from "@/core/api/client" + +const mockedApiFetch = vi.mocked(apiFetch) + +describe("team.api", () => { + it("listMembers returns empty array when API responds with null", async () => { + mockedApiFetch.mockResolvedValue({ + ok: true, + json: async () => null, + } as Response) + + await expect(listMembers("ws-1")).resolves.toEqual([]) + }) + + it("listInvitations returns empty array when API responds with null", async () => { + mockedApiFetch.mockResolvedValue({ + ok: true, + json: async () => null, + } as Response) + + await expect(listInvitations("ws-1")).resolves.toEqual([]) + }) +}) diff --git a/frontend/src/features/settings/api/team.api.ts b/frontend/src/features/settings/api/team.api.ts new file mode 100644 index 00000000..395531c8 --- /dev/null +++ b/frontend/src/features/settings/api/team.api.ts @@ -0,0 +1,63 @@ +import { apiFetch } from "@/core/api/client" + +import type { + CreateInvitationResult, + InvitableRole, + WorkspaceInvitation, + WorkspaceMember, +} from "../team.types" + +async function readApiErrorMessage(res: Response, fallback: string): Promise { + try { + const body = (await res.json()) as { message?: string } + const message = body.message?.trim() + return message || fallback + } catch { + return fallback + } +} + +function ensureArray(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : [] +} + +export async function listMembers(workspaceId: string): Promise { + const res = await apiFetch(`/api/v1/workspaces/${workspaceId}/members`) + if (!res.ok) { + throw new Error(await readApiErrorMessage(res, "Failed to load team members")) + } + return ensureArray(await res.json()) +} + +export async function removeMember(workspaceId: string, userId: string): Promise { + const res = await apiFetch(`/api/v1/workspaces/${workspaceId}/members/${userId}`, { + method: "DELETE", + }) + if (!res.ok) { + throw new Error(await readApiErrorMessage(res, "Failed to remove team member")) + } +} + +export async function listInvitations(workspaceId: string): Promise { + const res = await apiFetch(`/api/v1/workspaces/${workspaceId}/invitations`) + if (!res.ok) { + throw new Error(await readApiErrorMessage(res, "Failed to load pending invitations")) + } + return ensureArray(await res.json()) +} + +export async function createInvitation( + workspaceId: string, + email: string, + role: InvitableRole, +): Promise { + const res = await apiFetch(`/api/v1/workspaces/${workspaceId}/invitations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, role }), + }) + if (!res.ok) { + throw new Error(await readApiErrorMessage(res, "Failed to send invitation")) + } + return (await res.json()) as CreateInvitationResult +} diff --git a/frontend/src/features/settings/components/invitation-row/index.ts b/frontend/src/features/settings/components/invitation-row/index.ts new file mode 100644 index 00000000..df8981e3 --- /dev/null +++ b/frontend/src/features/settings/components/invitation-row/index.ts @@ -0,0 +1 @@ +export { InvitationRow } from "./invitation-row" diff --git a/frontend/src/features/settings/components/invitation-row/invitation-row.stories.tsx b/frontend/src/features/settings/components/invitation-row/invitation-row.stories.tsx new file mode 100644 index 00000000..d4f887e1 --- /dev/null +++ b/frontend/src/features/settings/components/invitation-row/invitation-row.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { Role } from "@/core/auth" + +import { InvitationRow } from "./invitation-row" + +const meta = { + title: "Features/Settings/InvitationRow", + component: InvitationRow, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + invitation: { + id: "inv-1", + workspace_id: "ws-1", + email: "invitee@example.com", + role: Role.Viewer, + expires_at: "2026-01-08T12:00:00Z", + status: "pending", + created_at: "2026-01-01T00:00:00Z", + }, + }, +} diff --git a/frontend/src/features/settings/components/invitation-row/invitation-row.tsx b/frontend/src/features/settings/components/invitation-row/invitation-row.tsx new file mode 100644 index 00000000..e58e63b3 --- /dev/null +++ b/frontend/src/features/settings/components/invitation-row/invitation-row.tsx @@ -0,0 +1,25 @@ +import { Badge } from "@/components/ui/badge" + +import { formatExpiryDate, roleLabel } from "../../team.utils" +import type { WorkspaceInvitation } from "../../team.types" + +interface InvitationRowProps { + invitation: WorkspaceInvitation +} + +export function InvitationRow({ invitation }: InvitationRowProps) { + return ( +
+
+
+

{invitation.email}

+ {roleLabel(invitation.role)} + Pending +
+

+ Expires {formatExpiryDate(invitation.expires_at)} +

+
+
+ ) +} diff --git a/frontend/src/features/settings/components/invitation-token-dialog/index.ts b/frontend/src/features/settings/components/invitation-token-dialog/index.ts new file mode 100644 index 00000000..9f2fc9c7 --- /dev/null +++ b/frontend/src/features/settings/components/invitation-token-dialog/index.ts @@ -0,0 +1 @@ +export { InvitationTokenDialog } from "./invitation-token-dialog" diff --git a/frontend/src/features/settings/components/invitation-token-dialog/invitation-token-dialog.stories.tsx b/frontend/src/features/settings/components/invitation-token-dialog/invitation-token-dialog.stories.tsx new file mode 100644 index 00000000..62228759 --- /dev/null +++ b/frontend/src/features/settings/components/invitation-token-dialog/invitation-token-dialog.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { Role } from "@/core/auth" + +import { InvitationTokenDialog } from "./invitation-token-dialog" + +const meta = { + title: "Features/Settings/InvitationTokenDialog", + component: InvitationTokenDialog, + parameters: { layout: "centered" }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Open: Story = { + args: { + open: true, + email: "invitee@example.com", + role: Role.Member, + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.demo-invitation-token", + onClose: () => undefined, + }, +} + +export const ViewerRole: Story = { + args: { + open: true, + email: "viewer@weprodev.com", + role: Role.Viewer, + token: "one-time-viewer-invite-token", + onClose: () => undefined, + }, +} diff --git a/frontend/src/features/settings/components/invitation-token-dialog/invitation-token-dialog.tsx b/frontend/src/features/settings/components/invitation-token-dialog/invitation-token-dialog.tsx new file mode 100644 index 00000000..5cfdf440 --- /dev/null +++ b/frontend/src/features/settings/components/invitation-token-dialog/invitation-token-dialog.tsx @@ -0,0 +1,70 @@ +import { useState } from "react" + +import { Button } from "@/components/ui/button" +import { Icon } from "@/components/ui/icon" +import { Input } from "@/components/ui/input" +import { Modal } from "@/components/ui/modal" + +import { roleLabel } from "../../team.utils" + +interface InvitationTokenDialogProps { + open: boolean + email: string + role: string + token: string + onClose: () => void +} + +export function InvitationTokenDialog({ + open, + email, + role, + token, + onClose, +}: InvitationTokenDialogProps) { + const [copied, setCopied] = useState(false) + + async function copyToken() { + try { + await navigator.clipboard.writeText(token) + setCopied(true) + window.setTimeout(() => setCopied(false), 2000) + } catch { + setCopied(false) + } + } + + return ( + +
+

+ Share this one-time token with {email} as{" "} + {roleLabel(role)}. It will not be shown again. +

+ +
+ +
+ event.target.select()} + /> + +
+
+ + +
+
+ ) +} diff --git a/frontend/src/features/settings/components/invite-member-dialog/index.ts b/frontend/src/features/settings/components/invite-member-dialog/index.ts new file mode 100644 index 00000000..1d78a51e --- /dev/null +++ b/frontend/src/features/settings/components/invite-member-dialog/index.ts @@ -0,0 +1 @@ +export { InviteMemberDialog } from "./invite-member-dialog" diff --git a/frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.stories.tsx b/frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.stories.tsx new file mode 100644 index 00000000..fdc11c90 --- /dev/null +++ b/frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { InviteMemberDialog } from "./invite-member-dialog" + +const meta = { + title: "Features/Settings/InviteMemberDialog", + component: InviteMemberDialog, + parameters: { layout: "centered" }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + open: true, + onClose: () => undefined, + onInvite: async (email, role) => ({ + id: "inv-1", + email, + role, + expires_at: "2026-01-08T00:00:00Z", + token: "sample-token", + }), + }, +} diff --git a/frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.test.tsx b/frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.test.tsx new file mode 100644 index 00000000..9ad0449f --- /dev/null +++ b/frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { describe, expect, it, vi } from "vitest" + +import { Role } from "@/core/auth" + +import type { WorkspaceMember } from "../../team.types" +import { InviteMemberDialog } from "./invite-member-dialog" + +describe("InviteMemberDialog", () => { + it("submits email and selected role", async () => { + const user = userEvent.setup() + const onInvite = vi.fn().mockResolvedValue({ + id: "inv-1", + email: "member@example.com", + role: Role.Viewer, + expires_at: "2026-01-08T00:00:00Z", + token: "token", + }) + + render() + + await user.type(screen.getByLabelText(/email address/i), "member@example.com") + await user.selectOptions(screen.getByLabelText(/role/i), Role.Viewer) + await user.click(screen.getByRole("button", { name: /send invitation/i })) + + expect(onInvite).toHaveBeenCalledWith("member@example.com", Role.Viewer) + }) + + it("shows validation error for invalid email", async () => { + const user = userEvent.setup() + + render() + + await user.type(screen.getByLabelText(/email address/i), "not-an-email") + await user.click(screen.getByRole("button", { name: /send invitation/i })) + + expect(screen.getByText(/valid email address/i)).toBeInTheDocument() + }) + + it("rejects invitations for existing members", async () => { + const user = userEvent.setup() + const onInvite = vi.fn() + + render( + ]} + />, + ) + + await user.type(screen.getByLabelText(/email address/i), "member@weprodev.com") + await user.click(screen.getByRole("button", { name: /send invitation/i })) + + expect(screen.getByText(/already a member/i)).toBeInTheDocument() + expect(onInvite).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.tsx b/frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.tsx new file mode 100644 index 00000000..53bc72b5 --- /dev/null +++ b/frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.tsx @@ -0,0 +1,154 @@ +import { useState } from "react" + +import { Role, type WorkspaceRole } from "@/core/auth" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Modal } from "@/components/ui/modal" +import { cn } from "@/lib/utils" + +import { INVITABLE_ROLES, hasPendingInvitationEmail, isExistingMemberEmail, roleLabel } from "../../team.utils" +import type { CreateInvitationResult, InvitableRole, WorkspaceInvitation, WorkspaceMember } from "../../team.types" + +interface InviteMemberDialogProps { + open: boolean + onClose: () => void + onInvite: (email: string, role: InvitableRole) => Promise + members?: WorkspaceMember[] + invitations?: WorkspaceInvitation[] +} + +export function InviteMemberDialog({ + open, + onClose, + onInvite, + members = [], + invitations = [], +}: InviteMemberDialogProps) { + const [email, setEmail] = useState("") + const [role, setRole] = useState(Role.Member) + const [validationError, setValidationError] = useState(null) + const [submitError, setSubmitError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + function resetForm() { + setEmail("") + setRole(Role.Member) + setValidationError(null) + setSubmitError(null) + setIsSubmitting(false) + } + + function handleClose() { + if (isSubmitting) return + resetForm() + onClose() + } + + function isValidEmail(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + const trimmedEmail = email.trim().toLowerCase() + if (!trimmedEmail) { + setValidationError("Email is required") + return + } + if (!isValidEmail(trimmedEmail)) { + setValidationError("Enter a valid email address") + return + } + if (isExistingMemberEmail(members, trimmedEmail)) { + setValidationError("This person is already a member of the workspace") + return + } + if (hasPendingInvitationEmail(invitations, trimmedEmail)) { + setValidationError("A pending invitation already exists for this email") + return + } + + setValidationError(null) + setSubmitError(null) + setIsSubmitting(true) + try { + await onInvite(trimmedEmail, role) + resetForm() + onClose() + } catch (err) { + setSubmitError(err instanceof Error ? err.message : "Failed to send invitation") + } finally { + setIsSubmitting(false) + } + } + + return ( + +
+

+ Send an invitation email with a role. The invitee uses the one-time token to join this workspace. +

+ +
+ + { + setEmail(event.target.value) + setValidationError(null) + setSubmitError(null) + }} + placeholder="teammate@company.com" + autoFocus + disabled={isSubmitting} + /> +
+ +
+ + +
+ + {validationError ? ( +

{validationError}

+ ) : null} + + {submitError ? ( +

+ {submitError} +

+ ) : null} + +
+ + +
+
+
+ ) +} diff --git a/frontend/src/features/settings/components/radio-option/radio-option.test.tsx b/frontend/src/features/settings/components/radio-option/radio-option.test.tsx index a1758174..af860fc5 100644 --- a/frontend/src/features/settings/components/radio-option/radio-option.test.tsx +++ b/frontend/src/features/settings/components/radio-option/radio-option.test.tsx @@ -40,4 +40,25 @@ describe("RadioOption", () => { await user.click(screen.getByRole("radio")) expect(onChange).toHaveBeenCalledTimes(1) }) + + it("does not call onChange when disabled", async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render( + , + ) + + expect(screen.getByRole("radio")).toBeDisabled() + await user.click(screen.getByRole("radio")) + expect(onChange).not.toHaveBeenCalled() + }) }) diff --git a/frontend/src/features/settings/components/radio-option/radio-option.tsx b/frontend/src/features/settings/components/radio-option/radio-option.tsx index ab26c56c..fa51eb06 100644 --- a/frontend/src/features/settings/components/radio-option/radio-option.tsx +++ b/frontend/src/features/settings/components/radio-option/radio-option.tsx @@ -7,15 +7,26 @@ interface RadioOptionProps { description: string checked: boolean onChange: () => void + disabled?: boolean } -export function RadioOption({ id, name, label, description, checked, onChange }: RadioOptionProps) { +export function RadioOption({ + id, + name, + label, + description, + checked, + onChange, + disabled = false, +}: RadioOptionProps) { return (
-
- -

Team Management

-

Coming soon — invite teammates and manage roles.

-
+
diff --git a/frontend/src/features/settings/team.types.ts b/frontend/src/features/settings/team.types.ts new file mode 100644 index 00000000..8766836d --- /dev/null +++ b/frontend/src/features/settings/team.types.ts @@ -0,0 +1,30 @@ +import type { WorkspaceRole } from "@/core/auth" + +export interface WorkspaceMember { + workspace_id: string + user_id: string + role: string + joined_at: string + user_email?: string + display_name?: string +} + +export interface WorkspaceInvitation { + id: string + workspace_id: string + email: string + role: string + expires_at: string + status: string + created_at: string +} + +export interface CreateInvitationResult { + id: string + email: string + role: string + expires_at: string + token: string +} + +export type InvitableRole = WorkspaceRole diff --git a/frontend/src/features/settings/team.utils.test.ts b/frontend/src/features/settings/team.utils.test.ts new file mode 100644 index 00000000..87966da9 --- /dev/null +++ b/frontend/src/features/settings/team.utils.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest" + +import { Role } from "@/core/auth" + +import { + hasPendingInvitationEmail, + isExistingMemberEmail, + roleLabel, +} from "./team.utils" + +describe("roleLabel", () => { + it("returns human-readable labels for workspace roles", () => { + expect(roleLabel(Role.Admin)).toBe("Admin") + expect(roleLabel(Role.Member)).toBe("Member") + expect(roleLabel(Role.Viewer)).toBe("Viewer") + }) + + it("falls back to raw role name", () => { + expect(roleLabel("custom")).toBe("custom") + }) +}) + +describe("invitation email guards", () => { + it("detects existing workspace members by email", () => { + expect( + isExistingMemberEmail([{ user_email: "Member@WeProDev.com" }], "member@weprodev.com"), + ).toBe(true) + expect(isExistingMemberEmail([{ user_email: "other@example.com" }], "member@weprodev.com")).toBe( + false, + ) + }) + + it("detects duplicate pending invitations by email", () => { + expect(hasPendingInvitationEmail([{ email: "Pending@Example.com" }], "pending@example.com")).toBe( + true, + ) + expect(hasPendingInvitationEmail([], "pending@example.com")).toBe(false) + }) +}) diff --git a/frontend/src/features/settings/team.utils.ts b/frontend/src/features/settings/team.utils.ts new file mode 100644 index 00000000..37e4f6b3 --- /dev/null +++ b/frontend/src/features/settings/team.utils.ts @@ -0,0 +1,56 @@ +import { Role, WorkspaceRoles, type WorkspaceRole } from "@/core/auth" + +const ROLE_LABELS: Record = { + [Role.Admin]: "Admin", + [Role.Member]: "Member", + [Role.Viewer]: "Viewer", +} + +export const INVITABLE_ROLES: readonly WorkspaceRole[] = WorkspaceRoles + +export function roleLabel(role: string): string { + if (role in ROLE_LABELS) { + return ROLE_LABELS[role as WorkspaceRole] + } + return role +} + +export function formatMemberName(member: { display_name?: string; user_email?: string }): string { + const name = member.display_name?.trim() + if (name) return name + return member.user_email ?? "Unknown member" +} + +export function formatJoinedDate(value: string): string { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleDateString() +} + +export function formatExpiryDate(value: string): string { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleString() +} + +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase() +} + +export function isExistingMemberEmail( + members: Array<{ user_email?: string }>, + email: string, +): boolean { + const normalized = normalizeEmail(email) + return members.some( + (member) => member.user_email && normalizeEmail(member.user_email) === normalized, + ) +} + +export function hasPendingInvitationEmail( + invitations: Array<{ email: string }>, + email: string, +): boolean { + const normalized = normalizeEmail(email) + return invitations.some((invitation) => normalizeEmail(invitation.email) === normalized) +} diff --git a/frontend/src/features/workspaces/workspaces.api.ts b/frontend/src/features/workspaces/api/workspaces.api.ts similarity index 90% rename from frontend/src/features/workspaces/workspaces.api.ts rename to frontend/src/features/workspaces/api/workspaces.api.ts index 0bbee147..3dde1110 100644 --- a/frontend/src/features/workspaces/workspaces.api.ts +++ b/frontend/src/features/workspaces/api/workspaces.api.ts @@ -1,6 +1,6 @@ import { apiFetch } from "@/core/api/client" -import type { Workspace } from "./workspace.types" +import type { Workspace } from "../workspace.types" export async function fetchWorkspaces(): Promise< { ok: true; workspaces: Workspace[] } | { ok: false; status: number; message?: string } diff --git a/frontend/src/features/workspaces/hooks/use-workspaces.hook.ts b/frontend/src/features/workspaces/hooks/use-workspaces.hook.ts index 447771a9..8c0edd75 100644 --- a/frontend/src/features/workspaces/hooks/use-workspaces.hook.ts +++ b/frontend/src/features/workspaces/hooks/use-workspaces.hook.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import { useNavigate } from "react-router-dom" import { ROUTES } from "@/core/router/routes" -import { fetchWorkspaces } from "../workspaces.api" +import { fetchWorkspaces } from "../api/workspaces.api" import type { Workspace } from "../workspace.types" type UseWorkspacesOptions = { diff --git a/internal/core/domain/permission.go b/internal/core/domain/permission.go index 3f7c0699..e8a426d7 100644 --- a/internal/core/domain/permission.go +++ b/internal/core/domain/permission.go @@ -62,6 +62,41 @@ var ReadPermissions = []string{ PermissionInvitationsRead, } +// WritePermissions require admin or member role (never viewer). +var WritePermissions = []string{ + PermissionWorkspacesWrite, + PermissionMembersWrite, + PermissionAPIKeysWrite, + PermissionIntegrationsWrite, + PermissionTemplatesWrite, + PermissionSettingsWrite, + PermissionInvitationsWrite, + PermissionInboxWrite, +} + +// AllPermissions is the full portal RBAC catalog (read + write). +var AllPermissions = append(append([]string{}, ReadPermissions...), WritePermissions...) + +// IsWritePermission reports whether name is a mutating portal permission. +func IsWritePermission(name string) bool { + for _, permission := range WritePermissions { + if permission == name { + return true + } + } + return false +} + +// IsReadPermission reports whether name is a read-only portal permission for workspace members. +func IsReadPermission(name string) bool { + for _, permission := range ReadPermissions { + if permission == name { + return true + } + } + return false +} + // PublicGuestPermissions are the only permissions non-members receive on public workspaces. // Narrower than viewer role: guests must not read members, API keys, logs, or settings. var PublicGuestPermissions = []string{ diff --git a/internal/core/domain/permission_test.go b/internal/core/domain/permission_test.go index 7a46a72c..23d9c60a 100644 --- a/internal/core/domain/permission_test.go +++ b/internal/core/domain/permission_test.go @@ -1,6 +1,9 @@ package domain -import "testing" +import ( + "slices" + "testing" +) func TestIsWorkspaceRole(t *testing.T) { t.Parallel() @@ -28,3 +31,64 @@ func TestIsPublicGuestPermission(t *testing.T) { t.Fatal("logs.read must not be granted to public guests") } } + +func TestReadPermissions_excludeWritePermissions(t *testing.T) { + t.Parallel() + + for _, readPermission := range ReadPermissions { + if IsWritePermission(readPermission) { + t.Fatalf("read permission %q must not be a write permission", readPermission) + } + } +} + +func TestWritePermissions_areNotReadPermissions(t *testing.T) { + t.Parallel() + + for _, writePermission := range WritePermissions { + if IsReadPermission(writePermission) { + t.Fatalf("write permission %q must not be a read permission", writePermission) + } + } +} + +func TestAllPermissions_coversPortalCatalog(t *testing.T) { + t.Parallel() + + if len(AllPermissions) != len(ReadPermissions)+len(WritePermissions) { + t.Fatalf("AllPermissions length = %d, want %d", len(AllPermissions), len(ReadPermissions)+len(WritePermissions)) + } + + seen := make(map[string]struct{}, len(AllPermissions)) + for _, permission := range AllPermissions { + if _, ok := seen[permission]; ok { + t.Fatalf("duplicate permission %q in AllPermissions", permission) + } + seen[permission] = struct{}{} + } +} + +func TestViewerReadPermissions_matchSeedCatalog(t *testing.T) { + t.Parallel() + + expected := []string{ + PermissionWorkspacesRead, + PermissionMembersRead, + PermissionAPIKeysRead, + PermissionLogsRead, + PermissionIntegrationsRead, + PermissionTemplatesRead, + PermissionSettingsRead, + PermissionInvitationsRead, + } + + if len(ReadPermissions) != len(expected) { + t.Fatalf("ReadPermissions length = %d, want %d", len(ReadPermissions), len(expected)) + } + + for _, permission := range expected { + if !slices.Contains(ReadPermissions, permission) { + t.Fatalf("ReadPermissions missing %q (viewer seed catalog)", permission) + } + } +} diff --git a/internal/core/port/portal.go b/internal/core/port/portal.go index 10bc4a66..2c58788e 100644 --- a/internal/core/port/portal.go +++ b/internal/core/port/portal.go @@ -13,12 +13,14 @@ type WorkspaceMemberRepository interface { Remove(ctx context.Context, workspaceID, userID string) error GetRole(ctx context.Context, workspaceID, userID string) (string, error) ListMembers(ctx context.Context, workspaceID string) ([]domain.WorkspaceMember, error) + MemberExistsByEmail(ctx context.Context, workspaceID, email string) (bool, error) } // InvitationRepository stores pending invites. type InvitationRepository interface { Create(ctx context.Context, inv *domain.Invitation) error ListPendingByWorkspace(ctx context.Context, workspaceID string) ([]domain.Invitation, error) + PendingInvitationExistsByEmail(ctx context.Context, workspaceID, email string) (bool, error) } // MessageLogQuery filters for listing request logs. diff --git a/internal/core/service/portal_service.go b/internal/core/service/portal_service.go index 098675dd..2346452a 100644 --- a/internal/core/service/portal_service.go +++ b/internal/core/service/portal_service.go @@ -579,7 +579,7 @@ const invitationTTL = 7 * 24 * time.Hour func (s *PortalService) NewPendingInvitation(workspaceID, email, role string) *domain.Invitation { return &domain.Invitation{ WorkspaceID: workspaceID, - Email: email, + Email: strings.ToLower(strings.TrimSpace(email)), Role: role, ExpiresAt: time.Now().Add(invitationTTL), Status: "pending", @@ -597,6 +597,28 @@ func (s *PortalService) CreateInvitation(ctx context.Context, inv *domain.Invita if !domain.IsWorkspaceRole(inv.Role) { return "", fmt.Errorf("invalid role %q: %w", inv.Role, port.ErrInvalidInput) } + + inv.Email = strings.ToLower(strings.TrimSpace(inv.Email)) + if inv.Email == "" { + return "", fmt.Errorf("email required: %w", port.ErrInvalidInput) + } + + isMember, err := s.members.MemberExistsByEmail(ctx, inv.WorkspaceID, inv.Email) + if err != nil { + return "", err + } + if isMember { + return "", fmt.Errorf("user is already a member of this workspace: %w", port.ErrInvalidInput) + } + + hasPending, err := s.invites.PendingInvitationExistsByEmail(ctx, inv.WorkspaceID, inv.Email) + if err != nil { + return "", err + } + if hasPending { + return "", fmt.Errorf("a pending invitation already exists for this email: %w", port.ErrInvalidInput) + } + // Generate a cryptographically random token from two UUIDs. rawToken = uuid.NewString() + uuid.NewString() sum := sha256.Sum256([]byte(rawToken)) diff --git a/internal/core/service/portal_service_extended_test.go b/internal/core/service/portal_service_extended_test.go index 969e836c..59177e10 100644 --- a/internal/core/service/portal_service_extended_test.go +++ b/internal/core/service/portal_service_extended_test.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "strings" "testing" "time" @@ -31,6 +32,35 @@ func (f *fakeInvitationRepo) ListPendingByWorkspace(_ context.Context, workspace return f.list, nil } +func (f *fakeInvitationRepo) PendingInvitationExistsByEmail(_ context.Context, workspaceID, email string) (bool, error) { + for _, inv := range f.list { + if inv.WorkspaceID == workspaceID && strings.EqualFold(inv.Email, email) && inv.Status == "pending" { + return true, nil + } + } + return false, nil +} + +type fakeMemberRepo struct { + emails map[string]struct{} +} + +func (f *fakeMemberRepo) Add(context.Context, string, string, string) error { return nil } +func (f *fakeMemberRepo) Remove(context.Context, string, string) error { return nil } +func (f *fakeMemberRepo) GetRole(context.Context, string, string) (string, error) { + return "", port.ErrNotFound +} +func (f *fakeMemberRepo) ListMembers(context.Context, string) ([]domain.WorkspaceMember, error) { + return nil, nil +} +func (f *fakeMemberRepo) MemberExistsByEmail(_ context.Context, workspaceID, email string) (bool, error) { + if f.emails == nil { + return false, nil + } + _, ok := f.emails[strings.ToLower(strings.TrimSpace(email))] + return ok, nil +} + type fakeAPIKeyRepo struct { keys map[string]*domain.APIKey byWS map[string][]domain.APIKey @@ -118,7 +148,7 @@ func TestPortalService_CreateInvitation(t *testing.T) { t.Parallel() repo := &fakeInvitationRepo{} - svc := NewPortalService(PortalDeps{Invitations: repo}) + svc := NewPortalService(PortalDeps{Invitations: repo, Members: &fakeMemberRepo{}}) inv := svc.NewPendingInvitation("ws-1", "user@example.com", domain.RoleMember) rawToken, err := svc.CreateInvitation(context.Background(), inv) @@ -143,7 +173,10 @@ func TestPortalService_CreateInvitation(t *testing.T) { func TestPortalService_CreateInvitation_rejectsInvalidRole(t *testing.T) { t.Parallel() - svc := NewPortalService(PortalDeps{Invitations: &fakeInvitationRepo{}}) + svc := NewPortalService(PortalDeps{ + Invitations: &fakeInvitationRepo{}, + Members: &fakeMemberRepo{}, + }) inv := svc.NewPendingInvitation("ws-1", "user@example.com", "owner") _, err := svc.CreateInvitation(context.Background(), inv) @@ -155,6 +188,50 @@ func TestPortalService_CreateInvitation_rejectsInvalidRole(t *testing.T) { } } +func TestPortalService_CreateInvitation_rejectsExistingMember(t *testing.T) { + t.Parallel() + + svc := NewPortalService(PortalDeps{ + Invitations: &fakeInvitationRepo{}, + Members: &fakeMemberRepo{ + emails: map[string]struct{}{"member@weprodev.com": {}}, + }, + }) + inv := svc.NewPendingInvitation("ws-1", "member@weprodev.com", domain.RoleViewer) + + _, err := svc.CreateInvitation(context.Background(), inv) + if err == nil { + t.Fatal("expected error for existing member") + } + if !errors.Is(err, port.ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput, got %v", err) + } +} + +func TestPortalService_CreateInvitation_rejectsPendingDuplicate(t *testing.T) { + t.Parallel() + + svc := NewPortalService(PortalDeps{ + Invitations: &fakeInvitationRepo{ + list: []domain.Invitation{{ + WorkspaceID: "ws-1", + Email: "invitee@example.com", + Status: "pending", + }}, + }, + Members: &fakeMemberRepo{}, + }) + inv := svc.NewPendingInvitation("ws-1", "invitee@example.com", domain.RoleMember) + + _, err := svc.CreateInvitation(context.Background(), inv) + if err == nil { + t.Fatal("expected error for duplicate pending invitation") + } + if !errors.Is(err, port.ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput, got %v", err) + } +} + func TestPortalService_APIKeys(t *testing.T) { t.Parallel() diff --git a/internal/infrastructure/repository/postgres/invitation_repository.go b/internal/infrastructure/repository/postgres/invitation_repository.go index a446d9ac..dab249ad 100644 --- a/internal/infrastructure/repository/postgres/invitation_repository.go +++ b/internal/infrastructure/repository/postgres/invitation_repository.go @@ -67,3 +67,21 @@ func (r *InvitationRepository) ListPendingByWorkspace(ctx context.Context, works } return out, nil } + +func (r *InvitationRepository) PendingInvitationExistsByEmail(ctx context.Context, workspaceID, email string) (bool, error) { + var exists bool + err := r.client.GetDB(ctx).QueryRowContext(ctx, ` + SELECT EXISTS( + SELECT 1 + FROM invitations + WHERE workspace_id = $1 + AND lower(email) = lower($2) + AND status = 'pending' + AND expires_at > NOW() + ) + `, workspaceID, email).Scan(&exists) + if err != nil { + slog.ErrorContext(ctx, "database error: failed to check pending invitation by email", "error", err, "workspace_id", workspaceID, "email", email) + } + return exists, err +} diff --git a/internal/infrastructure/repository/postgres/invitation_repository_test.go b/internal/infrastructure/repository/postgres/invitation_repository_test.go index 0500a870..ce6a20aa 100644 --- a/internal/infrastructure/repository/postgres/invitation_repository_test.go +++ b/internal/infrastructure/repository/postgres/invitation_repository_test.go @@ -65,3 +65,22 @@ func TestInvitationRepository_ListPendingByWorkspace(t *testing.T) { t.Fatalf("unexpected list: %+v", list) } } + +func TestInvitationRepository_PendingInvitationExistsByEmail(t *testing.T) { + t.Parallel() + + client, mock, _ := newMockPgClient(t) + repo := NewInvitationRepository(client) + + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("ws-1", "invitee@example.com"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + exists, err := repo.PendingInvitationExistsByEmail(context.Background(), "ws-1", "invitee@example.com") + if err != nil { + t.Fatalf("PendingInvitationExistsByEmail: %v", err) + } + if !exists { + t.Fatal("expected pending invitation to exist") + } +} diff --git a/internal/infrastructure/repository/postgres/workspace_member_repository.go b/internal/infrastructure/repository/postgres/workspace_member_repository.go index b08b604f..749f29c3 100644 --- a/internal/infrastructure/repository/postgres/workspace_member_repository.go +++ b/internal/infrastructure/repository/postgres/workspace_member_repository.go @@ -102,3 +102,19 @@ func (r *WorkspaceMemberRepository) ListMembers(ctx context.Context, workspaceID } return out, nil } + +func (r *WorkspaceMemberRepository) MemberExistsByEmail(ctx context.Context, workspaceID, email string) (bool, error) { + var exists bool + err := r.client.GetDB(ctx).QueryRowContext(ctx, ` + SELECT EXISTS( + SELECT 1 + FROM workspace_members wm + INNER JOIN users u ON u.id = wm.user_id + WHERE wm.workspace_id = $1 AND lower(u.email) = lower($2) + ) + `, workspaceID, email).Scan(&exists) + if err != nil { + slog.ErrorContext(ctx, "database error: failed to check workspace member by email", "error", err, "workspace_id", workspaceID, "email", email) + } + return exists, err +} diff --git a/internal/infrastructure/repository/postgres/workspace_member_repository_test.go b/internal/infrastructure/repository/postgres/workspace_member_repository_test.go index 8d473ea5..6d399428 100644 --- a/internal/infrastructure/repository/postgres/workspace_member_repository_test.go +++ b/internal/infrastructure/repository/postgres/workspace_member_repository_test.go @@ -70,3 +70,22 @@ func TestWorkspaceMemberRepository_ListMembers(t *testing.T) { t.Fatalf("unexpected members: %+v", members) } } + +func TestWorkspaceMemberRepository_MemberExistsByEmail(t *testing.T) { + t.Parallel() + + client, mock, _ := newMockPgClient(t) + repo := NewWorkspaceMemberRepository(client) + + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("ws-1", "member@weprodev.com"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + exists, err := repo.MemberExistsByEmail(context.Background(), "ws-1", "member@weprodev.com") + if err != nil { + t.Fatalf("MemberExistsByEmail: %v", err) + } + if !exists { + t.Fatal("expected member to exist") + } +} diff --git a/internal/presentation/handler/portal_workspace_handler.go b/internal/presentation/handler/portal_workspace_handler.go index 8e83f5e9..34debc3d 100644 --- a/internal/presentation/handler/portal_workspace_handler.go +++ b/internal/presentation/handler/portal_workspace_handler.go @@ -7,6 +7,7 @@ import ( "github.com/labstack/echo/v4" + "github.com/weprodev/wpd-message-gateway/internal/core/domain" "github.com/weprodev/wpd-message-gateway/internal/core/port" "github.com/weprodev/wpd-message-gateway/internal/core/service" "github.com/weprodev/wpd-message-gateway/internal/presentation/dto" @@ -99,6 +100,9 @@ func (h *PortalWorkspaceHandler) ListMembers(c echo.Context) error { slog.ErrorContext(c.Request().Context(), "failed to list members", "error", err, "workspace_id", wid) return safeHTTPError(err, http.StatusInternalServerError, "failed to list members") } + if members == nil { + members = []domain.WorkspaceMember{} + } return c.JSON(http.StatusOK, members) } @@ -119,6 +123,9 @@ func (h *PortalWorkspaceHandler) ListInvitations(c echo.Context) error { slog.ErrorContext(c.Request().Context(), "failed to list invitations", "error", err, "workspace_id", wid) return safeHTTPError(err, http.StatusInternalServerError, "failed to list invitations") } + if list == nil { + list = []domain.Invitation{} + } return c.JSON(http.StatusOK, list) } diff --git a/internal/presentation/handler/portal_workspace_handler_test.go b/internal/presentation/handler/portal_workspace_handler_test.go index 582af488..9b64b907 100644 --- a/internal/presentation/handler/portal_workspace_handler_test.go +++ b/internal/presentation/handler/portal_workspace_handler_test.go @@ -30,12 +30,38 @@ func (f *fakeInvitationRepository) ListPendingByWorkspace(context.Context, strin return nil, nil } +func (f *fakeInvitationRepository) PendingInvitationExistsByEmail(context.Context, string, string) (bool, error) { + return false, nil +} + +type fakeWorkspaceMemberRepository struct { + existingEmails map[string]struct{} +} + +func (f *fakeWorkspaceMemberRepository) Add(context.Context, string, string, string) error { + return nil +} +func (f *fakeWorkspaceMemberRepository) Remove(context.Context, string, string) error { return nil } +func (f *fakeWorkspaceMemberRepository) GetRole(context.Context, string, string) (string, error) { + return "", nil +} +func (f *fakeWorkspaceMemberRepository) ListMembers(context.Context, string) ([]domain.WorkspaceMember, error) { + return nil, nil +} +func (f *fakeWorkspaceMemberRepository) MemberExistsByEmail(_ context.Context, _ string, email string) (bool, error) { + _, ok := f.existingEmails[strings.ToLower(strings.TrimSpace(email))] + return ok, nil +} + func TestPortalWorkspaceHandler_CreateInvitation(t *testing.T) { t.Parallel() e := echo.New() repo := &fakeInvitationRepository{} - svc := service.NewPortalService(service.PortalDeps{Invitations: repo}) + svc := service.NewPortalService(service.PortalDeps{ + Invitations: repo, + Members: &fakeWorkspaceMemberRepository{}, + }) h := NewPortalWorkspaceHandler(svc) body := `{"email":"invitee@example.com","role":"member"}` @@ -72,6 +98,36 @@ func TestPortalWorkspaceHandler_CreateInvitation(t *testing.T) { } } +func TestPortalWorkspaceHandler_CreateInvitation_rejectsExistingMember(t *testing.T) { + t.Parallel() + e := echo.New() + + svc := service.NewPortalService(service.PortalDeps{ + Invitations: &fakeInvitationRepository{}, + Members: &fakeWorkspaceMemberRepository{ + existingEmails: map[string]struct{}{"member@weprodev.com": {}}, + }, + }) + h := NewPortalWorkspaceHandler(svc) + + body := `{"email":"member@weprodev.com","role":"viewer"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/ws-1/invitations", strings.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("wid") + c.SetParamValues("ws-1") + + err := h.CreateInvitation(c) + if err == nil { + t.Fatal("expected error") + } + var httpErr *echo.HTTPError + if !errors.As(err, &httpErr) || httpErr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %v", err) + } +} + func TestPortalWorkspaceHandler_CreateInvitation_rejectsInvalidJSON(t *testing.T) { t.Parallel() e := echo.New() diff --git a/internal/presentation/portal_write_routes_test.go b/internal/presentation/portal_write_routes_test.go new file mode 100644 index 00000000..a6b6dedf --- /dev/null +++ b/internal/presentation/portal_write_routes_test.go @@ -0,0 +1,48 @@ +package presentation + +import ( + "net/http" + "testing" + + "github.com/weprodev/wpd-message-gateway/internal/core/domain" +) + +// portalWriteRoutes documents mutating portal endpoints and their required write permissions. +// Keep in sync with Router.Setup — every state-changing workspace route must appear here. +var portalWriteRoutes = []struct { + method string + path string + permission string +}{ + {http.MethodPatch, "/api/v1/workspaces/:wid", domain.PermissionWorkspacesWrite}, + {http.MethodDelete, "/api/v1/workspaces/:wid/members/:userId", domain.PermissionMembersWrite}, + {http.MethodPost, "/api/v1/workspaces/:wid/api-keys", domain.PermissionAPIKeysWrite}, + {http.MethodDelete, "/api/v1/workspaces/:wid/api-keys/:keyId", domain.PermissionAPIKeysWrite}, + {http.MethodPost, "/api/v1/workspaces/:wid/api-keys/:keyId/regenerate", domain.PermissionAPIKeysWrite}, + {http.MethodPost, "/api/v1/workspaces/:wid/integrations", domain.PermissionIntegrationsWrite}, + {http.MethodDelete, "/api/v1/workspaces/:wid/integrations/:iid", domain.PermissionIntegrationsWrite}, + {http.MethodPost, "/api/v1/workspaces/:wid/templates", domain.PermissionTemplatesWrite}, + {http.MethodPatch, "/api/v1/workspaces/:wid/templates/:tid", domain.PermissionTemplatesWrite}, + {http.MethodDelete, "/api/v1/workspaces/:wid/templates/:tid", domain.PermissionTemplatesWrite}, + {http.MethodPatch, "/api/v1/workspaces/:wid/settings", domain.PermissionSettingsWrite}, + {http.MethodPost, "/api/v1/workspaces/:wid/invitations", domain.PermissionInvitationsWrite}, + {http.MethodDelete, "/api/v1/workspaces/:wid/inbox/emails/:id", domain.PermissionInboxWrite}, + {http.MethodDelete, "/api/v1/workspaces/:wid/inbox/sms/:id", domain.PermissionInboxWrite}, + {http.MethodDelete, "/api/v1/workspaces/:wid/inbox/push/:id", domain.PermissionInboxWrite}, + {http.MethodDelete, "/api/v1/workspaces/:wid/inbox/chat/:id", domain.PermissionInboxWrite}, + {http.MethodDelete, "/api/v1/workspaces/:wid/inbox/messages", domain.PermissionInboxWrite}, + {http.MethodPost, "/api/v1/workspaces/:wid/internal/email", domain.PermissionInboxWrite}, + {http.MethodPost, "/api/v1/workspaces/:wid/internal/sms", domain.PermissionInboxWrite}, + {http.MethodPost, "/api/v1/workspaces/:wid/internal/push", domain.PermissionInboxWrite}, + {http.MethodPost, "/api/v1/workspaces/:wid/internal/chat", domain.PermissionInboxWrite}, +} + +func TestPortalWriteRoutes_requireWritePermissions(t *testing.T) { + t.Parallel() + + for _, route := range portalWriteRoutes { + if !domain.IsWritePermission(route.permission) { + t.Fatalf("%s %s must require a write permission, got %q", route.method, route.path, route.permission) + } + } +} diff --git a/tests/bruno/Portal/00_Auth/Login Viewer.bru b/tests/bruno/Portal/00_Auth/Login Viewer.bru new file mode 100644 index 00000000..a3212c8a --- /dev/null +++ b/tests/bruno/Portal/00_Auth/Login Viewer.bru @@ -0,0 +1,27 @@ +meta { + name: Login Viewer + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/api/v1/auth/login + body: json + auth: none +} + +body:json { + { + "email": "viewer@weprodev.com", + "password": "secret" + } +} + +tests { + test("should login viewer successfully", function() { + expect(res.status).to.equal(200); + expect(res.body.token).to.be.a("string"); + }); + + bru.setVar("viewerPortalJwt", res.body.token); +} diff --git a/tests/bruno/Portal/RBAC/Invite Existing Member Forbidden.bru b/tests/bruno/Portal/RBAC/Invite Existing Member Forbidden.bru new file mode 100644 index 00000000..0d272537 --- /dev/null +++ b/tests/bruno/Portal/RBAC/Invite Existing Member Forbidden.bru @@ -0,0 +1,28 @@ +meta { + name: Viewer Invite Existing Member Forbidden + type: http + seq: 7 +} + +post { + url: {{baseUrl}}/api/v1/workspaces/{{workspaceId}}/invitations + body: json + auth: bearer +} + +auth:bearer { + token: {{portalJwt}} +} + +body:json { + { + "email": "member@weprodev.com", + "role": "viewer" + } +} + +tests { + test("cannot invite an existing workspace member", function() { + expect(res.status).to.equal(400); + }); +} diff --git a/tests/bruno/Portal/RBAC/Viewer Create API Key Forbidden.bru b/tests/bruno/Portal/RBAC/Viewer Create API Key Forbidden.bru new file mode 100644 index 00000000..b7fb6d49 --- /dev/null +++ b/tests/bruno/Portal/RBAC/Viewer Create API Key Forbidden.bru @@ -0,0 +1,27 @@ +meta { + name: Viewer Create API Key Forbidden + type: http + seq: 3 +} + +post { + url: {{baseUrl}}/api/v1/workspaces/{{workspaceId}}/api-keys + body: json + auth: bearer +} + +auth:bearer { + token: {{viewerPortalJwt}} +} + +body:json { + { + "name": "Viewer should not create" + } +} + +tests { + test("viewer cannot create API keys", function() { + expect(res.status).to.equal(403); + }); +} diff --git a/tests/bruno/Portal/RBAC/Viewer Create Template Forbidden.bru b/tests/bruno/Portal/RBAC/Viewer Create Template Forbidden.bru new file mode 100644 index 00000000..072d3483 --- /dev/null +++ b/tests/bruno/Portal/RBAC/Viewer Create Template Forbidden.bru @@ -0,0 +1,31 @@ +meta { + name: Viewer Create Template Forbidden + type: http + seq: 6 +} + +post { + url: {{baseUrl}}/api/v1/workspaces/{{workspaceId}}/templates + body: json + auth: bearer +} + +auth:bearer { + token: {{viewerPortalJwt}} +} + +body:json { + { + "name": "Viewer Template", + "unique_key": "viewer_template", + "category": "transactional", + "subject": "Should fail", + "content_html": "

no

" + } +} + +tests { + test("viewer cannot create templates", function() { + expect(res.status).to.equal(403); + }); +} diff --git a/tests/bruno/Portal/RBAC/Viewer Delete Email Forbidden.bru b/tests/bruno/Portal/RBAC/Viewer Delete Email Forbidden.bru new file mode 100644 index 00000000..127ad7a2 --- /dev/null +++ b/tests/bruno/Portal/RBAC/Viewer Delete Email Forbidden.bru @@ -0,0 +1,21 @@ +meta { + name: Viewer Delete Email Forbidden + type: http + seq: 5 +} + +delete { + url: {{baseUrl}}/api/v1/workspaces/{{workspaceId}}/inbox/emails/00000000-0000-0000-0000-000000000099 + body: none + auth: bearer +} + +auth:bearer { + token: {{viewerPortalJwt}} +} + +tests { + test("viewer cannot delete inbox messages", function() { + expect(res.status).to.equal(403); + }); +} diff --git a/tests/bruno/Portal/RBAC/Viewer Get Settings.bru b/tests/bruno/Portal/RBAC/Viewer Get Settings.bru new file mode 100644 index 00000000..eb5def42 --- /dev/null +++ b/tests/bruno/Portal/RBAC/Viewer Get Settings.bru @@ -0,0 +1,21 @@ +meta { + name: Viewer Get Settings + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/v1/workspaces/{{workspaceId}}/settings + body: none + auth: bearer +} + +auth:bearer { + token: {{viewerPortalJwt}} +} + +tests { + test("viewer can read settings", function() { + expect(res.status).to.equal(200); + }); +} diff --git a/tests/bruno/Portal/RBAC/Viewer Patch Settings Forbidden.bru b/tests/bruno/Portal/RBAC/Viewer Patch Settings Forbidden.bru new file mode 100644 index 00000000..a5dc8567 --- /dev/null +++ b/tests/bruno/Portal/RBAC/Viewer Patch Settings Forbidden.bru @@ -0,0 +1,27 @@ +meta { + name: Viewer Patch Settings Forbidden + type: http + seq: 2 +} + +patch { + url: {{baseUrl}}/api/v1/workspaces/{{workspaceId}}/settings + body: json + auth: bearer +} + +auth:bearer { + token: {{viewerPortalJwt}} +} + +body:json { + { + "message_dispatch_mode": "provider" + } +} + +tests { + test("viewer cannot patch settings", function() { + expect(res.status).to.equal(403); + }); +} diff --git a/tests/bruno/Portal/RBAC/Viewer Upsert Integration Forbidden.bru b/tests/bruno/Portal/RBAC/Viewer Upsert Integration Forbidden.bru new file mode 100644 index 00000000..05c026f6 --- /dev/null +++ b/tests/bruno/Portal/RBAC/Viewer Upsert Integration Forbidden.bru @@ -0,0 +1,31 @@ +meta { + name: Viewer Upsert Integration Forbidden + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/api/v1/workspaces/{{workspaceId}}/integrations + body: json + auth: bearer +} + +auth:bearer { + token: {{viewerPortalJwt}} +} + +body:json { + { + "channel_type": "email", + "provider_name": "memory", + "config": {}, + "status": "connected", + "is_default": true + } +} + +tests { + test("viewer cannot upsert integrations", function() { + expect(res.status).to.equal(403); + }); +} diff --git a/tests/bruno/Portal/Workspaces/Create Invitation.bru b/tests/bruno/Portal/Workspaces/Create Invitation.bru new file mode 100644 index 00000000..05991b4d --- /dev/null +++ b/tests/bruno/Portal/Workspaces/Create Invitation.bru @@ -0,0 +1,33 @@ +meta { + name: Create Invitation + type: http + seq: 7 +} + +post { + url: {{baseUrl}}/api/v1/workspaces/{{workspaceId}}/invitations + body: json + auth: bearer +} + +auth:bearer { + token: {{portalJwt}} +} + +body:json { + { + "email": "invitee-{{$timestamp}}@e2e.test", + "role": "member" + } +} + +tests { + test("should create invitation with role", function() { + expect(res.status).to.equal(201); + expect(res.body).to.have.property("id"); + expect(res.body.email).to.be.a("string"); + expect(res.body.role).to.equal("member"); + expect(res.body.token).to.be.a("string").and.not.empty; + expect(res.body.expires_at).to.be.a("string"); + }); +} diff --git a/tests/bruno/Portal/Workspaces/List Invitations.bru b/tests/bruno/Portal/Workspaces/List Invitations.bru new file mode 100644 index 00000000..b25b14f2 --- /dev/null +++ b/tests/bruno/Portal/Workspaces/List Invitations.bru @@ -0,0 +1,22 @@ +meta { + name: List Invitations + type: http + seq: 6 +} + +get { + url: {{baseUrl}}/api/v1/workspaces/{{workspaceId}}/invitations + body: none + auth: bearer +} + +auth:bearer { + token: {{portalJwt}} +} + +tests { + test("should list pending invitations", function() { + expect(res.status).to.equal(200); + expect(res.body).to.be.an("array"); + }); +} diff --git a/tests/bruno/Portal/Workspaces/List Members.bru b/tests/bruno/Portal/Workspaces/List Members.bru new file mode 100644 index 00000000..136455aa --- /dev/null +++ b/tests/bruno/Portal/Workspaces/List Members.bru @@ -0,0 +1,28 @@ +meta { + name: List Members + type: http + seq: 5 +} + +get { + url: {{baseUrl}}/api/v1/workspaces/{{workspaceId}}/members + body: none + auth: bearer +} + +auth:bearer { + token: {{portalJwt}} +} + +tests { + test("should list workspace members", function() { + expect(res.status).to.equal(200); + expect(res.body).to.be.an("array"); + if (res.body.length > 0) { + const member = res.body[0]; + expect(member).to.have.property("user_id"); + expect(member).to.have.property("role"); + expect(member).to.have.property("joined_at"); + } + }); +} From aaa8e2b0af89c52b7e521822c045c754f4f0f072 Mon Sep 17 00:00:00 2001 From: MichaelAndish Date: Sun, 5 Jul 2026 00:07:19 +0200 Subject: [PATCH 4/4] feat: add existing member mock to InviteMemberDialog tests This commit improves the testing structure for the InviteMemberDialog, ensuring better alignment with the component's expected behavior. --- .../invite-member-dialog/invite-member-dialog.test.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.test.tsx b/frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.test.tsx index 9ad0449f..49910dd0 100644 --- a/frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.test.tsx +++ b/frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.test.tsx @@ -7,6 +7,14 @@ import { Role } from "@/core/auth" import type { WorkspaceMember } from "../../team.types" import { InviteMemberDialog } from "./invite-member-dialog" +const existingMember: WorkspaceMember = { + workspace_id: "ws-1", + user_id: "user-member", + role: Role.Member, + joined_at: "2026-01-01T00:00:00Z", + user_email: "member@weprodev.com", +} + describe("InviteMemberDialog", () => { it("submits email and selected role", async () => { const user = userEvent.setup() @@ -47,7 +55,7 @@ describe("InviteMemberDialog", () => { open onClose={vi.fn()} onInvite={onInvite} - members={[{ user_email: "member@weprodev.com" } satisfies Pick]} + members={[existingMember]} />, )