# Super Admin Tenant Access — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Fix TenantMiddleware schema name bug and add super-admin tenant impersonation with session restore. **Architecture:** One-line backend bug fix; new `POST /api/v1/admin/tenants/:id/access` endpoint issues a tenant-scoped JWT; frontend authStore grows `previousSession` + two actions; TenantsPage adds "Gerir" button; AppLayout renders amber banner when impersonating. **Tech Stack:** Go 1.25 + Fiber v2, pgxpool, golang-jwt v5, React 19 + TypeScript strict, Zustand v5, TanStack Query v5 ## Global Constraints - Go module: `github.com/techxcar/backend` - All user-facing error messages in Português de Portugal (pt-PT) - API envelope: `{ "data": ..., "error": null }` - JWT: access 15 min, HS256 - Tenant schema name: `tenant_` + UUID with hyphens replaced by underscores - Frontend alias: `@/` → `src/` - No new npm packages --- ## File Map | File | Action | Responsibility | |---|---|---| | `backend/internal/auth/middleware.go` | Modify | Fix schema name construction (line 58) | | `backend/internal/tenant/handler.go` | Modify | Add `tenantAccessHandler` | | `backend/internal/tenant/routes.go` | Modify | Register new endpoint | | `frontend/src/store/authStore.ts` | Modify | Add `previousSession`, `impersonateTenant`, `restoreSession` | | `frontend/src/pages/admin/TenantsPage.tsx` | Modify | Add "Gerir" button per row | | `frontend/src/components/layout/AppLayout.tsx` | Modify | Add impersonation banner | --- ### Task 1: Fix TenantMiddleware schema name **Files:** - Modify: `backend/internal/auth/middleware.go` **Interfaces:** - Consumes: `claims.TenantID` (UUID string with hyphens, e.g. `590b9965-9f7b-4427-b1c8-2b695610e7bd`) - Produces: `search_path` set to `"tenant_590b9965_9f7b_4427_b1c8_2b695610e7bd", public` - [ ] **Step 1: Write failing test** Create `backend/internal/auth/middleware_test.go`: ```go package auth_test import ( "net/http/httptest" "testing" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/techxcar/backend/internal/auth" ) func TestRequireRole_allowsMatchingRole(t *testing.T) { app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("claims", &auth.Claims{Role: "tenant_admin"}) return c.Next() }) app.Get("/test", auth.RequireRole("tenant_admin"), func(c *fiber.Ctx) error { return c.SendStatus(200) }) req := httptest.NewRequest("GET", "/test", nil) resp, err := app.Test(req) assert.NoError(t, err) assert.Equal(t, 200, resp.StatusCode) } func TestRequireRole_rejectsMismatch(t *testing.T) { app := fiber.New() app.Use(func(c *fiber.Ctx) error { c.Locals("claims", &auth.Claims{Role: "technician"}) return c.Next() }) app.Get("/test", auth.RequireRole("super_admin"), func(c *fiber.Ctx) error { return c.SendStatus(200) }) req := httptest.NewRequest("GET", "/test", nil) resp, err := app.Test(req) assert.NoError(t, err) assert.Equal(t, 403, resp.StatusCode) } ``` - [ ] **Step 2: Run test to verify it passes (baseline)** ```bash cd backend && go test ./internal/auth/... -v -run TestRequireRole ``` Expected: PASS (these test existing behavior, not the bug) - [ ] **Step 3: Apply the bug fix** In `backend/internal/auth/middleware.go`, line 58, replace: ```go schema := `"tenant_` + claims.TenantID + `"` if _, err := conn.Exec(c.Context(), "SET search_path = "+schema+", public"); err != nil { ``` with: ```go schema := `"tenant_` + strings.ReplaceAll(claims.TenantID, "-", "_") + `"` if _, err := conn.Exec(c.Context(), "SET search_path = "+schema+", public"); err != nil { ``` Verify `strings` is already imported (it is — `strings.SplitN` is used in `RequireAuth`). - [ ] **Step 4: Run full auth package tests** ```bash cd backend && go test ./internal/auth/... -v ``` Expected: all PASS - [ ] **Step 5: Smoke test tenant login via curl** ```bash TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"admin@oficina.pt","password":"password123","tenant_slug":"oficina-demo"}' \ | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['data']['access_token'] if d['error'] is None else 'FAIL: '+d['error'])") echo $TOKEN ``` Expected: JWT token string (not "FAIL:...") - [ ] **Step 6: Commit** ```bash git add backend/internal/auth/middleware.go backend/internal/auth/middleware_test.go git commit -m "fix: TenantMiddleware schema name uses underscores to match provisioned schema" ``` --- ### Task 2: Backend — tenant access endpoint **Files:** - Modify: `backend/internal/tenant/handler.go` - Modify: `backend/internal/tenant/routes.go` **Interfaces:** - Consumes: `repo.GetTenantByID(ctx, id)` → `*Tenant`; `auth.GenerateAccessToken(userID, tenantID, role, secret)` → `(string, error)`; `auth.Claims` with `UserID`, `TenantID`, `Role` - Produces: `POST /api/v1/admin/tenants/:id/access` → `{ "data": { "access_token": string, "tenant": { "id": string, "name": string, "slug": string } }, "error": null }` - [ ] **Step 1: Write failing handler test** Add to `backend/internal/tenant/handler_test.go` (find the existing test file and append): ```go func TestTenantAccessHandler(t *testing.T) { secret := "test-secret-32-chars-minimum-len" app := fiber.New(fiber.Config{ErrorHandler: func(c *fiber.Ctx, err error) error { code := fiber.StatusInternalServerError if e, ok := err.(*fiber.Error); ok { code = e.Code } return c.Status(code).JSON(fiber.Map{"data": nil, "error": err.Error()}) }}) tok, _ := auth.GenerateAccessToken("sa-1", "", "super_admin", secret) app.Use(func(c *fiber.Ctx) error { c.Locals("claims", &auth.Claims{UserID: "sa-1", TenantID: "", Role: "super_admin"}) return c.Next() }) cfg := &config.Config{JWTSecret: secret} repo := &mockRepo{ tenant: &Tenant{ID: "abc-123", Slug: "t1", Name: "Tenant 1", Status: "active"}, } app.Post("/admin/tenants/:id/access", auth.RequireRole("super_admin"), tenantAccessHandler(repo, cfg), ) req := httptest.NewRequest("POST", "/admin/tenants/abc-123/access", nil) req.Header.Set("Authorization", "Bearer "+tok) resp, err := app.Test(req) require.NoError(t, err) assert.Equal(t, 200, resp.StatusCode) var body struct { Data struct { AccessToken string `json:"access_token"` Tenant struct { ID string `json:"id"` Name string `json:"name"` Slug string `json:"slug"` } `json:"tenant"` } `json:"data"` Error *string `json:"error"` } require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) assert.Nil(t, body.Error) assert.NotEmpty(t, body.Data.AccessToken) assert.Equal(t, "abc-123", body.Data.Tenant.ID) assert.Equal(t, "Tenant 1", body.Data.Tenant.Name) } ``` Note: check existing `handler_test.go` for how `mockRepo` is defined and extend it with a `tenant` field + `GetTenantByID` method if not already present. - [ ] **Step 2: Run test to verify it fails** ```bash cd backend && go test ./internal/tenant/... -v -run TestTenantAccessHandler ``` Expected: FAIL — `tenantAccessHandler undefined` - [ ] **Step 3: Implement the handler** Add to `backend/internal/tenant/handler.go`: ```go func tenantAccessHandler(repo *Repository, cfg *config.Config) fiber.Handler { return func(c *fiber.Ctx) error { claims, ok := c.Locals("claims").(*auth.Claims) if !ok { return fiber.NewError(401, "autenticação necessária") } tenantID := c.Params("id") ten, err := repo.GetTenantByID(c.Context(), tenantID) if err != nil || ten == nil { return fiber.NewError(404, "oficina não encontrada") } if ten.Status != "active" { return fiber.NewError(404, "oficina não encontrada ou inativa") } token, err := auth.GenerateAccessToken(claims.UserID, ten.ID, "tenant_admin", cfg.JWTSecret) if err != nil { return fiber.NewError(500, "erro ao gerar token") } return c.JSON(fiber.Map{ "data": fiber.Map{ "access_token": token, "tenant": fiber.Map{ "id": ten.ID, "name": ten.Name, "slug": ten.Slug, }, }, "error": nil, }) } } ``` - [ ] **Step 4: Register the route** In `backend/internal/tenant/routes.go`, inside the `admin` group (after existing routes): ```go admin.Post("/tenants/:id/access", tenantAccessHandler(repo, cfg)) ``` The existing `admin` group already has `RequireAuth` + `RequireRole("super_admin")` — no extra middleware needed. - [ ] **Step 5: Run tests** ```bash cd backend && go test ./internal/tenant/... -v -run TestTenantAccess ``` Expected: PASS - [ ] **Step 6: Smoke test via curl** ```bash # Login as super admin TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"admin@techxcar.com","password":"TechXCar2026!"}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['access_token'])") # Get tenant ID from list TENANT_ID=$(curl -s http://localhost:8080/api/v1/admin/tenants \ -H "Authorization: Bearer $TOKEN" \ | python3 -c "import sys,json; print(json.load(sys.stdin)['data'][0]['id'])") # Request tenant-scoped token curl -s -X POST http://localhost:8080/api/v1/admin/tenants/$TENANT_ID/access \ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool ``` Expected: `{ "data": { "access_token": "eyJ...", "tenant": { "id": "...", "name": "...", "slug": "..." } }, "error": null }` - [ ] **Step 7: Rebuild backend container** ```bash docker compose up --build -d backend ``` Wait for healthy, re-run smoke test against port 8080. - [ ] **Step 8: Commit** ```bash git add backend/internal/tenant/handler.go backend/internal/tenant/routes.go backend/internal/tenant/handler_test.go git commit -m "feat: tenant access endpoint — super admin can get tenant-scoped JWT" ``` --- ### Task 3: Frontend — authStore impersonation **Files:** - Modify: `frontend/src/store/authStore.ts` **Interfaces:** - Consumes: existing `AuthUser`, `accessToken` state - Produces: - `previousSession: { token: string; user: AuthUser } | null` (NOT persisted) - `impersonateTenant(token: string, user: AuthUser): void` - `restoreSession(): void` - [ ] **Step 1: Update authStore.ts** Replace the entire file content: ```typescript import { create } from 'zustand' import { persist } from 'zustand/middleware' export type UserRole = 'super_admin' | 'tenant_admin' | 'manager' | 'technician' export interface AuthUser { id: string email: string name: string role: UserRole tenantId?: string } interface PreviousSession { token: string user: AuthUser } interface AuthState { user: AuthUser | null accessToken: string | null isAuthenticated: boolean previousSession: PreviousSession | null setAuth: (user: AuthUser, accessToken: string) => void clearAuth: () => void updateToken: (accessToken: string) => void impersonateTenant: (token: string, user: AuthUser) => void restoreSession: () => void } export const useAuthStore = create()( persist( (set, get) => ({ user: null, accessToken: null, isAuthenticated: false, previousSession: null, setAuth: (user, accessToken) => set({ user, accessToken, isAuthenticated: true, previousSession: null }), clearAuth: () => set({ user: null, accessToken: null, isAuthenticated: false, previousSession: null }), updateToken: (accessToken) => set({ accessToken }), impersonateTenant: (token, user) => { const { accessToken, user: currentUser } = get() set({ accessToken: token, user, isAuthenticated: true, previousSession: currentUser && accessToken ? { token: accessToken, user: currentUser } : null, }) }, restoreSession: () => { const { previousSession } = get() if (!previousSession) return set({ accessToken: previousSession.token, user: previousSession.user, isAuthenticated: true, previousSession: null, }) }, }), { name: 'techxcar-auth', partialize: (state) => ({ user: state.user, accessToken: state.accessToken, isAuthenticated: state.isAuthenticated, // previousSession intentionally excluded — impersonation does not survive refresh }), } ) ) ``` - [ ] **Step 2: Verify TypeScript compiles** ```bash cd frontend && npm run build 2>&1 | tail -10 ``` Expected: build succeeds (or only pre-existing errors, none from authStore) - [ ] **Step 3: Commit** ```bash git add frontend/src/store/authStore.ts git commit -m "feat: authStore gains impersonateTenant and restoreSession for super-admin tenant access" ``` --- ### Task 4: Frontend — TenantsPage "Gerir" button **Files:** - Modify: `frontend/src/pages/admin/TenantsPage.tsx` **Interfaces:** - Consumes: - `POST /api/v1/admin/tenants/:id/access` → `{ access_token: string, tenant: { id, name, slug } }` - `useAuthStore().impersonateTenant(token, user)` - `useNavigate()` from `react-router` - Produces: button per row that triggers impersonation and navigates to `/app` - [ ] **Step 1: Add the "Gerir" mutation and button** In `frontend/src/pages/admin/TenantsPage.tsx`: 1. Add imports at top: ```typescript import { useNavigate } from 'react-router' import { useAuthStore } from '@/store/authStore' import type { AuthUser } from '@/store/authStore' ``` 2. Inside `TenantsPage` component, after existing mutations, add: ```typescript const navigate = useNavigate() const { impersonateTenant } = useAuthStore() const accessTenant = useMutation({ mutationFn: (tenantId: string) => apiFetch<{ access_token: string; tenant: { id: string; name: string; slug: string } }>( `/admin/tenants/${tenantId}/access`, { method: 'POST' } ), onSuccess: (data) => { const tenantUser: AuthUser = { id: data.tenant.id, email: '', name: data.tenant.name, role: 'tenant_admin', tenantId: data.tenant.id, } impersonateTenant(data.access_token, tenantUser) queryClient.clear() navigate('/app') }, }) ``` 3. In the table ``, add a fifth column header `` in ``: ```tsx Ações ``` 4. In each `` inside the `.map()`, add a fifth `` after the "Criada" cell: ```tsx ``` - [ ] **Step 2: Verify TypeScript compiles** ```bash cd frontend && npm run build 2>&1 | tail -15 ``` Expected: build succeeds - [ ] **Step 3: Commit** ```bash git add frontend/src/pages/admin/TenantsPage.tsx git commit -m "feat: TenantsPage — Gerir button triggers tenant impersonation" ``` --- ### Task 5: Frontend — AppLayout impersonation banner **Files:** - Modify: `frontend/src/components/layout/AppLayout.tsx` **Interfaces:** - Consumes: `useAuthStore().previousSession`, `useAuthStore().restoreSession()`, `useNavigate()` - Produces: amber banner at top when `previousSession !== null`; "Voltar ao painel" button restores session and navigates `/admin` - [ ] **Step 1: Add the banner to AppLayout** Replace `frontend/src/components/layout/AppLayout.tsx` with: ```tsx import { Outlet, NavLink, useNavigate } from 'react-router' import { useLogout } from '@/hooks/useAuth' import { useAuthStore } from '@/store/authStore' const nav = [ { to: '/app', label: 'Dashboard', end: true }, { to: '/app/work-orders', label: 'Ordens de Trabalho' }, { to: '/app/clients', label: 'Clientes' }, { to: '/app/catalog', label: 'Catálogo' }, ] export default function AppLayout() { const { mutate: logout } = useLogout() const { previousSession, restoreSession, user } = useAuthStore() const navigate = useNavigate() function handleRestore() { restoreSession() navigate('/admin') } return (
{previousSession && (
TechXCar Admin — a gerir: {user?.name}
)}
) } ``` - [ ] **Step 2: Verify TypeScript compiles** ```bash cd frontend && npm run build 2>&1 | tail -10 ``` Expected: build succeeds - [ ] **Step 3: Rebuild frontend container and smoke test** ```bash docker compose up --build -d frontend ``` Then in browser: 1. Log in as `admin@techxcar.com` / `TechXCar2026!` 2. Go to `/admin/tenants` 3. Click "Gerir" on a tenant row 4. Verify: navigates to `/app`, amber banner shows "TechXCar Admin — a gerir: Oficina Demo" 5. Click "← Voltar ao painel" 6. Verify: back at `/admin`, super_admin session restored, no banner - [ ] **Step 4: Commit** ```bash git add frontend/src/components/layout/AppLayout.tsx git commit -m "feat: AppLayout shows impersonation banner with session restore for super admin" ```