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:
@@ -0,0 +1,274 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user