espace-paie-odentas/app/(app)/vos-acces/page.tsx
2025-10-12 17:05:46 +02:00

545 lines
19 KiB
TypeScript

"use client";
import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import ConfirmableForm from "@/components/ConfirmableForm";
import { api } from "@/lib/fetcher";
import AccessDeniedCard from "@/components/AccessDeniedCard";
import { usePageTitle } from "@/hooks/usePageTitle";
// Types
type Member = {
user_id: string;
first_name?: string;
email: string;
role: "SUPER_ADMIN" | "ADMIN" | "AGENT" | "COMPTA";
created_at: string;
revoked: boolean;
revoked_at?: string;
};
type ClientInfo = {
id: string;
name: string;
api_name?: string;
} | null;
// Hook pour récupérer l'utilisateur courant (user_id) depuis /api/me
function useCurrentUserId() {
return useQuery({
queryKey: ["current-user-id"],
queryFn: async () => {
try {
const res = await fetch("/api/me", {
cache: "no-store",
headers: { Accept: "application/json" },
credentials: "include"
});
if (!res.ok) return null;
const me = await res.json();
return me.user_id || me.userId || me.id || null;
} catch {
return null;
}
},
staleTime: 30_000,
});
}
// Hook pour récupérer l'email de l'utilisateur courant depuis /api/me
function useCurrentUserEmail() {
return useQuery({
queryKey: ["current-user-email"],
queryFn: async () => {
try {
const res = await fetch("/api/me", {
cache: "no-store",
headers: { Accept: "application/json" },
credentials: "include",
});
if (!res.ok) return null;
const me = await res.json();
const rawEmail =
me.email ||
me.user_email ||
(typeof me.user === "object" && me.user?.email) ||
null;
return typeof rawEmail === "string" ? rawEmail : null;
} catch {
return null;
}
},
staleTime: 30_000,
});
}
// Hook pour récupérer les infos client depuis /api/me
function useClientInfo() {
return useQuery({
queryKey: ["client-info"],
queryFn: async () => {
try {
const res = await fetch("/api/me", {
cache: "no-store",
headers: { Accept: "application/json" },
credentials: "include"
});
if (!res.ok) return null;
const me = await res.json();
return {
id: me.active_org_id || me.orgId || "unknown",
name: me.active_org_name || me.orgName || "Organisation",
api_name: me.active_org_api_name || me.orgApiName
} as ClientInfo;
} catch {
return null;
}
},
staleTime: 30_000, // Cache 30s
});
}
// Hook pour récupérer les membres de l'organisation
function useOrganizationMembers(clientInfo: ClientInfo) {
return useQuery({
queryKey: ["organization-members", clientInfo?.id],
queryFn: async () => {
if (!clientInfo) throw new Error("Pas d'information client disponible");
// Utilise la fonction api() avec clientInfo pour injecter les bons headers
const response = await api<{ items: Member[] }>("/access", {}, clientInfo);
const raw = response.items || [];
const normalized = raw.map((it: any) => {
const uid =
it.user_id ||
it.user_uuid ||
it.userId ||
it.uid ||
it.id ||
(typeof it.user === "object" && (it.user.id || it.user.user_id || it.user.userId)) ||
null;
const role = (it.role || "").toUpperCase();
return {
...it,
user_id: uid,
role,
} as Member;
});
return normalized;
},
enabled: !!clientInfo, // Ne s'exécute que si on a les infos client
staleTime: 15_000,
});
}
export default function StaffUsersListPage() {
usePageTitle("Vos accès");
// Récupération des infos client
const { data: clientInfo = null, isLoading: isLoadingClient, error: clientError } = useClientInfo();
// Récupération de l'utilisateur courant
const { data: currentUserId, isLoading: isLoadingMe } = useCurrentUserId();
// Récupération de l'email utilisateur courant
const { data: currentUserEmail, isLoading: isLoadingEmail } = useCurrentUserEmail();
// Récupération des membres
const {
data: members = [],
isLoading: isLoadingMembers,
error: membersError,
refetch
} = useOrganizationMembers(clientInfo);
const isLoading = isLoadingClient || isLoadingMembers || isLoadingMe || isLoadingEmail;
const error = clientError || membersError;
const ROLE_PRIORITY: Record<Member["role"], number> = {
SUPER_ADMIN: 0,
ADMIN: 1,
AGENT: 2,
COMPTA: 3,
};
const sortedMembers = [...members].sort((a, b) => {
const pa = ROLE_PRIORITY[a.role];
const pb = ROLE_PRIORITY[b.role];
if (pa !== pb) return pa - pb;
const da = a.created_at ? new Date(a.created_at).getTime() : 0;
const db = b.created_at ? new Date(b.created_at).getTime() : 0;
// plus récent en premier
return db - da;
});
// États de chargement et erreur
if (isLoading) {
return (
<main className="p-6">
<h1 className="text-lg font-semibold">Utilisateurs de la structure</h1>
<p className="text-sm text-slate-600">Chargement...</p>
</main>
);
}
if (error || !clientInfo) {
const msg = (error as any)?.message || "Aucune structure active trouvée";
const isForbidden = /forbidden|403|not_allowed|insufficient|habilitations/i.test(String(msg));
return (
<main className="p-6 space-y-4">
<h1 className="text-lg font-semibold">Utilisateurs de la structure</h1>
{isForbidden ? (
<AccessDeniedCard
title="Accès restreint"
message="Vous n'avez pas les habilitations nécessaires pour accéder à la gestion des utilisateurs."
hint="Seul un ADMIN ou SUPER ADMIN peut accéder à cette section."
/>
) : (
<div className="rounded-xl border border-red-200 bg-red-50 text-red-700 p-4 text-sm">
Erreur de chargement : {msg}
</div>
)}
</main>
);
}
return (
<main className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold">
Utilisateurs de la structure {clientInfo.name}
</h1>
<Link
href="/vos-acces/nouveau"
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm bg-emerald-600 text-white hover:bg-emerald-700"
>
+ Créer un utilisateur
</Link>
</div>
<section className="rounded-2xl border bg-white p-4">
<h2 className="text-sm font-semibold mb-2">Niveaux d'habilitation</h2>
<ul className="text-sm leading-6 text-slate-700 list-disc pl-5 space-y-1">
<li>
<span className="font-medium uppercase tracking-wide text-xs">Super Admin</span> — accès principal avec un accès total : gestion des utilisateurs (création, modification de niveau, révocation),
toutes les données (contrats, paies, salarié·es, facturation). Ne peut être modifié que par le support Odentas.
</li>
<li>
<span className="font-medium uppercase tracking-wide text-xs">Admin</span> — accès total : gestion des utilisateurs (création, modification de niveau, révocation) sauf le Super Admin,
toutes les données (contrats, paies, salarié·es, facturation).
</li>
<li>
<span className="font-medium uppercase tracking-wide text-xs">Agent</span> — accès opérationnel courant : création et suivi des contrats/paies et consultation salariés.
Pas d'accès à la facturation ni à la gestion des utilisateurs.
</li>
<li>
<span className="font-medium uppercase tracking-wide text-xs">Compta</span> accès en lecture aux éléments financiers (paies, cotisations, facturation) et documents associés.
Pas de création/modification de contrats ni gestion des utilisateurs.
</li>
</ul>
</section>
<div className="rounded-2xl border bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-4 py-3">Prénom</th>
<th className="text-left px-4 py-3">Email</th>
<th className="text-left px-4 py-3">Niveau</th>
<th className="text-left px-4 py-3">Créé le</th>
<th className="text-left px-4 py-3">Statut</th>
<th className="text-left px-4 py-3">Actions</th>
</tr>
</thead>
<tbody>
{members.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-6 text-slate-500">
Aucun utilisateur pour cette structure.
</td>
</tr>
) : (
sortedMembers.map((m) => {
const created = m.created_at ? new Date(m.created_at as string) : null;
const createdFmt = created
? created.toLocaleString("fr-FR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "—";
const status = m.revoked ? "Révoqué" : "Actif";
const disabled = !!m.revoked;
const isSelf =
(currentUserId && m.user_id === currentUserId) ||
(currentUserEmail && typeof m.email === "string" && m.email.toLowerCase() === currentUserEmail.toLowerCase());
// console.debug("ROW SELF CHECK", { currentUserId, rowUserId: m.user_id, currentUserEmail, rowEmail: m.email, isSelf });
return (
<tr key={m.user_id} className="border-t align-top">
<td className="px-4 py-3 whitespace-nowrap">{m.first_name || "—"}</td>
<td className="px-4 py-3">{m.email}</td>
<td className="px-4 py-3 uppercase tracking-wide text-xs">{m.role || "—"}</td>
<td className="px-4 py-3 whitespace-nowrap">{createdFmt}</td>
<td className="px-4 py-3">{status}</td>
<td className="px-4 py-2">
{
m.role === "SUPER_ADMIN" ? (
// Cas SUPER_ADMIN : on garde la card actuelle
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 shadow-sm">
<div className="flex items-start gap-3">
<div className="space-y-1">
<p className="text-xs text-slate-600">
Contactez l'équipe Odentas pour modifier le <span className="font-medium uppercase tracking-wide text-xs">SUPER ADMIN</span>.
</p>
</div>
</div>
</div>
) : isSelf ? (
// Cas utilisateur connecté (non SUPER_ADMIN) : petite card informative
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 shadow-sm">
<div className="space-y-1">
<p className="text-xs text-slate-600">
Contactez votre <span className="font-medium uppercase tracking-wide text-xs">SUPER ADMIN</span> ou le Staff Odentas pour supprimer votre propre accès.
</p>
</div>
</div>
) : (
// Cas standard : formulaires d'action
<div className="flex flex-col gap-2">
<RoleUpdateForm
member={m}
orgId={clientInfo.id}
disabled={disabled}
onSuccess={() => refetch()}
/>
{!disabled ? (
<RevokeForm
member={m}
orgId={clientInfo.id}
onSuccess={() => refetch()}
/>
) : (
<UnrevokeForm
member={m}
orgId={clientInfo.id}
onSuccess={() => refetch()}
/>
)}
{disabled && (
<div className="text-xs text-slate-500">
Utilisateur révoqué accès désactivé
</div>
)}
</div>
)
}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<div className="text-xs text-slate-500">
* La suppression est une révocation : l'utilisateur reste historisé dans la base, mais ne peut plus se connecter.
</div>
</main>
);
}
// Composant pour la modification du rôle
function RoleUpdateForm({
member,
orgId,
disabled,
onSuccess
}: {
member: Member;
orgId: string;
disabled: boolean;
onSuccess: () => void;
}) {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (formData: FormData) => {
if (isSubmitting) return;
setIsSubmitting(true);
try {
const role = formData.get("role") as string;
const response = await fetch(`/api/access/${member.user_id}/role`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ role }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || "Erreur lors de la modification du rôle");
}
onSuccess();
} catch (error: any) {
alert(`Erreur: ${error.message}`);
} finally {
setIsSubmitting(false);
}
};
return (
<ConfirmableForm
action={handleSubmit}
className="flex items-center gap-2"
disabled={disabled || isSubmitting}
confirmTitle="Confirmer la modification du niveau"
confirmMessage={`Voulez-vous vraiment appliquer ce niveau à ${member.first_name || member.email} ?`}
>
<input type="hidden" name="org_id" value={orgId} />
<input type="hidden" name="user_id" value={member.user_id} />
<select
name="role"
defaultValue={member.role || "ADMIN"}
disabled={disabled || isSubmitting}
className="px-2 py-1 rounded border"
>
<option value="ADMIN">Admin</option>
<option value="AGENT">Agent</option>
<option value="COMPTA">Compta</option>
</select>
<button
type="submit"
disabled={disabled || isSubmitting}
className="px-3 py-1 rounded bg-slate-900 text-white text-xs hover:bg-slate-700 disabled:opacity-50"
>
{isSubmitting ? "..." : "Modifier"}
</button>
</ConfirmableForm>
);
}
// Composant pour la révocation
function RevokeForm({
member,
orgId,
onSuccess
}: {
member: Member;
orgId: string;
onSuccess: () => void;
}) {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
try {
const response = await fetch(`/api/access/${member.user_id}/revoke`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || "Erreur lors de la révocation");
}
onSuccess();
} catch (error: any) {
alert(`Erreur: ${error.message}`);
} finally {
setIsSubmitting(false);
}
};
return (
<ConfirmableForm
action={handleSubmit}
className="flex items-center gap-2"
disabled={isSubmitting}
confirmTitle="Confirmer la révocation"
confirmMessage={`Voulez-vous révoquer l'accès de ${member.first_name || member.email} pour cette structure ?`}
confirmCta="Révoquer"
>
<input type="hidden" name="org_id" value={orgId} />
<input type="hidden" name="user_id" value={member.user_id} />
<button
type="submit"
disabled={isSubmitting}
className="px-3 py-1 rounded text-xs bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
{isSubmitting ? "..." : "Supprimer"}
</button>
</ConfirmableForm>
);
}
// Composant pour la réintégration
function UnrevokeForm({
member,
orgId,
onSuccess
}: {
member: Member;
orgId: string;
onSuccess: () => void;
}) {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
try {
const response = await fetch(`/api/access/${member.user_id}/unrevoke`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || "Erreur lors de la réintégration");
}
onSuccess();
} catch (error: any) {
alert(`Erreur: ${error.message}`);
} finally {
setIsSubmitting(false);
}
};
return (
<ConfirmableForm
action={handleSubmit}
className="flex items-center gap-2"
disabled={isSubmitting}
confirmTitle="Confirmer la réintégration"
confirmMessage={`Réintégrer ${member.first_name || member.email} et lui redonner l'accès à cette structure ?`}
confirmCta="Réintégrer"
>
<input type="hidden" name="org_id" value={orgId} />
<input type="hidden" name="user_id" value={member.user_id} />
<button
type="submit"
disabled={isSubmitting}
className="px-3 py-1 rounded text-xs bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50"
>
{isSubmitting ? "..." : "Réintégrer"}
</button>
</ConfirmableForm>
);
}