SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
3 h agolast push
JavaScript 56.7% TypeScript 42.6%
11.3 KB · 234 lines tsx
Raw Blame History
1"use client";23import { useState, useEffect } from "react";4import { motion } from "framer-motion";5import { UserPlus, Trash2, ShieldCheck, Edit2, Users, FileText, Folder } from "lucide-react";6import Button from "../ui/Button";7import Modal from "../ui/Modal";8import UserAvatar from "../ui/UserAvatar";9import { EmptyState } from "../ui/PageHeader";10import toast from "react-hot-toast";1112interface UserData {13  id: string;14  username: string | null;15  email: string | null;16  name: string | null;17  role: string;18  twoFactorEnabled: boolean;19  createdAt: string;20  _count?: { files: number; folders: number };21}2223const roleLabels: Record<string, string> = { admin: "Admin", user: "Utilisateur", viewer: "Lecteur" };24const roleColors: Record<string, string> = {25  admin: "bg-rose-500/10 text-rose-600 dark:text-rose-400 ring-rose-500/20",26  user: "bg-blue-500/10 text-blue-700 dark:text-blue-300 ring-blue-500/20",27  viewer: "bg-gray-100 text-gray-600 dark:bg-white/[0.06] dark:text-gray-300 ring-gray-500/15",28};2930const emptyForm = { username: "", name: "", password: "", role: "user" };3132function UserForm({ create, form, setForm }: { create: boolean; form: typeof emptyForm; setForm: (f: typeof emptyForm) => void }) {33return (34  <div className="space-y-3.5">35    <div>36      <label className="block text-[12px] font-medium text-gray-500 dark:text-gray-400 mb-1.5">Nom d&apos;utilisateur{create ? " *" : ""}</label>37      <input type="text" placeholder="ex. simon, marie42…" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value.toLowerCase().replace(/\s/g, "") })} className="field h-11" autoFocus={create} autoComplete="off" />38    </div>39    <div>40      <label className="block text-[12px] font-medium text-gray-500 dark:text-gray-400 mb-1.5">Nom affiché</label>41      <input type="text" placeholder="ex. Simon Boucher" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="field h-11" />42    </div>43    <div>44      <label className="block text-[12px] font-medium text-gray-500 dark:text-gray-400 mb-1.5">{create ? "Mot de passe *" : "Nouveau mot de passe"}</label>45      <input type="password" placeholder={create ? "Mot de passe" : "Laisser vide pour ne pas changer"} value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className="field h-11" autoComplete="new-password" />46    </div>47    <div>48      <label className="block text-[12px] font-medium text-gray-500 dark:text-gray-400 mb-1.5">Rôle</label>49      <select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} className="field h-11">50        <option value="user">Utilisateur</option>51        <option value="viewer">Lecteur</option>52        <option value="admin">Admin</option>53      </select>54    </div>55  </div>56);57}5859export default function UserManagement() {60  const [users, setUsers] = useState<UserData[]>([]);61  const [loading, setLoading] = useState(true);62  const [showCreate, setShowCreate] = useState(false);63  const [editUser, setEditUser] = useState<UserData | null>(null);64  const [deleteUser, setDeleteUser] = useState<UserData | null>(null);65  const [saving, setSaving] = useState(false);66  const [form, setForm] = useState(emptyForm);6768  const fetchUsers = async () => {69    const res = await fetch("/api/users");70    if (res.ok) setUsers(await res.json());71    setLoading(false);72  };7374  useEffect(() => { fetchUsers(); }, []);7576  const handleCreate = async () => {77    setSaving(true);78    const res = await fetch("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });79    setSaving(false);80    if (res.ok) {81      toast.success("Utilisateur créé");82      setShowCreate(false);83      setForm(emptyForm);84      fetchUsers();85    } else {86      const data = await res.json();87      toast.error(data.error || "Erreur");88    }89  };9091  const handleUpdate = async () => {92    if (!editUser) return;93    const data: Record<string, string> = {};94    if (form.name) data.name = form.name;95    if (form.username) data.username = form.username;96    if (form.role) data.role = form.role;97    if (form.password) data.password = form.password;98    setSaving(true);99    const res = await fetch(`/api/users/${editUser.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) });100    setSaving(false);101    if (res.ok) {102      toast.success("Utilisateur modifié");103      setEditUser(null);104      fetchUsers();105    } else toast.error("Erreur lors de la modification");106  };107108  const handleDelete = async () => {109    if (!deleteUser) return;110    setSaving(true);111    const res = await fetch(`/api/users/${deleteUser.id}`, { method: "DELETE" });112    setSaving(false);113    setDeleteUser(null);114    if (res.ok) {115      toast.success("Utilisateur supprimé");116      fetchUsers();117    } else {118      const data = await res.json();119      toast.error(data.error || "Erreur");120    }121  };122123  return (124    <div className="space-y-4">125      <div className="flex items-center justify-between gap-3">126        <p className="text-[13px] text-gray-500 dark:text-gray-400">{loading ? "Chargement…" : `${users.length} compte${users.length !== 1 ? "s" : ""}`}</p>127        <Button variant="accent" size="sm" onClick={() => { setForm(emptyForm); setShowCreate(true); }}>128          <UserPlus className="w-4 h-4" /> Ajouter129        </Button>130      </div>131132      {loading ? (133        <div className="space-y-2">134          {[0, 1, 2].map((i) => (135            <div key={i} className="card p-4 flex items-center gap-3 animate-pulse">136              <div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-white/10" />137              <div className="flex-1 space-y-2"><div className="h-3 w-1/3 rounded bg-gray-200 dark:bg-white/10" /><div className="h-2 w-1/4 rounded bg-gray-200 dark:bg-white/10" /></div>138            </div>139          ))}140        </div>141      ) : users.length === 0 ? (142        <EmptyState icon={Users} title="Aucun utilisateur" description="Créez un premier compte pour partager votre cloud." />143      ) : (144        <div className="card p-0 overflow-hidden">145          {/* En-tête desktop */}146          <div className="hidden md:grid grid-cols-[1fr_120px_140px_110px_96px] items-center gap-3 px-4 h-10 border-b border-line dark:border-white/[0.07] text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-400 sticky top-0 bg-surface dark:bg-canvas z-10">147            <span>Utilisateur</span><span>Rôle</span><span>Contenu</span><span>2FA</span><span />148          </div>149          <div className="divide-y divide-line dark:divide-white/[0.06]">150            {users.map((user, i) => (151              <motion.div152                key={user.id}153                initial={{ opacity: 0, y: 5 }}154                animate={{ opacity: 1, y: 0 }}155                transition={{ delay: Math.min(i, 10) * 0.03 }}156                className="grid grid-cols-1 md:grid-cols-[1fr_120px_140px_110px_96px] items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-white/[0.03] transition-colors"157              >158                <div className="flex items-center gap-3 min-w-0">159                  <UserAvatar userId={user.id} name={user.name || user.username} size="md" />160                  <div className="min-w-0">161                    <p className="text-[14px] font-medium text-gray-900 dark:text-white truncate">{user.name || user.username || user.id}</p>162                    <p className="text-[12px] text-gray-500 truncate">@{user.username}{user.email ? ` · ${user.email}` : ""}</p>163                  </div>164                </div>165                <div className="flex items-center gap-2 md:block">166                  <span className={`inline-flex px-2 py-0.5 rounded-full text-[11px] font-medium ring-1 ring-inset ${roleColors[user.role] || roleColors.user}`}>{roleLabels[user.role] || user.role}</span>167                  <span className="md:hidden text-[12px] text-gray-500">{user._count?.files || 0} fichiers · {user._count?.folders || 0} dossiers</span>168                  {user.twoFactorEnabled && <ShieldCheck className="md:hidden w-4 h-4 text-emerald-500" aria-label="2FA activée" />}169                </div>170                <div className="hidden md:flex items-center gap-3 text-[12.5px] text-gray-500 tabular-nums">171                  <span className="inline-flex items-center gap-1"><FileText className="w-3.5 h-3.5" />{user._count?.files || 0}</span>172                  <span className="inline-flex items-center gap-1"><Folder className="w-3.5 h-3.5" />{user._count?.folders || 0}</span>173                </div>174                <div className="hidden md:block">175                  {user.twoFactorEnabled ? (176                    <span className="inline-flex items-center gap-1 text-[12px] text-emerald-600 dark:text-emerald-400"><ShieldCheck className="w-3.5 h-3.5" /> Activée</span>177                  ) : (178                    <span className="text-[12px] text-gray-400">—</span>179                  )}180                </div>181                <div className="flex gap-1 md:justify-end -ml-2 md:ml-0">182                  <button183                    onClick={() => { setEditUser(user); setForm({ username: user.username || "", name: user.name || "", password: "", role: user.role }); }}184                    className="w-10 h-10 flex items-center justify-center text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-xl hover:bg-gray-100 dark:hover:bg-white/[0.08] transition-colors"185                    title="Modifier"186                  >187                    <Edit2 className="w-4 h-4" />188                  </button>189                  <button190                    onClick={() => setDeleteUser(user)}191                    className="w-10 h-10 flex items-center justify-center text-gray-400 hover:text-rose-500 rounded-xl hover:bg-rose-500/10 transition-colors"192                    title="Supprimer"193                  >194                    <Trash2 className="w-4 h-4" />195                  </button>196                </div>197              </motion.div>198            ))}199          </div>200        </div>201      )}202203      <Modal204        isOpen={showCreate}205        onClose={() => setShowCreate(false)}206        title="Nouvel utilisateur"207        footer={<><Button variant="ghost" onClick={() => setShowCreate(false)}>Annuler</Button><Button variant="accent" onClick={handleCreate} loading={saving} disabled={!form.username || !form.password}>Créer</Button></>}208      >209        <UserForm create form={form} setForm={setForm} />210      </Modal>211212      <Modal213        isOpen={!!editUser}214        onClose={() => setEditUser(null)}215        title="Modifier l'utilisateur"216        description={editUser ? `@${editUser.username}` : undefined}217        footer={<><Button variant="ghost" onClick={() => setEditUser(null)}>Annuler</Button><Button variant="accent" onClick={handleUpdate} loading={saving}>Enregistrer</Button></>}218      >219        <UserForm create={false} form={form} setForm={setForm} />220      </Modal>221222      <Modal223        isOpen={!!deleteUser}224        onClose={() => setDeleteUser(null)}225        title="Supprimer cet utilisateur ?"226        description={deleteUser ? `${deleteUser.name || deleteUser.username}` : undefined}227        footer={<><Button variant="ghost" onClick={() => setDeleteUser(null)}>Annuler</Button><Button variant="danger" onClick={handleDelete} loading={saving}><Trash2 className="w-4 h-4" /> Supprimer</Button></>}228      >229        <p className="text-sm text-gray-600 dark:text-gray-300">Le compte sera supprimé et ne pourra plus se connecter.</p>230      </Modal>231    </div>232  );233}234