feat: implemented rules to manager users
parent
64b990d248
commit
0444ca949e
|
@ -5,7 +5,8 @@ from .extensions import init_mongo, init_jwt, init_redis
|
||||||
from .errors.handlers import register_error_handlers
|
from .errors.handlers import register_error_handlers
|
||||||
from .routes.usage_routes import usage_ns
|
from .routes.usage_routes import usage_ns
|
||||||
from .routes.auth_routes import auth_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
|
from flask_cors import CORS
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
|
@ -37,7 +38,8 @@ def create_app():
|
||||||
|
|
||||||
api.add_namespace(usage_ns, path='/usage')
|
api.add_namespace(usage_ns, path='/usage')
|
||||||
api.add_namespace(auth_ns, path='/auth')
|
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)
|
register_error_handlers(app)
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
from flask_bcrypt import generate_password_hash, check_password_hash
|
from flask_bcrypt import generate_password_hash, check_password_hash
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from pymongo import MongoClient
|
from pymongo import MongoClient
|
||||||
|
from bson.objectid import ObjectId
|
||||||
|
from bson.errors import InvalidId
|
||||||
|
|
||||||
class UserModel:
|
class UserModel:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
@ -13,7 +15,33 @@ class UserModel:
|
||||||
return self.collection.insert_one(user)
|
return self.collection.insert_one(user)
|
||||||
|
|
||||||
def find_by_email(self, email):
|
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):
|
def verify_password(self, hashed_password, plain_password):
|
||||||
return check_password_hash(hashed_password, plain_password)
|
return check_password_hash(hashed_password, plain_password)
|
||||||
|
|
|
@ -2,7 +2,13 @@ from flask_restx import fields, Namespace
|
||||||
|
|
||||||
auth_ns = Namespace('auth', description='Authentication')
|
auth_ns = Namespace('auth', description='Authentication')
|
||||||
|
|
||||||
signup_model = auth_ns.model('Signup', {
|
signup_model = auth_ns.model('SignUp', {
|
||||||
'email': fields.String(required=True),
|
'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)
|
||||||
})
|
})
|
|
@ -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')
|
||||||
|
})
|
|
@ -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')
|
||||||
|
})
|
||||||
|
|
||||||
|
|
|
@ -3,15 +3,21 @@ from flask import jsonify
|
||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
import traceback
|
import traceback
|
||||||
|
from bson.errors import InvalidId
|
||||||
|
|
||||||
|
|
||||||
def register_error_handlers(app):
|
def register_error_handlers(app):
|
||||||
@app.errorhandler(ValidationError)
|
@app.errorhandler(ValidationError)
|
||||||
def handle_validation_error(e):
|
def handle_validation_error(e):
|
||||||
return jsonify({"error": e.errors()}), 400
|
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)
|
@app.errorhandler(HTTPException)
|
||||||
def handle_http_exception(e):
|
def handle_http_exception(e):
|
||||||
return jsonify({"errror": e.description}), e.code
|
return jsonify({"error": e.description}), e.code
|
||||||
|
|
||||||
@app.errorhandler(Exception)
|
@app.errorhandler(Exception)
|
||||||
def handle_unexpected_exception(e):
|
def handle_unexpected_exception(e):
|
||||||
|
|
|
@ -1,31 +1,50 @@
|
||||||
from flask_restx import Namespace, Resource, fields
|
from flask_restx import Resource
|
||||||
from flask import request
|
from flask import request, current_app
|
||||||
from flask_jwt_extended import create_access_token
|
from flask_jwt_extended import create_access_token
|
||||||
from app.db.models import UserModel
|
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')
|
@auth_ns.route('/signup')
|
||||||
class SignUp(Resource):
|
class SignUp(Resource):
|
||||||
@auth_ns.expect(signup_model)
|
@auth_ns.expect(signup_model)
|
||||||
def post(self):
|
def post(self):
|
||||||
data = request.get_json()
|
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')
|
@auth_ns.route('/login')
|
||||||
class Login(Resource):
|
class Login(Resource):
|
||||||
@auth_ns.expect(signup_model)
|
@auth_ns.expect(signin_model)
|
||||||
def post(self):
|
def post(self):
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
||||||
|
validated = SigInRequest(**data)
|
||||||
|
|
||||||
user_model = UserModel()
|
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']):
|
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
|
return {'access_token': access_token}, 200
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
from flask_restx import Namespace, Resource, fields
|
from flask_restx import Resource
|
||||||
from flask import request, current_app
|
from flask import request
|
||||||
import requests
|
import requests
|
||||||
from bson import json_util
|
|
||||||
from app.db.models import UserModel
|
|
||||||
from app.docs.biling_models import billing_ns, product_model, update_price_model
|
from app.docs.biling_models import billing_ns, product_model, update_price_model
|
||||||
from app.config import Config
|
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
|
BILLING_API_URL = Config.BILLING_API_URL
|
||||||
HEADERS = {
|
HEADERS = {
|
||||||
|
@ -17,6 +16,8 @@ HEADERS = {
|
||||||
@billing_ns.route('/product')
|
@billing_ns.route('/product')
|
||||||
class CreateProduct(Resource):
|
class CreateProduct(Resource):
|
||||||
@billing_ns.expect(product_model)
|
@billing_ns.expect(product_model)
|
||||||
|
@jwt_required()
|
||||||
|
@role_required('admin', 'user')
|
||||||
def post(self):
|
def post(self):
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
||||||
|
@ -27,6 +28,8 @@ class CreateProduct(Resource):
|
||||||
@billing_ns.route('/product/<string:product_id>')
|
@billing_ns.route('/product/<string:product_id>')
|
||||||
class UpdateProduct(Resource):
|
class UpdateProduct(Resource):
|
||||||
@billing_ns.expect(update_price_model)
|
@billing_ns.expect(update_price_model)
|
||||||
|
@jwt_required()
|
||||||
|
@role_required('admin', 'user')
|
||||||
def patch(self, product_id):
|
def patch(self, product_id):
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
||||||
|
@ -36,6 +39,8 @@ class UpdateProduct(Resource):
|
||||||
|
|
||||||
@billing_ns.route('/products')
|
@billing_ns.route('/products')
|
||||||
class ListProducts(Resource):
|
class ListProducts(Resource):
|
||||||
|
@jwt_required()
|
||||||
|
@role_required('admin', 'user')
|
||||||
def get(self):
|
def get(self):
|
||||||
response = requests.get(url=f'{BILLING_API_URL}/billing/products', headers=HEADERS)
|
response = requests.get(url=f'{BILLING_API_URL}/billing/products', headers=HEADERS)
|
||||||
return response.json(), response.status_code
|
return response.json(), response.status_code
|
|
@ -1,5 +1,4 @@
|
||||||
import os
|
import os
|
||||||
import hashlib
|
|
||||||
from bson import json_util
|
from bson import json_util
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
@ -17,6 +16,7 @@ from app.docs.usage_models import (
|
||||||
transcription_data_query_params, usage_ns)
|
transcription_data_query_params, usage_ns)
|
||||||
from app.utils.current_date import is_current_date
|
from app.utils.current_date import is_current_date
|
||||||
from app.utils.hash_key import set_hash_key
|
from app.utils.hash_key import set_hash_key
|
||||||
|
from app.utils.role_required import role_required
|
||||||
|
|
||||||
TMP_DIR = '/tmp'
|
TMP_DIR = '/tmp'
|
||||||
|
|
||||||
|
@ -28,6 +28,7 @@ class TranscriptionExport(Resource):
|
||||||
@usage_ns.response(400, 'Validation error')
|
@usage_ns.response(400, 'Validation error')
|
||||||
@usage_ns.response(404, 'File not found')
|
@usage_ns.response(404, 'File not found')
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@role_required('admin', 'user')
|
||||||
def get(self):
|
def get(self):
|
||||||
"""
|
"""
|
||||||
Export transcription report in XLSX.
|
Export transcription report in XLSX.
|
||||||
|
@ -84,6 +85,7 @@ class TranscriptionUsageData(Resource):
|
||||||
@usage_ns.response(200, 'success')
|
@usage_ns.response(200, 'success')
|
||||||
@usage_ns.response(400, 'Validation error')
|
@usage_ns.response(400, 'Validation error')
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@role_required('admin', 'user')
|
||||||
def get(self):
|
def get(self):
|
||||||
"""
|
"""
|
||||||
Get transcription report data.
|
Get transcription report data.
|
||||||
|
@ -123,6 +125,7 @@ class TranscriptionModelPrices(Resource):
|
||||||
@usage_ns.response(200, 'success')
|
@usage_ns.response(200, 'success')
|
||||||
@usage_ns.response(400, 'Validation error')
|
@usage_ns.response(400, 'Validation error')
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@role_required('admin', 'user')
|
||||||
def get(self):
|
def get(self):
|
||||||
"""
|
"""
|
||||||
Get model pricing and supplier information
|
Get model pricing and supplier information
|
||||||
|
@ -161,6 +164,7 @@ class UpdateUsageCost(Resource):
|
||||||
@usage_ns.response(200, 'success')
|
@usage_ns.response(200, 'success')
|
||||||
@usage_ns.response(400, 'Validation error')
|
@usage_ns.response(400, 'Validation error')
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@role_required('admin', 'user')
|
||||||
def patch(self):
|
def patch(self):
|
||||||
"""
|
"""
|
||||||
Updates the total cost of using products by company
|
Updates the total cost of using products by company
|
||||||
|
@ -190,6 +194,7 @@ class UpdateModelPrice(Resource):
|
||||||
@usage_ns.response(200, 'Sucess')
|
@usage_ns.response(200, 'Sucess')
|
||||||
@usage_ns.response(400, 'Validation error')
|
@usage_ns.response(400, 'Validation error')
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@role_required('admin', 'user')
|
||||||
def patch(self, id):
|
def patch(self, id):
|
||||||
"""
|
"""
|
||||||
Updates the price of a specific model by ID.
|
Updates the price of a specific model by ID.
|
||||||
|
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class SigInRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
password: str
|
|
@ -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"]]
|
|
@ -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
|
|
@ -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
|
|
@ -8,22 +8,38 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { LogOut } from "lucide-react"
|
import { LogOut } from "lucide-react"
|
||||||
import TranscriptionTable from "@/components/transcription-table"
|
import TranscriptionTable from "@/components/transcription-table"
|
||||||
import ModelPricesTable from "@/components/model-prices-table"
|
import ModelPricesTable from "@/components/model-prices-table"
|
||||||
import CostUpdateForm from "@/components/cost-update-form"
|
|
||||||
import ProductManagement from "@/components/product-management"
|
import ProductManagement from "@/components/product-management"
|
||||||
|
import UserManagement from "@/components/user-management"
|
||||||
|
import { isAdmin, isTokenExpired, getCurrentUserEmail, getUserRoles } from "@/lib/auth"
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
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()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem("access_token")
|
const token = localStorage.getItem("access_token")
|
||||||
if (!token) {
|
|
||||||
|
if (!token || isTokenExpired()) {
|
||||||
|
// Token não existe ou expirou
|
||||||
|
localStorage.removeItem("access_token")
|
||||||
router.push("/")
|
router.push("/")
|
||||||
} else {
|
return
|
||||||
setIsAuthenticated(true)
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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])
|
}, [router])
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
|
@ -48,7 +64,15 @@ export default function Dashboard() {
|
||||||
<header className="bg-white shadow">
|
<header className="bg-white shadow">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between items-center py-6">
|
<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">
|
<Button onClick={handleLogout} variant="outline">
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
Sair
|
Sair
|
||||||
|
@ -59,11 +83,11 @@ export default function Dashboard() {
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
<Tabs defaultValue="transcription" className="space-y-6">
|
<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="transcription">Dados de Transcrição</TabsTrigger>
|
||||||
<TabsTrigger value="models">Preços dos Modelos</TabsTrigger>
|
<TabsTrigger value="models">Preços dos Modelos</TabsTrigger>
|
||||||
{/* <TabsTrigger value="costs">Atualizar Custos</TabsTrigger> */}
|
|
||||||
<TabsTrigger value="products">Produtos</TabsTrigger>
|
<TabsTrigger value="products">Produtos</TabsTrigger>
|
||||||
|
{userIsAdmin && <TabsTrigger value="users">Usuários</TabsTrigger>}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="transcription">
|
<TabsContent value="transcription">
|
||||||
|
@ -90,18 +114,6 @@ export default function Dashboard() {
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="products">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
@ -113,6 +125,20 @@ export default function Dashboard() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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 }
|
|
@ -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 }
|
|
|
@ -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 }
|
|
|
@ -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 }
|
|
|
@ -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,
|
|
||||||
}
|
|
|
@ -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 }
|
|
|
@ -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 }
|
|
|
@ -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,
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
}
|
|
|
@ -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 }
|
|
|
@ -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 }
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@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": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
|
Loading…
Reference in New Issue