This commit is contained in:
Luciano Milani
2026-07-02 12:47:55 +01:00
commit 5de37bb512
132 changed files with 28495 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,633 @@
# 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<AuthState>()(
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 `<tbody>`, add a fifth column header `<th>` in `<thead>`:
```tsx
<th className="text-left px-4 py-3 text-slate-400 font-medium">Ações</th>
```
4. In each `<tr>` inside the `.map()`, add a fifth `<td>` after the "Criada" cell:
```tsx
<td className="px-4 py-3">
<Button
size="sm"
variant="outline"
onClick={() => accessTenant.mutate(t.id)}
disabled={accessTenant.isPending}
>
{accessTenant.isPending ? '...' : 'Gerir'}
</Button>
</td>
```
- [ ] **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 (
<div className="flex flex-col h-screen bg-slate-950">
{previousSession && (
<div className="flex items-center justify-between px-4 py-2 bg-amber-500 text-amber-950 text-sm font-medium shrink-0">
<span>
TechXCar Admin a gerir: <strong>{user?.name}</strong>
</span>
<button
onClick={handleRestore}
className="px-3 py-1 rounded bg-amber-950 text-amber-100 hover:bg-amber-900 text-xs font-semibold transition-colors"
>
Voltar ao painel
</button>
</div>
)}
<div className="flex flex-1 overflow-hidden">
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex flex-col">
<div className="p-4 border-b border-slate-800">
<h1 className="text-lg font-bold text-white">TechXCar</h1>
<p className="text-xs text-slate-400 mt-0.5">Gestão de Oficina</p>
</div>
<nav className="flex-1 p-3 space-y-1">
{nav.map(({ to, label, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`
}
>
{label}
</NavLink>
))}
</nav>
<div className="p-3 border-t border-slate-800">
<button
onClick={() => logout()}
className="w-full text-left px-3 py-2 text-sm text-slate-400 hover:text-white rounded-md hover:bg-slate-800 transition-colors"
>
Terminar sessão
</button>
</div>
</aside>
<main className="flex-1 overflow-auto p-6 text-white">
<Outlet />
</main>
</div>
</div>
)
}
```
- [ ] **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"
```
File diff suppressed because it is too large Load Diff