547 lines
19 KiB
TypeScript
547 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 flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<h1 className="text-lg font-semibold">
|
|
Utilisateurs de la structure<span className="hidden sm:inline"> {clientInfo.name}</span>
|
|
</h1>
|
|
<Link
|
|
href="/vos-acces/nouveau"
|
|
className="inline-flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm bg-emerald-600 text-white hover:bg-emerald-700 w-full sm:w-auto"
|
|
>
|
|
+ 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">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-slate-50 text-slate-600">
|
|
<tr>
|
|
<th className="sticky left-0 z-10 bg-slate-50 text-left px-4 py-3 border-r">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="sticky left-0 z-10 bg-white px-4 py-3 whitespace-nowrap border-r">{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>
|
|
|
|
<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>
|
|
);
|
|
}
|