beetask/frontend/src/pages/OrganizationAdminPage.tsx
Gabriel Santana de Menezes 59a5c61585 feat: add MinIO image server and profile photo upload
Infrastructure: MinIO service added to docker-compose (prod + dev) with
minio-init container that creates the avatars bucket with public read
policy. Traefik routes minio.task.beetech.dev → port 9000.

Backend: native MinIO SDK integration via MinioStorageService;
UploadUserPhotoUseCase validates 5 MB limit and image content-type;
new POST /auth/profile/photo endpoint (multipart). All hardcoded
ui-avatars.com fallback URLs removed from 6 use cases — backend now
returns null when user has no photo.

Frontend: ProfilePage gains clickable avatar with camera overlay,
file picker, preview, and photo upload on save. UserAvatar gains
onError handler for broken URLs and xs size. OrganizationAdminPage
replaces hardcoded ui-avatars.com with UserAvatar component.

CI/CD: deploy-main.yml passes 5 new MinIO secrets to the server and
writes them to .env.production.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:01:59 -04:00

139 lines
5.2 KiB
TypeScript

import React, { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { Users, Check, X, Mail } from "lucide-react";
import {
organizationService,
type JoinRequest,
} from "../services/organizationService";
import { useAuth } from "../contexts/AuthContext";
import { UserAvatar } from "../components/UserAvatar";
export const OrganizationAdminPage: React.FC = () => {
const { activeOrg } = useAuth();
const [requests, setRequests] = useState<JoinRequest[]>([]);
const [isLoading, setIsLoading] = useState(true);
const orgToManage = activeOrg!;
useEffect(() => {
if (orgToManage) {
loadRequests();
}
}, [orgToManage]);
const loadRequests = async () => {
if (!orgToManage) return;
try {
const data = await organizationService.listPendingRequests(
orgToManage.id,
);
setRequests(data);
} catch (error) {
console.error("Failed to load requests", error);
} finally {
setIsLoading(false);
}
};
const handleAction = async (userId: number, approved: boolean) => {
if (!orgToManage) return;
try {
if (approved) {
await organizationService.approveRequest(orgToManage.id, userId);
} else {
await organizationService.rejectRequest(orgToManage.id, userId);
}
setRequests((prev) => prev.filter((r) => r.userId !== userId));
} catch (error) {
console.error("Action failed", error);
alert("Erro ao processar solicitação.");
}
};
return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-4xl font-black tracking-tight text-text-main">
Gestão da <span className="text-brand">{orgToManage.name}</span>
</h1>
<p className="mt-2 text-text-muted font-medium">
Gerencie membros e solicitações de entrada
</p>
</div>
</div>
<div className="bg-surface border border-border-soft rounded-[2.5rem] shadow-panel overflow-hidden">
<div className="p-8 border-b border-border-soft bg-surface-muted/30">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-brand/10 text-brand rounded-xl flex items-center justify-center">
<Users size={20} />
</div>
<h2 className="text-xl font-bold text-text-main">
Solicitações Pendentes
</h2>
<span className="ml-auto bg-brand text-white text-xs font-bold px-3 py-1 rounded-full">
{requests.length} novos
</span>
</div>
</div>
<div className="divide-y divide-border-soft">
{isLoading ? (
<div className="p-20 flex justify-center">
<div className="w-8 h-8 border-4 border-brand/30 border-t-brand rounded-full animate-spin" />
</div>
) : requests.length === 0 ? (
<div className="p-20 text-center text-text-muted font-medium">
Nenhuma solicitação pendente no momento.
</div>
) : (
requests.map((request) => (
<motion.div
key={request.userId}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="p-6 flex flex-col sm:flex-row items-center justify-between gap-6 hover:bg-surface-muted/20 transition-colors"
>
<div className="flex items-center gap-4">
<UserAvatar
name={request.name}
photo={request.photo}
size="md"
className="border border-border-soft"
/>
<div>
<h3 className="font-bold text-text-main text-lg">
{request.name}
</h3>
<div className="flex items-center gap-2 text-text-muted text-sm font-medium">
<Mail size={14} />
{request.email}
</div>
</div>
</div>
<div className="flex items-center gap-3 w-full sm:w-auto">
<button
onClick={() => handleAction(request.userId, false)}
className="flex-1 sm:flex-none flex items-center justify-center gap-2 px-6 py-2.5 rounded-xl border border-border-soft text-text-muted font-bold hover:bg-danger/10 hover:text-danger hover:border-danger/20 transition-all"
>
<X size={18} />
Recusar
</button>
<button
onClick={() => handleAction(request.userId, true)}
className="flex-1 sm:flex-none flex items-center justify-center gap-2 px-6 py-2.5 rounded-xl bg-brand text-white font-bold hover:bg-brand-strong shadow-lg shadow-brand/20 transition-all active:scale-[0.98]"
>
<Check size={18} />
Aprovar
</button>
</div>
</motion.div>
))
)}
</div>
</div>
</div>
);
};