634 lines
19 KiB
Markdown
634 lines
19 KiB
Markdown
# 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"
|
|
```
|