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