Inicial
This commit is contained in:
@@ -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"
|
||||
```
|
||||
Reference in New Issue
Block a user