diff --git a/app/Application/Members/MemberService.php b/app/Application/Members/MemberService.php new file mode 100644 index 0000000..fe54310 --- /dev/null +++ b/app/Application/Members/MemberService.php @@ -0,0 +1,130 @@ +where('email', $email)->first(); + $createdUser = false; + + if ($user === null) { + $resolvedName = filled($name) ? trim($name) : Str::before($email, '@'); + + $user = User::query()->create([ + 'name' => $resolvedName !== '' ? $resolvedName : $email, + 'email' => $email, + 'password' => Str::password(32), + ]); + $createdUser = true; + } + + if ( + TenantMembership::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->exists() + ) { + throw ValidationException::withMessages([ + 'email' => ['This user is already a member of this tenant.'], + ]); + } + + $membership = TenantMembership::query()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'role' => $role, + ]); + + if ($createdUser) { + Password::sendResetLink(['email' => $user->email]); + } + + return [ + 'membership' => $membership->load('user'), + 'created_user' => $createdUser, + ]; + } + + public function updateRole( + Tenant $tenant, + TenantMembership $membership, + TenantRole $role, + ): TenantMembership { + $this->assertMembershipBelongsToTenant($membership, $tenant); + + if ( + $membership->role === TenantRole::TenantAdmin + && $role !== TenantRole::TenantAdmin + && $this->isLastTenantAdmin($tenant, $membership) + ) { + throw ValidationException::withMessages([ + 'role' => ['Cannot demote the last tenant admin.'], + ]); + } + + $membership->role = $role; + $membership->save(); + + return $membership->fresh(['user']); + } + + public function remove( + Tenant $tenant, + TenantMembership $membership, + User $actor, + ): void { + $this->assertMembershipBelongsToTenant($membership, $tenant); + + if ($membership->user_id === $actor->id) { + throw ValidationException::withMessages([ + 'member' => ['You cannot remove yourself from the tenant.'], + ]); + } + + if ( + $membership->role === TenantRole::TenantAdmin + && $this->isLastTenantAdmin($tenant, $membership) + ) { + throw ValidationException::withMessages([ + 'member' => ['Cannot remove the last tenant admin.'], + ]); + } + + $membership->delete(); + } + + private function assertMembershipBelongsToTenant(TenantMembership $membership, Tenant $tenant): void + { + if ($membership->tenant_id !== $tenant->id) { + abort(404); + } + } + + private function isLastTenantAdmin(Tenant $tenant, TenantMembership $membership): bool + { + $adminCount = TenantMembership::query() + ->where('tenant_id', $tenant->id) + ->where('role', TenantRole::TenantAdmin->value) + ->count(); + + return $adminCount <= 1 && $membership->role === TenantRole::TenantAdmin; + } +} diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php index 3457a7f..cfab0e7 100644 --- a/app/Http/Controllers/Api/V1/MemberController.php +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -2,21 +2,31 @@ namespace App\Http\Controllers\Api\V1; +use App\Application\Audit\AuditLogger; +use App\Application\Members\MemberService; +use App\Domain\Shared\Enums\TenantRole; use App\Http\Controllers\Concerns\ResolvesTenantContext; use App\Http\Controllers\Controller; use App\Http\Resources\MemberResource; use App\Infrastructure\Persistence\Eloquent\TenantMembership; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Illuminate\Validation\Rule; class MemberController extends Controller { use ResolvesTenantContext; + public function __construct( + private readonly AuditLogger $auditLogger, + private readonly MemberService $memberService, + ) {} + public function index(Request $request, string $tenantId): JsonResponse { $tenant = $this->resolvedTenant($request); - $this->authorize('view', $tenant); + $this->authorize('viewAny', [TenantMembership::class, $tenant]); $members = TenantMembership::query() ->with('user') @@ -28,4 +38,120 @@ public function index(Request $request, string $tenantId): JsonResponse 'data' => MemberResource::collection($members), ]); } + + public function store(Request $request, string $tenantId): JsonResponse + { + $tenant = $this->resolvedTenant($request); + $this->authorize('create', [TenantMembership::class, $tenant]); + + $validated = $request->validate([ + 'email' => ['required', 'email', 'max:255'], + 'name' => ['nullable', 'string', 'max:255'], + 'role' => ['required', Rule::enum(TenantRole::class)], + ]); + + $role = $validated['role'] instanceof TenantRole + ? $validated['role'] + : TenantRole::from($validated['role']); + + $result = $this->memberService->invite( + $tenant, + $validated['email'], + $role, + $validated['name'] ?? null, + ); + + $membership = $result['membership']; + + $this->auditLogger->logFromRequest( + $request, + action: 'member.invited', + resourceType: 'tenant_membership', + resourceId: $membership->public_id, + tenantId: $tenant->id, + summary: [ + 'email' => $membership->user?->email, + 'role' => $membership->role?->value ?? $membership->role, + 'created_user' => $result['created_user'], + ], + ); + + return response()->json([ + 'data' => new MemberResource($membership), + ], 201); + } + + public function update(Request $request, string $tenantId, string $memberId): JsonResponse + { + $tenant = $this->resolvedTenant($request); + $membership = $this->findMembership($tenant->id, $memberId); + $this->authorize('update', [$membership, $tenant]); + + $validated = $request->validate([ + 'role' => ['required', Rule::enum(TenantRole::class)], + ]); + + $role = $validated['role'] instanceof TenantRole + ? $validated['role'] + : TenantRole::from($validated['role']); + + $previousRole = $membership->role?->value ?? $membership->role; + $membership = $this->memberService->updateRole( + $tenant, + $membership, + $role, + ); + + $this->auditLogger->logFromRequest( + $request, + action: 'member.role_changed', + resourceType: 'tenant_membership', + resourceId: $membership->public_id, + tenantId: $tenant->id, + summary: [ + 'email' => $membership->user?->email, + 'from' => $previousRole, + 'to' => $membership->role?->value ?? $membership->role, + ], + ); + + return response()->json([ + 'data' => new MemberResource($membership), + ]); + } + + public function destroy(Request $request, string $tenantId, string $memberId): Response + { + $tenant = $this->resolvedTenant($request); + $membership = $this->findMembership($tenant->id, $memberId); + $this->authorize('delete', [$membership, $tenant]); + + $actor = $this->resolvedActorUser($request); + $summary = [ + 'email' => $membership->user?->email, + 'role' => $membership->role?->value ?? $membership->role, + ]; + + $this->memberService->remove($tenant, $membership, $actor); + + $this->auditLogger->logFromRequest( + $request, + action: 'member.removed', + resourceType: 'tenant_membership', + resourceId: $memberId, + tenantId: $tenant->id, + summary: $summary, + ); + + return response()->noContent(); + } + + private function findMembership(int $tenantId, string $memberId): TenantMembership + { + return TenantMembership::query() + ->with('user') + ->where('tenant_id', $tenantId) + ->where('public_id', $memberId) + ->firstOrFail(); + } } diff --git a/app/Http/Resources/MemberResource.php b/app/Http/Resources/MemberResource.php index 58fb7f2..83456ed 100644 --- a/app/Http/Resources/MemberResource.php +++ b/app/Http/Resources/MemberResource.php @@ -18,6 +18,7 @@ public function toArray(Request $request): array 'name' => $this->user?->name, 'email' => $this->user?->email, 'role' => $this->role?->value ?? $this->role, + 'created_at' => $this->created_at?->toIso8601String(), ]; } } diff --git a/app/Policies/MemberPolicy.php b/app/Policies/MemberPolicy.php new file mode 100644 index 0000000..b0c25da --- /dev/null +++ b/app/Policies/MemberPolicy.php @@ -0,0 +1,37 @@ +actorHasTenantAccess($tenant); + } + + public function create(Authenticatable $user, Tenant $tenant): bool + { + return $this->actorIsTenantAdmin($tenant); + } + + public function update(Authenticatable $user, TenantMembership $membership, Tenant $tenant): bool + { + if ($membership->tenant_id !== $tenant->id) { + return false; + } + + return $this->actorIsTenantAdmin($tenant); + } + + public function delete(Authenticatable $user, TenantMembership $membership, Tenant $tenant): bool + { + return $this->update($user, $membership, $tenant); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index a38dca7..e777543 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -9,9 +9,11 @@ use App\Infrastructure\Persistence\Eloquent\Task; use App\Infrastructure\Persistence\Eloquent\TaskRun; use App\Infrastructure\Persistence\Eloquent\Tenant; +use App\Infrastructure\Persistence\Eloquent\TenantMembership; use App\Policies\ApiKeyPolicy; use App\Policies\EndpointProfilePolicy; use App\Policies\EnvironmentPolicy; +use App\Policies\MemberPolicy; use App\Policies\SecretPolicy; use App\Policies\TaskPolicy; use App\Policies\TaskRunPolicy; @@ -29,6 +31,7 @@ class AuthServiceProvider extends ServiceProvider EndpointProfile::class => EndpointProfilePolicy::class, Task::class => TaskPolicy::class, TaskRun::class => TaskRunPolicy::class, + TenantMembership::class => MemberPolicy::class, ]; public function boot(): void diff --git a/docs/superpowers/plans/2026-07-18-members-crud.md b/docs/superpowers/plans/2026-07-18-members-crud.md new file mode 100644 index 0000000..c3b8693 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-members-crud.md @@ -0,0 +1,73 @@ +# Members CRUD Implementation Plan + +> **For agentic workers:** Implement task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship invite/edit-role/remove UI for members on `/members`, with tenant-admin API guards, last-admin protection, and deploy to hub.taskconnect.com.br. + +**Architecture:** Extend tenant-scoped Member APIs. MembersPage owns inline invite/edit/remove UX mirroring EnvironmentsPage. + +**Tech Stack:** Vue 3 + Pinia + Vue I18n + Laravel API + Docker deploy. + +## Global Constraints + +- Immediate membership invite (no invitations table). +- Role-only updates. +- Cannot remove self or last tenant_admin. +- Preserve AppLayout / Environments visual patterns (violet CTAs, bordered tables). + +--- + +### Task 1: Design docs + +**Files:** +- Create: `docs/superpowers/specs/2026-07-18-members-crud-design.md` +- Create: `docs/superpowers/plans/2026-07-18-members-crud.md` + +- [x] Capture approved decisions and task breakdown + +### Task 2: Backend + +**Files:** +- Create: `app/Application/Members/MemberService.php` +- Create: `app/Policies/MemberPolicy.php` +- Modify: `app/Http/Controllers/Api/V1/MemberController.php` +- Modify: `app/Providers/AuthServiceProvider.php` +- Modify: `routes/api.php` +- Create: `tests/Feature/Phase1/MemberFeatureTest.php` + +- [ ] MemberService invite/update/remove with guards +- [ ] MemberPolicy viewAny/create/update/delete +- [ ] Controller store/update/destroy + audit +- [ ] Feature tests + +### Task 3: Types + i18n + +**Files:** +- Modify: `frontend/src/services/types.ts` +- Modify: `frontend/src/i18n/locales/en/settings.json` +- Modify: `frontend/src/i18n/locales/pt-BR/settings.json` + +- [ ] `TenantRole`, `MemberPayload` +- [ ] Form/role/remove strings en + pt-BR + +### Task 4: MembersPage UI + +**Files:** +- Modify: `frontend/src/pages/MembersPage.vue` + +- [ ] Table + role badges + Edit/Remove +- [ ] Invite/edit inline form +- [ ] Remove confirm → DELETE + +### Task 5: Build + deploy + +- [ ] Frontend build +- [ ] Full deploy (PHP + assets) +- [ ] Smoke: `/members` invite/edit/remove + +### Task 6: Verification + +- [ ] Invite existing + new user +- [ ] Role change; last-admin demote fails +- [ ] Remove; self-remove and last-admin remove fail +- [ ] Non-admin cannot write diff --git a/docs/superpowers/specs/2026-07-18-members-crud-design.md b/docs/superpowers/specs/2026-07-18-members-crud-design.md new file mode 100644 index 0000000..3b1f15a --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-members-crud-design.md @@ -0,0 +1,65 @@ +# Members CRUD Design + +**Date:** 2026-07-18 +**Status:** Approved +**Site:** https://hub.taskconnect.com.br/members + +## Problem + +The Members page is read-only. Spec requires tenant admins to invite members, change roles, and remove members, with audit events. + +## Decisions + +- **Invite model (MVP):** Immediate membership — no invitations table or accept-token flow. + - `POST` with `{ email, name?, role }` + - Existing user → add membership (422 if already a member) + - New user → create user with random password, create membership, send password-reset link +- **Update:** Role only. Email/name are not edited from this surface. +- **Remove:** Hard-delete the `tenant_memberships` row. +- **Auth:** Tenant admin or platform admin (`actorIsTenantAdmin`). List remains available to any tenant member. +- **Guards:** Cannot remove yourself; cannot demote or remove the last `tenant_admin`. +- **UI:** Inline CRUD on a single `/members` page, matching Environments / API Keys. +- **Roles:** `tenant_admin` | `tenant_member` | `read_only_viewer` + +## Backend + +Routes under `/api/v1/tenants/{tenantId}/members`: + +| Method | Action | +|--------|--------| +| GET | List members (any tenant access) | +| POST | Invite / create membership | +| PATCH | Update role on membership | +| DELETE | Remove membership | + +Audit events: `member.invited`, `member.role_changed`, `member.removed`. + +## Frontend + +### Types + +`Member` with `id`, `name`, `email`, `role`. Add `MemberPayload` and `TenantRole` union. + +### MembersPage + +- Header CTA “Invite member”. +- Table: name, email, localized role, Edit / Remove. +- Inline invite form: email, name, role. +- Inline edit form: role only. +- Remove confirm dialog. + +### i18n + +en + pt-BR under `settings.members`. + +## Out of scope + +- Pending invitation tokens / accept-invite UI +- Public self-registration +- Editing user name/email from members page +- Pagination +- API-key-scoped member management + +## Deploy + +Full deploy (PHP + frontend assets), then hard-refresh smoke on production. diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 726550c..359bfcf 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -57,8 +57,28 @@ "title": "Members", "subtitle": "People with access to this tenant", "invite": "Invite member", + "inviteTitle": "Invite member", + "editTitle": "Edit member role", + "save": "Save member", + "remove": "Remove", + "removeConfirm": "Remove this member from the tenant? They will lose access immediately.", "empty": "No members found.", - "loadError": "Unable to load members" + "emptyHint": "Invite a teammate to collaborate in this tenant.", + "loadError": "Unable to load members", + "saveError": "Unable to save member", + "removeError": "Unable to remove member", + "needsTenant": "Select a tenant before managing members.", + "fields": { + "name": "Name", + "email": "Email", + "role": "Role", + "nameHint": "Used when creating a new account for this email." + }, + "roles": { + "tenant_admin": "Tenant admin", + "tenant_member": "Tenant member", + "read_only_viewer": "Read-only viewer" + } }, "platformHealth": { "title": "Platform Health", diff --git a/frontend/src/i18n/locales/pt-BR/settings.json b/frontend/src/i18n/locales/pt-BR/settings.json index 055e393..92ade1b 100644 --- a/frontend/src/i18n/locales/pt-BR/settings.json +++ b/frontend/src/i18n/locales/pt-BR/settings.json @@ -57,8 +57,28 @@ "title": "Membros", "subtitle": "Pessoas com acesso a este tenant", "invite": "Convidar membro", + "inviteTitle": "Convidar membro", + "editTitle": "Editar função do membro", + "save": "Salvar membro", + "remove": "Remover", + "removeConfirm": "Remover este membro do tenant? O acesso será perdido imediatamente.", "empty": "Nenhum membro encontrado.", - "loadError": "Não foi possível carregar os membros" + "emptyHint": "Convide alguém da equipe para colaborar neste tenant.", + "loadError": "Não foi possível carregar os membros", + "saveError": "Não foi possível salvar o membro", + "removeError": "Não foi possível remover o membro", + "needsTenant": "Selecione um tenant antes de gerenciar membros.", + "fields": { + "name": "Nome", + "email": "E-mail", + "role": "Função", + "nameHint": "Usado ao criar uma nova conta para este e-mail." + }, + "roles": { + "tenant_admin": "Admin do tenant", + "tenant_member": "Membro do tenant", + "read_only_viewer": "Visualizador" + } }, "platformHealth": { "title": "Saúde da plataforma", diff --git a/frontend/src/pages/MembersPage.vue b/frontend/src/pages/MembersPage.vue index 028e2a6..6dc42b8 100644 --- a/frontend/src/pages/MembersPage.vue +++ b/frontend/src/pages/MembersPage.vue @@ -1,31 +1,249 @@ diff --git a/frontend/src/services/types.ts b/frontend/src/services/types.ts index 1ea7a53..33e6dbf 100644 --- a/frontend/src/services/types.ts +++ b/frontend/src/services/types.ts @@ -143,11 +143,20 @@ export interface ApiKeyPayload { expires_at?: string | null } +export type TenantRole = 'tenant_admin' | 'tenant_member' | 'read_only_viewer' + export interface Member { id: string name: string email: string - role: string + role: TenantRole | string + created_at?: string +} + +export interface MemberPayload { + email?: string + name?: string + role: TenantRole } export interface DashboardStats { diff --git a/routes/api.php b/routes/api.php index 0df1c72..0b9d0c5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -46,6 +46,9 @@ Route::delete('tenants/{tenantId}/api-keys/{apiKeyId}', [ApiKeyController::class, 'destroy']); Route::get('tenants/{tenantId}/members', [MemberController::class, 'index']); + Route::post('tenants/{tenantId}/members', [MemberController::class, 'store']); + Route::patch('tenants/{tenantId}/members/{memberId}', [MemberController::class, 'update']); + Route::delete('tenants/{tenantId}/members/{memberId}', [MemberController::class, 'destroy']); Route::get('tenants/{tenantId}/environments/{environmentId}/dashboard', DashboardController::class); diff --git a/tests/Feature/Phase1/MemberFeatureTest.php b/tests/Feature/Phase1/MemberFeatureTest.php new file mode 100644 index 0000000..25b192e --- /dev/null +++ b/tests/Feature/Phase1/MemberFeatureTest.php @@ -0,0 +1,174 @@ +createTenantAdmin(); + $existing = User::factory()->create([ + 'email' => 'existing@example.com', + 'name' => 'Existing User', + ]); + + $response = $this->actingAs($admin)->postJson( + $this->tenantRoute($tenant, '/members'), + [ + 'email' => 'existing@example.com', + 'role' => TenantRole::TenantMember->value, + ], + ); + + $response->assertCreated() + ->assertJsonPath('data.email', 'existing@example.com') + ->assertJsonPath('data.name', 'Existing User') + ->assertJsonPath('data.role', TenantRole::TenantMember->value); + + $this->assertDatabaseHas('tenant_memberships', [ + 'tenant_id' => $tenant->id, + 'user_id' => $existing->id, + 'role' => TenantRole::TenantMember->value, + ]); + } + + public function test_invite_creates_user_and_membership_for_new_email(): void + { + Notification::fake(); + + [$admin, $tenant] = $this->createTenantAdmin(); + + $response = $this->actingAs($admin)->postJson( + $this->tenantRoute($tenant, '/members'), + [ + 'email' => 'newbie@example.com', + 'name' => 'New Member', + 'role' => TenantRole::ReadOnlyViewer->value, + ], + ); + + $response->assertCreated() + ->assertJsonPath('data.email', 'newbie@example.com') + ->assertJsonPath('data.name', 'New Member') + ->assertJsonPath('data.role', TenantRole::ReadOnlyViewer->value); + + $user = User::query()->where('email', 'newbie@example.com')->first(); + $this->assertNotNull($user); + Notification::assertSentTo($user, ResetPassword::class); + } + + public function test_invite_rejects_duplicate_membership(): void + { + [$admin, $tenant] = $this->createTenantAdmin(); + + $this->actingAs($admin)->postJson( + $this->tenantRoute($tenant, '/members'), + [ + 'email' => $admin->email, + 'role' => TenantRole::TenantMember->value, + ], + )->assertUnprocessable() + ->assertJsonValidationErrors(['email']); + } + + public function test_update_changes_role_and_rejects_demoting_last_admin(): void + { + [$admin, $tenant] = $this->createTenantAdmin(); + + $memberUser = User::factory()->create(); + $membership = TenantMembership::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $memberUser->id, + 'role' => TenantRole::TenantMember, + ]); + + $this->actingAs($admin)->patchJson( + $this->tenantRoute($tenant, '/members/'.$membership->public_id), + ['role' => TenantRole::ReadOnlyViewer->value], + )->assertOk() + ->assertJsonPath('data.role', TenantRole::ReadOnlyViewer->value); + + $adminMembership = TenantMembership::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $admin->id) + ->firstOrFail(); + + $this->actingAs($admin)->patchJson( + $this->tenantRoute($tenant, '/members/'.$adminMembership->public_id), + ['role' => TenantRole::TenantMember->value], + )->assertUnprocessable() + ->assertJsonValidationErrors(['role']); + } + + public function test_remove_works_and_rejects_self_and_last_admin(): void + { + [$admin, $tenant] = $this->createTenantAdmin(); + + $memberUser = User::factory()->create(); + $membership = TenantMembership::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $memberUser->id, + 'role' => TenantRole::TenantMember, + ]); + + $this->actingAs($admin)->deleteJson( + $this->tenantRoute($tenant, '/members/'.$membership->public_id), + )->assertNoContent(); + + $this->assertDatabaseMissing('tenant_memberships', [ + 'id' => $membership->id, + ]); + + $adminMembership = TenantMembership::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $admin->id) + ->firstOrFail(); + + $this->actingAs($admin)->deleteJson( + $this->tenantRoute($tenant, '/members/'.$adminMembership->public_id), + )->assertUnprocessable() + ->assertJsonValidationErrors(['member']); + + $platformAdmin = User::factory()->platformAdmin()->create(); + + $this->actingAs($platformAdmin)->deleteJson( + $this->tenantRoute($tenant, '/members/'.$adminMembership->public_id), + )->assertUnprocessable() + ->assertJsonValidationErrors(['member']); + } + + public function test_non_admin_can_list_but_cannot_write(): void + { + [$admin, $tenant] = $this->createTenantAdmin(); + [$member] = $this->createTenantAdmin('Other', TenantRole::TenantMember); + + TenantMembership::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $member->id, + 'role' => TenantRole::TenantMember, + ]); + + $this->actingAs($member)->getJson($this->tenantRoute($tenant, '/members')) + ->assertOk(); + + $this->actingAs($member)->postJson( + $this->tenantRoute($tenant, '/members'), + [ + 'email' => 'blocked@example.com', + 'role' => TenantRole::TenantMember->value, + ], + )->assertForbidden(); + } +}