Panel admin, planning, tags, suivi du temps, thèmes clair/sombre

- Admin: création/suppression de comptes + rôles via Edge Function sécurisée
- Config Supabase intégrée au build (.env), écran Setup supprimé
- Page Planning (vue mois/semaine)
- Tags "jobs" sur les tâches + filtres (membre / tag)
- Suivi du temps par tâche, total par jalon
- Toasts + confirmations in-app (fini alert/confirm natifs)
- Thème Notion clair + mode sombre (accent monochrome, bascule persistée)
- .env suivi par git (public uniquement) pour synchro multi-machines

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 09:51:40 +02:00
parent 0806be135d
commit b054e36df0
30 changed files with 1991 additions and 411 deletions
+144
View File
@@ -0,0 +1,144 @@
// =====================================================================
// Edge Function : admin-users
// Gère les comptes (lister / créer / supprimer / modifier rôle & admin).
// Tourne sur les serveurs Supabase et garde la clé service_role en
// sécurité — elle n'est JAMAIS exposée dans l'appli.
//
// Déploiement (voir le guide) :
// supabase functions deploy admin-users --no-verify-jwt
// (la fonction vérifie elle-même l'identité de l'appelant ci-dessous.)
// =====================================================================
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')!
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...cors, 'Content-Type': 'application/json' }
})
}
Deno.serve(async (req) => {
// Pré-vol CORS (envoyé automatiquement par le navigateur/webview).
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': {
// Emails dans auth.users + métadonnées dans profiles → on fusionne.
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: any) => [pr.id, pr]))
const users = list.users.map((u) => {
const pr: any = byId.get(u.id) ?? {}
return {
id: u.id,
email: u.email ?? '',
full_name: pr.full_name ?? '',
role: pr.role ?? 'other',
is_admin: pr.is_admin ?? false,
created_at: pr.created_at ?? u.created_at
}
})
return json(users)
}
case 'create': {
const { email, password, full_name, role, 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)
// Crée le compte (email déjà confirmé : connexion immédiate possible).
const { data: created, error } = await admin.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { full_name: full_name || email.split('@')[0], role: role || 'other' }
})
if (error) return json({ error: error.message }, 400)
// Le trigger handle_new_user a créé le profil ; on cale les champs.
await admin
.from('profiles')
.update({
full_name: full_name || email.split('@')[0],
role: role || 'other',
is_admin: !!is_admin
})
.eq('id', created.user.id)
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, role, is_admin } = 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)
const patch: Record<string, unknown> = {}
if (full_name !== undefined) patch.full_name = full_name
if (role !== undefined) patch.role = role
if (is_admin !== undefined) patch.is_admin = !!is_admin
if (Object.keys(patch).length === 0) return json({ error: 'Rien à modifier.' }, 400)
const { error } = await admin.from('profiles').update(patch).eq('id', id)
if (error) return json({ error: error.message }, 400)
return json({ ok: true })
}
default:
return json({ error: 'Action inconnue.' }, 400)
}
} catch (e) {
return json({ error: (e as Error).message }, 500)
}
})
+90
View File
@@ -155,3 +155,93 @@ exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.milestones;
exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.profiles;
exception when duplicate_object then null; end $$;
-- =====================================================================
-- ADMINISTRATION : comptes & rôles gérés par un panel admin in-app
-- =====================================================================
-- ---------- Champ "administrateur" sur les profils -------------------
alter table public.profiles
add column if not exists is_admin boolean not null default false;
-- ---------- Sécurité fine sur les profils ----------------------------
-- On remplace la policy permissive "profiles_all" par des règles précises :
-- * tout le monde (connecté) PEUT LIRE les profils (affichage de l'équipe)
-- * chacun ne peut MODIFIER que SON propre profil
-- * la création/suppression de comptes passe uniquement par l'Edge
-- Function "admin-users" (clé service_role, côté serveur).
drop policy if exists profiles_all on public.profiles;
drop policy if exists profiles_select on public.profiles;
drop policy if exists profiles_update_self on public.profiles;
create policy profiles_select on public.profiles
for select to authenticated using (true);
create policy profiles_update_self on public.profiles
for update to authenticated
using (id = auth.uid()) with check (id = auth.uid());
-- ---------- Garde-fou : on ne se promeut pas admin soi-même ----------
-- Empêche un membre connecté (rôle "authenticated") de changer le champ
-- is_admin. Les opérations légitimes passent par l'Edge Function
-- (rôle "service_role") ou par le SQL Editor (rôle "postgres"), tous deux
-- autorisés ici. Un admin déjà en place peut aussi modifier le statut.
create or replace function public.guard_is_admin()
returns trigger language plpgsql security definer set search_path = public as $$
begin
if new.is_admin is distinct from old.is_admin
and current_user = 'authenticated'
and not exists (
select 1 from public.profiles p where p.id = auth.uid() and p.is_admin
) then
raise exception 'Seul un administrateur peut modifier le statut admin.';
end if;
return new;
end $$;
drop trigger if exists trg_profiles_guard_admin on public.profiles;
create trigger trg_profiles_guard_admin before update on public.profiles
for each row execute function public.guard_is_admin();
-- ---------- Amorçage du PREMIER admin (à faire UNE fois) -------------
-- Après ta première inscription dans l'appli, exécute cette ligne en
-- remplaçant l'email par le tien pour devenir administrateur :
--
-- update public.profiles set is_admin = true
-- where id = (select id from auth.users where email = 'TON_EMAIL@ exemple.com');
--
-- Ensuite, tu pourras créer/gérer tous les autres comptes depuis l'appli.
-- =====================================================================
-- TAGS (« jobs ») & SUIVI DU TEMPS
-- =====================================================================
-- ---------- Tags sur les tâches (ex: Programmation, Art, Audio) ------
alter table public.tasks
add column if not exists tags text[] not null default '{}';
-- ---------- Journal du temps passé (par tâche, par membre) -----------
create table if not exists public.time_logs (
id uuid primary key default gen_random_uuid(),
task_id uuid not null references public.tasks(id) on delete cascade,
user_id uuid references public.profiles(id) on delete set null,
minutes integer not null check (minutes > 0),
note text,
logged_at date not null default current_date,
created_at timestamptz not null default now()
);
create index if not exists idx_time_logs_task on public.time_logs(task_id);
create index if not exists idx_time_logs_user on public.time_logs(user_id);
alter table public.time_logs enable row level security;
drop policy if exists time_logs_all on public.time_logs;
create policy time_logs_all on public.time_logs
for all to authenticated using (true) with check (true);
do $$ begin
alter publication supabase_realtime add table public.time_logs;
exception when duplicate_object then null; end $$;