feat: implemented rules to manager users

master
adriano 2025-06-24 16:08:08 -03:00
parent 64b990d248
commit 0444ca949e
30 changed files with 952 additions and 739 deletions

View File

@ -5,7 +5,8 @@ from .extensions import init_mongo, init_jwt, init_redis
from .errors.handlers import register_error_handlers
from .routes.usage_routes import usage_ns
from .routes.auth_routes import auth_ns
from .routes.billing_routes import billing_ns
from .routes.billing_routes import billing_ns
from .routes.users_routes import user_ns
from flask_cors import CORS
def create_app():
@ -37,7 +38,8 @@ def create_app():
api.add_namespace(usage_ns, path='/usage')
api.add_namespace(auth_ns, path='/auth')
api.add_namespace(billing_ns, path='/billing')
api.add_namespace(billing_ns, path='/billing')
api.add_namespace(user_ns, path='/users')
register_error_handlers(app)

View File

@ -1,6 +1,8 @@
from flask_bcrypt import generate_password_hash, check_password_hash
from flask import current_app
from pymongo import MongoClient
from bson.objectid import ObjectId
from bson.errors import InvalidId
class UserModel:
def __init__(self):
@ -13,7 +15,33 @@ class UserModel:
return self.collection.insert_one(user)
def find_by_email(self, email):
return self.collection.find_one({"email": email})
return self.collection.find_one({"email": email})
def get_user_by_id(self, user_id):
object_id = ObjectId(user_id)
return self.collection.find_one({"_id": object_id},{"email": 1, "roles": 1,})
def update_user(self, user_id, update_data: dict):
object_id = ObjectId(user_id)
if "password" in update_data:
update_data["password"] = generate_password_hash(update_data["password"]).decode('utf-8')
result = self.collection.update_one(
{"_id": object_id},
{"$set": update_data}
)
return result.modified_count > 0
def delete_user(self, user_id):
object_id = ObjectId(user_id)
result = self.collection.delete_one({"_id": object_id})
return result.deleted_count > 0
def list_users(self):
users = self.collection.find({}, {"email": 1, "roles": 1,})
return users
def verify_password(self, hashed_password, plain_password):
return check_password_hash(hashed_password, plain_password)

View File

@ -2,7 +2,13 @@ from flask_restx import fields, Namespace
auth_ns = Namespace('auth', description='Authentication')
signup_model = auth_ns.model('Signup', {
signup_model = auth_ns.model('SignUp', {
'email': fields.String(required=True),
'password': fields.String(required=True)
'password': fields.String(required=True),
'roles': fields.List(fields.String, required=False, default=["user"])
})
signin_model = auth_ns.model('SignIn', {
'email': fields.String(required=True),
'password': fields.String(required=True)
})

View File

@ -0,0 +1,8 @@
from flask_restx import Resource, Namespace, fields
role_ns = Namespace('roles', description="Role management")
role_model = role_ns.model('Role', {
'email': fields.String(required=True, description='User email'),
'roles': fields.List(fields.String, required=True, description='List of roles')
})

View File

@ -0,0 +1,11 @@
from flask_restx import fields, Namespace
user_ns = Namespace('user', description='Users')
update_user = user_ns.model('Update', {
'email': fields.String(required=False, description='User email'),
'password': fields.String(required=False, description='User password'),
'roles': fields.String(required=False, description='User roles')
})

View File

@ -3,15 +3,21 @@ from flask import jsonify
from werkzeug.exceptions import HTTPException
from pydantic import ValidationError
import traceback
from bson.errors import InvalidId
def register_error_handlers(app):
@app.errorhandler(ValidationError)
def handle_validation_error(e):
return jsonify({"error": e.errors()}), 400
@app.errorhandler(InvalidId)
def handle_invalid_id_error(e):
return jsonify({"error": "Invalid ID format"}), 400
@app.errorhandler(HTTPException)
def handle_http_exception(e):
return jsonify({"errror": e.description}), e.code
return jsonify({"error": e.description}), e.code
@app.errorhandler(Exception)
def handle_unexpected_exception(e):

View File

@ -1,31 +1,50 @@
from flask_restx import Namespace, Resource, fields
from flask import request
from flask_restx import Resource
from flask import request, current_app
from flask_jwt_extended import create_access_token
from app.db.models import UserModel
from app.docs.auth_models import auth_ns, signup_model
from app.docs.auth_models import auth_ns, signup_model, signin_model
from app.schemas.auth_sigin_schema import SigInRequest
from app.schemas.auth_sigup_schema import SigUpRequest
@auth_ns.route('/signup')
class SignUp(Resource):
@auth_ns.expect(signup_model)
def post(self):
data = request.get_json()
user_model = UserModel()
if user_model.find_by_email(data['email']):
return {'message': 'User already exists'}, 400
user_model.create_user(data['email'], data['password'])
return {'message': 'success'}, 201
validated = SigUpRequest(**data)
roles = data.get("roles", [])
user_model = UserModel()
if user_model.find_by_email(validated.email):
return {'message': 'User already exists'}, 400
result = user_model.create_user(validated.email, validated.password)
user_model.update_user(result.inserted_id, {"email": validated.email, "roles": roles})
return {'message': 'success'}, 201
@auth_ns.route('/login')
class Login(Resource):
@auth_ns.expect(signup_model)
@auth_ns.expect(signin_model)
def post(self):
data = request.get_json()
validated = SigInRequest(**data)
user_model = UserModel()
user = user_model.find_by_email(data['email'])
user = user_model.find_by_email(validated.email)
if not user or not user_model.verify_password(user['password'], data['password']):
return {'message': 'Invalid credentials'}, 401
return {'message': 'Invalid credentials'}, 401
roles = user.get("roles", []) if user else []
access_token = create_access_token(
identity=user['email'],
additional_claims={"roles": roles}
)
access_token = create_access_token(identity=user['email'])
return {'access_token': access_token}, 200

View File

@ -1,11 +1,10 @@
from flask_restx import Namespace, Resource, fields
from flask import request, current_app
import requests
from bson import json_util
from app.db.models import UserModel
from flask_restx import Resource
from flask import request
import requests
from app.docs.biling_models import billing_ns, product_model, update_price_model
from app.config import Config
from app.utils.role_required import role_required
from flask_jwt_extended import jwt_required
BILLING_API_URL = Config.BILLING_API_URL
HEADERS = {
@ -17,6 +16,8 @@ HEADERS = {
@billing_ns.route('/product')
class CreateProduct(Resource):
@billing_ns.expect(product_model)
@jwt_required()
@role_required('admin', 'user')
def post(self):
data = request.get_json()
@ -27,6 +28,8 @@ class CreateProduct(Resource):
@billing_ns.route('/product/<string:product_id>')
class UpdateProduct(Resource):
@billing_ns.expect(update_price_model)
@jwt_required()
@role_required('admin', 'user')
def patch(self, product_id):
data = request.get_json()
@ -36,6 +39,8 @@ class UpdateProduct(Resource):
@billing_ns.route('/products')
class ListProducts(Resource):
@jwt_required()
@role_required('admin', 'user')
def get(self):
response = requests.get(url=f'{BILLING_API_URL}/billing/products', headers=HEADERS)
return response.json(), response.status_code

View File

@ -1,5 +1,4 @@
import os
import hashlib
import os
from bson import json_util
from flask_restx import Resource
from flask_jwt_extended import jwt_required
@ -17,6 +16,7 @@ from app.docs.usage_models import (
transcription_data_query_params, usage_ns)
from app.utils.current_date import is_current_date
from app.utils.hash_key import set_hash_key
from app.utils.role_required import role_required
TMP_DIR = '/tmp'
@ -28,6 +28,7 @@ class TranscriptionExport(Resource):
@usage_ns.response(400, 'Validation error')
@usage_ns.response(404, 'File not found')
@jwt_required()
@role_required('admin', 'user')
def get(self):
"""
Export transcription report in XLSX.
@ -84,6 +85,7 @@ class TranscriptionUsageData(Resource):
@usage_ns.response(200, 'success')
@usage_ns.response(400, 'Validation error')
@jwt_required()
@role_required('admin', 'user')
def get(self):
"""
Get transcription report data.
@ -123,6 +125,7 @@ class TranscriptionModelPrices(Resource):
@usage_ns.response(200, 'success')
@usage_ns.response(400, 'Validation error')
@jwt_required()
@role_required('admin', 'user')
def get(self):
"""
Get model pricing and supplier information
@ -161,6 +164,7 @@ class UpdateUsageCost(Resource):
@usage_ns.response(200, 'success')
@usage_ns.response(400, 'Validation error')
@jwt_required()
@role_required('admin', 'user')
def patch(self):
"""
Updates the total cost of using products by company
@ -190,6 +194,7 @@ class UpdateModelPrice(Resource):
@usage_ns.response(200, 'Sucess')
@usage_ns.response(400, 'Validation error')
@jwt_required()
@role_required('admin', 'user')
def patch(self, id):
"""
Updates the price of a specific model by ID.

View File

@ -0,0 +1,75 @@
from flask_restx import Resource
from app.docs.user_models import user_ns, update_user
from flask import current_app, request
from bson import json_util
from app.db.models import UserModel
from app.schemas.update_user_schema import UpdateUserRequest
from app.utils.role_required import role_required
from flask_jwt_extended import jwt_required
@user_ns.route('')
@user_ns.doc(security='Bearer Auth')
@user_ns.response(200, 'success')
class Users(Resource):
@jwt_required()
@role_required('admin', 'user')
def get(self):
user_model = UserModel()
users = user_model.list_users()
return current_app.response_class(
response=json_util.dumps({"success": True, "data": users}),
mimetype='application/json'
)
@user_ns.route('/<string:user_id>')
@user_ns.doc(security='Bearer Auth')
@user_ns.response(200, 'success')
@user_ns.response(400, 'Validation error')
class User(Resource):
def __init__(self, api=None, *args, **kwargs):
super().__init__(api, *args, **kwargs)
self.user_model = UserModel()
@jwt_required()
@role_required('admin', 'user')
def get(self, user_id):
if user := self.user_model.get_user_by_id(user_id):
return current_app.response_class(
response=json_util.dumps({"success": True, "user": user}),
mimetype='application/json'
)
return {"success": False, 'message': 'User not found'}, 404
@user_ns.expect(update_user)
@jwt_required()
@role_required('admin', 'user')
def patch(self, user_id):
data = request.get_json()
validated = UpdateUserRequest(**data)
if not self.user_model.get_user_by_id(user_id):
return {"success": False, 'message': 'User not found'}, 404
update_data = validated.model_dump(exclude_none=True)
self.user_model.update_user(user_id, update_data)
return {"success": True, 'message': f'User {user_id} updated!'}, 200
@jwt_required()
@role_required('admin', 'user')
def delete(self, user_id):
if not self.user_model.get_user_by_id(user_id):
return {"success": False, 'message': 'User not found'}, 404
self.user_model.delete_user(user_id)
return {"success": True, 'message': f'User {user_id} deleted!'}, 200

View File

@ -0,0 +1,5 @@
from pydantic import BaseModel
class SigInRequest(BaseModel):
email: str
password: str

View File

@ -0,0 +1,7 @@
from pydantic import BaseModel
from typing import List, Literal
class SigUpRequest(BaseModel):
email: str
password: str
roles: List[Literal["admin", "user"]]

View File

@ -0,0 +1,7 @@
from pydantic import BaseModel
from typing import List, Optional, Literal
class UpdateUserRequest(BaseModel):
email: Optional[str] = None
password: Optional[str] = None
roles: Optional[List[str]] = None

View File

@ -0,0 +1,16 @@
from functools import wraps
from flask_jwt_extended import get_jwt
from flask import abort
def role_required(*required_roles):
def wrapper(fn):
@wraps(fn)
def decorator(*args, **kwargs):
claims = get_jwt()
user_roles = claims.get("roles", [])
if not any(role in user_roles for role in required_roles):
abort(403, description="Access forbidden: insufficient role")
return fn(*args, **kwargs)
return decorator
return wrapper

View File

@ -8,22 +8,38 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { LogOut } from "lucide-react"
import TranscriptionTable from "@/components/transcription-table"
import ModelPricesTable from "@/components/model-prices-table"
import CostUpdateForm from "@/components/cost-update-form"
import ProductManagement from "@/components/product-management"
import UserManagement from "@/components/user-management"
import { isAdmin, isTokenExpired, getCurrentUserEmail, getUserRoles } from "@/lib/auth"
export default function Dashboard() {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [userIsAdmin, setUserIsAdmin] = useState(false)
const [userEmail, setUserEmail] = useState<string | null>(null)
const [userRoles, setUserRoles] = useState<string[]>([])
const router = useRouter()
useEffect(() => {
const token = localStorage.getItem("access_token")
if (!token) {
if (!token || isTokenExpired()) {
// Token não existe ou expirou
localStorage.removeItem("access_token")
router.push("/")
} else {
setIsAuthenticated(true)
setIsLoading(false)
return
}
// Token válido, obter informações do usuário
const email = getCurrentUserEmail()
const roles = getUserRoles()
const adminStatus = isAdmin()
setUserEmail(email)
setUserRoles(roles)
setUserIsAdmin(adminStatus)
setIsAuthenticated(true)
setIsLoading(false)
}, [router])
const handleLogout = () => {
@ -48,7 +64,15 @@ export default function Dashboard() {
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<h1 className="text-3xl font-bold text-gray-900">Dashboard - Custo de Produtos</h1>
<div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard - Sistema de Transcrição</h1>
{userEmail && (
<p className="text-sm text-gray-600 mt-1">
Logado como: <span className="font-medium">{userEmail}</span>
{userRoles.length > 0 && <span className="ml-2">({userRoles.join(", ")})</span>}
</p>
)}
</div>
<Button onClick={handleLogout} variant="outline">
<LogOut className="mr-2 h-4 w-4" />
Sair
@ -59,11 +83,11 @@ export default function Dashboard() {
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<Tabs defaultValue="transcription" className="space-y-6">
<TabsList className="grid w-full grid-cols-3">
<TabsList className={`grid w-full ${userIsAdmin ? "grid-cols-4" : "grid-cols-3"}`}>
<TabsTrigger value="transcription">Dados de Transcrição</TabsTrigger>
<TabsTrigger value="models">Preços dos Modelos</TabsTrigger>
{/* <TabsTrigger value="costs">Atualizar Custos</TabsTrigger> */}
<TabsTrigger value="products">Produtos</TabsTrigger>
{userIsAdmin && <TabsTrigger value="users">Usuários</TabsTrigger>}
</TabsList>
<TabsContent value="transcription">
@ -90,18 +114,6 @@ export default function Dashboard() {
</Card>
</TabsContent>
<TabsContent value="costs">
<Card>
<CardHeader>
<CardTitle>Atualizar Custos</CardTitle>
<CardDescription>Atualize os custos de uso dos produtos</CardDescription>
</CardHeader>
<CardContent>
<CostUpdateForm />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="products">
<Card>
<CardHeader>
@ -113,6 +125,20 @@ export default function Dashboard() {
</CardContent>
</Card>
</TabsContent>
{userIsAdmin && (
<TabsContent value="users">
<Card>
<CardHeader>
<CardTitle>Gerenciar Usuários</CardTitle>
<CardDescription>Gerencie usuários e suas permissões no sistema</CardDescription>
</CardHeader>
<CardContent>
<UserManagement />
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
</main>
</div>

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@ -1,66 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@ -1,46 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -1,59 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -1,92 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -1,21 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -1,24 +0,0 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -1,185 +0,0 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -1,116 +0,0 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -1,66 +0,0 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -0,0 +1,523 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Edit2, Plus, Save, X, Loader2, RefreshCw, Users, Shield, Trash2 } from "lucide-react"
import { isAdmin, getCurrentUserEmail } from "@/lib/auth"
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000/api/v1"
interface User {
_id: {
$oid: string
}
email: string
roles: string[]
}
interface UsersResponse {
success: boolean
data: User[]
}
interface UserResponse {
success: boolean
user: User
}
interface CreateUserRequest {
email: string
password: string
roles: string[]
}
interface UpdateUserRequest {
email: string
password?: string
roles: string[]
}
interface DeleteUserResponse {
success: boolean
message: string
}
interface UpdateUserResponse {
success: boolean
message: string
}
// Roles disponíveis no sistema
const AVAILABLE_ROLES = [
{ id: "admin", label: "Administrador", description: "Acesso total ao sistema" },
{ id: "user", label: "Usuário", description: "Acesso parcial, não pode gerenciar usuários" },
]
export default function UserManagement() {
const [users, setUsers] = useState<User[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
// Estados para criação de usuário
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [createForm, setCreateForm] = useState<CreateUserRequest>({
email: "",
password: "",
roles: [],
})
// Estados para edição de usuário
const [editingUser, setEditingUser] = useState<string | null>(null)
const [editForm, setEditForm] = useState<UpdateUserRequest>({
email: "",
password: "",
roles: [],
})
const [isUpdating, setIsUpdating] = useState(false)
// Estados para exclusão
const [isDeleting, setIsDeleting] = useState<string | null>(null)
const getAuthHeaders = () => {
const token = localStorage.getItem("access_token")
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
}
}
const fetchUsers = async () => {
setIsLoading(true)
setError("")
try {
const response = await fetch(`${API_BASE_URL}/users`, {
method: "GET",
headers: getAuthHeaders(),
})
if (response.ok) {
const result: UsersResponse = await response.json()
setUsers(result.data || [])
} else {
const errorData = await response.json()
setError(errorData.message || "Erro ao buscar usuários")
}
} catch (err) {
setError("Erro de conexão com o servidor")
} finally {
setIsLoading(false)
}
}
const createUser = async () => {
if (!createForm.email || !createForm.password || createForm.roles.length === 0) {
setError("Preencha todos os campos obrigatórios")
return
}
setIsCreating(true)
setError("")
setSuccess("")
try {
const response = await fetch(`${API_BASE_URL}/auth/signup`, {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify(createForm),
})
if (response.ok) {
const result = await response.json()
setSuccess("Usuário criado com sucesso!")
setCreateForm({ email: "", password: "", roles: [] })
setIsCreateDialogOpen(false)
await fetchUsers()
} else {
const errorData = await response.json()
setError(errorData.message || "Erro ao criar usuário")
}
} catch (err) {
setError("Erro de conexão com o servidor")
} finally {
setIsCreating(false)
}
}
const updateUser = async (userId: string) => {
if (!editForm.email || editForm.roles.length === 0) {
setError("Preencha todos os campos obrigatórios")
return
}
setIsUpdating(true)
setError("")
setSuccess("")
try {
const updateData: UpdateUserRequest = {
email: editForm.email,
roles: editForm.roles,
}
// Só incluir password se foi preenchido
if (editForm.password && editForm.password.trim() !== "") {
updateData.password = editForm.password
}
const response = await fetch(`${API_BASE_URL}/users/${userId}`, {
method: "PATCH",
headers: getAuthHeaders(),
body: JSON.stringify(updateData),
})
if (response.ok) {
const result: UpdateUserResponse = await response.json()
setSuccess("Usuário atualizado com sucesso!")
setEditingUser(null)
setEditForm({ email: "", password: "", roles: [] })
await fetchUsers()
} else {
const errorData = await response.json()
setError(errorData.message || "Erro ao atualizar usuário")
}
} catch (err) {
setError("Erro de conexão com o servidor")
} finally {
setIsUpdating(false)
}
}
const deleteUser = async (userId: string) => {
if (!confirm("Tem certeza que deseja excluir este usuário? Esta ação não pode ser desfeita.")) {
return
}
setIsDeleting(userId)
setError("")
setSuccess("")
try {
const response = await fetch(`${API_BASE_URL}/users/${userId}`, {
method: "DELETE",
headers: getAuthHeaders(),
})
if (response.ok) {
const result: DeleteUserResponse = await response.json()
setSuccess("Usuário excluído com sucesso!")
await fetchUsers()
} else {
const errorData = await response.json()
setError(errorData.message || "Erro ao excluir usuário")
}
} catch (err) {
setError("Erro de conexão com o servidor")
} finally {
setIsDeleting(null)
}
}
const startEditUser = (user: User) => {
setEditingUser(user._id.$oid)
setEditForm({
email: user.email,
password: "", // Deixar vazio para não alterar a senha
roles: [...user.roles],
})
}
const cancelEditUser = () => {
setEditingUser(null)
setEditForm({ email: "", password: "", roles: [] })
}
const handleEditRoleChange = (roleId: string, checked: boolean | "indeterminate") => {
if (checked === true) {
setEditForm((prev) => ({ ...prev, roles: [...prev.roles, roleId] }))
} else {
setEditForm((prev) => ({ ...prev, roles: prev.roles.filter((role) => role !== roleId) }))
}
}
const handleCreateRoleChange = (roleId: string, checked: boolean | "indeterminate") => {
if (checked === true) {
setCreateForm((prev) => ({ ...prev, roles: [...prev.roles, roleId] }))
} else {
setCreateForm((prev) => ({ ...prev, roles: prev.roles.filter((role) => role !== roleId) }))
}
}
const getRoleBadgeVariant = (role: string) => {
switch (role) {
case "admin":
return "destructive" as const
case "financeiro":
return "default" as const
case "user":
return "secondary" as const
default:
return "outline" as const
}
}
const getRoleLabel = (roleId: string) => {
const role = AVAILABLE_ROLES.find((r) => r.id === roleId)
return role ? role.label : roleId
}
const canDeleteUser = (user: User) => {
const currentUserEmail = getCurrentUserEmail()
// Não permitir que o usuário delete a si mesmo
return user.email !== currentUserEmail
}
useEffect(() => {
fetchUsers()
}, [])
// Verificar se o usuário tem permissão para gerenciar usuários
if (!isAdmin()) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-red-100 p-6 mb-4">
<Shield className="h-8 w-8 text-red-600" />
</div>
<h3 className="text-lg font-medium mb-2">Acesso Negado</h3>
<p className="text-gray-500 max-w-md">
Você não tem permissão para acessar o gerenciamento de usuários. Entre em contato com um administrador.
</p>
</div>
)
}
return (
<div className="space-y-6">
{/* Cabeçalho com botão de criar */}
<div className="flex justify-end items-center">
<div className="flex gap-2">
<Button onClick={fetchUsers} disabled={isLoading} variant="outline">
{isLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
Atualizar
</Button>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Novo Usuário
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Criar Novo Usuário</DialogTitle>
<DialogDescription>Preencha as informações do novo usuário e suas permissões</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
value={createForm.email}
onChange={(e) => setCreateForm((prev) => ({ ...prev, email: e.target.value }))}
placeholder="usuario@exemplo.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Senha *</Label>
<Input
id="password"
type="password"
value={createForm.password}
onChange={(e) => setCreateForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="Senha do usuário"
minLength={8}
/>
</div>
<div className="space-y-2">
<Label>Permissões *</Label>
<div className="space-y-2">
{AVAILABLE_ROLES.map((role) => (
<div key={role.id} className="flex items-center space-x-2">
<Checkbox
id={`create-${role.id}`}
checked={createForm.roles.includes(role.id)}
onCheckedChange={(checked) => handleCreateRoleChange(role.id, checked as boolean)}
/>
<div className="flex-1">
<Label htmlFor={`create-${role.id}`} className="text-sm font-medium">
{role.label}
</Label>
<p className="text-xs text-gray-500">{role.description}</p>
</div>
</div>
))}
</div>
</div>
<div className="flex gap-2 pt-4">
<Button onClick={createUser} disabled={isCreating} className="flex-1">
{isCreating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
Criar Usuário
</Button>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)} disabled={isCreating}>
Cancelar
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert>
<AlertDescription>{success}</AlertDescription>
</Alert>
)}
{/* Tabela de Usuários */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Lista de Usuários ({users.length} usuários)
</CardTitle>
</CardHeader>
<CardContent>
{users.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Permissões</TableHead>
<TableHead>Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user._id.$oid}>
<TableCell>
{editingUser === user._id.$oid ? (
<Input
type="email"
value={editForm.email}
onChange={(e) => setEditForm((prev) => ({ ...prev, email: e.target.value }))}
placeholder="Email do usuário"
/>
) : (
<div className="font-medium">{user.email}</div>
)}
{editingUser === user._id.$oid && (
<div className="mt-2">
<Input
type="password"
value={editForm.password}
onChange={(e) => setEditForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="Nova senha (deixe vazio para não alterar)"
/>
</div>
)}
</TableCell>
<TableCell>
{editingUser === user._id.$oid ? (
<div className="space-y-2">
{AVAILABLE_ROLES.map((role) => (
<div key={role.id} className="flex items-center space-x-2">
<Checkbox
id={`edit-${role.id}-${user._id.$oid}`}
checked={editForm.roles.includes(role.id)}
onCheckedChange={(checked) => handleEditRoleChange(role.id, checked as boolean)}
/>
<Label htmlFor={`edit-${role.id}-${user._id.$oid}`} className="text-sm">
{role.label}
</Label>
</div>
))}
</div>
) : (
<div className="flex flex-wrap gap-1">
{user.roles.map((role) => (
<Badge key={role} variant={getRoleBadgeVariant(role)}>
{getRoleLabel(role)}
</Badge>
))}
</div>
)}
</TableCell>
<TableCell>
<div className="flex gap-2">
{editingUser === user._id.$oid ? (
<>
<Button size="sm" onClick={() => updateUser(user._id.$oid)} disabled={isUpdating}>
{isUpdating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
</Button>
<Button size="sm" variant="outline" onClick={cancelEditUser} disabled={isUpdating}>
<X className="h-4 w-4" />
</Button>
</>
) : (
<>
<Button size="sm" variant="outline" onClick={() => startEditUser(user)}>
<Edit2 className="h-4 w-4" />
</Button>
{canDeleteUser(user) && (
<Button
size="sm"
variant="outline"
onClick={() => deleteUser(user._id.$oid)}
disabled={isDeleting === user._id.$oid}
>
{isDeleting === user._id.$oid ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
)}
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-8 text-gray-500">
{isLoading ? "Carregando..." : "Nenhum usuário encontrado."}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,98 @@
interface JWTPayload {
fresh: boolean
iat: number
jti: string
type: string
sub: string
nbf: number
csrf: string
exp: number
roles: string[]
}
export function decodeJWT(token: string): JWTPayload | null {
try {
// JWT tem 3 partes separadas por pontos: header.payload.signature
const parts = token.split(".")
if (parts.length !== 3) {
return null
}
// Decodificar a parte do payload (segunda parte)
const payload = parts[1]
// Adicionar padding se necessário para base64
const paddedPayload = payload + "=".repeat((4 - (payload.length % 4)) % 4)
// Decodificar de base64
const decodedPayload = atob(paddedPayload)
// Parse do JSON
const parsedPayload: JWTPayload = JSON.parse(decodedPayload)
return parsedPayload
} catch (error) {
console.error("Erro ao decodificar JWT:", error)
return null
}
}
export function getUserRoles(): string[] {
try {
const token = localStorage.getItem("access_token")
if (!token) {
return []
}
const payload = decodeJWT(token)
return payload?.roles || []
} catch (error) {
console.error("Erro ao obter roles do usuário:", error)
return []
}
}
export function hasRole(role: string): boolean {
const roles = getUserRoles()
return roles.includes(role)
}
export function isAdmin(): boolean {
return hasRole("admin")
}
export function getCurrentUserEmail(): string | null {
try {
const token = localStorage.getItem("access_token")
if (!token) {
return null
}
const payload = decodeJWT(token)
return payload?.sub || null
} catch (error) {
console.error("Erro ao obter email do usuário:", error)
return null
}
}
export function isTokenExpired(): boolean {
try {
const token = localStorage.getItem("access_token")
if (!token) {
return true
}
const payload = decodeJWT(token)
if (!payload) {
return true
}
// exp está em segundos, Date.now() está em milissegundos
const currentTime = Math.floor(Date.now() / 1000)
return payload.exp < currentTime
} catch (error) {
console.error("Erro ao verificar expiração do token:", error)
return true
}
}

View File

@ -8,6 +8,7 @@
"name": "frontend",
"version": "0.1.0",
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
@ -952,6 +953,35 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz",
"integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",

View File

@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",