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/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..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) --- Drop conflicting rows so fixed demo UUIDs stay stable after manual sign-up. +-- 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 + +-- 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,22 +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) -VALUES ( - '00000000-0000-0000-0000-000000000001', - '00000000-0000-0000-0000-000000000010', - '00000000-0000-0000-0000-000000000020' -) +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) -VALUES ( - '00000000-0000-0000-0000-000000000020', - 'users', - '00000000-0000-0000-0000-000000000010', - '00000000-0000-0000-0000-000000000001' -) +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 674c7670..113adb94 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) @@ -242,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. --- @@ -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/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 06b68b6e..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,23 +342,31 @@ 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) 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`). +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 21430b9a..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 ``` @@ -60,11 +61,32 @@ 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 -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 new file mode 100644 index 00000000..b38ac4e6 --- /dev/null +++ b/frontend/src/core/auth/authorization.test.ts @@ -0,0 +1,42 @@ +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) + 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/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..d1ebfd8c --- /dev/null +++ b/frontend/src/core/auth/index.ts @@ -0,0 +1,22 @@ +export { Can, type CanProps } from "./can" +export { + createWorkspaceAuthorization, + type WorkspaceAuthorization, + type WorkspaceAuthorizationScope, +} from "./authorization" +export { + AllPermissions, + Permission, + Role, + WorkspaceRoles, + hasRolePermission, + isPublicGuestPermission, + isWorkspaceRole, + PublicGuestPermissions, + 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..6f77d0d2 --- /dev/null +++ b/frontend/src/core/auth/permissions.ts @@ -0,0 +1,93 @@ +/** 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, +]) + +/** 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) { + 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/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/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/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..49910dd0 --- /dev/null +++ b/frontend/src/features/settings/components/invite-member-dialog/invite-member-dialog.test.tsx @@ -0,0 +1,68 @@ +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" + +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() + 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 (