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
+ Admin panel
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{searchQuery ? "No templates match your search criteria." : "Create your first reusable email template template."}
- {!searchQuery && ( + {!searchQuery && canManageTemplates ? ( - )} + ) : null} ) : ({invitation.email}
++ Expires {formatExpiryDate(invitation.expires_at)} +
++ Share this one-time token with {email} as{" "} + {roleLabel(role)}. It will not be shown again. +
+ +