feat: RBAC implementation and Seeder refactor

This commit is contained in:
Tiago Yamamoto 2025-12-22 01:30:55 -03:00
parent 6e2b6a8e89
commit e624d642aa
9 changed files with 368 additions and 8 deletions

View file

@ -26,6 +26,14 @@ type Tenant struct {
// Company is an alias for Tenant for backward compatibility.
type Company = Tenant
// Role constants
const (
RoleAdmin = "Admin"
RoleOwner = "Dono"
RoleEmployee = "Colaborador"
RoleDelivery = "Entregador"
)
// User represents an authenticated actor inside a company.
type User struct {
ID uuid.UUID `db:"id" json:"id"`

View file

@ -8,20 +8,56 @@ import { OrdersPage } from './pages/Orders'
import { InventoryPage } from './pages/Inventory'
import { CompanyPage } from './pages/Company'
import { SellerDashboardPage } from './pages/SellerDashboard'
import { AdminDashboardPage } from './pages/AdminDashboard'
import { EmployeeDashboardPage } from './pages/EmployeeDashboard'
import { DeliveryDashboardPage } from './pages/DeliveryDashboard'
import { ProtectedRoute } from './components/ProtectedRoute'
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
{/* Owner / Seller Dashboard */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<ProtectedRoute allowedRoles={['owner', 'seller']}>
<DashboardPage />
</ProtectedRoute>
}
/>
{/* Admin Dashboard */}
<Route
path="/admin"
element={
<ProtectedRoute allowedRoles={['admin']}>
<AdminDashboardPage />
</ProtectedRoute>
}
/>
{/* Employee (Colaborador) Dashboard */}
<Route
path="/colaborador"
element={
<ProtectedRoute allowedRoles={['employee']}>
<EmployeeDashboardPage />
</ProtectedRoute>
}
/>
{/* Delivery (Entregador) Dashboard */}
<Route
path="/entregas"
element={
<ProtectedRoute allowedRoles={['delivery']}>
<DeliveryDashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/cart"
element={
@ -79,7 +115,7 @@ function App() {
}
/>
<Route path="/search" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
)
}

View file

@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { apiClient } from '../services/apiClient'
import { authService } from '../services/auth'
export type UserRole = 'admin' | 'seller' | 'customer'
export type UserRole = 'admin' | 'owner' | 'employee' | 'delivery' | 'seller' | 'customer'
export interface AuthUser {
name: string
@ -46,7 +46,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = (token: string, role: UserRole, name: string) => {
setUser({ token, role, name })
// Redirect based on role
switch (role) {
case 'admin':
navigate('/admin', { replace: true })
break
case 'owner':
case 'seller':
navigate('/dashboard', { replace: true })
break
case 'employee':
navigate('/colaborador', { replace: true })
break
case 'delivery':
navigate('/entregas', { replace: true })
break
default:
navigate('/dashboard', { replace: true })
}
}
const logout = () => {

View file

@ -0,0 +1,32 @@
import { useAuth } from '../context/AuthContext'
export function AdminDashboardPage() {
const { user, logout } = useAuth()
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="mx-auto max-w-7xl">
<div className="flex items-center justify-between rounded-lg bg-white p-6 shadow">
<div>
<h1 className="text-2xl font-bold text-gray-900">Painel do Administrador</h1>
<p className="text-gray-600">Bem-vindo, {user?.name} (Admin)</p>
</div>
<button
onClick={logout}
className="rounded bg-red-600 px-4 py-2 font-bold text-white hover:bg-red-700"
>
Sair
</button>
</div>
<div className="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div className="rounded-lg bg-white p-6 shadow">
<h3 className="text-lg font-bold">Resumo Geral</h3>
<p className="text-gray-600 mt-2">Visão geral do sistema.</p>
</div>
{/* Add more admin widgets here */}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,30 @@
import { useAuth } from '../context/AuthContext'
export function DeliveryDashboardPage() {
const { user, logout } = useAuth()
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="mx-auto max-w-7xl">
<div className="flex items-center justify-between rounded-lg bg-white p-6 shadow">
<div>
<h1 className="text-2xl font-bold text-gray-900">Painel do Entregador</h1>
<p className="text-gray-600">Bem-vindo, {user?.name}</p>
</div>
<button
onClick={logout}
className="rounded bg-red-600 px-4 py-2 font-bold text-white hover:bg-red-700"
>
Sair
</button>
</div>
<div className="mt-8 rounded-lg bg-white p-6 shadow">
<h3 className="text-lg font-bold">Minhas Entregas</h3>
<p className="mt-2 text-gray-600">Visualize as entregas pendentes e o mapa de rotas.</p>
{/* Map Integration would go here */}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,37 @@
import { useAuth } from '../context/AuthContext'
export function EmployeeDashboardPage() {
const { user, logout } = useAuth()
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="mx-auto max-w-7xl">
<div className="flex items-center justify-between rounded-lg bg-white p-6 shadow">
<div>
<h1 className="text-2xl font-bold text-gray-900">Painel do Colaborador</h1>
<p className="text-gray-600">Bem-vindo, {user?.name}</p>
</div>
<button
onClick={logout}
className="rounded bg-red-600 px-4 py-2 font-bold text-white hover:bg-red-700"
>
Sair
</button>
</div>
<div className="mt-8 grid gap-6 md:grid-cols-2">
<div className="rounded-lg bg-white p-6 shadow">
<h3 className="text-lg font-bold">Pedidos</h3>
<p className="mt-2 text-gray-600">Gerenciar pedidos recebidos.</p>
{/* Link to Orders */}
</div>
<div className="rounded-lg bg-white p-6 shadow">
<h3 className="text-lg font-bold">Estoque</h3>
<p className="mt-2 text-gray-600">Consultar e ajustar estoque.</p>
{/* Link to Inventory */}
</div>
</div>
</div>
</div>
)
}

View file

@ -12,14 +12,22 @@ export function LoginPage() {
const [loading, setLoading] = useState(false)
const resolveRole = (role?: string): UserRole => {
console.log('🔐 [Login] Component rendering')
console.log('🔐 [Login] Resolving role:', role)
switch (role?.toLowerCase()) {
case 'admin':
return 'admin'
case 'dono':
return 'owner'
case 'colaborador':
return 'employee'
case 'entregador':
return 'delivery'
case 'customer':
return 'customer'
case 'seller':
case 'seller': // keep legacy
default:
// Default to seller/owner or log warning?
console.warn('⚠️ [Login] Unknown role, defaulting to seller:', role)
return 'seller'
}
}

View file

@ -28,7 +28,8 @@ func main() {
return
}
result, err := seeder.Seed(dsn)
mode := r.URL.Query().Get("mode")
result, err := seeder.Seed(dsn, mode)
if err != nil {
log.Printf("Seeder error: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)

View file

@ -11,6 +11,7 @@ import (
"github.com/gofrs/uuid/v5"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
)
// Anápolis, GO coordinates
@ -66,7 +67,183 @@ var pharmacyNames = []string{
"Vida Saudável", "Mais Saúde", "Farmácia do Povo", "Super Farma",
}
func Seed(dsn string) (string, error) {
// Seed dispatches based on mode
func Seed(dsn, mode string) (string, error) {
if mode == "lean" {
return SeedLean(dsn)
}
return SeedFull(dsn)
}
func SeedLean(dsn string) (string, error) {
if dsn == "" {
return "", fmt.Errorf("DATABASE_URL not set")
}
db, err := sqlx.Connect("pgx", dsn)
if err != nil {
return "", fmt.Errorf("db connect: %v", err)
}
defer db.Close()
ctx := context.Background()
log.Println("🧹 [Lean] Resetting database...")
// Re-create tables
mustExec(db, `DROP TABLE IF EXISTS inventory_adjustments CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS order_items CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS orders CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS cart_items CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS reviews CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS products CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS users CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS companies CASCADE`)
// Create tables (Schema must match backend migrations!)
mustExec(db, `CREATE TABLE companies (
id UUID PRIMARY KEY,
cnpj TEXT NOT NULL UNIQUE,
corporate_name TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'farmacia',
license_number TEXT NOT NULL,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
latitude DOUBLE PRECISION NOT NULL DEFAULT 0,
longitude DOUBLE PRECISION NOT NULL DEFAULT 0,
city TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
)`)
// Add missing users table creation here to be complete for independent seeder run
mustExec(db, `CREATE TABLE users (
id UUID PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id),
role TEXT NOT NULL,
name TEXT NOT NULL,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
)`)
mustExec(db, `CREATE TABLE products (
id UUID PRIMARY KEY,
seller_id UUID NOT NULL REFERENCES companies(id),
name TEXT NOT NULL,
description TEXT,
batch TEXT NOT NULL,
expires_at DATE NOT NULL,
price_cents BIGINT NOT NULL,
stock BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
)`)
// Create 1 Pharmacy
pharmacyID := uuid.Must(uuid.NewV7())
now := time.Now().UTC()
_, err = db.ExecContext(ctx, `
INSERT INTO companies (id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
pharmacyID, "12345678000199", "Farmácia Modelo", "farmacia", "CRF-GO-12345", true, AnapolisLat, AnapolisLng, "Anápolis", "GO", now, now,
)
if err != nil {
return "", fmt.Errorf("create pharmacy: %v", err)
}
// Create standard password hash (e.g. "123456")
// Using a fixed hash for speed/reproducibility. hash("$2a$10$3Y... for '123456'")
// Or generating one? Let's use a known hash from backend or generate one locally if possible.
// To avoid dep on bcrypt, I will assume one.
// But `users` table needs it.
// "123456" bcrypt hash (cost 10): $2a$10$Vj.uOq/e/3.t/2.r/1.s/e
// "admin123" bcrypt hash: $2y$10$vI8aWBdWs/.r/2/.r.. (Let's stick to "123456" for simplicity or use one from backend?)
// User requested "dono/123456".
pwdHash123456 := "$2a$10$x86K.S/3/1./2./3./4./5./6./" // PLACHOLDER? No, I should generate or use a real one.
// Real hash for "123456" generated previously or online: $2a$10$2.1.1.1.1.1.1.1.1.1.1.
// Actually, I'll use a mocked valid hash.
// $2a$10$2.1.1.1.1.1.1.1.1.1.1 is not valid.
// I'll leave a TODO or use a hardcoded one if I can.
// Better: use the same one as Admin ("admin123" -> "$2a$10$...")
// Let's use a valid hash for '123456'.
// Generated: $2a$10$4.1.1.1.1.1.1.1.1.1.1. (Fake)
// I will use a known one. From previous logs?
// In `server.go`, admin password is env var.
// I'll grab a valid hash for "123456" -> `$2a$10$6.1.1.1.1.1.1.1.1.1.1` (Just kidding).
// I'll use a placeholder that works.
validHash123456 := "$2a$10$e.g.e.g.e.g.e.g.e.g.e.g." // Requires real generation.
// I'll import bcrypt?
// `seeder-api` doesn't have bcrypt in imports?
// It has `math/rand`, `time`.
// I should add `golang.org/x/crypto/bcrypt` if needed or use raw SQL pgcrypto if available.
// I'll add bcrypt to imports in a separate step or just assume the hash.
// Let's assume the hash for "123456" is: $2a$10$N.z.y.x...
// I'll proceed with creating users:
// Helper for hashing
hashPwd := func(pwd string) string {
h, _ := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
return string(h)
}
// 1. Admin
adminID := uuid.Must(uuid.NewV7())
mustExec(db, fmt.Sprintf(`INSERT INTO users (id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at)
VALUES ('%s', '%s', 'Admin', 'Administrador', 'admin', 'admin@saveinmed.com', true, '%s', NOW(), NOW())`,
adminID, pharmacyID, hashPwd("admin123"),
))
// 2. Owner (Dono)
ownerID := uuid.Must(uuid.NewV7())
mustExec(db, fmt.Sprintf(`INSERT INTO users (id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at)
VALUES ('%s', '%s', 'Dono', 'João Dono', 'dono', 'dono@farmacia.com', true, '%s', NOW(), NOW())`,
ownerID, pharmacyID, hashPwd("123456"),
))
// 3. Employee (Colaborador)
empID := uuid.Must(uuid.NewV7())
mustExec(db, fmt.Sprintf(`INSERT INTO users (id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at)
VALUES ('%s', '%s', 'Colaborador', 'Maria Colaboradora', 'colaborador', 'colaborador@farmacia.com', true, '%s', NOW(), NOW())`,
empID, pharmacyID, hashPwd("123456"),
))
// 4. Delivery (Entregador)
// Delivery person usually needs their own "company" or is linked to the pharmacy?
// For now, linking to the same pharmacy for simplicity, or creating a carrier?
// The prompt implies "entregador" as a user role.
// Linking to Pharmacy for simplicity (internal delivery fleet).
delID := uuid.Must(uuid.NewV7())
mustExec(db, fmt.Sprintf(`INSERT INTO users (id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at)
VALUES ('%s', '%s', 'Entregador', 'José Entregador', 'entregador', 'entregador@farmacia.com', true, '%s', NOW(), NOW())`,
delID, pharmacyID, hashPwd("123456"),
))
log.Println("✅ [Lean] Users created: admin, dono, colaborador, entregador")
// Create Products for the Pharmacy
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
products := generateProducts(rng, pharmacyID, 15)
for _, p := range products {
_, err := db.NamedExecContext(ctx, `
INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at)
VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock, :created_at, :updated_at)
ON CONFLICT DO NOTHING`, p)
if err != nil {
log.Printf("insert product lean: %v", err)
}
}
log.Println("✅ [Lean] Created 15 products")
return fmt.Sprintf("Lean seed completed. Users: admin, dono, colaborador, entregador (Pass: 123456/admin123)"), nil
}
func SeedFull(dsn string) (string, error) {
if dsn == "" {
return "", fmt.Errorf("DATABASE_URL not set")
}
@ -106,6 +283,19 @@ func Seed(dsn string) (string, error) {
updated_at TIMESTAMPTZ NOT NULL
)`)
mustExec(db, `CREATE TABLE users (
id UUID PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id),
role TEXT NOT NULL,
name TEXT NOT NULL,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
)`)
mustExec(db, `CREATE TABLE products (
id UUID PRIMARY KEY,
seller_id UUID NOT NULL REFERENCES companies(id),