
Le contexte
Suite de la série "ce que l'IA fait mal quand tu vibe codes ton SaaS". L'épisode précédent était sur un checkout Stripe. Aujourd'hui, on monte d'un cran dans le critique : la page Équipe.
C'est la page admin où tu invites tes collègues, où tu changes leur rôle, où tu révoques des invitations. Sur la plupart des SaaS, c'est une feature qui semble simple. Un formulaire, une table, deux dropdowns. L'IA te génère ça en 30 secondes.
Le problème : c'est aussi la page qui décide qui peut faire quoi dans ton outil. Et un bug ici ne te coûte pas de l'argent, il te coûte surtout le contrôle.
J'ai récupéré une page Équipe générée par un agent IA dans mon exemple de CRM généré en 20 min avec Lovable. 90 lignes de logique métier (sans le JSX). Six problèmes. Un d'entre eux te permet de virer tous les admins du workspace en ouvrant DevTools.
C'est parti.
Si le code est du charabia pour toi : saute les blocs et lis juste les titres des sections et les paragraphes "Ce qu'il faut faire à la place". La règle qui résume tout est en bas.
Le code en question
// Imports et JSX omis. On regarde uniquement la logique métier.
interface Member {
id: string;
full_name: string | null;
email: string;
role: 'admin' | 'rep';
}
interface Invite {
id: string;
email: string;
role: 'admin' | 'rep';
token: string;
expires_at: string;
accepted_at: string | null;
}
export default function Team() {
const { company, user } = useAuth();
const [members, setMembers] = useState<Member[]>([]);
const [invites, setInvites] = useState<Invite[]>([]);
const [email, setEmail] = useState('');
const [role, setRole] = useState<'admin' | 'rep'>('rep');
const load = async () => {
if (!company) return;
const [profilesRes, rolesRes, invitesRes] = await Promise.all([
supabase
.from('profiles')
.select('id, full_name, email')
.eq('company_id', company.id),
supabase
.from('user_roles')
.select('user_id, role')
.eq('company_id', company.id),
supabase
.from('invitations')
.select('id, email, role, token, expires_at, accepted_at')
.eq('company_id', company.id)
.is('accepted_at', null),
]);
// ... met à jour le state
};
const invite = async () => {
if (!company) return;
const parsed = z.string().trim().email().max(255).safeParse(email);
if (!parsed.success) return toast.error('Valid email required');
const { data, error } = await supabase
.from('invitations')
.insert({
company_id: company.id,
email: parsed.data,
role,
invited_by: user?.id ?? null,
})
.select()
.single();
if (error) return toast.error(error.message);
const link = `${window.location.origin}/auth/signup?invite=${data.token}`;
await navigator.clipboard.writeText(link).catch(() => {});
toast.success('Invite created — link copied to clipboard');
};
const revoke = async (id: string) => {
const { error } = await supabase.from('invitations').delete().eq('id', id);
if (error) return toast.error(error.message);
load();
};
const changeRole = async (userId: string, newRole: 'admin' | 'rep') => {
if (!company) return;
if (userId === user?.id && newRole === 'rep') {
const adminCount = members.filter((m) => m.role === 'admin').length;
if (adminCount <= 1) return toast.error('At least one admin required');
}
await supabase
.from('user_roles')
.delete()
.eq('user_id', userId)
.eq('company_id', company.id);
const { error } = await supabase
.from('user_roles')
.insert({ user_id: userId, company_id: company.id, role: newRole });
if (error) return toast.error(error.message);
toast.success('Role updated');
load();
};
// ... return JSX avec les formulaires et la table
}
À première vue, c'est carré. Les Hooks sont propres, il y a une validation de l'email avec zod, gestion d'erreur via toast, le code est lisible. Un dev junior signe ça en revue. Un agent IA aussi.
Maintenant on regarde ce qui se passe vraiment.
Problème 1 : le check "au moins un admin" tourne dans le navigateur (très grave)
C'est le morceau le plus parlant du fichier :
const changeRole = async (userId, newRole) => {
if (userId === user?.id && newRole === "rep") {
const adminCount = members.filter((m) => m.role === "admin").length;
if (adminCount <= 1) return toast.error("At least one admin required");
}
await supabase.from("user_roles").delete()...
await supabase.from("user_roles").insert(...);
};
Tu vois la garde "au moins un admin requis" ? Elle est dans le JavaScript du navigateur. Le if, le members.filter, le toast.error, tout ça tourne sur la machine du user. Et juste en dessous, le code appelle directement Supabase pour faire le DELETE et l'INSERT.
Donc :
- J'ouvre la console du navigateur (F12).
- Je tape :
await supabase.from('user_roles').delete().eq('company_id', myCompanyId). - Tous les rôles du workspace sont supprimés. Plus aucun admin.
- Plus personne ne peut inviter, plus personne ne peut changer un rôle, plus personne ne peut accéder aux pages admin. Le workspace est mort.
Un toast d'erreur n'est pas une protection. C'est juste un message poli. La protection, elle vit côté serveur, ou elle n'existe pas.
Ce qu'il faut faire à la place : la règle "il doit toujours rester au moins un admin par company" est une règle métier. Elle vit dans une fonction côté serveur (ton backend dédié, ou un trigger Postgres), qui est la seule autorisée à modifier user_roles. Le navigateur ne peut pas écrire dans cette table directement.
Variante plus stricte : un trigger Postgres BEFORE DELETE ON user_roles qui refuse la suppression si elle laisserait la company sans admin. Comme ça, peu importe d'où vient la requête, la base de données refuse.
Le problème de fond, derrière ce bug précis : ton composant React parle directement à Postgres. Tant que c'est le cas, n'importe quelle règle métier mise dans le navigateur peut être contournée, et tu vas reproduire le même bug sur chaque page sensible. La vraie correction n'est pas locale, elle est architecturale : il faut une couche entre le front et la base. Un backend dédié, ou une route API si tu es sur Next.js, peu importe la forme, mais quelque chose dont la responsabilité est de recevoir des intentions du client ("change le rôle de X en Y"), de vérifier qui appelle et s'il en a le droit, d'appliquer les règles métier, puis d'exécuter en base. Le navigateur ne devrait même pas savoir que la table user_roles existe.
Cette séparation paraît lourde quand tu vibe codes une appli en 20 minutes. C'est pourtant ce qui distingue un prototype d'un produit. Le jour où tu auras dix règles autour des rôles, des invitations et de la facturation, soit elles vivent dans une couche serveur que tu peux tester et faire évoluer, soit elles seront éparpillées dans des composants React qui finiront par se contredire.
Problème 2 : changeRole n'est pas atomique (grave)
Toujours dans changeRole :
await supabase.from("user_roles").delete().eq("user_id", userId)...
const { error } = await supabase.from("user_roles").insert({ user_id: userId, role: newRole });
Deux opérations séparées. DELETE puis INSERT. Sans transaction.
Que se passe-t-il si l'INSERT échoue (réseau qui coupe, contrainte DB violée, RLS qui refuse) ? L'utilisateur perd son rôle pour toujours. Le DELETE est passé, l'INSERT non, et le code s'arrête sur un toast.error. Personne ne nettoie. Le user est dans la base sans aucun rôle.
Cas concret : un admin essaie de changer le rôle d'un collègue. La requête INSERT échoue (raison X). Le collègue perd son accès. L'admin pense que rien n'a marché et ne réessaie pas tout de suite. Le collègue ne peut plus se connecter à rien tant que quelqu'un ne fixe pas la base à la main.
Plus subtil : entre le DELETE et l'INSERT, il y a une race window de quelques millisecondes pendant laquelle le user n'a aucun rôle. Si une requête arrive pendant cet instant et lit user_roles, elle voit zéro rôle. Si ta logique de RLS dépend de la présence d'un rôle, l'utilisateur peut être bloqué brièvement (ou faire passer une requête qu'il ne devrait pas).
Ce qu'il faut faire à la place : une seule opération atomique. Soit UPSERT avec une contrainte d'unicité sur (user_id, company_id), soit une fonction RPC Postgres qui fait les deux dans une transaction. Une seule unité, qui réussit ou échoue ensemble.
Problème 3 : invite écrit directement dans la base depuis le client
const { data, error } = await supabase
.from('invitations')
.insert({
company_id: company.id,
email: parsed.data,
role,
invited_by: user?.id ?? null,
})
.select()
.single();
Le client envoie un INSERT direct dans la table invitations. Le company_id, le role, le email, tout vient du navigateur. Encore une fois, tout repose sur les RLS de Supabase pour empêcher les abus.
Si les RLS autorisent un user authentifié à insérer dans invitations (ce qui est la config par défaut quand on dit "les admins peuvent inviter"), un utilisateur peut, via la console :
- S'inviter lui-même en tant qu'admin sur n'importe quelle company dont il connaît l'id.
- Inviter une adresse poubelle qu'il contrôle, en mettant le rôle "admin" et en pointant sur le
company_idd'une autre boîte.
La défense réelle, c'est une RLS qui dit : "Un user peut insérer dans invitations SEULEMENT si son user_id apparaît dans user_roles avec le rôle 'admin' pour ce company_id précis". Ça se code, mais c'est rarement bien fait dès le premier jet.
Ce qu'il faut faire à la place : passer par un endpoint dédié de ton backend. Le client appelle inviteMember({ email, role }), le serveur lit l'identité depuis la session, vérifie que l'utilisateur est admin de SA company, fait l'INSERT lui-même, et renvoie juste un OK. Plus aucun champ sensible ne traîne dans le body de la requête.
Problème 4 : le token d'invitation est stocké en clair en base (sérieux)
const { data, error } = await supabase.from("invitations").insert({ ... }).select().single();
const link = `${window.location.origin}/auth/signup?invite=${data.token}`;
Le token est généré, stocké dans la table invitations, puis relu tel quel depuis la base pour fabriquer le lien d'invitation. Donc en base, il est en clair.
Conséquence : si un attaquant accède au contenu de la table (backup volé, accès employé un peu trop large, faille SQL injection ailleurs dans le code), il récupère tous les tokens non consommés. Chaque token est une invitation valide sur la company correspondante, avec le rôle choisi. Une seule requête SELECT token, company_id, role FROM invitations WHERE accepted_at IS NULL te donne une porte d'entrée par invitation en attente.
Ce qu'il faut faire à la place : traiter le token comme un mot de passe. Le serveur le génère, en stocke le hash en base, et renvoie la version brute au client UNE seule fois (au moment de la création). Quand l'invité utilise son lien, le serveur re-hash le token reçu et le compare à la valeur en base. La version brute n'existe plus nulle part de récupérable.
Bonus défense en profondeur (pas P0)
Une fois le hash en place, il reste quelques fuites mineures parce que le token brut transite quand même par l'URL :
- Logs hébergeur (Vercel, Netlify, Cloudflare) : l'URL complète est loguée, donc le token apparaît dans les logs accessibles aux opérationnels et au support de l'hébergeur. Rétention typique : 30 jours.
- Analytics : si tu as GA ou Plausible sur ta page de signup, les query params sont capturés par défaut. À vérifier au cas par cas — souvent la page de signup n'a pas de tracking, justement.
- Historique navigateur et
Referer: risque réel mais faible si tu invalides le token dès qu'il est consommé. Les navigateurs modernes ont aussiReferrer-Policy: strict-origin-when-cross-originpar défaut, qui strippe le query string sur les requêtes cross-origin.
Pour ces trois vecteurs, le fix propre est de mettre le token dans le fragment de l'URL (#invite=...) plutôt qu'en query param. Les fragments ne sont jamais envoyés au serveur ni dans le Referer. C'est de la bonne hygiène, à faire si tu veux serrer la défense en profondeur, mais ce n'est pas ce qui te sauve d'un incident sérieux. Le hash en base, lui, oui.
Problème 5 : revoke ne vérifie pas le scope
const revoke = async (id: string) => {
const { error } = await supabase.from('invitations').delete().eq('id', id);
if (error) return toast.error(error.message);
load();
};
Le DELETE filtre uniquement sur l'id de l'invitation. Pas de filtre sur company_id. Si tu peux deviner ou intercepter l'id d'une invitation (souvent un UUID, mais quand même), et que les RLS ne sont pas parfaitement scopées, tu peux supprimer l'invitation d'une autre company.
Ce n'est pas catastrophique en soi (tu ne donnes pas accès, tu retires un invité). Mais ça illustre le pattern dangereux : faire confiance à un id sans le scoper à la company de l'utilisateur courant.
Ce qu'il faut faire à la place : toujours filtrer par les deux : eq("id", id).eq("company_id", company.id). Au moins, si la RLS est mal configurée, le code applicatif ne fait pas le travail à la place de l'attaquant.
Problème 6 : le check anti-self-demote a un trou
Re-regarde la garde :
if (userId === user?.id && newRole === 'rep') {
const adminCount = members.filter((m) => m.role === 'admin').length;
if (adminCount <= 1) return toast.error('At least one admin required');
}
Cette garde se déclenche uniquement si je rétrograde moi-même. Donc :
- Admin A et Admin B sont les deux seuls admins.
- Admin A clique sur le dropdown d'Admin B et le passe à "rep". Pas de garde, ça passe.
- Maintenant il n'y a plus que Admin A.
- Admin A se rétrograde lui-même. La garde se déclenche : "il faut au moins un admin". Stop.
- Mais Admin B est déjà rétrogradé. Le workspace n'a plus qu'un admin parce que Admin A a fait deux clics.
Pas une attaque, juste une logique cassée. La règle "garder au moins un admin" doit s'appliquer à chaque action, pas seulement quand le user se cible lui-même. Et idéalement, comme dit au problème 1, elle ne vit pas dans le navigateur.
Bonus : ce qui ne casse pas la sécurité, mais agace
navigator.clipboard.writeText(...).catch(() => {}): si la copie échoue, échec silencieux. Le toast dit "link copied", mais rien n'est dans le presse-papier. L'utilisateur envoie un mail vide à son collègue.(profilesRes.data ?? []) as any[]: un castanypour masquer un problème de typage. Quand un castanyapparaît, c'est presque toujours qu'on cache un problème réel dessous.- Pas de pagination sur
members: pour une équipe de 5 personnes, OK. Pour 1000 reps, ça charge tout d'un coup. Pas critique, mais ça finira par coincer.
La règle qui résume tout
Ce fichier est un cas d'école pour une règle simple :
Une vérification côté client, c'est de l'UX. Une vérification côté serveur, c'est de la sécurité. Il ne faut jamais mélanger les deux.
Le toast.error("At least one admin required"), c'est de l'UX : c'est sympa de dire à l'utilisateur "non, tu peux pas faire ça" plutôt que de lui faire essayer. Mais si l'utilisateur a la moindre malice (ou les bonnes extensions navigateur), il contourne ça en 5 secondes. La vraie protection vit en base : un trigger SQL, une RLS qui refuse, une fonction côté serveur qui valide.
Sur ce fichier, presque toutes les "protections" sont en réalité des messages d'erreur déguisés. Toute la sécurité repose, en silence, sur des RLS Supabase qu'on ne voit pas dans le code. C'est exactement le type de stack qui passe la démo et casse à la première personne motivée.
Ce que ferait un audit sur ce fichier
L'audit que je passerais ici :
- Lecture des écritures DB depuis le client. Lister chaque
.insert,.update,.deleteexécutée depuis React. Pour chacune, demander : "Si je l'appelle directement depuis la console avec n'importe quels arguments, qu'est-ce qui m'arrête ?". La réponse "rien" doit être inacceptable. - Lecture des RLS Supabase. Chaque table touchée par le client a une policy. Est-ce qu'elle scope par company ? Par rôle ? Est-ce qu'elle empêche les écritures sensibles ?
- Identification des règles métier qui doivent vivre côté serveur. "Au moins un admin", "un rôle par user et par company", "pas d'auto-promotion", etc. Pour chacune : est-ce qu'elle vit dans un endroit où le client ne peut pas y toucher ?
- Remplacement des
.insertdirect par des endpoints backend. Pour les opérations sensibles (invite, change role, revoke), passer par une fonction serveur qui valide et exécute. Le client appelle, ne décide pas. - Plan de fix priorisé. Le lockout du workspace en P0, les tokens en clair en P0, le reste en P1.
Sur ce fichier, sur les 6 problèmes, 4 disparaissent dès qu'on déplace les écritures vers ton backend.
L'IA n'est pas l'ennemi
Le composant ci-dessus est lisible. Bien typé. Le formulaire est validé avec zod. Le code que l'IA pond ressemble à du code de junior compétent.
Le problème, c'est qu'un junior compétent en React n'est pas un junior compétent en sécurité. Et l'IA, à ce jour, fait plutôt du React. Elle te génère du code qui a l'air bon. Elle ne te génère pas du code qui résiste.
Si tu construis un SaaS multi-tenant, les pages "Équipe", "Settings", "Billing" sont les zones où l'erreur la plus discrète te coûte le plus cher. C'est exactement les pages qui méritent une relecture humaine.
Tu construis un SaaS multi-tenant avec une IA ?
Si tu as une page de gestion d'équipe, de rôles ou de permissions générée par un agent IA, et que tu veux savoir ce qui se passe quand un utilisateur ouvre la console, découvre l'offre Audit. Je liste les écritures DB côté client, je vérifie les RLS qui les protègent (ou pas), et je te donne un plan de fix priorisé.
Et si tu veux recevoir ce genre de retours sur le vibe coding (audits de code IA, guides pour passer en prod, retours d'expérience), inscris-toi à la newsletter.

