Files
techxcar/docs/superpowers/plans/2026-06-29-super-admin-tenant-access.md
Luciano Milani 5de37bb512 Inicial
2026-07-02 12:47:55 +01:00

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_path set 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.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):

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, 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:

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() 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:
import { useNavigate } from 'react-router'
import { useAuthStore } from '@/store/authStore'
import type { AuthUser } from '@/store/authStore'
  1. Inside TenantsPage component, 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')
  },
})
  1. 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>
  1. 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:

  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
git add frontend/src/components/layout/AppLayout.tsx
git commit -m "feat: AppLayout shows impersonation banner with session restore for super admin"