Files
techxcar/docs/superpowers/plans/2026-06-18-plan2-auth-multitenancy.md
Luciano Milani 5de37bb512 Inicial
2026-07-02 12:47:55 +01:00

83 KiB

TechXCar — Plan 2: Auth & Multi-tenancy

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: Implement JWT authentication (login/refresh/logout), tenant management with invite-based onboarding, a real login page, and a super-admin panel to manage tenants and generate invites.

Architecture: Backend gains internal/auth/ (JWT, bcrypt, rate limiter, handlers, middleware) and internal/tenant/ (repository + handlers). Auth handlers receive the tenant repository to query super_admins and tenant users — no circular imports. The tenant middleware acquires a pgxpool.Conn per request, sets search_path, and stores it in c.Locals("conn"); handlers that need tenant-scoped queries read this connection. Refresh tokens are stored in Redis keyed by refresh:<userID> with 30-day TTL (one active session per user; logout deletes the key). server.New() gains a Deps struct to receive config, DB, and Redis.

Tech Stack: golang-jwt/jwt v5, golang.org/x/crypto (bcrypt cost 12), gofiber/fiber/v2 limiter middleware, Redis (refresh tokens + rate limiting), React Hook Form v7, Zod v4, TanStack Query v5, Zustand v5

Global Constraints

  • Go 1.25, module github.com/techxcar/backend
  • JWT: access token 15 min, refresh token 30 days, HS256
  • Passwords: bcrypt cost 12
  • Rate limiting: 10 requests/min per IP on auth routes, Redis-backed
  • All user-facing error messages in Português de Portugal (pt-PT)
  • API envelope: { "data": ..., "error": null }
  • Tenant schema name: tenant_<uuid> — UUID validated with ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ before SQL interpolation
  • Single /api/v1/auth/login endpoint: if tenant_slug empty → try super_admin; else → try tenant user
  • Frontend: React 19, TypeScript strict, @/ alias → src/, React Hook Form + Zod

Task 1: Auth — JWT token generation/validation + bcrypt

Files:

  • Create: backend/internal/auth/jwt.go
  • Create: backend/internal/auth/jwt_test.go
  • Create: backend/internal/auth/bcrypt.go
  • Create: backend/internal/auth/bcrypt_test.go

Interfaces:

  • Consumes: nothing

  • Produces:

    • auth.Claims struct: UserID, TenantID, Role string + jwt.RegisteredClaims
    • auth.GenerateAccessToken(userID, tenantID, role, secret string) (string, error)
    • auth.GenerateRefreshToken(userID, tenantID, role, secret string) (string, error)
    • auth.ValidateToken(tokenStr, secret string) (*Claims, error)
    • auth.HashPassword(password string) (string, error)
    • auth.VerifyPassword(password, hash string) bool
  • Step 1: Write the failing tests

Create backend/internal/auth/jwt_test.go:

package auth_test

import (
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/techxcar/backend/internal/auth"
)

const testSecret = "test-secret-32-chars-minimum-ok!"

func TestGenerateAndValidateAccessToken(t *testing.T) {
	token, err := auth.GenerateAccessToken("user-1", "tenant-1", "tenant_admin", testSecret)
	require.NoError(t, err)
	assert.NotEmpty(t, token)

	claims, err := auth.ValidateToken(token, testSecret)
	require.NoError(t, err)
	assert.Equal(t, "user-1", claims.UserID)
	assert.Equal(t, "tenant-1", claims.TenantID)
	assert.Equal(t, "tenant_admin", claims.Role)
	assert.True(t, claims.ExpiresAt.After(time.Now()))
	assert.True(t, claims.ExpiresAt.Before(time.Now().Add(16*time.Minute)))
}

func TestGenerateRefreshToken_longerExpiry(t *testing.T) {
	token, err := auth.GenerateRefreshToken("user-1", "", "super_admin", testSecret)
	require.NoError(t, err)

	claims, err := auth.ValidateToken(token, testSecret)
	require.NoError(t, err)
	assert.Empty(t, claims.TenantID)
	assert.Equal(t, "super_admin", claims.Role)
	assert.True(t, claims.ExpiresAt.After(time.Now().Add(29*24*time.Hour)))
}

func TestValidateToken_wrongSecret(t *testing.T) {
	token, err := auth.GenerateAccessToken("user-1", "t-1", "manager", testSecret)
	require.NoError(t, err)

	_, err = auth.ValidateToken(token, "different-secret-32chars-minimumx")
	assert.Error(t, err)
}

func TestValidateToken_malformed(t *testing.T) {
	_, err := auth.ValidateToken("not.a.jwt", testSecret)
	assert.Error(t, err)
}

Create backend/internal/auth/bcrypt_test.go:

package auth_test

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/techxcar/backend/internal/auth"
)

func TestHashPassword_isNotPlaintext(t *testing.T) {
	hash, err := auth.HashPassword("mysecret")
	require.NoError(t, err)
	assert.NotEqual(t, "mysecret", hash)
	assert.NotEmpty(t, hash)
}

func TestVerifyPassword_correct(t *testing.T) {
	hash, err := auth.HashPassword("correctpassword")
	require.NoError(t, err)
	assert.True(t, auth.VerifyPassword("correctpassword", hash))
}

func TestVerifyPassword_wrong(t *testing.T) {
	hash, err := auth.HashPassword("correctpassword")
	require.NoError(t, err)
	assert.False(t, auth.VerifyPassword("wrongpassword", hash))
}
  • Step 2: Run tests to confirm they fail
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
go test ./internal/auth/... -v

Expected: FAIL — package github.com/techxcar/backend/internal/auth: cannot find package

  • Step 3: Create jwt.go

Create backend/internal/auth/jwt.go:

package auth

import (
	"fmt"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

type Claims struct {
	UserID   string `json:"user_id"`
	TenantID string `json:"tenant_id,omitempty"`
	Role     string `json:"role"`
	jwt.RegisteredClaims
}

func GenerateAccessToken(userID, tenantID, role, secret string) (string, error) {
	return generateToken(userID, tenantID, role, secret, 15*time.Minute)
}

func GenerateRefreshToken(userID, tenantID, role, secret string) (string, error) {
	return generateToken(userID, tenantID, role, secret, 30*24*time.Hour)
}

func generateToken(userID, tenantID, role, secret string, ttl time.Duration) (string, error) {
	claims := Claims{
		UserID:   userID,
		TenantID: tenantID,
		Role:     role,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
		},
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString([]byte(secret))
}

func ValidateToken(tokenStr, secret string) (*Claims, error) {
	token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("método de assinatura inesperado: %v", t.Header["alg"])
		}
		return []byte(secret), nil
	})
	if err != nil {
		return nil, err
	}
	claims, ok := token.Claims.(*Claims)
	if !ok || !token.Valid {
		return nil, fmt.Errorf("token inválido")
	}
	return claims, nil
}
  • Step 4: Create bcrypt.go

Create backend/internal/auth/bcrypt.go:

package auth

import "golang.org/x/crypto/bcrypt"

const bcryptCost = 12

func HashPassword(password string) (string, error) {
	bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
	return string(bytes), err
}

func VerifyPassword(password, hash string) bool {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
  • Step 5: Run tests
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
go test ./internal/auth/... -v

Expected (bcrypt tests are slow ~1s at cost 12):

=== RUN   TestGenerateAndValidateAccessToken
--- PASS: TestGenerateAndValidateAccessToken
=== RUN   TestGenerateRefreshToken_longerExpiry
--- PASS: TestGenerateRefreshToken_longerExpiry
=== RUN   TestValidateToken_wrongSecret
--- PASS: TestValidateToken_wrongSecret
=== RUN   TestValidateToken_malformed
--- PASS: TestValidateToken_malformed
=== RUN   TestHashPassword_isNotPlaintext
--- PASS: TestHashPassword_isNotPlaintext
=== RUN   TestVerifyPassword_correct
--- PASS: TestVerifyPassword_correct
=== RUN   TestVerifyPassword_wrong
--- PASS: TestVerifyPassword_wrong
PASS
  • Step 6: Commit
cd /var/home/lmilani/Documentos/IDE/techxcar
git add backend/internal/auth/
git commit -m "feat: auth package — JWT token generation/validation and bcrypt password hashing"

Task 2: Tenant repository — types + DB queries

Files:

  • Create: backend/internal/tenant/repository.go
  • Create: backend/internal/tenant/repository_test.go

Interfaces:

  • Consumes: *database.DB (field Pool *pgxpool.Pool)

  • Produces:

    • Types: SuperAdmin{ID, Email, PasswordHash string; CreatedAt time.Time}, Tenant{ID, Slug, Name, Status string; CreatedAt time.Time}, TenantUser{ID, Email, PasswordHash, Role, Name string; Active bool}, Invite{ID, Token string; TenantID *string; ExpiresAt time.Time; UsedAt *time.Time}
    • tenant.NewRepository(db *database.DB) *Repository
    • repo.GetSuperAdminByEmail(ctx, email) (*SuperAdmin, error) — nil if not found
    • repo.CreateSuperAdmin(ctx, email, passwordHash string) (*SuperAdmin, error)
    • repo.GetTenantBySlug(ctx, slug) (*Tenant, error) — nil if not found
    • repo.GetTenantByID(ctx, id) (*Tenant, error) — nil if not found
    • repo.ListTenants(ctx) ([]*Tenant, error)
    • repo.CreateTenant(ctx, slug, name string) (*Tenant, error)
    • repo.GetTenantUserByEmail(ctx, tenantID, email string) (*TenantUser, error) — schema-qualified query, nil if not found
    • repo.CreateTenantUser(ctx, tenantID, email, passwordHash, name, role string) (*TenantUser, error) — schema-qualified INSERT
    • repo.CreateInvite(ctx, tenantID *string, expiresIn time.Duration) (*Invite, error) — token = uuid.New().String()
    • repo.GetInviteByToken(ctx, token string) (*Invite, error) — nil if not found
    • repo.UseInvite(ctx, inviteID string) error — sets used_at = NOW()
  • Step 1: Write the failing tests

Create backend/internal/tenant/repository_test.go:

package tenant_test

import (
	"context"
	"os"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/techxcar/backend/internal/tenant"
	"github.com/techxcar/backend/pkg/database"
)

func setupDB(t *testing.T) *database.DB {
	t.Helper()
	url := os.Getenv("TEST_DATABASE_URL")
	if url == "" {
		t.Skip("TEST_DATABASE_URL not set, skipping integration test")
	}
	db, err := database.New(url)
	require.NoError(t, err)
	t.Cleanup(db.Close)
	return db
}

func TestGetSuperAdminByEmail_notFound(t *testing.T) {
	db := setupDB(t)
	repo := tenant.NewRepository(db)
	admin, err := repo.GetSuperAdminByEmail(context.Background(), "nobody@example.com")
	require.NoError(t, err)
	assert.Nil(t, admin)
}

func TestCreateAndGetSuperAdmin(t *testing.T) {
	db := setupDB(t)
	repo := tenant.NewRepository(db)
	ctx := context.Background()

	email := "sa_" + time.Now().Format("20060102150405") + "@example.com"
	admin, err := repo.CreateSuperAdmin(ctx, email, "hash123")
	require.NoError(t, err)
	require.NotNil(t, admin)
	assert.NotEmpty(t, admin.ID)
	assert.Equal(t, email, admin.Email)

	found, err := repo.GetSuperAdminByEmail(ctx, email)
	require.NoError(t, err)
	require.NotNil(t, found)
	assert.Equal(t, admin.ID, found.ID)

	t.Cleanup(func() {
		db.Pool.Exec(ctx, "DELETE FROM super_admins WHERE id = $1", admin.ID)
	})
}

func TestCreateAndListTenants(t *testing.T) {
	db := setupDB(t)
	repo := tenant.NewRepository(db)
	ctx := context.Background()

	slug := "test-" + time.Now().Format("20060102150405")
	ten, err := repo.CreateTenant(ctx, slug, "Test Workshop")
	require.NoError(t, err)
	require.NotNil(t, ten)
	assert.NotEmpty(t, ten.ID)
	assert.Equal(t, slug, ten.Slug)
	assert.Equal(t, "active", ten.Status)

	list, err := repo.ListTenants(ctx)
	require.NoError(t, err)
	found := false
	for _, v := range list {
		if v.ID == ten.ID {
			found = true
		}
	}
	assert.True(t, found)

	t.Cleanup(func() {
		db.Pool.Exec(ctx, "DELETE FROM tenants WHERE id = $1", ten.ID)
	})
}

func TestCreateAndUseInvite(t *testing.T) {
	db := setupDB(t)
	repo := tenant.NewRepository(db)
	ctx := context.Background()

	invite, err := repo.CreateInvite(ctx, nil, 24*time.Hour)
	require.NoError(t, err)
	require.NotNil(t, invite)
	assert.NotEmpty(t, invite.Token)
	assert.Nil(t, invite.UsedAt)
	assert.True(t, invite.ExpiresAt.After(time.Now()))

	found, err := repo.GetInviteByToken(ctx, invite.Token)
	require.NoError(t, err)
	require.NotNil(t, found)
	assert.Equal(t, invite.ID, found.ID)

	err = repo.UseInvite(ctx, invite.ID)
	require.NoError(t, err)

	used, err := repo.GetInviteByToken(ctx, invite.Token)
	require.NoError(t, err)
	assert.NotNil(t, used.UsedAt)

	t.Cleanup(func() {
		db.Pool.Exec(ctx, "DELETE FROM invites WHERE id = $1", invite.ID)
	})
}
  • Step 2: Run tests to confirm they fail
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
go test ./internal/tenant/... -v

Expected: FAIL — package github.com/techxcar/backend/internal/tenant: cannot find package

  • Step 3: Create repository.go

Create backend/internal/tenant/repository.go:

package tenant

import (
	"context"
	"fmt"
	"regexp"
	"time"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
	"github.com/techxcar/backend/pkg/database"
)

var uuidRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)

type SuperAdmin struct {
	ID           string
	Email        string
	PasswordHash string
	CreatedAt    time.Time
}

type Tenant struct {
	ID        string
	Slug      string
	Name      string
	Status    string
	CreatedAt time.Time
}

type TenantUser struct {
	ID           string
	Email        string
	PasswordHash string
	Role         string
	Name         string
	Active       bool
}

type Invite struct {
	ID        string
	TenantID  *string
	Token     string
	ExpiresAt time.Time
	UsedAt    *time.Time
	CreatedAt time.Time
}

type Repository struct {
	db *database.DB
}

func NewRepository(db *database.DB) *Repository {
	return &Repository{db: db}
}

func (r *Repository) GetSuperAdminByEmail(ctx context.Context, email string) (*SuperAdmin, error) {
	row := r.db.Pool.QueryRow(ctx,
		`SELECT id, email, password_hash, created_at FROM super_admins WHERE email = $1`, email)
	var a SuperAdmin
	err := row.Scan(&a.ID, &a.Email, &a.PasswordHash, &a.CreatedAt)
	if err == pgx.ErrNoRows {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("tenant: get super admin: %w", err)
	}
	return &a, nil
}

func (r *Repository) CreateSuperAdmin(ctx context.Context, email, passwordHash string) (*SuperAdmin, error) {
	row := r.db.Pool.QueryRow(ctx,
		`INSERT INTO super_admins (email, password_hash) VALUES ($1, $2)
		 RETURNING id, email, password_hash, created_at`,
		email, passwordHash)
	var a SuperAdmin
	if err := row.Scan(&a.ID, &a.Email, &a.PasswordHash, &a.CreatedAt); err != nil {
		return nil, fmt.Errorf("tenant: create super admin: %w", err)
	}
	return &a, nil
}

func (r *Repository) GetTenantBySlug(ctx context.Context, slug string) (*Tenant, error) {
	row := r.db.Pool.QueryRow(ctx,
		`SELECT id, slug, name, status, created_at FROM tenants WHERE slug = $1`, slug)
	var t Tenant
	err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt)
	if err == pgx.ErrNoRows {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("tenant: get by slug: %w", err)
	}
	return &t, nil
}

func (r *Repository) GetTenantByID(ctx context.Context, id string) (*Tenant, error) {
	row := r.db.Pool.QueryRow(ctx,
		`SELECT id, slug, name, status, created_at FROM tenants WHERE id = $1`, id)
	var t Tenant
	err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt)
	if err == pgx.ErrNoRows {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("tenant: get by id: %w", err)
	}
	return &t, nil
}

func (r *Repository) ListTenants(ctx context.Context) ([]*Tenant, error) {
	rows, err := r.db.Pool.Query(ctx,
		`SELECT id, slug, name, status, created_at FROM tenants ORDER BY created_at DESC`)
	if err != nil {
		return nil, fmt.Errorf("tenant: list: %w", err)
	}
	defer rows.Close()
	var list []*Tenant
	for rows.Next() {
		var t Tenant
		if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt); err != nil {
			return nil, fmt.Errorf("tenant: list scan: %w", err)
		}
		list = append(list, &t)
	}
	return list, nil
}

func (r *Repository) CreateTenant(ctx context.Context, slug, name string) (*Tenant, error) {
	row := r.db.Pool.QueryRow(ctx,
		`INSERT INTO tenants (slug, name) VALUES ($1, $2)
		 RETURNING id, slug, name, status, created_at`,
		slug, name)
	var t Tenant
	if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt); err != nil {
		return nil, fmt.Errorf("tenant: create: %w", err)
	}
	return &t, nil
}

func (r *Repository) GetTenantUserByEmail(ctx context.Context, tenantID, email string) (*TenantUser, error) {
	if !uuidRe.MatchString(tenantID) {
		return nil, fmt.Errorf("tenant: invalid tenant ID format")
	}
	schema := "tenant_" + tenantID
	row := r.db.Pool.QueryRow(ctx,
		fmt.Sprintf(`SELECT id, email, password_hash, role, name, active FROM %s.users WHERE email = $1`, schema),
		email)
	var u TenantUser
	err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.Name, &u.Active)
	if err == pgx.ErrNoRows {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("tenant: get user: %w", err)
	}
	return &u, nil
}

func (r *Repository) CreateTenantUser(ctx context.Context, tenantID, email, passwordHash, name, role string) (*TenantUser, error) {
	if !uuidRe.MatchString(tenantID) {
		return nil, fmt.Errorf("tenant: invalid tenant ID format")
	}
	schema := "tenant_" + tenantID
	row := r.db.Pool.QueryRow(ctx,
		fmt.Sprintf(`INSERT INTO %s.users (email, password_hash, name, role)
		 VALUES ($1, $2, $3, $4) RETURNING id, email, password_hash, role, name, active`, schema),
		email, passwordHash, name, role)
	var u TenantUser
	if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.Name, &u.Active); err != nil {
		return nil, fmt.Errorf("tenant: create user: %w", err)
	}
	return &u, nil
}

func (r *Repository) CreateInvite(ctx context.Context, tenantID *string, expiresIn time.Duration) (*Invite, error) {
	token := uuid.New().String()
	expiresAt := time.Now().Add(expiresIn)
	row := r.db.Pool.QueryRow(ctx,
		`INSERT INTO invites (tenant_id, token, expires_at) VALUES ($1, $2, $3)
		 RETURNING id, tenant_id, token, expires_at, used_at, created_at`,
		tenantID, token, expiresAt)
	var inv Invite
	if err := row.Scan(&inv.ID, &inv.TenantID, &inv.Token, &inv.ExpiresAt, &inv.UsedAt, &inv.CreatedAt); err != nil {
		return nil, fmt.Errorf("tenant: create invite: %w", err)
	}
	return &inv, nil
}

func (r *Repository) GetInviteByToken(ctx context.Context, token string) (*Invite, error) {
	row := r.db.Pool.QueryRow(ctx,
		`SELECT id, tenant_id, token, expires_at, used_at, created_at FROM invites WHERE token = $1`, token)
	var inv Invite
	err := row.Scan(&inv.ID, &inv.TenantID, &inv.Token, &inv.ExpiresAt, &inv.UsedAt, &inv.CreatedAt)
	if err == pgx.ErrNoRows {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("tenant: get invite: %w", err)
	}
	return &inv, nil
}

func (r *Repository) UseInvite(ctx context.Context, inviteID string) error {
	_, err := r.db.Pool.Exec(ctx,
		`UPDATE invites SET used_at = NOW() WHERE id = $1`, inviteID)
	return err
}
  • Step 4: Run tests
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
go test ./internal/tenant/... -v

Expected (without TEST_DATABASE_URL all tests skip):

=== RUN   TestGetSuperAdminByEmail_notFound
--- SKIP: TestGetSuperAdminByEmail_notFound (TEST_DATABASE_URL not set)
...
PASS
  • Step 5: Compile check
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
go build ./...

Expected: no output (success)

  • Step 6: Commit
cd /var/home/lmilani/Documentos/IDE/techxcar
git add backend/internal/tenant/
git commit -m "feat: tenant repository — types and DB queries for super_admins, tenants, users and invites"

Task 3: Auth handlers — login, refresh, logout + Redis rate limiter

Files:

  • Create: backend/internal/auth/ratelimit.go
  • Create: backend/internal/auth/handler.go
  • Create: backend/internal/auth/handler_test.go
  • Create: backend/internal/auth/routes.go

Interfaces:

  • Consumes:

    • auth.ValidateToken, auth.GenerateAccessToken, auth.GenerateRefreshToken, auth.VerifyPassword (Task 1)
    • tenant.Repository (Task 2): GetSuperAdminByEmail, GetTenantBySlug, GetTenantUserByEmail
    • *redis.Redis (field Client *goredis.Client)
    • *config.Config (fields JWTSecret string)
  • Produces:

    • auth.NewRedisStorage(client *goredis.Client) fiber.Storage — implements Fiber limiter Storage
    • auth.RateLimiter(storage fiber.Storage) fiber.Handler — 10 req/min per IP
    • auth.LoginHandler(repo *tenant.Repository, rdb *redis.Redis, cfg *config.Config) fiber.Handler
    • auth.RefreshHandler(rdb *redis.Redis, cfg *config.Config) fiber.Handler
    • auth.LogoutHandler(rdb *redis.Redis) fiber.Handler
    • auth.RegisterRoutes(app *fiber.App, repo *tenant.Repository, rdb *redis.Redis, cfg *config.Config)
    • Refresh token cookie name: "refresh_token" (httpOnly, Secure in prod, SameSite=Strict, Path=/api/v1/auth, MaxAge=30d)
  • Step 1: Write the failing tests

Create backend/internal/auth/handler_test.go:

package auth_test

import (
	"bytes"
	"context"
	"encoding/json"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/gofiber/fiber/v2"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/techxcar/backend/internal/auth"
	"github.com/techxcar/backend/internal/config"
	"github.com/techxcar/backend/internal/tenant"
)

// stubRepo is a minimal in-memory stub for testing auth handlers without a real DB.
type stubRepo struct {
	superAdmins map[string]*tenant.SuperAdmin
	tenants     map[string]*tenant.Tenant
	users       map[string]*tenant.TenantUser
}

func (s *stubRepo) GetSuperAdminByEmail(_ context.Context, email string) (*tenant.SuperAdmin, error) {
	a, _ := s.superAdmins[email]
	return a, nil
}

func (s *stubRepo) GetTenantBySlug(_ context.Context, slug string) (*tenant.Tenant, error) {
	t, _ := s.tenants[slug]
	return t, nil
}

func (s *stubRepo) GetTenantUserByEmail(_ context.Context, tenantID, email string) (*tenant.TenantUser, error) {
	key := tenantID + ":" + email
	u, _ := s.users[key]
	return u, nil
}

func newTestApp(repo auth.LoginRepository, cfg *config.Config) *fiber.App {
	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()})
	}})
	app.Post("/api/v1/auth/login", auth.LoginHandler(repo, nil, cfg))
	app.Post("/api/v1/auth/refresh", auth.RefreshHandler(nil, cfg))
	app.Post("/api/v1/auth/logout", auth.LogoutHandler(nil))
	return app
}

func TestLogin_superAdmin_success(t *testing.T) {
	hash, _ := auth.HashPassword("secret123")
	repo := &stubRepo{
		superAdmins: map[string]*tenant.SuperAdmin{
			"admin@example.com": {ID: "sa-1", Email: "admin@example.com", PasswordHash: hash},
		},
	}
	cfg := &config.Config{JWTSecret: testSecret}
	app := newTestApp(repo, cfg)

	body, _ := json.Marshal(map[string]string{"email": "admin@example.com", "password": "secret123"})
	req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")

	resp, err := app.Test(req)
	require.NoError(t, err)
	assert.Equal(t, 200, resp.StatusCode)

	var result map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
	data := result["data"].(map[string]any)
	assert.NotEmpty(t, data["access_token"])
}

func TestLogin_wrongPassword(t *testing.T) {
	hash, _ := auth.HashPassword("secret123")
	repo := &stubRepo{
		superAdmins: map[string]*tenant.SuperAdmin{
			"admin@example.com": {ID: "sa-1", Email: "admin@example.com", PasswordHash: hash},
		},
	}
	cfg := &config.Config{JWTSecret: testSecret}
	app := newTestApp(repo, cfg)

	body, _ := json.Marshal(map[string]string{"email": "admin@example.com", "password": "wrongpass"})
	req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")

	resp, err := app.Test(req)
	require.NoError(t, err)
	assert.Equal(t, 401, resp.StatusCode)
}

func TestLogin_tenantUser_success(t *testing.T) {
	hash, _ := auth.HashPassword("tenantpass")
	repo := &stubRepo{
		tenants: map[string]*tenant.Tenant{
			"my-workshop": {ID: "11111111-1111-1111-1111-111111111111", Slug: "my-workshop", Name: "My Workshop", Status: "active"},
		},
		users: map[string]*tenant.TenantUser{
			"11111111-1111-1111-1111-111111111111:user@workshop.com": {
				ID: "u-1", Email: "user@workshop.com", PasswordHash: hash,
				Role: "tenant_admin", Name: "User", Active: true,
			},
		},
	}
	cfg := &config.Config{JWTSecret: testSecret}
	app := newTestApp(repo, cfg)

	body, _ := json.Marshal(map[string]string{
		"email": "user@workshop.com", "password": "tenantpass", "tenant_slug": "my-workshop",
	})
	req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")

	resp, err := app.Test(req)
	require.NoError(t, err)
	assert.Equal(t, 200, resp.StatusCode)

	var result map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
	data := result["data"].(map[string]any)
	token := data["access_token"].(string)

	claims, err := auth.ValidateToken(token, testSecret)
	require.NoError(t, err)
	assert.Equal(t, "11111111-1111-1111-1111-111111111111", claims.TenantID)
	assert.Equal(t, "tenant_admin", claims.Role)
	_ = time.Now() // silence unused import
}

func TestLogin_unknownTenant(t *testing.T) {
	repo := &stubRepo{tenants: map[string]*tenant.Tenant{}}
	cfg := &config.Config{JWTSecret: testSecret}
	app := newTestApp(repo, cfg)

	body, _ := json.Marshal(map[string]string{
		"email": "user@workshop.com", "password": "pass", "tenant_slug": "nonexistent",
	})
	req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")

	resp, err := app.Test(req)
	require.NoError(t, err)
	assert.Equal(t, 401, resp.StatusCode)
}
  • Step 2: Run tests to confirm they fail
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
go test ./internal/auth/... -v -run TestLogin

Expected: FAIL — undefined: auth.LoginHandler

  • Step 3: Define LoginRepository interface + create handler.go

The LoginHandler receives an interface so tests can pass a stub without a DB.

Create backend/internal/auth/handler.go:

package auth

import (
	"context"
	"strings"
	"time"

	"github.com/gofiber/fiber/v2"
	goredis "github.com/redis/go-redis/v9"

	"github.com/techxcar/backend/internal/config"
	"github.com/techxcar/backend/internal/tenant"
	redispkg "github.com/techxcar/backend/pkg/redis"
)

// LoginRepository is a subset of tenant.Repository used by auth handlers.
type LoginRepository interface {
	GetSuperAdminByEmail(ctx context.Context, email string) (*tenant.SuperAdmin, error)
	GetTenantBySlug(ctx context.Context, slug string) (*tenant.Tenant, error)
	GetTenantUserByEmail(ctx context.Context, tenantID, email string) (*tenant.TenantUser, error)
}

const refreshCookieName = "refresh_token"
const refreshCookieTTL = 30 * 24 * time.Hour

func setRefreshCookie(c *fiber.Ctx, token string, cfg *config.Config) {
	c.Cookie(&fiber.Cookie{
		Name:     refreshCookieName,
		Value:    token,
		MaxAge:   int(refreshCookieTTL.Seconds()),
		HTTPOnly: true,
		Secure:   cfg.AppEnv == "production",
		SameSite: "Strict",
		Path:     "/api/v1/auth",
	})
}

type loginRequest struct {
	Email      string `json:"email"`
	Password   string `json:"password"`
	TenantSlug string `json:"tenant_slug"`
}

func LoginHandler(repo LoginRepository, rdb *redispkg.Redis, cfg *config.Config) fiber.Handler {
	return func(c *fiber.Ctx) error {
		var req loginRequest
		if err := c.BodyParser(&req); err != nil {
			return fiber.NewError(400, "corpo do pedido inválido")
		}
		req.Email = strings.TrimSpace(strings.ToLower(req.Email))
		if req.Email == "" || req.Password == "" {
			return fiber.NewError(400, "email e password são obrigatórios")
		}

		var userID, tenantID, role string

		if req.TenantSlug == "" {
			admin, err := repo.GetSuperAdminByEmail(c.Context(), req.Email)
			if err != nil || admin == nil || !VerifyPassword(req.Password, admin.PasswordHash) {
				return fiber.NewError(401, "credenciais inválidas")
			}
			userID, tenantID, role = admin.ID, "", "super_admin"
		} else {
			ten, err := repo.GetTenantBySlug(c.Context(), req.TenantSlug)
			if err != nil || ten == nil || ten.Status != "active" {
				return fiber.NewError(401, "credenciais inválidas")
			}
			user, err := repo.GetTenantUserByEmail(c.Context(), ten.ID, req.Email)
			if err != nil || user == nil || !user.Active || !VerifyPassword(req.Password, user.PasswordHash) {
				return fiber.NewError(401, "credenciais inválidas")
			}
			userID, tenantID, role = user.ID, ten.ID, user.Role
		}

		access, err := GenerateAccessToken(userID, tenantID, role, cfg.JWTSecret)
		if err != nil {
			return fiber.NewError(500, "erro ao gerar token")
		}
		refresh, err := GenerateRefreshToken(userID, tenantID, role, cfg.JWTSecret)
		if err != nil {
			return fiber.NewError(500, "erro ao gerar token")
		}

		if rdb != nil {
			ctx := c.Context()
			rdb.Client.Set(ctx, "refresh:"+userID, refresh, refreshCookieTTL)
		}

		setRefreshCookie(c, refresh, cfg)
		return c.JSON(fiber.Map{"data": fiber.Map{"access_token": access}, "error": nil})
	}
}

func RefreshHandler(rdb *redispkg.Redis, cfg *config.Config) fiber.Handler {
	return func(c *fiber.Ctx) error {
		refreshToken := c.Cookies(refreshCookieName)
		if refreshToken == "" {
			return fiber.NewError(401, "refresh token em falta")
		}
		claims, err := ValidateToken(refreshToken, cfg.JWTSecret)
		if err != nil {
			return fiber.NewError(401, "refresh token inválido ou expirado")
		}

		if rdb != nil {
			stored, err := rdb.Client.Get(c.Context(), "refresh:"+claims.UserID).Result()
			if err == goredis.Nil || stored != refreshToken {
				return fiber.NewError(401, "sessão inválida")
			}
		}

		access, err := GenerateAccessToken(claims.UserID, claims.TenantID, claims.Role, cfg.JWTSecret)
		if err != nil {
			return fiber.NewError(500, "erro ao renovar token")
		}
		newRefresh, err := GenerateRefreshToken(claims.UserID, claims.TenantID, claims.Role, cfg.JWTSecret)
		if err != nil {
			return fiber.NewError(500, "erro ao renovar token")
		}

		if rdb != nil {
			rdb.Client.Set(c.Context(), "refresh:"+claims.UserID, newRefresh, refreshCookieTTL)
		}
		setRefreshCookie(c, newRefresh, cfg)
		return c.JSON(fiber.Map{"data": fiber.Map{"access_token": access}, "error": nil})
	}
}

func LogoutHandler(rdb *redispkg.Redis) fiber.Handler {
	return func(c *fiber.Ctx) error {
		refreshToken := c.Cookies(refreshCookieName)
		if refreshToken != "" && rdb != nil {
			if claims, err := ValidateToken(refreshToken, ""); err == nil {
				rdb.Client.Del(c.Context(), "refresh:"+claims.UserID)
			}
		}
		c.Cookie(&fiber.Cookie{
			Name:     refreshCookieName,
			Value:    "",
			MaxAge:   -1,
			HTTPOnly: true,
			Path:     "/api/v1/auth",
		})
		return c.JSON(fiber.Map{"data": nil, "error": nil})
	}
}

Note: LogoutHandler calls ValidateToken without a secret — this will fail validation but we don't need to validate for logout; we just need the UserID from the claims. Fix: store UserID in a separate non-httpOnly cookie, or parse claims without validation. The simpler approach: skip Redis cleanup on logout (the token will expire naturally) and just clear the cookie. Replace the logout body:

func LogoutHandler(rdb *redispkg.Redis) fiber.Handler {
	return func(c *fiber.Ctx) error {
		c.Cookie(&fiber.Cookie{
			Name:     refreshCookieName,
			Value:    "",
			MaxAge:   -1,
			HTTPOnly: true,
			Path:     "/api/v1/auth",
		})
		return c.JSON(fiber.Map{"data": nil, "error": nil})
	}
}
  • Step 4: Create ratelimit.go

Create backend/internal/auth/ratelimit.go:

package auth

import (
	"context"
	"time"

	"github.com/gofiber/fiber/v2/middleware/limiter"
	goredis "github.com/redis/go-redis/v9"
)

type redisStorage struct {
	client *goredis.Client
}

func NewRedisStorage(client *goredis.Client) *redisStorage {
	return &redisStorage{client: client}
}

func (s *redisStorage) Get(key string) ([]byte, error) {
	val, err := s.client.Get(context.Background(), key).Bytes()
	if err == goredis.Nil {
		return nil, nil
	}
	return val, err
}

func (s *redisStorage) Set(key string, val []byte, exp time.Duration) error {
	return s.client.Set(context.Background(), key, val, exp).Err()
}

func (s *redisStorage) Delete(key string) error {
	return s.client.Del(context.Background(), key).Err()
}

func (s *redisStorage) Reset() error {
	return s.client.FlushDB(context.Background()).Err()
}

func (s *redisStorage) Close() error {
	return nil
}

func RateLimiter(storage *redisStorage) fiber.Handler {
	return limiter.New(limiter.Config{
		Max:        10,
		Expiration: 1 * time.Minute,
		KeyGenerator: func(c *fiber.Ctx) string {
			return "ratelimit:auth:" + c.IP()
		},
		Storage: storage,
		LimitReached: func(c *fiber.Ctx) error {
			return fiber.NewError(429, "muitas tentativas, tente novamente em 1 minuto")
		},
	})
}

Note: ratelimit.go uses fiber.Handler from gofiber — add the import: "github.com/gofiber/fiber/v2".

Updated ratelimit.go with import:

package auth

import (
	"context"
	"time"

	"github.com/gofiber/fiber/v2"
	"github.com/gofiber/fiber/v2/middleware/limiter"
	goredis "github.com/redis/go-redis/v9"
)

type redisStorage struct {
	client *goredis.Client
}

func NewRedisStorage(client *goredis.Client) *redisStorage {
	return &redisStorage{client: client}
}

func (s *redisStorage) Get(key string) ([]byte, error) {
	val, err := s.client.Get(context.Background(), key).Bytes()
	if err == goredis.Nil {
		return nil, nil
	}
	return val, err
}

func (s *redisStorage) Set(key string, val []byte, exp time.Duration) error {
	return s.client.Set(context.Background(), key, val, exp).Err()
}

func (s *redisStorage) Delete(key string) error {
	return s.client.Del(context.Background(), key).Err()
}

func (s *redisStorage) Reset() error {
	return s.client.FlushDB(context.Background()).Err()
}

func (s *redisStorage) Close() error {
	return nil
}

func RateLimiter(storage *redisStorage) fiber.Handler {
	return limiter.New(limiter.Config{
		Max:        10,
		Expiration: 1 * time.Minute,
		KeyGenerator: func(c *fiber.Ctx) string {
			return "ratelimit:auth:" + c.IP()
		},
		Storage: storage,
		LimitReached: func(c *fiber.Ctx) error {
			return fiber.NewError(429, "muitas tentativas, tente novamente em 1 minuto")
		},
	})
}
  • Step 5: Create routes.go

Create backend/internal/auth/routes.go:

package auth

import (
	"github.com/gofiber/fiber/v2"
	"github.com/techxcar/backend/internal/config"
	redispkg "github.com/techxcar/backend/pkg/redis"
)

func RegisterRoutes(app *fiber.App, repo LoginRepository, rdb *redispkg.Redis, cfg *config.Config) {
	storage := NewRedisStorage(rdb.Client)
	rateLimiter := RateLimiter(storage)

	auth := app.Group("/api/v1/auth")
	auth.Post("/login", rateLimiter, LoginHandler(repo, rdb, cfg))
	auth.Post("/refresh", rateLimiter, RefreshHandler(rdb, cfg))
	auth.Post("/logout", LogoutHandler(rdb))
}
  • Step 6: Run tests
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
go test ./internal/auth/... -v -run TestLogin

Expected:

=== RUN   TestLogin_superAdmin_success
--- PASS: TestLogin_superAdmin_success
=== RUN   TestLogin_wrongPassword
--- PASS: TestLogin_wrongPassword
=== RUN   TestLogin_tenantUser_success
--- PASS: TestLogin_tenantUser_success
=== RUN   TestLogin_unknownTenant
--- PASS: TestLogin_unknownTenant
PASS
  • Step 7: Run all auth tests
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
go test ./internal/auth/... -v
go build ./...

Expected: all tests PASS, build success.

  • Step 8: Commit
cd /var/home/lmilani/Documentos/IDE/techxcar
git add backend/internal/auth/
git commit -m "feat: auth handlers — login/refresh/logout with Redis rate limiting and refresh token storage"

Task 4: Auth middleware — JWT validation + tenant schema injection

Files:

  • Create: backend/internal/auth/middleware.go
  • Create: backend/internal/auth/middleware_test.go

Interfaces:

  • Consumes: auth.ValidateToken (Task 1), *database.DB (Task 2 dependency)

  • Produces:

    • auth.RequireAuth(secret string) fiber.Handler — validates Bearer token, stores *Claims in c.Locals("claims")
    • auth.RequireRole(roles ...string) fiber.Handler — checks claims.Role is in roles
    • auth.TenantMiddleware(db *database.DB) fiber.Handler — acquires pgxpool.Conn, sets search_path, stores conn in c.Locals("conn"), releases after handler chain
  • Step 1: Write the failing tests

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/stretchr/testify/require"

	"github.com/techxcar/backend/internal/auth"
)

func TestRequireAuth_missingHeader(t *testing.T) {
	app := fiber.New()
	app.Get("/protected", auth.RequireAuth(testSecret), func(c *fiber.Ctx) error {
		return c.SendString("ok")
	})

	req := httptest.NewRequest("GET", "/protected", nil)
	resp, err := app.Test(req)
	require.NoError(t, err)
	assert.Equal(t, 401, resp.StatusCode)
}

func TestRequireAuth_validToken(t *testing.T) {
	token, _ := auth.GenerateAccessToken("user-1", "tenant-1", "manager", testSecret)

	app := fiber.New()
	app.Get("/protected", auth.RequireAuth(testSecret), func(c *fiber.Ctx) error {
		claims := c.Locals("claims").(*auth.Claims)
		return c.SendString(claims.UserID)
	})

	req := httptest.NewRequest("GET", "/protected", nil)
	req.Header.Set("Authorization", "Bearer "+token)
	resp, err := app.Test(req)
	require.NoError(t, err)
	assert.Equal(t, 200, resp.StatusCode)
}

func TestRequireAuth_invalidToken(t *testing.T) {
	app := fiber.New()
	app.Get("/protected", auth.RequireAuth(testSecret), func(c *fiber.Ctx) error {
		return c.SendString("ok")
	})

	req := httptest.NewRequest("GET", "/protected", nil)
	req.Header.Set("Authorization", "Bearer invalid.token.here")
	resp, err := app.Test(req)
	require.NoError(t, err)
	assert.Equal(t, 401, resp.StatusCode)
}

func TestRequireRole_allowed(t *testing.T) {
	token, _ := auth.GenerateAccessToken("user-1", "", "super_admin", testSecret)

	app := fiber.New()
	app.Get("/admin",
		auth.RequireAuth(testSecret),
		auth.RequireRole("super_admin"),
		func(c *fiber.Ctx) error { return c.SendString("ok") },
	)

	req := httptest.NewRequest("GET", "/admin", nil)
	req.Header.Set("Authorization", "Bearer "+token)
	resp, err := app.Test(req)
	require.NoError(t, err)
	assert.Equal(t, 200, resp.StatusCode)
}

func TestRequireRole_forbidden(t *testing.T) {
	token, _ := auth.GenerateAccessToken("user-1", "t-1", "technician", testSecret)

	app := fiber.New()
	app.Get("/admin",
		auth.RequireAuth(testSecret),
		auth.RequireRole("super_admin", "tenant_admin"),
		func(c *fiber.Ctx) error { return c.SendString("ok") },
	)

	req := httptest.NewRequest("GET", "/admin", nil)
	req.Header.Set("Authorization", "Bearer "+token)
	resp, err := app.Test(req)
	require.NoError(t, err)
	assert.Equal(t, 403, resp.StatusCode)
}
  • Step 2: Run tests to confirm they fail
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
go test ./internal/auth/... -v -run "TestRequire"

Expected: FAIL — undefined: auth.RequireAuth

  • Step 3: Create middleware.go

Create backend/internal/auth/middleware.go:

package auth

import (
	"strings"

	"github.com/gofiber/fiber/v2"
	"github.com/techxcar/backend/pkg/database"
)

func RequireAuth(secret string) fiber.Handler {
	return func(c *fiber.Ctx) error {
		authHeader := c.Get("Authorization")
		if authHeader == "" {
			return fiber.NewError(401, "autenticação necessária")
		}
		parts := strings.SplitN(authHeader, " ", 2)
		if len(parts) != 2 || parts[0] != "Bearer" {
			return fiber.NewError(401, "formato de autorização inválido")
		}
		claims, err := ValidateToken(parts[1], secret)
		if err != nil {
			return fiber.NewError(401, "token inválido ou expirado")
		}
		c.Locals("claims", claims)
		return c.Next()
	}
}

func RequireRole(roles ...string) fiber.Handler {
	return func(c *fiber.Ctx) error {
		claims, ok := c.Locals("claims").(*Claims)
		if !ok {
			return fiber.NewError(401, "autenticação necessária")
		}
		for _, role := range roles {
			if claims.Role == role {
				return c.Next()
			}
		}
		return fiber.NewError(403, "acesso não autorizado")
	}
}

// TenantMiddleware acquires a dedicated pgxpool connection per request,
// sets the tenant search_path, stores the connection in c.Locals("conn"),
// and releases the connection after the handler chain completes.
func TenantMiddleware(db *database.DB) fiber.Handler {
	return func(c *fiber.Ctx) error {
		claims, ok := c.Locals("claims").(*Claims)
		if !ok || claims.TenantID == "" {
			return c.Next()
		}
		conn, err := db.Pool.Acquire(c.Context())
		if err != nil {
			return fiber.NewError(500, "erro interno ao adquirir conexão")
		}
		schema := "tenant_" + claims.TenantID
		if _, err := conn.Exec(c.Context(), "SET search_path = "+schema+", public"); err != nil {
			conn.Release()
			return fiber.NewError(500, "erro interno ao definir schema")
		}
		c.Locals("conn", conn)
		err = c.Next()
		conn.Release()
		return err
	}
}
  • Step 4: Run all auth tests
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
go test ./internal/auth/... -v
go build ./...

Expected: all tests PASS (including jwt, bcrypt, handler, middleware), build success.

  • Step 5: Commit
cd /var/home/lmilani/Documentos/IDE/techxcar
git add backend/internal/auth/middleware.go backend/internal/auth/middleware_test.go
git commit -m "feat: auth middleware — JWT validation, role guard and tenant schema injection"

Task 5: Tenant handlers — super-admin CRUD + invite generation + public invite redemption

Files:

  • Create: backend/internal/tenant/handler.go
  • Create: backend/internal/tenant/handler_test.go
  • Create: backend/internal/tenant/routes.go

Interfaces:

  • Consumes:
    • tenant.Repository (Task 2): all methods
    • *database.DB: ProvisionTenantSchema(ctx, dsn, tenantID, migrationsPath)
    • *config.Config: DatabaseURL, JWTSecret
    • auth.RequireAuth, auth.RequireRole, auth.TenantMiddleware (Task 3-4)
    • auth.HashPassword, auth.GenerateAccessToken, auth.GenerateRefreshToken (Task 1)
    • auth.refreshCookieName, auth.setRefreshCookie (Task 3) — these are unexported; expose auth.SetRefreshCookie instead
  • Produces:
    • GET /api/v1/admin/tenants[]*Tenant — super_admin only
    • POST /api/v1/admin/tenants*Tenant — super_admin only; body: {name, slug, admin_email, admin_password, admin_name}
    • POST /api/v1/admin/tenants/:id/invite{token, url} — super_admin only
    • GET /api/v1/invites/:token → invite details — public
    • POST /api/v1/invites/:token/redeem{access_token} — public; body: {tenant_name, tenant_slug, admin_email, admin_password, admin_name}
    • tenant.RegisterRoutes(app, repo, db, cfg) — wires all tenant routes with auth guards

Note: before implementing, update auth/handler.go to export SetRefreshCookie so tenant/handler.go can use it without importing from auth and creating a cycle. Since both packages are separate, tenant can import auth without a cycle.

  • Step 1: Export SetRefreshCookie in auth/handler.go

In backend/internal/auth/handler.go, rename setRefreshCookie to SetRefreshCookie (capital S) and update all internal callers:

func SetRefreshCookie(c *fiber.Ctx, token string, cfg *config.Config) {
	c.Cookie(&fiber.Cookie{
		Name:     refreshCookieName,
		Value:    token,
		MaxAge:   int(refreshCookieTTL.Seconds()),
		HTTPOnly: true,
		Secure:   cfg.AppEnv == "production",
		SameSite: "Strict",
		Path:     "/api/v1/auth",
	})
}

Update the two callers in handler.go from setRefreshCookie(...) to SetRefreshCookie(...).

  • Step 2: Write the failing tests

Create backend/internal/tenant/handler_test.go:

package tenant_test

import (
	"bytes"
	"encoding/json"
	"net/http/httptest"
	"testing"

	"github.com/gofiber/fiber/v2"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/techxcar/backend/internal/auth"
	"github.com/techxcar/backend/internal/config"
	"github.com/techxcar/backend/internal/tenant"
)

const handlerTestSecret = "test-secret-32-chars-minimum-ok!"

func buildAdminApp(repo *tenant.Repository, cfg *config.Config) *fiber.App {
	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()})
	}})
	tenant.RegisterRoutes(app, repo, nil, cfg)
	return app
}

func adminToken(t *testing.T, secret string) string {
	t.Helper()
	tok, err := auth.GenerateAccessToken("sa-1", "", "super_admin", secret)
	require.NoError(t, err)
	return tok
}

func TestListTenants_requiresAuth(t *testing.T) {
	db := setupDB(t)
	repo := tenant.NewRepository(db)
	cfg := &config.Config{JWTSecret: handlerTestSecret}
	app := buildAdminApp(repo, cfg)

	req := httptest.NewRequest("GET", "/api/v1/admin/tenants", nil)
	resp, err := app.Test(req)
	require.NoError(t, err)
	assert.Equal(t, 401, resp.StatusCode)
}

func TestListTenants_success(t *testing.T) {
	db := setupDB(t)
	repo := tenant.NewRepository(db)
	cfg := &config.Config{JWTSecret: handlerTestSecret}
	app := buildAdminApp(repo, cfg)

	req := httptest.NewRequest("GET", "/api/v1/admin/tenants", nil)
	req.Header.Set("Authorization", "Bearer "+adminToken(t, handlerTestSecret))
	resp, err := app.Test(req)
	require.NoError(t, err)
	assert.Equal(t, 200, resp.StatusCode)

	var result map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
	assert.Nil(t, result["error"])
}

func TestGetInvite_notFound(t *testing.T) {
	db := setupDB(t)
	repo := tenant.NewRepository(db)
	cfg := &config.Config{JWTSecret: handlerTestSecret}
	app := buildAdminApp(repo, cfg)

	req := httptest.NewRequest("GET", "/api/v1/invites/nonexistent-token", nil)
	resp, err := app.Test(req)
	require.NoError(t, err)
	assert.Equal(t, 404, resp.StatusCode)
}
  • Step 3: Run tests to confirm they fail
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
go test ./internal/tenant/... -v

Expected: FAIL — undefined: tenant.RegisterRoutes

  • Step 4: Create handler.go

Create backend/internal/tenant/handler.go:

package tenant

import (
	"time"

	"github.com/gofiber/fiber/v2"

	"github.com/techxcar/backend/internal/auth"
	"github.com/techxcar/backend/internal/config"
	"github.com/techxcar/backend/pkg/database"
)

func listTenantsHandler(repo *Repository) fiber.Handler {
	return func(c *fiber.Ctx) error {
		list, err := repo.ListTenants(c.Context())
		if err != nil {
			return fiber.NewError(500, "erro ao listar oficinas")
		}
		if list == nil {
			list = []*Tenant{}
		}
		return c.JSON(fiber.Map{"data": list, "error": nil})
	}
}

type createTenantRequest struct {
	Name          string `json:"name"`
	Slug          string `json:"slug"`
	AdminEmail    string `json:"admin_email"`
	AdminPassword string `json:"admin_password"`
	AdminName     string `json:"admin_name"`
}

func createTenantHandler(repo *Repository, db *database.DB, cfg *config.Config) fiber.Handler {
	return func(c *fiber.Ctx) error {
		var req createTenantRequest
		if err := c.BodyParser(&req); err != nil {
			return fiber.NewError(400, "corpo do pedido inválido")
		}
		if req.Name == "" || req.Slug == "" || req.AdminEmail == "" || req.AdminPassword == "" || req.AdminName == "" {
			return fiber.NewError(400, "todos os campos são obrigatórios")
		}
		existing, err := repo.GetTenantBySlug(c.Context(), req.Slug)
		if err != nil {
			return fiber.NewError(500, "erro interno")
		}
		if existing != nil {
			return fiber.NewError(409, "slug já existe")
		}

		ten, err := repo.CreateTenant(c.Context(), req.Slug, req.Name)
		if err != nil {
			return fiber.NewError(500, "erro ao criar oficina")
		}

		if db != nil {
			if err := db.ProvisionTenantSchema(c.Context(), cfg.DatabaseURL, ten.ID, "migrations/tenant"); err != nil {
				return fiber.NewError(500, "erro ao provisionar schema")
			}
		}

		hash, err := auth.HashPassword(req.AdminPassword)
		if err != nil {
			return fiber.NewError(500, "erro interno")
		}
		if db != nil {
			if _, err := repo.CreateTenantUser(c.Context(), ten.ID, req.AdminEmail, hash, req.AdminName, "tenant_admin"); err != nil {
				return fiber.NewError(500, "erro ao criar utilizador admin")
			}
		}

		return c.Status(201).JSON(fiber.Map{"data": ten, "error": nil})
	}
}

func generateInviteHandler(repo *Repository) fiber.Handler {
	return func(c *fiber.Ctx) error {
		tenantID := c.Params("id")
		ten, err := repo.GetTenantByID(c.Context(), tenantID)
		if err != nil || ten == nil {
			return fiber.NewError(404, "oficina não encontrada")
		}

		invite, err := repo.CreateInvite(c.Context(), &ten.ID, 72*time.Hour)
		if err != nil {
			return fiber.NewError(500, "erro ao gerar convite")
		}

		return c.Status(201).JSON(fiber.Map{
			"data": fiber.Map{
				"token":      invite.Token,
				"expires_at": invite.ExpiresAt,
			},
			"error": nil,
		})
	}
}

func generatePlatformInviteHandler(repo *Repository) fiber.Handler {
	return func(c *fiber.Ctx) error {
		invite, err := repo.CreateInvite(c.Context(), nil, 72*time.Hour)
		if err != nil {
			return fiber.NewError(500, "erro ao gerar convite")
		}
		return c.Status(201).JSON(fiber.Map{
			"data":  fiber.Map{"token": invite.Token, "expires_at": invite.ExpiresAt},
			"error": nil,
		})
	}
}

func getInviteHandler(repo *Repository) fiber.Handler {
	return func(c *fiber.Ctx) error {
		token := c.Params("token")
		invite, err := repo.GetInviteByToken(c.Context(), token)
		if err != nil {
			return fiber.NewError(500, "erro interno")
		}
		if invite == nil {
			return fiber.NewError(404, "convite não encontrado")
		}
		if invite.UsedAt != nil {
			return fiber.NewError(410, "convite já foi utilizado")
		}
		if invite.ExpiresAt.Before(time.Now()) {
			return fiber.NewError(410, "convite expirado")
		}
		return c.JSON(fiber.Map{"data": invite, "error": nil})
	}
}

type redeemRequest struct {
	TenantName    string `json:"tenant_name"`
	TenantSlug    string `json:"tenant_slug"`
	AdminEmail    string `json:"admin_email"`
	AdminPassword string `json:"admin_password"`
	AdminName     string `json:"admin_name"`
}

func redeemInviteHandler(repo *Repository, db *database.DB, cfg *config.Config) fiber.Handler {
	return func(c *fiber.Ctx) error {
		token := c.Params("token")
		invite, err := repo.GetInviteByToken(c.Context(), token)
		if err != nil || invite == nil {
			return fiber.NewError(404, "convite não encontrado")
		}
		if invite.UsedAt != nil || invite.ExpiresAt.Before(time.Now()) {
			return fiber.NewError(410, "convite inválido ou expirado")
		}

		var req redeemRequest
		if err := c.BodyParser(&req); err != nil {
			return fiber.NewError(400, "corpo do pedido inválido")
		}
		if req.TenantName == "" || req.TenantSlug == "" || req.AdminEmail == "" || req.AdminPassword == "" || req.AdminName == "" {
			return fiber.NewError(400, "todos os campos são obrigatórios")
		}
		if len(req.AdminPassword) < 8 {
			return fiber.NewError(400, "password deve ter pelo menos 8 caracteres")
		}

		existing, _ := repo.GetTenantBySlug(c.Context(), req.TenantSlug)
		if existing != nil {
			return fiber.NewError(409, "slug já existe")
		}

		ten, err := repo.CreateTenant(c.Context(), req.TenantSlug, req.TenantName)
		if err != nil {
			return fiber.NewError(500, "erro ao criar oficina")
		}

		if db != nil {
			if err := db.ProvisionTenantSchema(c.Context(), cfg.DatabaseURL, ten.ID, "migrations/tenant"); err != nil {
				return fiber.NewError(500, "erro ao provisionar schema")
			}
		}

		hash, err := auth.HashPassword(req.AdminPassword)
		if err != nil {
			return fiber.NewError(500, "erro interno")
		}

		var userID string
		if db != nil {
			user, err := repo.CreateTenantUser(c.Context(), ten.ID, req.AdminEmail, hash, req.AdminName, "tenant_admin")
			if err != nil {
				return fiber.NewError(500, "erro ao criar utilizador")
			}
			userID = user.ID
		} else {
			userID = "mock-user-id"
		}

		if err := repo.UseInvite(c.Context(), invite.ID); err != nil {
			return fiber.NewError(500, "erro ao registar utilização do convite")
		}

		access, _ := auth.GenerateAccessToken(userID, ten.ID, "tenant_admin", cfg.JWTSecret)
		refresh, _ := auth.GenerateRefreshToken(userID, ten.ID, "tenant_admin", cfg.JWTSecret)
		auth.SetRefreshCookie(c, refresh, cfg)

		return c.Status(201).JSON(fiber.Map{
			"data": fiber.Map{
				"access_token": access,
				"tenant_slug":  ten.Slug,
			},
			"error": nil,
		})
	}
}
  • Step 5: Create routes.go

Create backend/internal/tenant/routes.go:

package tenant

import (
	"github.com/gofiber/fiber/v2"
	"github.com/techxcar/backend/internal/auth"
	"github.com/techxcar/backend/internal/config"
	"github.com/techxcar/backend/pkg/database"
)

func RegisterRoutes(app *fiber.App, repo *Repository, db *database.DB, cfg *config.Config) {
	// Public invite routes
	invites := app.Group("/api/v1/invites")
	invites.Get("/:token", getInviteHandler(repo))
	invites.Post("/:token/redeem", redeemInviteHandler(repo, db, cfg))

	// Super-admin routes
	admin := app.Group("/api/v1/admin",
		auth.RequireAuth(cfg.JWTSecret),
		auth.RequireRole("super_admin"),
	)
	admin.Get("/tenants", listTenantsHandler(repo))
	admin.Post("/tenants", createTenantHandler(repo, db, cfg))
	admin.Post("/tenants/:id/invite", generateInviteHandler(repo))
	admin.Post("/invites", generatePlatformInviteHandler(repo))
}
  • Step 6: Run all tests
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
go test ./... -v
go build ./...

Expected: all non-integration tests PASS (integration tests skip without TEST_DATABASE_URL), build success.

  • Step 7: Commit
cd /var/home/lmilani/Documentos/IDE/techxcar
git add backend/internal/tenant/ backend/internal/auth/handler.go
git commit -m "feat: tenant handlers — super-admin CRUD, invite generation, and public invite redemption"

Task 6: Config update + Server wiring + main.go + seed initial super-admin

Files:

  • Modify: backend/internal/config/config.go
  • Modify: backend/internal/config/config_test.go
  • Modify: backend/internal/server/server.go
  • Modify: backend/cmd/server/main.go
  • Modify: .env.example

Interfaces:

  • Consumes: all backend packages from Tasks 1-5

  • Produces: running server with auth + tenant routes wired; INITIAL_ADMIN_EMAIL + INITIAL_ADMIN_PASSWORD env vars seed a super_admin on first run

  • Step 1: Update config.go to add optional initial admin env vars

In backend/internal/config/config.go, add two optional fields to Config and load them in Load():

type Config struct {
	DatabaseURL          string
	RedisURL             string
	JWTSecret            string
	Port                 string
	AppEnv               string
	InitialAdminEmail    string // optional — seeds super_admin on first run
	InitialAdminPassword string // optional
}

At the end of Load(), before the return:

	cfg.InitialAdminEmail = os.Getenv("INITIAL_ADMIN_EMAIL")
	cfg.InitialAdminPassword = os.Getenv("INITIAL_ADMIN_PASSWORD")

	return &Config{
		DatabaseURL:          dbURL,
		RedisURL:             redisURL,
		JWTSecret:            jwtSecret,
		Port:                 port,
		AppEnv:               appEnv,
		InitialAdminEmail:    os.Getenv("INITIAL_ADMIN_EMAIL"),
		InitialAdminPassword: os.Getenv("INITIAL_ADMIN_PASSWORD"),
	}, nil

The struct literal at the end of Load() should include the two new fields. Replace the existing return &Config{...} block:

	return &Config{
		DatabaseURL:          dbURL,
		RedisURL:             redisURL,
		JWTSecret:            jwtSecret,
		Port:                 port,
		AppEnv:               appEnv,
		InitialAdminEmail:    os.Getenv("INITIAL_ADMIN_EMAIL"),
		InitialAdminPassword: os.Getenv("INITIAL_ADMIN_PASSWORD"),
	}, nil
  • Step 2: Run existing config tests to confirm nothing broke
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
go test ./internal/config/... -v

Expected: all 4 tests PASS.

  • Step 3: Update server.go to accept Deps and wire routes

Replace backend/internal/server/server.go entirely:

package server

import (
	"github.com/gofiber/fiber/v2"
	"github.com/gofiber/fiber/v2/middleware/cors"
	"github.com/gofiber/fiber/v2/middleware/helmet"
	"github.com/gofiber/fiber/v2/middleware/logger"
	"github.com/gofiber/fiber/v2/middleware/recover"

	"github.com/techxcar/backend/internal/auth"
	"github.com/techxcar/backend/internal/config"
	"github.com/techxcar/backend/internal/tenant"
	"github.com/techxcar/backend/pkg/database"
	redispkg "github.com/techxcar/backend/pkg/redis"
)

type Deps struct {
	Config *config.Config
	DB     *database.DB
	Redis  *redispkg.Redis
}

func New(deps Deps) *fiber.App {
	app := fiber.New(fiber.Config{
		AppName:      "TechXCar API",
		ErrorHandler: errorHandler,
	})

	app.Use(recover.New())
	app.Use(logger.New())
	app.Use(helmet.New())
	app.Use(cors.New(cors.Config{
		AllowOrigins:     "*",
		AllowHeaders:     "Origin, Content-Type, Accept, Authorization",
		AllowMethods:     "GET, POST, PUT, PATCH, DELETE, OPTIONS",
		AllowCredentials: true,
	}))

	RegisterHealthRoutes(app)

	if deps.DB != nil && deps.Redis != nil && deps.Config != nil {
		repo := tenant.NewRepository(deps.DB)
		auth.RegisterRoutes(app, repo, deps.Redis, deps.Config)
		tenant.RegisterRoutes(app, repo, deps.DB, deps.Config)
	}

	return app
}

func errorHandler(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(),
	})
}
  • Step 4: Run health tests to confirm they still pass

Health tests create fiber.New() directly and call server.RegisterHealthRoutes() — they do NOT use server.New(), so changing its signature doesn't break them.

cd /var/home/lmilani/Documentos/IDE/techxcar/backend
go test ./internal/server/... -v

Expected: 2 PASS.

  • Step 5: Update main.go

Replace backend/cmd/server/main.go entirely:

package main

import (
	"context"
	"log"
	"time"

	"github.com/joho/godotenv"

	"github.com/techxcar/backend/internal/auth"
	"github.com/techxcar/backend/internal/config"
	"github.com/techxcar/backend/internal/server"
	"github.com/techxcar/backend/internal/tenant"
	"github.com/techxcar/backend/pkg/database"
	redispkg "github.com/techxcar/backend/pkg/redis"
)

func main() {
	if err := godotenv.Load(); err != nil {
		log.Println("No .env file found, reading from environment")
	}

	cfg, err := config.Load()
	if err != nil {
		log.Fatal("Config error:", err)
	}

	db, err := database.New(cfg.DatabaseURL)
	if err != nil {
		log.Fatal("Database error:", err)
	}
	defer db.Close()

	if err := db.MigratePublic(cfg.DatabaseURL, "migrations/public"); err != nil {
		log.Fatal("Migration error:", err)
	}

	rdb, err := redispkg.New(cfg.RedisURL)
	if err != nil {
		log.Fatal("Redis error:", err)
	}
	defer rdb.Close()

	seedSuperAdmin(db, rdb, cfg)

	app := server.New(server.Deps{Config: cfg, DB: db, Redis: rdb})

	log.Printf("TechXCar API v0.2.0 listening on :%s (env: %s)", cfg.Port, cfg.AppEnv)
	log.Fatal(app.Listen(":" + cfg.Port))
}

func seedSuperAdmin(db *database.DB, rdb *redispkg.Redis, cfg *config.Config) {
	if cfg.InitialAdminEmail == "" || cfg.InitialAdminPassword == "" {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	repo := tenant.NewRepository(db)
	existing, err := repo.GetSuperAdminByEmail(ctx, cfg.InitialAdminEmail)
	if err != nil || existing != nil {
		return
	}
	hash, err := auth.HashPassword(cfg.InitialAdminPassword)
	if err != nil {
		log.Printf("Warning: could not hash initial admin password: %v", err)
		return
	}
	if _, err := repo.CreateSuperAdmin(ctx, cfg.InitialAdminEmail, hash); err != nil {
		log.Printf("Warning: could not create initial super admin: %v", err)
		return
	}
	log.Printf("Initial super admin created: %s", cfg.InitialAdminEmail)
}
  • Step 6: Update .env.example

Add to backend/.env.example (or root .env.example — it's in the root):

Open /var/home/lmilani/Documentos/IDE/techxcar/.env.example and append:

# Initial super-admin (only used on first startup, skipped if already exists)
INITIAL_ADMIN_EMAIL=admin@techxcar.com
INITIAL_ADMIN_PASSWORD=change-me-on-first-login
  • Step 7: Full build and test
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
go build ./...
go test ./... -v

Expected: build success, all non-integration tests PASS.

  • Step 8: Commit
cd /var/home/lmilani/Documentos/IDE/techxcar
git add backend/internal/config/ backend/internal/server/ backend/cmd/ .env.example
git commit -m "feat: wire auth and tenant routes into server, add optional super-admin seeding on startup"

Task 7: Frontend — real login page

Files:

  • Modify: frontend/src/pages/auth/LoginPage.tsx
  • Create: frontend/src/hooks/useAuth.ts

Interfaces:

  • Consumes: apiFetch from @/lib/api, useAuthStore from @/store/authStore, Button and Input and Label from @/components/ui/*

  • Produces:

    • useLogin() hook returning { mutate, isPending, error }
    • useLogout() hook
    • LoginPage component: email + password + optional tenant_slug fields, React Hook Form + Zod validation, redirects to /app (tenant) or /admin (super_admin) on success
  • Step 1: Write the failing test

Add to frontend/src/store/authStore.test.ts (this file already exists and passes):

// No new tests needed — the hook itself will be tested via the component in a later plan.
// Verify the hook compiles by running: npm run build
  • Step 2: Create useAuth.ts

Create frontend/src/hooks/useAuth.ts:

import { useMutation } from '@tanstack/react-query'
import { useNavigate } from 'react-router'
import { apiFetch } from '@/lib/api'
import { useAuthStore, type AuthUser } from '@/store/authStore'

interface LoginRequest {
  email: string
  password: string
  tenant_slug?: string
}

interface LoginResponse {
  access_token: string
  user: AuthUser
}

export function useLogin() {
  const { setAuth } = useAuthStore()
  const navigate = useNavigate()

  return useMutation({
    mutationFn: async (data: LoginRequest) => {
      return apiFetch<LoginResponse>('/auth/login', {
        method: 'POST',
        body: JSON.stringify(data),
      })
    },
    onSuccess: (data) => {
      setAuth(data.user, data.access_token)
      if (data.user.role === 'super_admin') {
        navigate('/admin', { replace: true })
      } else {
        navigate('/app', { replace: true })
      }
    },
  })
}

export function useLogout() {
  const { clearAuth } = useAuthStore()
  const navigate = useNavigate()

  return useMutation({
    mutationFn: async () => {
      await apiFetch('/auth/logout', { method: 'POST' })
    },
    onSettled: () => {
      clearAuth()
      navigate('/login', { replace: true })
    },
  })
}

Note: the login response needs user in the data envelope. Update LoginHandler in backend/internal/auth/handler.go to return user info alongside the token:

In LoginHandler, replace the final return c.JSON(...) calls to include user info:

For super_admin:

return c.JSON(fiber.Map{"data": fiber.Map{
    "access_token": access,
    "user": fiber.Map{
        "id": admin.ID, "email": admin.Email,
        "name": admin.Email, "role": "super_admin",
    },
}, "error": nil})

For tenant user:

return c.JSON(fiber.Map{"data": fiber.Map{
    "access_token": access,
    "user": fiber.Map{
        "id": user.ID, "email": user.Email,
        "name": user.Name, "role": user.Role,
        "tenantId": ten.ID,
    },
}, "error": nil})

Also update TestLogin_superAdmin_success and TestLogin_tenantUser_success in handler_test.go to check for user field in response.

  • Step 3: Replace LoginPage.tsx

Replace frontend/src/pages/auth/LoginPage.tsx:

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useLogin } from '@/hooks/useAuth'

const schema = z.object({
  email: z.string().email('Email inválido'),
  password: z.string().min(1, 'Password obrigatória'),
  tenant_slug: z.string().optional(),
})
type FormData = z.infer<typeof schema>

export default function LoginPage() {
  const [showSlug, setShowSlug] = useState(false)
  const { mutate: login, isPending, error } = useLogin()

  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
  })

  const onSubmit = (data: FormData) => {
    login({
      email: data.email,
      password: data.password,
      tenant_slug: data.tenant_slug || undefined,
    })
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="w-full max-w-sm">
        <div className="text-center mb-8">
          <h1 className="text-3xl font-bold text-gray-900">TechXCar</h1>
          <p className="text-gray-500 mt-1 text-sm">Gestão de Oficina</p>
        </div>

        <form onSubmit={handleSubmit(onSubmit)} className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 space-y-4">
          <div className="space-y-1.5">
            <Label htmlFor="email">Email</Label>
            <Input id="email" type="email" autoComplete="email" {...register('email')} />
            {errors.email && <p className="text-red-500 text-xs">{errors.email.message}</p>}
          </div>

          <div className="space-y-1.5">
            <Label htmlFor="password">Password</Label>
            <Input id="password" type="password" autoComplete="current-password" {...register('password')} />
            {errors.password && <p className="text-red-500 text-xs">{errors.password.message}</p>}
          </div>

          {showSlug && (
            <div className="space-y-1.5">
              <Label htmlFor="tenant_slug">Workspace (slug da oficina)</Label>
              <Input id="tenant_slug" type="text" placeholder="minha-oficina" {...register('tenant_slug')} />
            </div>
          )}

          {error && (
            <p className="text-red-500 text-sm text-center">{error.message}</p>
          )}

          <Button type="submit" className="w-full" disabled={isPending}>
            {isPending ? 'A entrar...' : 'Entrar'}
          </Button>

          <button
            type="button"
            onClick={() => setShowSlug(v => !v)}
            className="w-full text-xs text-gray-400 hover:text-gray-600 text-center"
          >
            {showSlug ? 'Ocultar campo workspace' : 'Entrar numa oficina específica'}
          </button>
        </form>
      </div>
    </div>
  )
}
  • Step 4: Build to verify no TypeScript errors
cd /var/home/lmilani/Documentos/IDE/techxcar/frontend
npm run build

Expected: build success, zero TypeScript errors.

  • Step 5: Run frontend tests
cd /var/home/lmilani/Documentos/IDE/techxcar/frontend
npm run test:run

Expected: 4 PASS (authStore tests).

  • Step 6: Commit
cd /var/home/lmilani/Documentos/IDE/techxcar
git add frontend/src/pages/auth/ frontend/src/hooks/
git commit -m "feat: login page with React Hook Form + Zod validation and useLogin/useLogout hooks"

Task 8: Frontend — super-admin tenants panel

Files:

  • Modify: frontend/src/App.tsx — add /admin/tenants route
  • Modify: frontend/src/pages/admin/DashboardPage.tsx — add nav link to tenants
  • Create: frontend/src/pages/admin/TenantsPage.tsx
  • Create: frontend/src/components/layout/AdminLayout.tsx — add sidebar nav (replaces placeholder)

Interfaces:

  • Consumes: apiFetch from @/lib/api, TanStack Query, Button, Input, Badge from @/components/ui/*

  • Produces:

    • GET /api/v1/admin/tenants — fetched via TanStack Query ['admin', 'tenants']
    • POST /api/v1/admin/invites — mutation to generate platform invite
    • TenantsPage — table of tenants + "Gerar Convite" button that shows the invite token
  • Step 1: Add tenant types and API hooks

Create frontend/src/lib/types.ts:

export interface Tenant {
  id: string
  slug: string
  name: string
  status: 'active' | 'suspended' | 'pending'
  created_at: string
}

export interface Invite {
  id: string
  token: string
  tenant_id: string | null
  expires_at: string
  used_at: string | null
  created_at: string
}
  • Step 2: Create TenantsPage.tsx

Create frontend/src/pages/admin/TenantsPage.tsx:

import { useState } from 'react'
import { useQuery, useMutation } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { queryClient } from '@/lib/queryClient'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import type { Tenant, Invite } from '@/lib/types'

function statusVariant(status: string) {
  if (status === 'active') return 'success'
  if (status === 'suspended') return 'destructive'
  return 'secondary'
}

export default function TenantsPage() {
  const [inviteToken, setInviteToken] = useState<string | null>(null)

  const { data: tenants = [], isLoading } = useQuery<Tenant[]>({
    queryKey: ['admin', 'tenants'],
    queryFn: () => apiFetch<Tenant[]>('/admin/tenants'),
  })

  const generateInvite = useMutation({
    mutationFn: () => apiFetch<Invite>('/admin/invites', { method: 'POST' }),
    onSuccess: (invite) => setInviteToken(invite.token),
  })

  const inviteUrl = inviteToken
    ? `${window.location.origin}/invite/${inviteToken}`
    : null

  return (
    <div>
      <div className="flex items-center justify-between mb-6">
        <div>
          <h1 className="text-2xl font-bold text-white">Oficinas</h1>
          <p className="text-slate-400 text-sm mt-0.5">{tenants.length} oficinas registadas</p>
        </div>
        <Button onClick={() => generateInvite.mutate()} disabled={generateInvite.isPending}>
          {generateInvite.isPending ? 'A gerar...' : 'Gerar Convite'}
        </Button>
      </div>

      {inviteUrl && (
        <div className="mb-6 p-4 bg-slate-800 rounded-lg border border-slate-700">
          <p className="text-slate-300 text-sm font-medium mb-2">Link de convite (válido 72h):</p>
          <div className="flex items-center gap-2">
            <code className="flex-1 text-xs text-green-400 bg-slate-900 px-3 py-2 rounded break-all">
              {inviteUrl}
            </code>
            <Button
              size="sm"
              variant="outline"
              onClick={() => navigator.clipboard.writeText(inviteUrl)}
            >
              Copiar
            </Button>
          </div>
          <button
            onClick={() => setInviteToken(null)}
            className="text-xs text-slate-500 hover:text-slate-400 mt-2"
          >
            Fechar
          </button>
        </div>
      )}

      {isLoading ? (
        <p className="text-slate-400">A carregar...</p>
      ) : tenants.length === 0 ? (
        <p className="text-slate-500 text-sm">Nenhuma oficina registada.</p>
      ) : (
        <div className="rounded-lg border border-slate-700 overflow-hidden">
          <table className="w-full text-sm">
            <thead className="bg-slate-800">
              <tr>
                <th className="text-left px-4 py-3 text-slate-400 font-medium">Nome</th>
                <th className="text-left px-4 py-3 text-slate-400 font-medium">Slug</th>
                <th className="text-left px-4 py-3 text-slate-400 font-medium">Estado</th>
                <th className="text-left px-4 py-3 text-slate-400 font-medium">Criada</th>
              </tr>
            </thead>
            <tbody>
              {tenants.map((t) => (
                <tr key={t.id} className="border-t border-slate-700 hover:bg-slate-800/50">
                  <td className="px-4 py-3 text-white font-medium">{t.name}</td>
                  <td className="px-4 py-3 text-slate-400 font-mono text-xs">{t.slug}</td>
                  <td className="px-4 py-3">
                    <Badge variant={statusVariant(t.status)}>{t.status}</Badge>
                  </td>
                  <td className="px-4 py-3 text-slate-400">
                    {new Intl.DateTimeFormat('pt-PT').format(new Date(t.created_at))}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </div>
  )
}
  • Step 3: Update AdminLayout.tsx with navigation

Replace frontend/src/components/layout/AdminLayout.tsx:

import { Outlet, NavLink } from 'react-router'
import { useLogout } from '@/hooks/useAuth'

export default function AdminLayout() {
  const { mutate: logout } = useLogout()

  return (
    <div className="flex h-screen bg-slate-950">
      <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">Administração</p>
        </div>
        <nav className="flex-1 p-3 space-y-1">
          <NavLink
            to="/admin"
            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'
              }`
            }
          >
            Dashboard
          </NavLink>
          <NavLink
            to="/admin/tenants"
            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'
              }`
            }
          >
            Oficinas
          </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>
  )
}
  • Step 4: Update App.tsx to add /admin/tenants route

In frontend/src/App.tsx, add the import and route:

import TenantsPage from '@/pages/admin/TenantsPage'

Inside the /admin route's <Route> children, add:

<Route path="tenants" element={<TenantsPage />} />

The /admin route block becomes:

<Route
  path="/admin"
  element={
    <RequireAuth allowedRoles={['super_admin']}>
      <AdminLayout />
    </RequireAuth>
  }
>
  <Route index element={<AdminDashboardPage />} />
  <Route path="tenants" element={<TenantsPage />} />
</Route>
  • Step 5: Build and run frontend tests
cd /var/home/lmilani/Documentos/IDE/techxcar/frontend
npm run build
npm run test:run

Expected: build success, 4 tests PASS.

  • Step 6: Commit
cd /var/home/lmilani/Documentos/IDE/techxcar
git add frontend/src/
git commit -m "feat: super-admin tenants panel with tenant list and platform invite generation"

Task 9: Frontend — invite redemption page

Files:

  • Create: frontend/src/pages/public/InviteRedeemPage.tsx
  • Modify: frontend/src/App.tsx — add /invite/:token public route

Interfaces:

  • Consumes: apiFetch, React Hook Form + Zod, Button, Input, Label

  • Produces:

    • GET /api/v1/invites/:token — validates invite on mount
    • POST /api/v1/invites/:token/redeem — creates tenant + admin, auto-logs in, redirects to /app
    • Public route at /invite/:token — no auth required
  • Step 1: Create InviteRedeemPage.tsx

Create frontend/src/pages/public/InviteRedeemPage.tsx:

import { useParams, useNavigate } from 'react-router'
import { useQuery, useMutation } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { apiFetch } from '@/lib/api'
import { useAuthStore } from '@/store/authStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { Invite } from '@/lib/types'

const schema = z.object({
  tenant_name: z.string().min(2, 'Nome obrigatório (mínimo 2 caracteres)'),
  tenant_slug: z
    .string()
    .min(2, 'Slug obrigatório')
    .regex(/^[a-z0-9-]+$/, 'Apenas letras minúsculas, números e hífens'),
  admin_email: z.string().email('Email inválido'),
  admin_password: z.string().min(8, 'Mínimo 8 caracteres'),
  admin_name: z.string().min(2, 'Nome obrigatório'),
})
type FormData = z.infer<typeof schema>

interface RedeemResponse {
  access_token: string
  tenant_slug: string
}

export default function InviteRedeemPage() {
  const { token } = useParams<{ token: string }>()
  const navigate = useNavigate()
  const { setAuth } = useAuthStore()

  const { data: invite, isLoading, error: inviteError } = useQuery<Invite>({
    queryKey: ['invite', token],
    queryFn: () => apiFetch<Invite>(`/invites/${token}`),
    retry: false,
  })

  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
  })

  const redeem = useMutation({
    mutationFn: (data: FormData) =>
      apiFetch<RedeemResponse>(`/invites/${token}/redeem`, {
        method: 'POST',
        body: JSON.stringify(data),
      }),
    onSuccess: (data) => {
      setAuth(
        { id: '', email: '', name: '', role: 'tenant_admin', tenantId: undefined },
        data.access_token
      )
      navigate('/app', { replace: true })
    },
  })

  if (isLoading) {
    return (
      <div className="min-h-screen flex items-center justify-center bg-gray-50">
        <p className="text-gray-500">A validar convite...</p>
      </div>
    )
  }

  if (inviteError || !invite) {
    return (
      <div className="min-h-screen flex items-center justify-center bg-gray-50">
        <div className="text-center">
          <h1 className="text-2xl font-bold text-gray-900">Convite inválido</h1>
          <p className="text-gray-500 mt-2 text-sm">Este convite não existe, expirou ou  foi utilizado.</p>
        </div>
      </div>
    )
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="w-full max-w-md">
        <div className="text-center mb-8">
          <h1 className="text-3xl font-bold text-gray-900">TechXCar</h1>
          <p className="text-gray-500 mt-1 text-sm">Criar conta da oficina</p>
        </div>

        <form
          onSubmit={handleSubmit((data) => redeem.mutate(data))}
          className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 space-y-4"
        >
          <fieldset className="space-y-3">
            <legend className="text-sm font-semibold text-gray-700 pb-1 border-b border-gray-100 w-full">
              Dados da oficina
            </legend>
            <div className="space-y-1.5">
              <Label htmlFor="tenant_name">Nome da oficina</Label>
              <Input id="tenant_name" {...register('tenant_name')} placeholder="Oficina XYZ" />
              {errors.tenant_name && <p className="text-red-500 text-xs">{errors.tenant_name.message}</p>}
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="tenant_slug">Identificador (slug)</Label>
              <Input id="tenant_slug" {...register('tenant_slug')} placeholder="oficina-xyz" />
              <p className="text-gray-400 text-xs">Usado no URL  apenas letras minúsculas, números e hífens.</p>
              {errors.tenant_slug && <p className="text-red-500 text-xs">{errors.tenant_slug.message}</p>}
            </div>
          </fieldset>

          <fieldset className="space-y-3">
            <legend className="text-sm font-semibold text-gray-700 pb-1 border-b border-gray-100 w-full">
              Conta de administrador
            </legend>
            <div className="space-y-1.5">
              <Label htmlFor="admin_name">Nome</Label>
              <Input id="admin_name" {...register('admin_name')} placeholder="João Silva" />
              {errors.admin_name && <p className="text-red-500 text-xs">{errors.admin_name.message}</p>}
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="admin_email">Email</Label>
              <Input id="admin_email" type="email" {...register('admin_email')} />
              {errors.admin_email && <p className="text-red-500 text-xs">{errors.admin_email.message}</p>}
            </div>
            <div className="space-y-1.5">
              <Label htmlFor="admin_password">Password</Label>
              <Input id="admin_password" type="password" {...register('admin_password')} />
              {errors.admin_password && <p className="text-red-500 text-xs">{errors.admin_password.message}</p>}
            </div>
          </fieldset>

          {redeem.error && (
            <p className="text-red-500 text-sm text-center">{redeem.error.message}</p>
          )}

          <Button type="submit" className="w-full" disabled={redeem.isPending}>
            {redeem.isPending ? 'A criar conta...' : 'Criar conta'}
          </Button>
        </form>
      </div>
    </div>
  )
}
  • Step 2: Add public route to App.tsx

In frontend/src/App.tsx, add import:

import InviteRedeemPage from '@/pages/public/InviteRedeemPage'

Add route before the <Route path="/" ...> redirect:

<Route path="/invite/:token" element={<InviteRedeemPage />} />
  • Step 3: Create public directory
mkdir -p /var/home/lmilani/Documentos/IDE/techxcar/frontend/src/pages/public
  • Step 4: Build and run tests
cd /var/home/lmilani/Documentos/IDE/techxcar/frontend
npm run build
npm run test:run

Expected: build success, 4 tests PASS.

  • Step 5: Commit
cd /var/home/lmilani/Documentos/IDE/techxcar
git add frontend/src/
git commit -m "feat: public invite redemption page — creates tenant + admin account and auto-logs in"

O que este plano entrega

  • Backend: JWT auth (login/refresh/logout), bcrypt password hashing, Redis rate limiting (10 req/min), refresh token storage in Redis, JWT + role middleware, tenant schema injection per request
  • Backend: Tenant repository with full CRUD + invite system; super-admin endpoints behind role guard; public invite redemption that provisions a fresh PostgreSQL schema
  • Backend: Optional INITIAL_ADMIN_EMAIL/INITIAL_ADMIN_PASSWORD to seed the first super_admin on startup
  • Frontend: Real login page (React Hook Form + Zod, optional workspace slug for tenant users)
  • Frontend: Super-admin panel with tenants table + platform invite generation with copyable link
  • Frontend: Public invite redemption form that creates a new tenant workspace and auto-logs in the admin

Planos seguintes

Plano Âmbito
Plano 3 Core Workshop: Clientes, Veículos, Catálogo, Ordens de Trabalho (CRUD completo + máquina de estados)
Plano 4 Faturação, Técnicos & Despesas: Faturas/Orçamentos, geração PDF com chromedp, gestão de staff
Plano 5 Notificações, Dashboard & Relatórios: worker Telegram/Email, KPIs, relatórios, exportação