feat: Gestion complète des utilisateurs staff avec filtres et tri

- Ajout de la création d'utilisateurs staff (STAFF et SUPER_STAFF)
- Email de notification avec lien d'activation (paie.odentas.fr)
- API de révocation/restauration des utilisateurs staff
- Sécurité: SUPER_STAFF ne peut pas être révoqué
- Sécurité: Seul SUPER_STAFF peut créer d'autres SUPER_STAFF
- Tableau des utilisateurs clients avec filtres (organisation, niveau, statut)
- Tri dynamique sur toutes les colonnes (prénom, email, organisation, niveau, date)
- Utilisation du client admin pour contourner les RLS
- Interface avec recherche et filtres avancés
This commit is contained in:
odentas 2025-11-28 00:48:14 +01:00
parent ad2a9c6b7d
commit 956d655b7a
8 changed files with 1095 additions and 135 deletions

View file

@ -3,6 +3,7 @@ import { createSbServer } from "@/lib/supabaseServer";
import Link from "next/link";
import { Suspense } from "react";
import InviteForm from "@/components/staff/InviteForm";
import CreateStaffForm from "@/components/staff/CreateStaffForm";
import { Metadata } from "next";
export const metadata: Metadata = {
@ -13,11 +14,11 @@ async function getContext() {
const sb = createSbServer();
const { data: { user } } = await sb.auth.getUser();
if (!user) return { isStaff: false, orgs: [] };
if (!user) return { isStaff: false, isSuperStaff: false, orgs: [] };
const { data: me } = await sb
.from("staff_users")
.select("is_staff")
.select("is_staff, super_staff")
.eq("user_id", user.id)
.maybeSingle();
@ -26,11 +27,15 @@ async function getContext() {
.select("id,name")
.order("name", { ascending: true });
return { isStaff: !!me?.is_staff, orgs: orgs || [] };
return {
isStaff: !!me?.is_staff,
isSuperStaff: !!me?.super_staff,
orgs: orgs || []
};
}
export default async function Page() {
const { isStaff, orgs } = await getContext();
export default async function Page({ searchParams }: { searchParams: { type?: string } }) {
const { isStaff, isSuperStaff, orgs } = await getContext();
if (!isStaff) {
return (
<div className="max-w-3xl mx-auto p-6">
@ -40,6 +45,8 @@ export default async function Page() {
);
}
const type = searchParams.type || 'client';
return (
<div className="max-w-3xl mx-auto p-6">
<div className="mb-6 flex items-center justify-between">
@ -47,8 +54,38 @@ export default async function Page() {
<Link href="/staff/utilisateurs" className="text-sm text-slate-600 hover:underline">Retour</Link>
</div>
{/* Onglets pour choisir le type d'utilisateur */}
<div className="mb-6 flex gap-2 border-b border-slate-200">
<Link
href="/staff/utilisateurs/nouveau?type=client"
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
type === 'client'
? 'border-indigo-600 text-indigo-600'
: 'border-transparent text-slate-600 hover:text-slate-900'
}`}
>
Utilisateur Client
</Link>
{isSuperStaff && (
<Link
href="/staff/utilisateurs/nouveau?type=staff"
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
type === 'staff'
? 'border-indigo-600 text-indigo-600'
: 'border-transparent text-slate-600 hover:text-slate-900'
}`}
>
Utilisateur Staff
</Link>
)}
</div>
<Suspense>
<InviteForm orgs={orgs} />
{type === 'staff' && isSuperStaff ? (
<CreateStaffForm isSuperStaff={isSuperStaff} />
) : (
<InviteForm orgs={orgs} />
)}
</Suspense>
</div>
);

View file

@ -1,6 +1,7 @@
// app/(app)/staff/utilisateurs/page.tsx
import Link from "next/link";
import ConfirmableForm from "@/components/ConfirmableForm";
import ClientUsersTable from "@/components/staff/ClientUsersTable";
import { createSbServer } from "@/lib/supabaseServer";
import { createClient } from "@supabase/supabase-js";
import { Metadata } from "next";
@ -82,6 +83,71 @@ async function fetchAllStaffUsers() {
}
}
async function fetchStaffMembers() {
try {
// Utiliser directement le client admin pour contourner les RLS
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { autoRefreshToken: false, persistSession: false } }
);
// Récupérer tous les utilisateurs staff (actifs ET révoqués) avec le client admin
const { data: staffUsers, error: staffError } = await admin
.from("staff_users")
.select("user_id, is_staff, super_staff, created_at")
.order("created_at", { ascending: false });
if (staffError) {
console.error('Erreur récupération staff_users:', staffError);
return [];
}
if (!staffUsers || staffUsers.length === 0) {
console.log("Aucun staff_users trouvé");
return [];
}
console.log(`${staffUsers.length} staff_users trouvés:`, staffUsers.map(s => ({ id: s.user_id, is_staff: s.is_staff, super_staff: s.super_staff })));
// Récupérer les informations de chaque utilisateur staff
const staffData: any[] = [];
for (const staffUser of staffUsers) {
try {
const { data: userData, error: userError } = await admin.auth.admin.getUserById(staffUser.user_id);
if (userError) {
console.error(`❌ Erreur getUserById pour ${staffUser.user_id}:`, userError);
continue;
}
if (userData?.user) {
console.log(`✅ User récupéré: ${userData.user.email}`);
staffData.push({
user_id: staffUser.user_id,
email: userData.user.email,
first_name: userData.user.user_metadata?.first_name || userData.user.user_metadata?.display_name?.split(' ')[0] || null,
is_staff: staffUser.is_staff,
super_staff: staffUser.super_staff || false,
created_at: staffUser.created_at || userData.user.created_at,
});
} else {
console.warn(`⚠️ Pas de userData.user pour ${staffUser.user_id}`);
}
} catch (e) {
console.error("❌ Exception récupération user staff", staffUser.user_id, ":", e);
}
}
console.log(`📊 Résultat final: ${staffData.length} staff members`);
return staffData;
} catch (error) {
console.error('Erreur lors de la récupération des utilisateurs staff:', error);
return [];
}
}
async function fetchOrganizations() {
const sb = createSbServer();
const { data: orgs } = await sb
@ -108,11 +174,13 @@ export default async function StaffUsersListPage() {
// 2) Vérifier Staff
const { data: me } = await sb
.from("staff_users")
.select("is_staff")
.select("is_staff, super_staff")
.eq("user_id", user.id)
.maybeSingle();
const isStaff = !!me?.is_staff;
const isSuperStaff = !!me?.super_staff;
if (!isStaff) {
return (
<main className="p-6">
@ -123,9 +191,10 @@ export default async function StaffUsersListPage() {
}
// 3) Récupérer tous les utilisateurs (mode staff global) et les organisations
const [members, organizations] = await Promise.all([
const [members, organizations, staffMembers] = await Promise.all([
fetchAllStaffUsers(),
fetchOrganizations()
fetchOrganizations(),
fetchStaffMembers()
]);
// Créer un map des organisations pour l'affichage
@ -145,7 +214,7 @@ export default async function StaffUsersListPage() {
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold">Gestion des utilisateurs</h1>
<Link
href="/staff/utilisateurs/nouveau"
href={`/staff/utilisateurs/nouveau${isSuperStaff ? '?type=staff' : '?type=client'}`}
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
@ -174,132 +243,110 @@ export default async function StaffUsersListPage() {
</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">Organisation</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={7} className="px-4 py-6 text-slate-500">
Aucun utilisateur trouvé.
</td>
</tr>
) : (
members.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;
return (
<tr key={`${m.user_id}-${m.org_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">{m.organization_name}</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">
<div className="flex flex-col gap-2">
<ConfirmableForm
method="post"
action="/api/staff/users/update-role"
className="flex items-center gap-2"
disabled={disabled}
confirmTitle="Confirmer la modification du niveau"
confirmMessage={`Voulez-vous vraiment appliquer ce niveau à ${m.first_name || m.email} ?`}
>
<input type="hidden" name="org_id" value={m.org_id} />
<input type="hidden" name="user_id" value={m.user_id} />
<select
name="role"
defaultValue={m.role || "ADMIN"}
disabled={disabled}
className="px-2 py-1 rounded border"
>
<option value="SUPER_ADMIN">Super Admin</option>
<option value="ADMIN">Admin</option>
<option value="AGENT">Agent</option>
<option value="COMPTA">Compta</option>
</select>
<button
type="submit"
disabled={disabled}
className="px-3 py-1 rounded bg-slate-900 text-white text-xs hover:bg-slate-700 disabled:opacity-50"
>
Modifier
</button>
</ConfirmableForm>
{/* Section Utilisateurs Staff */}
{staffMembers && staffMembers.length > 0 && (
<section className="space-y-2">
<h2 className="text-base font-semibold">Utilisateurs Staff ({staffMembers.length})</h2>
<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">Type</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>
{staffMembers.map((s) => {
const created = s.created_at ? new Date(s.created_at) : null;
const createdFmt = created
? created.toLocaleString("fr-FR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "—";
const status = s.is_staff ? "Actif" : "Révoqué";
const isActive = s.is_staff;
return (
<tr key={s.user_id} className="border-t align-top">
<td className="px-4 py-3 whitespace-nowrap">{s.first_name || "—"}</td>
<td className="px-4 py-3">{s.email}</td>
<td className="px-4 py-3 uppercase tracking-wide text-xs font-medium">
<span className={s.super_staff ? 'text-purple-600' : 'text-indigo-600'}>
{s.super_staff ? 'SUPER_STAFF' : 'STAFF'}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap">{createdFmt}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${
isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{status}
</span>
</td>
<td className="px-4 py-2">
<div className="flex flex-col gap-2">
{isActive ? (
s.super_staff ? (
<div className="text-xs text-slate-500 italic">
Super Staff ne peut pas être révoqué
</div>
) : (
<ConfirmableForm
method="post"
action="/api/staff/users/revoke-staff"
className="flex items-center gap-2"
confirmTitle="Confirmer la révocation"
confirmMessage={`Voulez-vous révoquer l'accès staff de ${s.first_name || s.email} ?`}
confirmCta="Révoquer"
>
<input type="hidden" name="user_id" value={s.user_id} />
<button
type="submit"
className="px-3 py-1 rounded text-xs bg-red-600 text-white hover:bg-red-700"
>
Révoquer
</button>
</ConfirmableForm>
)
) : (
<ConfirmableForm
method="post"
action="/api/staff/users/restore-staff"
className="flex items-center gap-2"
confirmTitle="Confirmer la restauration"
confirmMessage={`Restaurer ${s.first_name || s.email} en tant qu'utilisateur staff ?`}
confirmCta="Restaurer"
>
<input type="hidden" name="user_id" value={s.user_id} />
<button
type="submit"
className="px-3 py-1 rounded text-xs bg-emerald-600 text-white hover:bg-emerald-700"
>
Restaurer
</button>
</ConfirmableForm>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)}
{!disabled ? (
<ConfirmableForm
method="post"
action="/api/staff/users/revoke"
className="flex items-center gap-2"
disabled={disabled}
confirmTitle="Confirmer la révocation"
confirmMessage={`Voulez-vous révoquer l'accès de ${m.first_name || m.email} pour cette structure ?`}
confirmCta="Révoquer"
>
<input type="hidden" name="org_id" value={m.org_id} />
<input type="hidden" name="user_id" value={m.user_id} />
<button
type="submit"
disabled={disabled}
className="px-3 py-1 rounded text-xs bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
Supprimer
</button>
</ConfirmableForm>
) : (
<ConfirmableForm
method="post"
action="/api/staff/users/unrevoke"
className="flex items-center gap-2"
disabled={false}
confirmTitle="Confirmer la réintégration"
confirmMessage={`Réintégrer ${m.first_name || m.email} et lui redonner l'accès à cette structure ?`}
confirmCta="Réintégrer"
>
<input type="hidden" name="org_id" value={m.org_id} />
<input type="hidden" name="user_id" value={m.user_id} />
<button
type="submit"
className="px-3 py-1 rounded text-xs bg-emerald-600 text-white hover:bg-emerald-700"
>
Réintégrer
</button>
</ConfirmableForm>
)}
{disabled && (
<div className="text-xs text-slate-500">Utilisateur révoqué accès désactivé</div>
)}
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Section Utilisateurs Clients */}
<ClientUsersTable members={members} organizations={organizations} />
<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.

View file

@ -0,0 +1,152 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { createClient } from "@supabase/supabase-js";
import { sendUniversalEmailV2 } from "@/lib/emailTemplateService";
/**
* POST /api/staff/users/create-staff
* Crée un nouvel utilisateur staff avec notification email
* Réservé aux utilisateurs staff existants
*/
export async function POST(req: Request) {
try {
const sb = createRouteHandlerClient({ cookies });
const { data: { user } } = await sb.auth.getUser();
if (!user) return new NextResponse("Unauthorized", { status: 401 });
// Vérifier que l'utilisateur courant est staff
const { data: staff } = await sb
.from("staff_users")
.select("is_staff, super_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staff?.is_staff) return new NextResponse("Forbidden", { status: 403 });
// Récupérer les données
const body = await req.json();
const email = String(body.email || "").trim().toLowerCase();
const firstName = String(body.firstName || "").trim();
const isSuperStaff = Boolean(body.isSuperStaff || false);
if (!email) {
return NextResponse.json({ error: "Email requis" }, { status: 400 });
}
// Seul un super_staff peut créer un autre super_staff
if (isSuperStaff && !staff.super_staff) {
return NextResponse.json({
error: "Seul un Super Staff peut créer un autre Super Staff"
}, { status: 403 });
}
// Créer le client admin pour gérer les utilisateurs
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { autoRefreshToken: false, persistSession: false } }
);
const origin = process.env.NEXT_PUBLIC_BASE_URL || "https://paie.odentas.fr";
// Générer un lien d'activation
const { data: linkData, error: linkError } = await admin.auth.admin.generateLink({
type: "magiclink",
email,
options: {
redirectTo: `${origin}/`,
data: {
first_name: firstName || null,
is_staff: true,
},
}
});
if (linkError) {
console.error("Erreur génération lien:", linkError);
return NextResponse.json({
error: "Erreur lors de la génération du lien d'activation",
message: linkError.message
}, { status: 400 });
}
const actionLink = linkData?.properties?.action_link;
const newUserId = linkData?.user?.id;
if (!newUserId || !actionLink) {
return NextResponse.json({ error: "Erreur lors de la création de l'utilisateur" }, { status: 400 });
}
// Ajouter l'utilisateur à la table staff_users
const { error: staffError } = await admin
.from("staff_users")
.insert({
user_id: newUserId,
is_staff: true,
super_staff: isSuperStaff
});
if (staffError) {
console.error("Erreur insertion staff_users:", staffError);
return NextResponse.json({
error: "Erreur lors de l'ajout aux utilisateurs staff",
message: staffError.message
}, { status: 400 });
}
// Configurer les préférences d'authentification
await admin
.from("user_auth_prefs")
.upsert({ user_id: newUserId, allow_magic_link: true, allow_password: false });
// Envoyer l'email de notification avec le système universel V2
try {
const creatorName = (user.user_metadata as any)?.first_name || user.email || 'Administrateur';
// Forcer le domaine production dans le lien d'activation
// Remplacer localhost, app-paie, ou tout autre domaine par paie.odentas.fr
const productionLink = actionLink
.replace(/http:\/\/localhost:\d+/, 'https://paie.odentas.fr')
.replace(/https?:\/\/app-paie\.odentas\.fr/, 'https://paie.odentas.fr')
.replace(/https?:\/\/[^/]+\//, 'https://paie.odentas.fr/');
await sendUniversalEmailV2({
type: 'staff-account-created',
toEmail: email,
data: {
firstName: firstName || undefined,
userEmail: email,
accountType: isSuperStaff ? 'Super Administrateur Staff' : 'Administrateur Staff',
createdBy: creatorName,
createdAt: new Date().toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'long',
year: 'numeric',
}),
linkValidity: '7 jours',
ctaUrl: productionLink,
}
});
console.log(`Email d'activation envoyé à ${email}`);
} catch (emailError) {
console.error("Erreur envoi email:", emailError);
// On ne bloque pas la création si l'email échoue
}
return NextResponse.json({
success: true,
userId: newUserId,
email,
message: "Utilisateur staff créé avec succès"
});
} catch (e: any) {
console.error("Erreur create-staff:", e);
return NextResponse.json({
error: "Erreur serveur",
message: e?.message || "Erreur interne"
}, { status: 500 });
}
}

View file

@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { createClient } from "@supabase/supabase-js";
/**
* POST /api/staff/users/restore-staff
* Restaure un utilisateur staff révoqué
* Réservé aux utilisateurs staff existants
*/
export async function POST(req: Request) {
try {
const sb = createRouteHandlerClient({ cookies });
const { data: { user } } = await sb.auth.getUser();
if (!user) return new NextResponse("Unauthorized", { status: 401 });
// Vérifier que l'utilisateur courant est staff
const { data: staff } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staff?.is_staff) return new NextResponse("Forbidden", { status: 403 });
// Récupérer les données (supporter à la fois JSON et FormData)
let targetUserId: string;
const contentType = req.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const body = await req.json();
targetUserId = String(body.user_id || "").trim();
} else {
const formData = await req.formData();
targetUserId = String(formData.get("user_id") || "").trim();
}
if (!targetUserId) {
return NextResponse.json({ error: "user_id requis" }, { status: 400 });
}
// Créer le client admin
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { autoRefreshToken: false, persistSession: false } }
);
// Restaurer le statut staff
const { error: restoreError } = await admin
.from("staff_users")
.upsert({ user_id: targetUserId, is_staff: true });
if (restoreError) {
console.error("Erreur restauration staff:", restoreError);
return NextResponse.json({
error: "Erreur lors de la restauration",
message: restoreError.message
}, { status: 400 });
}
// Rediriger si c'est une requête HTML
const accept = req.headers.get("accept") || "";
if (accept.includes("text/html")) {
return NextResponse.redirect(new URL("/staff/utilisateurs", req.url), { status: 303 });
}
return NextResponse.json({
success: true,
message: "Utilisateur staff restauré avec succès"
});
} catch (e: any) {
console.error("Erreur restore-staff:", e);
return NextResponse.json({
error: "Erreur serveur",
message: e?.message || "Erreur interne"
}, { status: 500 });
}
}

View file

@ -0,0 +1,101 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { createClient } from "@supabase/supabase-js";
/**
* POST /api/staff/users/revoke-staff
* Révoque un utilisateur staff
* Réservé aux utilisateurs staff existants
*/
export async function POST(req: Request) {
try {
const sb = createRouteHandlerClient({ cookies });
const { data: { user } } = await sb.auth.getUser();
if (!user) return new NextResponse("Unauthorized", { status: 401 });
// Vérifier que l'utilisateur courant est staff
const { data: staff } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
if (!staff?.is_staff) return new NextResponse("Forbidden", { status: 403 });
// Récupérer les données (supporter à la fois JSON et FormData)
let targetUserId: string;
const contentType = req.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const body = await req.json();
targetUserId = String(body.user_id || "").trim();
} else {
const formData = await req.formData();
targetUserId = String(formData.get("user_id") || "").trim();
}
if (!targetUserId) {
return NextResponse.json({ error: "user_id requis" }, { status: 400 });
}
// Empêcher l'auto-révocation
if (targetUserId === user.id) {
return NextResponse.json({
error: "Vous ne pouvez pas révoquer votre propre compte"
}, { status: 400 });
}
// Créer le client admin
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { autoRefreshToken: false, persistSession: false } }
);
// Vérifier si l'utilisateur ciblé est super_staff
const { data: targetStaff } = await admin
.from("staff_users")
.select("super_staff")
.eq("user_id", targetUserId)
.maybeSingle();
if (targetStaff?.super_staff) {
return NextResponse.json({
error: "Les utilisateurs Super Staff ne peuvent pas être révoqués"
}, { status: 403 });
}
// Retirer le statut staff
const { error: revokeError } = await admin
.from("staff_users")
.update({ is_staff: false })
.eq("user_id", targetUserId);
if (revokeError) {
console.error("Erreur révocation staff:", revokeError);
return NextResponse.json({
error: "Erreur lors de la révocation",
message: revokeError.message
}, { status: 400 });
}
// Rediriger si c'est une requête HTML
const accept = req.headers.get("accept") || "";
if (accept.includes("text/html")) {
return NextResponse.redirect(new URL("/staff/utilisateurs", req.url), { status: 303 });
}
return NextResponse.json({
success: true,
message: "Utilisateur staff révoqué avec succès"
});
} catch (e: any) {
console.error("Erreur revoke-staff:", e);
return NextResponse.json({
error: "Erreur serveur",
message: e?.message || "Erreur interne"
}, { status: 500 });
}
}

View file

@ -0,0 +1,363 @@
"use client";
import { useMemo, useState } from "react";
import ConfirmableForm from "@/components/ConfirmableForm";
type ClientMember = {
user_id: string;
email: string;
first_name: string | null;
organization_name: string;
role: string | null;
created_at: string;
revoked: boolean;
revoked_at: string | null;
org_id: string;
};
type Organization = {
id: string;
name: string;
structure_api: string | null;
};
type SortField = "first_name" | "email" | "organization_name" | "role" | "created_at";
export default function ClientUsersTable({
members,
organizations
}: {
members: ClientMember[];
organizations: Organization[];
}) {
// Filtres
const [searchQuery, setSearchQuery] = useState("");
const [orgFilter, setOrgFilter] = useState<string | null>(null);
const [roleFilter, setRoleFilter] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string | null>(null);
// Tri
const [sortField, setSortField] = useState<SortField>("created_at");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
// Gestion du tri
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortOrder("asc");
}
};
// Listes pour les filtres
const uniqueOrgs = useMemo(() => {
return Array.from(new Set(members.map(m => m.organization_name)))
.filter(Boolean)
.sort();
}, [members]);
const uniqueRoles = useMemo(() => {
return Array.from(new Set(members.map(m => m.role)))
.filter((r): r is string => Boolean(r))
.sort();
}, [members]);
// Filtrage et tri
const filteredAndSortedMembers = useMemo(() => {
let filtered = members;
// Recherche
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(m =>
m.first_name?.toLowerCase().includes(query) ||
m.email?.toLowerCase().includes(query) ||
m.organization_name?.toLowerCase().includes(query)
);
}
// Filtre organisation
if (orgFilter) {
filtered = filtered.filter(m => m.organization_name === orgFilter);
}
// Filtre rôle
if (roleFilter) {
filtered = filtered.filter(m => m.role === roleFilter);
}
// Filtre statut
if (statusFilter === "active") {
filtered = filtered.filter(m => !m.revoked);
} else if (statusFilter === "revoked") {
filtered = filtered.filter(m => m.revoked);
}
// Tri
const sorted = [...filtered].sort((a, b) => {
let aValue: any = a[sortField];
let bValue: any = b[sortField];
// Gérer les valeurs nulles
if (aValue === null || aValue === undefined) return 1;
if (bValue === null || bValue === undefined) return -1;
// Tri par date
if (sortField === "created_at") {
aValue = new Date(aValue).getTime();
bValue = new Date(bValue).getTime();
} else {
// Tri alphabétique
aValue = String(aValue).toLowerCase();
bValue = String(bValue).toLowerCase();
}
if (aValue < bValue) return sortOrder === "asc" ? -1 : 1;
if (aValue > bValue) return sortOrder === "asc" ? 1 : -1;
return 0;
});
return sorted;
}, [members, searchQuery, orgFilter, roleFilter, statusFilter, sortField, sortOrder]);
const hasActiveFilters = searchQuery || orgFilter || roleFilter || statusFilter;
const clearFilters = () => {
setSearchQuery("");
setOrgFilter(null);
setRoleFilter(null);
setStatusFilter(null);
};
// Composant pour l'en-tête triable
const SortableHeader = ({ field, label }: { field: SortField; label: string }) => (
<th
className="text-left px-4 py-3 font-medium cursor-pointer hover:bg-slate-100 select-none transition-colors"
onClick={() => handleSort(field)}
>
<div className="flex items-center gap-2">
{label}
{sortField === field && (
<span className="text-xs">{sortOrder === "asc" ? "↑" : "↓"}</span>
)}
</div>
</th>
);
return (
<section className="space-y-3">
<h2 className="text-base font-semibold">
Utilisateurs Clients ({filteredAndSortedMembers.length}{members.length !== filteredAndSortedMembers.length && ` / ${members.length}`})
</h2>
{/* Filtres */}
<div className="bg-slate-50 rounded-xl p-4">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
{/* Recherche */}
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Recherche</label>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Nom, email, organisation..."
className="w-full px-3 py-2 border rounded-lg text-sm bg-white"
/>
</div>
{/* Filtre Organisation */}
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Organisation</label>
<select
value={orgFilter || ""}
onChange={(e) => setOrgFilter(e.target.value || null)}
className="w-full px-3 py-2 border rounded-lg text-sm bg-white"
>
<option value="">Toutes</option>
{uniqueOrgs.map(org => (
<option key={org} value={org}>{org}</option>
))}
</select>
</div>
{/* Filtre Rôle */}
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Niveau</label>
<select
value={roleFilter || ""}
onChange={(e) => setRoleFilter(e.target.value || null)}
className="w-full px-3 py-2 border rounded-lg text-sm bg-white"
>
<option value="">Tous</option>
{uniqueRoles.map(role => (
<option key={role} value={role}>{role}</option>
))}
</select>
</div>
{/* Filtre Statut */}
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Statut</label>
<select
value={statusFilter || ""}
onChange={(e) => setStatusFilter(e.target.value || null)}
className="w-full px-3 py-2 border rounded-lg text-sm bg-white"
>
<option value="">Tous</option>
<option value="active">Actifs</option>
<option value="revoked">Révoqués</option>
</select>
</div>
</div>
{/* Bouton réinitialiser */}
{hasActiveFilters && (
<div className="mt-3">
<button
onClick={clearFilters}
className="px-4 py-2 rounded-lg text-sm bg-slate-200 hover:bg-slate-300 transition-colors"
>
Réinitialiser les filtres
</button>
</div>
)}
</div>
{/* Tableau */}
<div className="rounded-2xl border bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600">
<tr>
<SortableHeader field="first_name" label="Prénom" />
<SortableHeader field="email" label="Email" />
<SortableHeader field="organization_name" label="Organisation" />
<SortableHeader field="role" label="Niveau" />
<SortableHeader field="created_at" label="Créé le" />
<th className="text-left px-4 py-3">Statut</th>
<th className="text-left px-4 py-3">Actions</th>
</tr>
</thead>
<tbody>
{filteredAndSortedMembers.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-6 text-center text-slate-500">
{hasActiveFilters ? "Aucun utilisateur ne correspond aux filtres." : "Aucun utilisateur trouvé."}
</td>
</tr>
) : (
filteredAndSortedMembers.map((m) => {
const created = m.created_at ? new Date(m.created_at) : 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;
return (
<tr key={`${m.user_id}-${m.org_id}`} className="border-t align-top hover:bg-slate-50 transition-colors">
<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">{m.organization_name}</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">
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${
!disabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{status}
</span>
</td>
<td className="px-4 py-2">
<div className="flex flex-col gap-2">
<ConfirmableForm
method="post"
action="/api/staff/users/update-role"
className="flex items-center gap-2"
disabled={disabled}
confirmTitle="Confirmer la modification du niveau"
confirmMessage={`Voulez-vous vraiment appliquer ce niveau à ${m.first_name || m.email} ?`}
>
<input type="hidden" name="org_id" value={m.org_id} />
<input type="hidden" name="user_id" value={m.user_id} />
<select
name="role"
defaultValue={m.role || "ADMIN"}
disabled={disabled}
className="px-2 py-1 rounded border text-xs"
>
<option value="SUPER_ADMIN">Super Admin</option>
<option value="ADMIN">Admin</option>
<option value="AGENT">Agent</option>
<option value="COMPTA">Compta</option>
</select>
<button
type="submit"
disabled={disabled}
className="px-3 py-1 rounded bg-slate-900 text-white text-xs hover:bg-slate-700 disabled:opacity-50"
>
Modifier
</button>
</ConfirmableForm>
{!disabled ? (
<ConfirmableForm
method="post"
action="/api/staff/users/revoke"
className="flex items-center gap-2"
disabled={disabled}
confirmTitle="Confirmer la révocation"
confirmMessage={`Voulez-vous révoquer l'accès de ${m.first_name || m.email} pour cette structure ?`}
confirmCta="Révoquer"
>
<input type="hidden" name="org_id" value={m.org_id} />
<input type="hidden" name="user_id" value={m.user_id} />
<button
type="submit"
disabled={disabled}
className="px-3 py-1 rounded text-xs bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
Supprimer
</button>
</ConfirmableForm>
) : (
<ConfirmableForm
method="post"
action="/api/staff/users/unrevoke"
className="flex items-center gap-2"
disabled={false}
confirmTitle="Confirmer la réintégration"
confirmMessage={`Réintégrer ${m.first_name || m.email} et lui redonner l'accès à cette structure ?`}
confirmCta="Réintégrer"
>
<input type="hidden" name="org_id" value={m.org_id} />
<input type="hidden" name="user_id" value={m.user_id} />
<button
type="submit"
className="px-3 py-1 rounded text-xs bg-emerald-600 text-white hover:bg-emerald-700"
>
Réintégrer
</button>
</ConfirmableForm>
)}
{disabled && (
<div className="text-xs text-slate-500">Utilisateur révoqué accès désactivé</div>
)}
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</section>
);
}

View file

@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
interface CreateStaffFormProps {
isSuperStaff: boolean;
}
export default function CreateStaffForm({ isSuperStaff }: CreateStaffFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(false);
const formData = new FormData(e.currentTarget);
const email = formData.get("email") as string;
const firstName = formData.get("firstName") as string;
const isSuperStaff = formData.get("isSuperStaff") === "on";
try {
const res = await fetch("/api/staff/users/create-staff", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, firstName, isSuperStaff }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || data.message || "Erreur lors de la création");
}
setSuccess(true);
setTimeout(() => {
router.push("/staff/utilisateurs");
router.refresh();
}, 2000);
} catch (err: any) {
setError(err.message || "Erreur lors de la création de l'utilisateur staff");
} finally {
setLoading(false);
}
};
return (
<div className="rounded-2xl border bg-white p-6">
<h2 className="text-lg font-semibold mb-4">Créer un utilisateur staff</h2>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-50 border border-red-200 text-red-800 text-sm">
{error}
</div>
)}
{success && (
<div className="mb-4 p-3 rounded-lg bg-green-50 border border-green-200 text-green-800 text-sm">
Utilisateur staff créé avec succès ! Un email d&apos;activation a é envoyé. Redirection...
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700 mb-1">
Email *
</label>
<input
type="email"
id="email"
name="email"
required
disabled={loading || success}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:bg-slate-100"
placeholder="utilisateur@odentas.fr"
/>
</div>
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-slate-700 mb-1">
Prénom
</label>
<input
type="text"
id="firstName"
name="firstName"
disabled={loading || success}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:bg-slate-100"
placeholder="Jean"
/>
</div>
{isSuperStaff && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isSuperStaff"
name="isSuperStaff"
disabled={loading || success}
className="w-4 h-4 text-purple-600 border-slate-300 rounded focus:ring-purple-500"
/>
<label htmlFor="isSuperStaff" className="text-sm font-medium text-slate-700">
Super Staff (accès étendu)
</label>
</div>
)}
<div className="bg-slate-50 p-4 rounded-lg text-sm text-slate-600">
<p className="font-medium mb-2">Informations importantes :</p>
<ul className="list-disc list-inside space-y-1">
<li>L&apos;utilisateur recevra un email avec un lien d&apos;activation</li>
<li>Le lien est valable pendant 7 jours</li>
<li>L&apos;utilisateur aura un accès complet en tant que staff</li>
<li>Il pourra gérer tous les clients et toutes les données</li>
{isSuperStaff && <li>Le statut Super Staff donne des privilèges étendus (création d&apos;autres Super Staff, non-révocable)</li>}
</ul>
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={loading || success}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Création en cours..." : "Créer l'utilisateur staff"}
</button>
<button
type="button"
onClick={() => router.back()}
disabled={loading}
className="px-4 py-2 rounded-lg border border-slate-300 text-slate-700 text-sm font-medium hover:bg-slate-50 disabled:opacity-50"
>
Annuler
</button>
</div>
</form>
</div>
);
}

View file

@ -59,6 +59,7 @@ export type EmailTypeV2 =
| 'account-activation'
| 'access-updated'
| 'access-revoked'
| 'staff-account-created'
// Sécurité compte
| 'password-created'
| 'password-changed'
@ -1320,6 +1321,40 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
{ label: 'Production', key: 'productionName' },
]
}
},
'staff-account-created': {
subject: 'Votre compte staff Odentas Paie a été créé',
title: 'Bienvenue sur Odentas Paie',
greeting: '{{#if firstName}}Bonjour {{firstName}},{{/if}}',
mainMessage: 'Votre compte administrateur Odentas Paie a été créé avec succès.<br><br>Vous pouvez dès maintenant vous connecter en utilisant le lien d\'activation ci-dessous. Ce lien est valable pendant 7 jours.',
ctaText: 'Activer mon compte',
closingMessage: 'Pour toute question, contactez-nous à <a href="mailto:paie@odentas.fr" style="color:#0B5FFF; text-decoration:none;">paie@odentas.fr</a>.',
footerText: 'Vous recevez cet e-mail car un compte administrateur Odentas a été créé avec votre adresse email.',
preheaderText: 'Votre compte staff Odentas Paie a été créé',
colors: {
headerColor: STANDARD_COLORS.HEADER,
titleColor: '#0F172A',
buttonColor: STANDARD_COLORS.BUTTON,
buttonTextColor: STANDARD_COLORS.BUTTON_TEXT,
cardBackgroundColor: '#FFFFFF',
cardBorder: '#E5E7EB',
cardTitleColor: '#0F172A',
alertIndicatorColor: '#22C55E',
},
infoCard: [
{ label: 'Email', key: 'userEmail' },
{ label: 'Type de compte', key: 'accountType' },
{ label: 'Créé par', key: 'createdBy' },
],
detailsCard: {
title: 'Informations importantes',
rows: [
{ label: 'Date de création', key: 'createdAt' },
{ label: 'Validité du lien', key: 'linkValidity' },
],
disclaimer: 'Pour des raisons de sécurité, le lien d\'activation expire au bout de 7 jours. Vous devrez ensuite contacter l\'équipe technique pour obtenir un nouveau lien.'
}
}
};