b054e36df0
- 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>
275 lines
9.2 KiB
TypeScript
275 lines
9.2 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { ShieldCheck, UserPlus, Trash2, Loader2, Crown, Mail } from 'lucide-react'
|
|
import { useApp } from '../lib/AppContext'
|
|
import { useFeedback } from '../lib/feedback'
|
|
import { MemberRole, ROLE_LABELS } from '../lib/types'
|
|
import {
|
|
AdminUser,
|
|
adminListUsers,
|
|
adminCreateUser,
|
|
adminDeleteUser,
|
|
adminUpdateUser
|
|
} from '../lib/admin'
|
|
import { Modal, Select, EmptyState } from '../components/ui'
|
|
import PageHeader from '../components/PageHeader'
|
|
|
|
export default function Admin(): JSX.Element {
|
|
const { profile } = useApp()
|
|
const { toast, confirm } = useFeedback()
|
|
const [users, setUsers] = useState<AdminUser[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [creating, setCreating] = useState(false)
|
|
|
|
const load = async (): Promise<void> => {
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
setUsers(await adminListUsers())
|
|
} catch (e) {
|
|
setError((e as Error).message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
useEffect(() => {
|
|
load()
|
|
}, [])
|
|
|
|
const setRole = async (u: AdminUser, role: MemberRole): Promise<void> => {
|
|
setUsers((list) => list.map((x) => (x.id === u.id ? { ...x, role } : x)))
|
|
try {
|
|
await adminUpdateUser(u.id, { role })
|
|
} catch (e) {
|
|
setError((e as Error).message)
|
|
load()
|
|
}
|
|
}
|
|
|
|
const toggleAdmin = async (u: AdminUser): Promise<void> => {
|
|
try {
|
|
await adminUpdateUser(u.id, { is_admin: !u.is_admin })
|
|
setUsers((list) => list.map((x) => (x.id === u.id ? { ...x, is_admin: !u.is_admin } : x)))
|
|
} catch (e) {
|
|
setError((e as Error).message)
|
|
}
|
|
}
|
|
|
|
const remove = async (u: AdminUser): Promise<void> => {
|
|
const ok = await confirm({
|
|
title: 'Supprimer ce compte ?',
|
|
message: `${u.full_name} (${u.email}) sera définitivement supprimé.`,
|
|
danger: true,
|
|
confirmLabel: 'Supprimer'
|
|
})
|
|
if (!ok) return
|
|
try {
|
|
await adminDeleteUser(u.id)
|
|
setUsers((list) => list.filter((x) => x.id !== u.id))
|
|
toast('Compte supprimé.', 'success')
|
|
} catch (e) {
|
|
setError((e as Error).message)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<PageHeader title="Administration" subtitle={`${users.length} compte(s)`}>
|
|
<button className="btn-primary" onClick={() => setCreating(true)}>
|
|
<UserPlus size={16} /> Créer un compte
|
|
</button>
|
|
</PageHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
{error && (
|
|
<div className="mb-4 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="flex justify-center py-16 text-muted">
|
|
<Loader2 className="animate-spin" size={28} />
|
|
</div>
|
|
) : users.length === 0 ? (
|
|
<EmptyState icon={<ShieldCheck size={40} />} text="Aucun compte." />
|
|
) : (
|
|
<div className="overflow-hidden rounded-xl border border-border">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-panel2 text-left text-xs uppercase text-subtle">
|
|
<tr>
|
|
<th className="px-4 py-3">Membre</th>
|
|
<th className="px-4 py-3">Rôle</th>
|
|
<th className="px-4 py-3">Admin</th>
|
|
<th className="px-4 py-3 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map((u) => {
|
|
const isMe = u.id === profile?.id
|
|
return (
|
|
<tr key={u.id} className="border-t border-border">
|
|
<td className="px-4 py-3">
|
|
<div className="font-medium text-ink">
|
|
{u.full_name}
|
|
{isMe && <span className="ml-1 text-xs text-accent2">(moi)</span>}
|
|
</div>
|
|
<div className="flex items-center gap-1 text-xs text-subtle">
|
|
<Mail size={11} /> {u.email}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<Select
|
|
className="!py-1 text-xs"
|
|
value={u.role}
|
|
onChange={(e) => setRole(u, e.target.value as MemberRole)}
|
|
>
|
|
{Object.entries(ROLE_LABELS).map(([v, l]) => (
|
|
<option key={v} value={v}>
|
|
{l}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<button
|
|
onClick={() => toggleAdmin(u)}
|
|
disabled={isMe}
|
|
title={isMe ? 'Tu ne peux pas retirer ton propre statut' : 'Basculer admin'}
|
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs ${
|
|
u.is_admin
|
|
? 'bg-amber-100 text-amber-700'
|
|
: 'bg-panel2 text-muted hover:text-ink'
|
|
} ${isMe ? 'cursor-not-allowed opacity-60' : ''}`}
|
|
>
|
|
<Crown size={12} /> {u.is_admin ? 'Admin' : 'Membre'}
|
|
</button>
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<button
|
|
onClick={() => remove(u)}
|
|
disabled={isMe}
|
|
title={isMe ? 'Impossible de supprimer son propre compte' : 'Supprimer'}
|
|
className={`text-muted hover:text-rose-600 ${
|
|
isMe ? 'cursor-not-allowed opacity-40' : ''
|
|
}`}
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{creating && (
|
|
<CreateUserModal
|
|
onClose={() => setCreating(false)}
|
|
onCreated={async () => {
|
|
setCreating(false)
|
|
await load()
|
|
toast('Compte créé.', 'success')
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CreateUserModal({
|
|
onClose,
|
|
onCreated
|
|
}: {
|
|
onClose: () => void
|
|
onCreated: () => Promise<void>
|
|
}): JSX.Element {
|
|
const [email, setEmail] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [fullName, setFullName] = useState('')
|
|
const [role, setRole] = useState<MemberRole>('programmer')
|
|
const [isAdmin, setIsAdmin] = useState(false)
|
|
const [busy, setBusy] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
const submit = async (): Promise<void> => {
|
|
setError('')
|
|
if (!email.trim() || password.length < 6) {
|
|
setError('Email valide et mot de passe (6+ caractères) requis.')
|
|
return
|
|
}
|
|
setBusy(true)
|
|
try {
|
|
await adminCreateUser({
|
|
email: email.trim(),
|
|
password,
|
|
full_name: fullName.trim(),
|
|
role,
|
|
is_admin: isAdmin
|
|
})
|
|
await onCreated()
|
|
} catch (e) {
|
|
setError((e as Error).message)
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal title="Créer un compte" onClose={onClose}>
|
|
<label className="label">Nom complet</label>
|
|
<input
|
|
className="input mb-3"
|
|
placeholder="Alex Martin"
|
|
value={fullName}
|
|
onChange={(e) => setFullName(e.target.value)}
|
|
/>
|
|
<label className="label">Email</label>
|
|
<input
|
|
className="input mb-3"
|
|
type="email"
|
|
placeholder="alex@email.com"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
/>
|
|
<label className="label">Mot de passe provisoire</label>
|
|
<input
|
|
className="input mb-3"
|
|
type="text"
|
|
placeholder="au moins 6 caractères"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
/>
|
|
<label className="label">Rôle</label>
|
|
<Select className="mb-3" value={role} onChange={(e) => setRole(e.target.value as MemberRole)}>
|
|
{Object.entries(ROLE_LABELS).map(([v, l]) => (
|
|
<option key={v} value={v}>
|
|
{l}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
<label className="mb-4 flex cursor-pointer items-center gap-2 text-sm text-gray-600">
|
|
<input
|
|
type="checkbox"
|
|
checked={isAdmin}
|
|
onChange={(e) => setIsAdmin(e.target.checked)}
|
|
className="h-4 w-4 accent-accent"
|
|
/>
|
|
Donner les droits administrateur
|
|
</label>
|
|
|
|
{error && <p className="mb-3 text-sm text-rose-600">{error}</p>}
|
|
|
|
<button className="btn-primary w-full" onClick={submit} disabled={busy}>
|
|
{busy && <Loader2 className="animate-spin" size={16} />}
|
|
Créer le compte
|
|
</button>
|
|
<p className="mt-3 text-center text-xs text-subtle">
|
|
Communique l'email et le mot de passe provisoire au membre.
|
|
</p>
|
|
</Modal>
|
|
)
|
|
}
|