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:
parent
ad2a9c6b7d
commit
956d655b7a
8 changed files with 1095 additions and 135 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
152
app/api/staff/users/create-staff/route.ts
Normal file
152
app/api/staff/users/create-staff/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
80
app/api/staff/users/restore-staff/route.ts
Normal file
80
app/api/staff/users/restore-staff/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
101
app/api/staff/users/revoke-staff/route.ts
Normal file
101
app/api/staff/users/revoke-staff/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
363
components/staff/ClientUsersTable.tsx
Normal file
363
components/staff/ClientUsersTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
components/staff/CreateStaffForm.tsx
Normal file
145
components/staff/CreateStaffForm.tsx
Normal 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'activation a été 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'utilisateur recevra un email avec un lien d'activation</li>
|
||||
<li>Le lien est valable pendant 7 jours</li>
|
||||
<li>L'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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue