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
@@ -0,0 +1,296 @@
|
||||
# TechXCar — Design Spec
|
||||
**Data:** 2026-06-16
|
||||
**Versão:** 1.0
|
||||
|
||||
---
|
||||
|
||||
## 1. Visão Geral
|
||||
|
||||
**TechXCar** é um SaaS multi-tenant de gestão de oficina automóvel. Cada oficina tem o seu espaço isolado com clientes, veículos, ordens de trabalho, faturação, técnicos e relatórios. A plataforma é gerida por um super-admin que cria e convida oficinas manualmente.
|
||||
|
||||
**Inspiração:** LubeLogger, mas focado na gestão operacional da oficina (não no diário do proprietário do veículo).
|
||||
|
||||
---
|
||||
|
||||
## 2. Stack Técnica
|
||||
|
||||
| Camada | Tecnologia |
|
||||
|---|---|
|
||||
| Frontend | React 19 + TypeScript + Vite |
|
||||
| Backend | Go + Fiber (framework HTTP) |
|
||||
| Base de dados | PostgreSQL 16 |
|
||||
| Cache / Filas | Redis 7 |
|
||||
| Auth | JWT (access 15min + refresh 30d, httpOnly cookie) |
|
||||
| Passwords | bcrypt cost 12 |
|
||||
| PDF | Geração server-side em Go (html/template → PDF) |
|
||||
| Notificações | Telegram Bot API + SMTP |
|
||||
| Frontend UI | Tailwind CSS 4 + shadcn/ui |
|
||||
| Estado cliente | TanStack Query + Zustand |
|
||||
| Formulários | React Hook Form + Zod |
|
||||
| Roteamento | React Router v7 |
|
||||
| Migrations | golang-migrate |
|
||||
| Deploy | Docker Compose → Coolify |
|
||||
|
||||
---
|
||||
|
||||
## 3. Arquitectura de Deploy
|
||||
|
||||
### Serviços Docker
|
||||
```
|
||||
techxcar/
|
||||
├── frontend/ # React 19 + TypeScript + Vite (servido via Nginx)
|
||||
├── backend/ # Go + Fiber
|
||||
├── docker-compose.yml # Desenvolvimento
|
||||
└── docker-compose.prod.yml # Produção (Coolify)
|
||||
```
|
||||
|
||||
Quatro serviços:
|
||||
- `frontend` — Nginx a servir o build Vite na porta 80
|
||||
- `backend` — Binário Go na porta 8080
|
||||
- `postgres` — PostgreSQL 16
|
||||
- `redis` — Redis 7
|
||||
|
||||
### Configuração em dois níveis
|
||||
1. **`.env`** — valores de arranque imutáveis em runtime: `DATABASE_URL`, `REDIS_URL`, `JWT_SECRET`, `PORT`, `APP_ENV`. Requerem reinício do container para alterar.
|
||||
2. **`platform_settings` / `tenant_settings` (DB)** — configuração dinâmica gerida pela Web UI sem redeploy: SMTP, Telegram Bot Token, templates de notificação, limites, dados de faturação. Cached em Redis por 5 minutos.
|
||||
|
||||
### Coolify
|
||||
- Secrets geridos pelo Coolify (injectados como variáveis de ambiente)
|
||||
- HTTPS automático via Coolify (Let's Encrypt)
|
||||
- Health checks em `/api/v1/health`
|
||||
|
||||
---
|
||||
|
||||
## 4. Multi-tenancy
|
||||
|
||||
### Estratégia: Schema-per-tenant no PostgreSQL
|
||||
- Schema `public`: tabelas globais (`tenants`, `invites`, `super_admins`, `platform_settings`)
|
||||
- Schema `tenant_<uuid>`: dados isolados de cada oficina
|
||||
- Middleware no backend define `SET search_path = tenant_<uuid>` por request, com base no JWT
|
||||
|
||||
### Onboarding de oficinas
|
||||
**Via convite:** Super-admin gera token assinado com expiração configurável. Oficina clica no link, preenche nome, NIF, email, password → schema PostgreSQL provisionado automaticamente via migrations.
|
||||
|
||||
**Via criação manual:** Super-admin cria a oficina no painel `/admin`, define credenciais iniciais, envia email de boas-vindas.
|
||||
|
||||
---
|
||||
|
||||
## 5. Autenticação & Autorização
|
||||
|
||||
### Níveis de acesso
|
||||
```
|
||||
super_admin — gestão da plataforma, todos os tenants
|
||||
tenant_admin — dono/gestor principal da oficina
|
||||
manager — gestor operacional, sem acesso a settings financeiras
|
||||
technician — técnico interno, acesso a OTs atribuídas
|
||||
external_technician — sem login; apenas para atribuição e registo de custos
|
||||
```
|
||||
|
||||
### Fluxo JWT
|
||||
- `POST /api/v1/auth/login` → devolve `access_token` (body) + `refresh_token` (httpOnly cookie)
|
||||
- `POST /api/v1/auth/refresh` → renova access token usando refresh token do cookie
|
||||
- `POST /api/v1/auth/logout` → invalida refresh token
|
||||
- Rate limiting: 10 tentativas/min por IP nas rotas de auth (Redis)
|
||||
|
||||
---
|
||||
|
||||
## 6. Módulos de Negócio
|
||||
|
||||
### 6.1 Clientes & Veículos
|
||||
- **Cliente**: nome, NIF, telefone, email, morada, notas
|
||||
- **Veículo**: pertence a um cliente (opcional), matrícula, marca, modelo, ano, VIN, km actual
|
||||
- Um cliente pode ter N veículos
|
||||
- OT pode ser criada sem cliente nem veículo (cliente temporário)
|
||||
|
||||
### 6.2 Ordens de Trabalho (OT)
|
||||
|
||||
**Estados e transições:**
|
||||
```
|
||||
Aberta → Em Progresso → Concluída → Faturada
|
||||
↓
|
||||
Cancelada (de qualquer estado excepto Faturada)
|
||||
```
|
||||
|
||||
**Conteúdo de uma OT:**
|
||||
- Cliente + Veículo (ambos opcionais)
|
||||
- Items: serviços/peças do catálogo (qty, preço unitário, desconto por item)
|
||||
- Técnico(s) atribuído(s) + registo de horas (horas × custo/hora)
|
||||
- Notas internas (não visíveis ao cliente)
|
||||
- Notas para o cliente (aparecem no PDF)
|
||||
- Log de estados: timestamp + utilizador responsável por cada transição
|
||||
- Total calculado: soma de items + mão de obra
|
||||
|
||||
### 6.3 Catálogo de Serviços & Peças
|
||||
- Item: código, nome, categoria, unidade (un/hora/litro/kg), preço base, activo/inactivo
|
||||
- Categorias livres (criadas pela oficina)
|
||||
- Ao adicionar a OT: preço copiado do catálogo mas editável por OT
|
||||
- Pesquisa por código ou nome no momento de adição à OT
|
||||
|
||||
### 6.4 Faturação & Orçamentos
|
||||
- **Orçamento**: gerado a partir de OT em estado "Aberta", PDF com logo e dados da oficina
|
||||
- **Fatura**: gerada quando OT passa a "Faturada", numeração sequencial por oficina
|
||||
- PDFs gerados server-side em Go com **`chromedp`** (render HTML → PDF via Chrome headless); armazenados localmente (volume Docker) ou S3-compatible (configurável nas Settings)
|
||||
- Dados da oficina para PDF: logo, nome, NIF, morada, IBAN — configuráveis nas Settings da Web UI
|
||||
|
||||
### 6.5 Técnicos (Staff)
|
||||
- **Interno**: tem conta no sistema (role `technician`), aparece na atribuição de OTs
|
||||
- **Externo**: registo simplificado (nome, contacto, custo/hora), sem login
|
||||
- Registo de horas por OT: horas trabalhadas × custo/hora = custo de mão de obra
|
||||
- Listagem com estado activo/inactivo
|
||||
|
||||
### 6.6 Combustíveis & Despesas da Oficina
|
||||
- Registo: data, tipo (combustível / peças / ferramentas / outros), valor, descrição, veículo (opcional)
|
||||
- Filtros por período e tipo
|
||||
- Totais agregados no dashboard e relatórios
|
||||
|
||||
### 6.7 Notificações ao Cliente
|
||||
- **Telegram**: token do bot configurável nas Settings; envia mensagem quando OT muda para "Concluída" ou "Faturada"
|
||||
- **Email**: SMTP configurável nas Settings; template HTML editável na Web UI
|
||||
- Configurável por oficina: quais eventos disparam notificação e qual canal
|
||||
- Envio assíncrono: o handler HTTP publica um job numa lista Redis; uma goroutine dedicada consome a lista e envia as notificações (não bloqueia o request HTTP)
|
||||
|
||||
### 6.8 Dashboard & Relatórios
|
||||
**Dashboard (tempo real):**
|
||||
- Receita do mês actual vs mês anterior
|
||||
- Nº de OTs por estado (cards)
|
||||
- OTs abertas recentes (tabela)
|
||||
- Serviços mais realizados (top 5)
|
||||
|
||||
**Relatórios (com filtro de período):**
|
||||
- Receita por período (dia/semana/mês)
|
||||
- OTs por estado e por técnico
|
||||
- Despesas vs Receita
|
||||
- Exportação em PDF e CSV
|
||||
|
||||
---
|
||||
|
||||
## 7. Estrutura do Backend Go
|
||||
|
||||
```
|
||||
backend/
|
||||
├── cmd/server/main.go
|
||||
├── internal/
|
||||
│ ├── auth/ # JWT, middleware, bcrypt
|
||||
│ ├── tenant/ # Gestão tenants, schema provisioning
|
||||
│ ├── workorder/ # Ordens de trabalho
|
||||
│ ├── client/ # Clientes & veículos
|
||||
│ ├── catalog/ # Serviços & peças
|
||||
│ ├── invoice/ # Faturação, geração PDF
|
||||
│ ├── staff/ # Técnicos
|
||||
│ ├── expense/ # Combustíveis & despesas
|
||||
│ ├── notification/ # Telegram + Email (worker assíncrono)
|
||||
│ ├── report/ # Relatórios & exportações
|
||||
│ └── settings/ # Settings dinâmicas (DB + cache Redis)
|
||||
├── pkg/
|
||||
│ ├── database/ # Pool PostgreSQL, migrations
|
||||
│ ├── redis/ # Cliente Redis
|
||||
│ └── pdf/ # Geração PDF
|
||||
└── migrations/
|
||||
├── public/ # Schema público
|
||||
└── tenant/ # Schema tenant (aplicado no provisionamento)
|
||||
```
|
||||
|
||||
### API
|
||||
- Base: `/api/v1/`
|
||||
- Autenticação: Bearer token no header `Authorization`
|
||||
- Tenant resolvido via claim `tenant_id` no JWT
|
||||
- Respostas: `{ "data": ..., "error": null, "meta": { "page": ..., "total": ... } }`
|
||||
- Paginação cursor-based
|
||||
|
||||
---
|
||||
|
||||
## 8. Estrutura do Frontend React
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── pages/
|
||||
│ │ ├── auth/ # Login
|
||||
│ │ ├── admin/ # Super-admin (dashboard, tenants, settings)
|
||||
│ │ └── app/ # Área da oficina
|
||||
│ │ ├── dashboard/
|
||||
│ │ ├── work-orders/
|
||||
│ │ ├── clients/
|
||||
│ │ ├── vehicles/
|
||||
│ │ ├── catalog/
|
||||
│ │ ├── staff/
|
||||
│ │ ├── expenses/
|
||||
│ │ ├── reports/
|
||||
│ │ └── settings/
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # shadcn/ui base components
|
||||
│ │ └── shared/ # Componentes reutilizáveis da app
|
||||
│ ├── hooks/ # Custom hooks (useAuth, useTenant, etc.)
|
||||
│ ├── lib/
|
||||
│ │ ├── api.ts # Cliente HTTP (fetch + interceptors)
|
||||
│ │ └── queryClient.ts # TanStack Query config
|
||||
│ ├── store/ # Zustand stores
|
||||
│ └── i18n/ # Estrutura i18n (PT por defeito, EN preparado)
|
||||
```
|
||||
|
||||
### Rotas protegidas
|
||||
- `/login` — público
|
||||
- `/admin/*` — requer role `super_admin`
|
||||
- `/app/*` — requer role `tenant_admin`, `manager` ou `technician`
|
||||
- Guards no React Router v7 redireccionam para `/login` se JWT expirado
|
||||
|
||||
---
|
||||
|
||||
## 9. Base de Dados — Schemas
|
||||
|
||||
### Schema `public`
|
||||
```sql
|
||||
tenants (id uuid PK, slug text UNIQUE, name text, status, plan, created_at)
|
||||
invites (id uuid PK, tenant_id uuid FK, token text UNIQUE, expires_at, used_at nullable)
|
||||
super_admins (id uuid PK, email text UNIQUE, password_hash text, created_at)
|
||||
platform_settings (key text PK, value text, updated_at)
|
||||
```
|
||||
|
||||
### Schema `tenant_<uuid>` (por oficina)
|
||||
```sql
|
||||
users (id uuid PK, email, password_hash, role, name, active)
|
||||
clients (id uuid PK, name, nif, phone, email, address, notes, created_at)
|
||||
vehicles (id uuid PK, client_id uuid nullable FK, plate, brand, model, year, vin, mileage, notes)
|
||||
work_orders (id uuid PK, number serial, client_id nullable, vehicle_id nullable, status, internal_notes, client_notes, created_by, created_at, updated_at)
|
||||
wo_items (id uuid PK, work_order_id FK, catalog_item_id nullable FK, description, qty, unit_price, discount_pct, total)
|
||||
wo_staff_hours (id uuid PK, work_order_id FK, staff_id FK, hours, cost_per_hour, total)
|
||||
wo_status_log (id uuid PK, work_order_id FK, from_status, to_status, changed_by FK, changed_at)
|
||||
catalog_items (id uuid PK, code, name, category, unit, base_price, active)
|
||||
invoices (id uuid PK, work_order_id FK, type: quote|invoice, number serial, pdf_path, issued_at)
|
||||
staff (id uuid PK, user_id nullable FK, name, email, phone, type: internal|external, hourly_rate, active)
|
||||
expenses (id uuid PK, vehicle_id nullable FK, type, amount, description, date)
|
||||
tenant_settings (key text PK, value text, updated_at)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Internacionalização (i18n)
|
||||
|
||||
- Estrutura i18n no frontend desde o início (ficheiros JSON por locale em `src/i18n/`)
|
||||
- Idioma padrão e único no MVP: **Português de Portugal (pt-PT)**
|
||||
- Inglês (en) preparado na estrutura mas não traduzido no MVP
|
||||
- Datas, moeda e números formatados com `Intl` API (locale `pt-PT`, moeda `EUR`)
|
||||
|
||||
---
|
||||
|
||||
## 11. Segurança
|
||||
|
||||
- HTTPS obrigatório em produção (Coolify / Let's Encrypt)
|
||||
- JWT com expiração curta + refresh token rotativo em httpOnly cookie
|
||||
- Passwords com bcrypt cost 12
|
||||
- Rate limiting em todas as rotas de auth (Redis)
|
||||
- Row-level isolation via PostgreSQL `search_path` por tenant
|
||||
- Headers de segurança: CSP, HSTS, X-Frame-Options (middleware Fiber)
|
||||
- Inputs validados com Zod (frontend) e struct validation em Go (backend)
|
||||
- Ficheiros PDF armazenados fora do webroot, servidos via endpoint autenticado
|
||||
|
||||
---
|
||||
|
||||
## 12. Fora de Âmbito (MVP)
|
||||
|
||||
- Billing automático / Stripe
|
||||
- App mobile nativa
|
||||
- Integração com sistemas de diagnóstico OBD
|
||||
- Portal self-service para clientes da oficina
|
||||
- Multi-idioma completo (EN traduzido)
|
||||
- Backups automáticos (responsabilidade do Coolify/infra)
|
||||
@@ -0,0 +1,175 @@
|
||||
# TechXCar — Plan 3: Core Workshop Pages
|
||||
|
||||
**Date:** 2026-06-22
|
||||
**Status:** Approved
|
||||
|
||||
## Scope
|
||||
|
||||
Build the 3 missing tenant-facing pages: Clients, Catalog, Work Orders. Backend has all 31 endpoints ready. Frontend needs hooks, pages, modals, and routing wired up.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Approach: **hooks-per-domain + pages**. Each domain has a dedicated hook file in `frontend/src/hooks/` that wraps TanStack Query. Pages consume hooks and stay presentational.
|
||||
|
||||
### New files
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── hooks/
|
||||
│ ├── useClients.ts
|
||||
│ ├── useCatalog.ts
|
||||
│ └── useWorkOrders.ts
|
||||
├── components/
|
||||
│ └── ui/
|
||||
│ └── dialog.tsx ← shadcn Dialog (shared by all modals)
|
||||
└── pages/app/
|
||||
├── clients/
|
||||
│ ├── ClientsPage.tsx
|
||||
│ └── ClientDetailPage.tsx
|
||||
├── catalog/
|
||||
│ └── CatalogPage.tsx
|
||||
└── work-orders/
|
||||
├── WorkOrdersPage.tsx
|
||||
└── WorkOrderDetailPage.tsx
|
||||
```
|
||||
|
||||
### New routes (added to `App.tsx`)
|
||||
|
||||
```
|
||||
/app/clients → ClientsPage
|
||||
/app/clients/:id → ClientDetailPage
|
||||
/app/catalog → CatalogPage
|
||||
/app/work-orders → WorkOrdersPage
|
||||
/app/work-orders/:id → WorkOrderDetailPage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Domain: Clients
|
||||
|
||||
### `useClients.ts`
|
||||
|
||||
| Hook | Method | Endpoint |
|
||||
|---|---|---|
|
||||
| `useClients()` | GET | `/clients` |
|
||||
| `useClient(id)` | GET | `/clients/:id` |
|
||||
| `useCreateClient()` | POST | `/clients` |
|
||||
| `useUpdateClient()` | PUT | `/clients/:id` |
|
||||
| `useDeleteClient()` | DELETE | `/clients/:id` |
|
||||
| `useClientVehicles(clientId)` | GET | `/clients/:id/vehicles` |
|
||||
| `useCreateVehicle(clientId)` | POST | `/clients/:id/vehicles` |
|
||||
| `useUpdateVehicle()` | PUT | `/vehicles/:id` |
|
||||
|
||||
All mutations call `queryClient.invalidateQueries` on success.
|
||||
|
||||
### `ClientsPage`
|
||||
|
||||
- Table columns: Nome, NIF, Telefone, Email, Data criação
|
||||
- "Novo Cliente" button → modal (create)
|
||||
- Edit icon per row → same modal prefilled
|
||||
- Modal fields: nome (required), NIF, telefone, email, morada, notas
|
||||
|
||||
### `ClientDetailPage`
|
||||
|
||||
- Header: client name + fields (NIF, phone, email, address, notes) + Edit button
|
||||
- "Veículos" section below: table with matrícula, marca, modelo, ano
|
||||
- "Adicionar Veículo" → modal: matrícula (required), marca (required), modelo (required), ano, VIN, quilómetros, notas
|
||||
- Edit icon per vehicle row → same modal prefilled
|
||||
- Back link → `/app/clients`
|
||||
|
||||
---
|
||||
|
||||
## Domain: Catalog
|
||||
|
||||
### `useCatalog.ts`
|
||||
|
||||
| Hook | Method | Endpoint |
|
||||
|---|---|---|
|
||||
| `useCatalog()` | GET | `/catalog` |
|
||||
| `useCreateCatalogItem()` | POST | `/catalog` |
|
||||
| `useUpdateCatalogItem()` | PUT | `/catalog/:id` |
|
||||
| `useDeleteCatalogItem()` | DELETE | `/catalog/:id` |
|
||||
|
||||
### `CatalogPage`
|
||||
|
||||
- Table columns: Código, Nome, Categoria, Unidade, Preço Base, Estado (badge)
|
||||
- "Novo Item" button → modal (create)
|
||||
- Edit icon per row → modal prefilled
|
||||
- Delete: button toggles to "Confirmar?" before calling DELETE
|
||||
- Modal fields: código, nome (required), categoria, unidade (select: un/hora/litro/kg), preço base (number), activo (checkbox)
|
||||
|
||||
---
|
||||
|
||||
## Domain: Work Orders
|
||||
|
||||
### `useWorkOrders.ts`
|
||||
|
||||
| Hook | Method | Endpoint |
|
||||
|---|---|---|
|
||||
| `useWorkOrders(status?)` | GET | `/work-orders?status=` |
|
||||
| `useWorkOrder(id)` | GET | `/work-orders/:id` |
|
||||
| `useCreateWorkOrder()` | POST | `/work-orders` |
|
||||
| `useTransitionWorkOrder()` | POST | `/work-orders/:id/transition` |
|
||||
| `useAddWOItem()` | POST | `/work-orders/:id/items` |
|
||||
| `useRemoveWOItem()` | DELETE | `/work-orders/:id/items/:itemId` |
|
||||
| `useAddStaffHours()` | POST | `/work-orders/:id/staff-hours` |
|
||||
| `useRemoveStaffHours()` | DELETE | `/work-orders/:id/staff-hours/:shId` |
|
||||
|
||||
### `WorkOrdersPage`
|
||||
|
||||
- Table columns: Nº OT, Cliente, Veículo, Estado (badge colorido), Data
|
||||
- Status filter: tabs (Todas / Abertas / Em Progresso / Concluídas / Faturadas / Canceladas)
|
||||
- "Nova OT" → modal: cliente (searchable dropdown from `/clients`), veículo (filtered by client), notas internas, notas cliente
|
||||
- Click row → navigate to `/app/work-orders/:id`
|
||||
|
||||
### `WorkOrderDetailPage`
|
||||
|
||||
Two-column layout:
|
||||
|
||||
**Left column — info + transitions:**
|
||||
- OT number, status badge, client name, vehicle plate + brand/model
|
||||
- Internal notes, client notes (editable inline via PUT)
|
||||
- State stepper: `Aberta → Em Progresso → Concluída → Faturada`
|
||||
- Transition buttons: advance to next state, or "Cancelar OT" (any state except invoiced)
|
||||
- Back link → `/app/work-orders`
|
||||
|
||||
**Right column — items + hours:**
|
||||
- "Peças / Serviços" table: descrição, qty, preço unitário, desconto %, total
|
||||
- "Adicionar Item" → modal: select catalog item (searchable), qty, unit price (prefilled from catalog), discount %
|
||||
- Remove icon per row (DELETE)
|
||||
- "Horas de Técnico" table: técnico (staff_id), horas, custo/hora, total
|
||||
- "Adicionar Horas" → modal: staff_id (text for now — staff module in Plan 4), horas, custo/hora
|
||||
- Remove icon per row (DELETE)
|
||||
- Totals row at bottom: subtotal peças + subtotal horas + total geral
|
||||
|
||||
### State badge colours
|
||||
|
||||
| Status | Colour |
|
||||
|---|---|
|
||||
| open | slate |
|
||||
| in_progress | blue |
|
||||
| completed | green |
|
||||
| invoiced | purple |
|
||||
| cancelled | red |
|
||||
|
||||
---
|
||||
|
||||
## Shared components
|
||||
|
||||
- **`dialog.tsx`** — shadcn Dialog, used by all create/edit modals. Install via shadcn CLI or copy pattern from existing ui components.
|
||||
- All pages follow existing dark slate theme: `bg-slate-950` root, `bg-slate-900` panels, `border-slate-700/800`, white text.
|
||||
- Loading states: `<p className="text-slate-400">A carregar...</p>`
|
||||
- Empty states: `<p className="text-slate-500 text-sm">Nenhum registo.</p>`
|
||||
- Error states: show `ApiError.message` in a red banner.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (Plan 4+)
|
||||
|
||||
- Staff management (staff_id is free text for now)
|
||||
- PDF invoice generation
|
||||
- Notifications (Telegram / email)
|
||||
- Dashboard statistics
|
||||
- Billing / invoicing module
|
||||
@@ -0,0 +1,115 @@
|
||||
# Super Admin — Tenant Access Design
|
||||
|
||||
**Date:** 2026-06-29
|
||||
**Status:** Approved
|
||||
**Scope:** Bug fix (TenantMiddleware schema name) + feature (super admin impersonation with session restore)
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
1. **Bug:** `TenantMiddleware` constructs schema name as `"tenant_<uuid-with-hyphens>"` but schemas are named `tenant_<uuid-with-underscores>`. All tenant user requests fail with SET search_path error. Independent of the feature — must always be fixed.
|
||||
|
||||
2. **Feature gap:** Super admin can list and create tenants but cannot access or manage data within individual tenant workspaces.
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
### Bug Fix — TenantMiddleware
|
||||
|
||||
Replace raw UUID concatenation with normalized schema name (hyphens → underscores):
|
||||
|
||||
```go
|
||||
// Before
|
||||
schema := `"tenant_` + claims.TenantID + `"`
|
||||
|
||||
// After
|
||||
schema := `"tenant_` + strings.ReplaceAll(claims.TenantID, "-", "_") + `"`
|
||||
```
|
||||
|
||||
### Feature — Tenant Impersonation with Session Restore
|
||||
|
||||
**Flow:**
|
||||
1. Super admin clicks "Gerir" on any tenant row in TenantsPage
|
||||
2. Frontend calls `POST /api/v1/admin/tenants/:id/access`
|
||||
3. Backend validates tenant (exists + active), generates a 15-min access token with `tenantID=<id>` and `role="tenant_admin"`
|
||||
4. Frontend saves current session to `previousSession` in authStore, sets new token+user
|
||||
5. Navigates to `/app` — super admin now sees the tenant workspace
|
||||
6. AppLayout shows a banner: `[TechXCar Admin] A gerir: <tenant name> [← Voltar ao painel]`
|
||||
7. Clicking "Voltar" restores the previous session and navigates to `/admin`
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### New endpoint
|
||||
|
||||
`POST /api/v1/admin/tenants/:id/access`
|
||||
- Auth: `RequireAuth` + `RequireRole("super_admin")`
|
||||
- Validates: tenant ID format, tenant exists, tenant status = "active"
|
||||
- Returns: `{ "data": { "access_token": "...", "tenant": { "id", "name", "slug" } }, "error": null }`
|
||||
- Token: standard 15-min access token, `userID = super_admin_id`, `tenantID = tenant.ID`, `role = "tenant_admin"`
|
||||
- No new refresh token — the impersonation session is access-token only
|
||||
|
||||
### Route registration
|
||||
|
||||
Added to `tenant.RegisterRoutes` under the existing `admin` group:
|
||||
```
|
||||
POST /api/v1/admin/tenants/:id/access
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### authStore changes
|
||||
|
||||
New fields:
|
||||
```typescript
|
||||
previousSession?: { token: string; user: AuthUser }
|
||||
```
|
||||
|
||||
New actions:
|
||||
- `impersonateTenant(token, user)` — saves current `{ token, user }` to `previousSession`, sets new token+user
|
||||
- `restoreSession()` — restores `previousSession` into token+user, clears `previousSession`
|
||||
|
||||
`previousSession` is NOT persisted to localStorage (impersonation does not survive page refresh — intentional).
|
||||
|
||||
### TenantsPage
|
||||
|
||||
Each tenant row gains a "Gerir" button (secondary/outline variant).
|
||||
|
||||
On click:
|
||||
1. POST `/admin/tenants/:id/access`
|
||||
2. On success: `impersonateTenant(data.access_token, { ...data.tenant, role: 'tenant_admin' })`
|
||||
3. Invalidate TanStack Query cache (avoid stale super-admin-scoped data)
|
||||
4. Navigate to `/app`
|
||||
|
||||
### AppLayout
|
||||
|
||||
When `authStore.previousSession` is defined, render a fixed banner at the top:
|
||||
|
||||
```
|
||||
[TechXCar Admin] A gerir: <user.name> [← Voltar ao painel]
|
||||
```
|
||||
|
||||
- Background: amber/yellow to visually distinguish from normal tenant UI
|
||||
- "Voltar" button: calls `restoreSession()`, navigates to `/admin`
|
||||
- Banner height: ~40px; main content padding-top adjusts accordingly
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Tenant not found or not active → 404 "oficina não encontrada ou inativa"
|
||||
- `impersonateTenant` failure (network) → show error toast, do not change session
|
||||
- Restoring session never fails (purely client-side state)
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Refresh token for impersonation sessions (15-min window is intentional)
|
||||
- Audit log of impersonation events (future)
|
||||
- Super admin creating/editing data within tenant as themselves (they appear as tenant_admin)
|
||||
Reference in New Issue
Block a user