209 lines
7.9 KiB
TypeScript
209 lines
7.9 KiB
TypeScript
// =====================================================================
|
|
// Edge Function : admin-users
|
|
// Gère les comptes (lister / créer / supprimer / modifier).
|
|
// Tourne sur les serveurs Supabase et garde la clé service_role en
|
|
// sécurité — elle n'est JAMAIS exposée dans l'application.
|
|
//
|
|
// Déploiement :
|
|
// supabase functions deploy admin-users --no-verify-jwt
|
|
// (la fonction vérifie elle-même l'identité de l'appelant ci-dessous)
|
|
//
|
|
// v10 : la colonne `profiles.role` a disparu ; seul `roles[]` fait foi.
|
|
// =====================================================================
|
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
|
|
|
const cors = {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
'Access-Control-Allow-Methods': 'POST, OPTIONS'
|
|
}
|
|
|
|
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!
|
|
const ANON_KEY = Deno.env.get('SUPABASE_ANON_KEY')!
|
|
const SERVICE_ROLE = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
|
|
|
const VALID_ROLES = [
|
|
'lead',
|
|
'programmer',
|
|
'artist',
|
|
'game_designer',
|
|
'sound',
|
|
'writer',
|
|
'qa',
|
|
'other'
|
|
]
|
|
|
|
function json(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { ...cors, 'Content-Type': 'application/json' }
|
|
})
|
|
}
|
|
|
|
/** Ne garde que des pôles valides (l'enum SQL rejetterait le reste). */
|
|
function cleanRoles(input: unknown): string[] {
|
|
if (!Array.isArray(input)) return []
|
|
return [...new Set(input.filter((r) => typeof r === 'string' && VALID_ROLES.includes(r)))]
|
|
}
|
|
|
|
Deno.serve(async (req) => {
|
|
if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })
|
|
|
|
try {
|
|
// --- 1) Identifier l'appelant à partir de son token de connexion ---
|
|
const authHeader = req.headers.get('Authorization') ?? ''
|
|
const caller = createClient(SUPABASE_URL, ANON_KEY, {
|
|
global: { headers: { Authorization: authHeader } }
|
|
})
|
|
const {
|
|
data: { user },
|
|
error: userErr
|
|
} = await caller.auth.getUser()
|
|
if (userErr || !user) return json({ error: 'Non authentifié.' }, 401)
|
|
|
|
// --- 2) Client « admin » (service_role) pour les opérations sensibles ---
|
|
const admin = createClient(SUPABASE_URL, SERVICE_ROLE, {
|
|
auth: { autoRefreshToken: false, persistSession: false }
|
|
})
|
|
|
|
// --- 3) Vérifier que l'appelant est bien administrateur ---
|
|
const { data: me } = await admin
|
|
.from('profiles')
|
|
.select('is_admin')
|
|
.eq('id', user.id)
|
|
.single()
|
|
if (!me?.is_admin) return json({ error: 'Accès réservé aux administrateurs.' }, 403)
|
|
|
|
// --- 4) Exécuter l'action demandée ---
|
|
const { action, ...p } = await req.json()
|
|
|
|
switch (action) {
|
|
case 'list': {
|
|
// Les emails sont dans auth.users, le reste dans profiles → fusion.
|
|
const { data: list, error } = await admin.auth.admin.listUsers({ perPage: 1000 })
|
|
if (error) throw error
|
|
const { data: profiles } = await admin.from('profiles').select('*')
|
|
const byId = new Map((profiles ?? []).map((pr: Record<string, unknown>) => [pr.id, pr]))
|
|
|
|
const users = list.users
|
|
.map((u) => {
|
|
const pr = (byId.get(u.id) ?? {}) as Record<string, unknown>
|
|
return {
|
|
id: u.id,
|
|
email: u.email ?? '',
|
|
full_name: (pr.full_name as string) ?? '',
|
|
roles: Array.isArray(pr.roles) ? pr.roles : [],
|
|
is_admin: Boolean(pr.is_admin),
|
|
avatar_color: (pr.avatar_color as string) ?? '#7c5cff',
|
|
avatar_url: (pr.avatar_url as string) ?? null,
|
|
created_at: (pr.created_at as string) ?? u.created_at
|
|
}
|
|
})
|
|
.sort((a, b) => a.full_name.localeCompare(b.full_name, 'fr'))
|
|
|
|
return json(users)
|
|
}
|
|
|
|
case 'create': {
|
|
const { email, password, full_name, roles, is_admin } = p
|
|
if (!email || !password) return json({ error: 'Email et mot de passe requis.' }, 400)
|
|
if (String(password).length < 6) {
|
|
return json({ error: 'Le mot de passe doit faire au moins 6 caractères.' }, 400)
|
|
}
|
|
|
|
const roleList = cleanRoles(roles)
|
|
const primary = roleList[0] ?? 'other'
|
|
const name = full_name || String(email).split('@')[0]
|
|
|
|
// email_confirm : le membre peut se connecter immédiatement.
|
|
const { data: created, error } = await admin.auth.admin.createUser({
|
|
email,
|
|
password,
|
|
email_confirm: true,
|
|
user_metadata: { full_name: name, role: primary }
|
|
})
|
|
if (error) return json({ error: error.message }, 400)
|
|
|
|
// Le trigger handle_new_user a créé le profil ; on cale les champs.
|
|
const { error: profErr } = await admin
|
|
.from('profiles')
|
|
.update({
|
|
full_name: name,
|
|
roles: roleList.length ? roleList : [primary],
|
|
is_admin: Boolean(is_admin)
|
|
})
|
|
.eq('id', created.user.id)
|
|
if (profErr) return json({ error: profErr.message }, 400)
|
|
|
|
return json({ ok: true, id: created.user.id })
|
|
}
|
|
|
|
case 'delete': {
|
|
const { id } = p
|
|
if (!id) return json({ error: 'Identifiant manquant.' }, 400)
|
|
if (id === user.id) {
|
|
return json({ error: 'Tu ne peux pas supprimer ton propre compte.' }, 400)
|
|
}
|
|
const { error } = await admin.auth.admin.deleteUser(id) // profil supprimé en cascade
|
|
if (error) return json({ error: error.message }, 400)
|
|
return json({ ok: true })
|
|
}
|
|
|
|
case 'update': {
|
|
const { id, full_name, email, password, roles, is_admin, avatar_url } = p
|
|
if (!id) return json({ error: 'Identifiant manquant.' }, 400)
|
|
if (id === user.id && is_admin === false) {
|
|
return json({ error: 'Tu ne peux pas retirer ton propre statut admin.' }, 400)
|
|
}
|
|
|
|
// --- a) Compte auth (email / mot de passe / métadonnées) ---
|
|
const authPatch: Record<string, unknown> = {}
|
|
if (email !== undefined && String(email).trim()) {
|
|
authPatch.email = String(email).trim()
|
|
authPatch.email_confirm = true // évite le mail de confirmation
|
|
}
|
|
if (password !== undefined && String(password) !== '') {
|
|
if (String(password).length < 6) {
|
|
return json({ error: 'Le mot de passe doit faire au moins 6 caractères.' }, 400)
|
|
}
|
|
authPatch.password = String(password)
|
|
}
|
|
if (full_name !== undefined) {
|
|
// On fusionne avec les métadonnées existantes plutôt que de les écraser.
|
|
const { data: existing } = await admin.auth.admin.getUserById(id)
|
|
authPatch.user_metadata = {
|
|
...(existing?.user?.user_metadata ?? {}),
|
|
full_name
|
|
}
|
|
}
|
|
if (Object.keys(authPatch).length > 0) {
|
|
const { error: authErr } = await admin.auth.admin.updateUserById(id, authPatch)
|
|
if (authErr) return json({ error: authErr.message }, 400)
|
|
}
|
|
|
|
// --- b) Profil (nom, pôles, admin, avatar) ---
|
|
const patch: Record<string, unknown> = {}
|
|
if (full_name !== undefined) patch.full_name = full_name
|
|
if (is_admin !== undefined) patch.is_admin = Boolean(is_admin)
|
|
if (avatar_url !== undefined) patch.avatar_url = avatar_url || null
|
|
if (roles !== undefined) patch.roles = cleanRoles(roles)
|
|
|
|
if (Object.keys(patch).length > 0) {
|
|
const { error } = await admin.from('profiles').update(patch).eq('id', id)
|
|
if (error) return json({ error: error.message }, 400)
|
|
}
|
|
|
|
if (Object.keys(authPatch).length === 0 && Object.keys(patch).length === 0) {
|
|
return json({ error: 'Rien à modifier.' }, 400)
|
|
}
|
|
return json({ ok: true })
|
|
}
|
|
|
|
default:
|
|
return json({ error: 'Action inconnue.' }, 400)
|
|
}
|
|
} catch (e) {
|
|
return json({ error: (e as Error).message }, 500)
|
|
}
|
|
})
|