19 KiB
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_pathset to"tenant_590b9965_9f7b_4427_b1c8_2b695610e7bd", public -
Step 1: Write failing test
Create backend/internal/auth/middleware_test.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)
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:
schema := `"tenant_` + claims.TenantID + `"`
if _, err := conn.Exec(c.Context(), "SET search_path = "+schema+", public"); err != nil {
with:
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
cd backend && go test ./internal/auth/... -v
Expected: all PASS
- Step 5: Smoke test tenant login via curl
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
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.ClaimswithUserID,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):
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
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:
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):
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
cd backend && go test ./internal/tenant/... -v -run TestTenantAccess
Expected: PASS
- Step 6: Smoke test via curl
# 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
docker compose up --build -d backend
Wait for healthy, re-run smoke test against port 8080.
- Step 8: Commit
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,accessTokenstate -
Produces:
previousSession: { token: string; user: AuthUser } | null(NOT persisted)impersonateTenant(token: string, user: AuthUser): voidrestoreSession(): void
-
Step 1: Update authStore.ts
Replace the entire file content:
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
cd frontend && npm run build 2>&1 | tail -10
Expected: build succeeds (or only pre-existing errors, none from authStore)
- Step 3: Commit
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()fromreact-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:
- Add imports at top:
import { useNavigate } from 'react-router'
import { useAuthStore } from '@/store/authStore'
import type { AuthUser } from '@/store/authStore'
- Inside
TenantsPagecomponent, after existing mutations, add:
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')
},
})
- In the table
<tbody>, add a fifth column header<th>in<thead>:
<th className="text-left px-4 py-3 text-slate-400 font-medium">Ações</th>
- In each
<tr>inside the.map(), add a fifth<td>after the "Criada" cell:
<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
cd frontend && npm run build 2>&1 | tail -15
Expected: build succeeds
- Step 3: Commit
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:
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
cd frontend && npm run build 2>&1 | tail -10
Expected: build succeeds
- Step 3: Rebuild frontend container and smoke test
docker compose up --build -d frontend
Then in browser:
- Log in as
admin@techxcar.com/TechXCar2026! - Go to
/admin/tenants - Click "Gerir" on a tenant row
- Verify: navigates to
/app, amber banner shows "TechXCar Admin — a gerir: Oficina Demo" - Click "← Voltar ao painel"
- Verify: back at
/admin, super_admin session restored, no banner
- Step 4: Commit
git add frontend/src/components/layout/AppLayout.tsx
git commit -m "feat: AppLayout shows impersonation banner with session restore for super admin"