Jelajahi Sumber

feat(webui): add a standalone Users management page

Move user administration off Settings and onto its own /users route with
a sidebar entry (admin/owner only). Adds create, role change, and
deactivate, all backed by fields the API already returns. Delete keeps
the existing warning about orphaned personal projects, since the backend
quietly keeps a project that still holds work rather than refusing the
delete.

Deliberately does not offer a "reset password" action for another user:
verified against the running server that /users/{id}/change-password
checks the account's own current password regardless of caller role, so
an admin who does not know that password cannot use it. Shipping the
button would mean a control that always fails; the page explains the gap
instead and says what a fix would need (an endpoint that does not require
the old password).

Removes the old embedded UsersSection from the settings page - its logic
moved here rather than staying duplicated.
fszontagh 1 bulan lalu
induk
melakukan
972dce2797

+ 2 - 0
webui/src/App.tsx

@@ -12,6 +12,7 @@ import SettingsPage from './pages/SettingsPage'
 import NodesPage from './pages/NodesPage'
 import DatabasePage from './pages/DatabasePage'
 import CredentialsPage from './pages/CredentialsPage'
+import UsersPage from './pages/UsersPage'
 
 function PrivateRoute({ children }: { children: React.ReactNode }) {
   const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
@@ -50,6 +51,7 @@ function App() {
         <Route path="credentials" element={<CredentialsPage />} />
         <Route path="database" element={<DatabasePage />} />
         <Route path="projects" element={<ProjectsPage />} />
+        <Route path="users" element={<UsersPage />} />
         <Route path="settings" element={<SettingsPage />} />
       </Route>
     </Routes>

+ 8 - 2
webui/src/api/users.ts

@@ -34,8 +34,14 @@ export const usersApi = {
     return data
   },
 
-  setPassword: async (id: string, newPassword: string) => {
-    const { data } = await api.post(`/users/${id}/change-password`, { newPassword })
+  /**
+   * Changes an account's own password. The backend verifies `oldPassword`
+   * against the account's current hash regardless of who is calling - there
+   * is no way to set a new password for somebody else without knowing the
+   * one they have now, so this only makes sense for `id === yourself`.
+   */
+  changeOwnPassword: async (id: string, oldPassword: string, newPassword: string) => {
+    const { data } = await api.post(`/users/${id}/change-password`, { oldPassword, newPassword })
     return data
   },
 }

+ 6 - 1
webui/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { Outlet, NavLink, useNavigate } from 'react-router-dom'
 import { useAuthStore } from '../stores/authStore'
-import { Workflow, Play, Settings, LogOut, Menu, Database, Code, Key, FolderKanban} from 'lucide-react'
+import { Workflow, Play, Settings, LogOut, Menu, Database, Code, Key, FolderKanban, Users } from 'lucide-react'
 import { useState, useEffect } from 'react'
 import { useQuery } from '@tanstack/react-query'
 import { useProjectStore } from '../stores/projectStore'
@@ -34,6 +34,10 @@ export default function Layout() {
     navigate('/login')
   }
 
+  // The users endpoints are admin-only - a member following the link would
+  // just get a 403, so the entry is not offered to them in the first place.
+  const isAdmin = user?.role === 'admin' || user?.role === 'owner'
+
   const navItems = [
     { to: '/workflows', icon: Workflow, label: 'Workflows' },
     { to: '/executions', icon: Play, label: 'Executions' },
@@ -41,6 +45,7 @@ export default function Layout() {
     { to: '/credentials', icon: Key, label: 'Credentials' },
     { to: '/projects', icon: FolderKanban, label: 'Projects' },
     { to: '/database', icon: Database, label: 'Database' },
+    ...(isAdmin ? [{ to: '/users', icon: Users, label: 'Users' }] : []),
     { to: '/settings', icon: Settings, label: 'Settings' },
   ]
 

+ 0 - 280
webui/src/components/settings/UsersSection.tsx

@@ -1,280 +0,0 @@
-import { useState } from 'react'
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import { UserPlus, Trash2, KeyRound, ShieldCheck, X } from 'lucide-react'
-import { usersApi, User } from '../../api/users'
-import { useAuthStore } from '../../stores/authStore'
-
-/**
- * The accounts on this installation.
- *
- * The endpoints for this existed from the beginning and had no screen at all,
- * so the only way to add somebody was a curl command - which is why there has
- * only ever been one account.
- *
- * Roles here are instance-wide. What somebody may do to a particular workflow
- * is decided by the project it lives in, not here; this is the difference
- * between "runs this installation" and "has been given a job in one project".
- */
-
-const ROLES = [
-  { value: 'owner', label: 'Owner', help: 'Runs the installation. Cannot be demoted or deleted.' },
-  { value: 'admin', label: 'Admin', help: 'Everything, including every project.' },
-  { value: 'member', label: 'Member', help: 'Only the projects they belong to.' },
-]
-
-export function UsersSection() {
-  const queryClient = useQueryClient()
-  const me = useAuthStore((s) => s.user)
-
-  const [showNew, setShowNew] = useState(false)
-  const [newUser, setNewUser] = useState({ username: '', password: '', email: '', role: 'member' })
-  const [resetting, setResetting] = useState<User | null>(null)
-  const [newPassword, setNewPassword] = useState('')
-  const [error, setError] = useState<string | null>(null)
-
-  const { data: users = [], isLoading, isError } = useQuery({
-    queryKey: ['users'],
-    queryFn: () => usersApi.list(),
-  })
-
-  const refresh = () => queryClient.invalidateQueries({ queryKey: ['users'] })
-  const fail = (e: any) => setError(e?.response?.data?.error || e.message || 'That did not work')
-
-  const createMutation = useMutation({
-    mutationFn: () => usersApi.create(newUser),
-    onSuccess: () => {
-      refresh()
-      setShowNew(false)
-      setNewUser({ username: '', password: '', email: '', role: 'member' })
-      setError(null)
-    },
-    onError: fail,
-  })
-
-  const updateMutation = useMutation({
-    mutationFn: ({ id, changes }: { id: string; changes: any }) => usersApi.update(id, changes),
-    onSuccess: () => { refresh(); setError(null) },
-    onError: fail,
-  })
-
-  const deleteMutation = useMutation({
-    mutationFn: (id: string) => usersApi.remove(id),
-    onSuccess: () => { refresh(); setError(null) },
-    onError: fail,
-  })
-
-  const passwordMutation = useMutation({
-    mutationFn: ({ id, password }: { id: string; password: string }) => usersApi.setPassword(id, password),
-    onSuccess: () => { setResetting(null); setNewPassword(''); setError(null) },
-    onError: fail,
-  })
-
-  // The endpoint is admin-only, so a member opening Settings gets a 403 here.
-  // That is not an error worth showing them - they simply do not manage users.
-  if (isError) return null
-
-  const isOwner = (u: User) => u.role === 'owner'
-
-  return (
-    <section className="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 p-6 mb-6">
-      <div className="flex items-center justify-between mb-4">
-        <div className="flex items-center gap-2">
-          <ShieldCheck className="w-5 h-5 text-gray-400" />
-          <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Users</h2>
-          <span className="text-sm text-gray-400">({users.length})</span>
-        </div>
-        <button
-          onClick={() => { setShowNew(true); setError(null) }}
-          className="flex items-center gap-2 px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700"
-        >
-          <UserPlus className="w-4 h-4" />
-          Add user
-        </button>
-      </div>
-
-      {error && (
-        <div className="mb-3 px-3 py-2 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm flex items-center justify-between">
-          <span>{error}</span>
-          <button onClick={() => setError(null)}><X className="w-4 h-4" /></button>
-        </div>
-      )}
-
-      {isLoading ? (
-        <p className="text-sm text-gray-500 dark:text-gray-400">Reading the accounts...</p>
-      ) : (
-        <div className="divide-y divide-gray-100 dark:divide-slate-700">
-          {users.map((user) => (
-            <div key={user.id} className="py-3 flex items-center gap-3">
-              <div className="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300 flex items-center justify-center uppercase text-sm">
-                {user.username.slice(0, 1)}
-              </div>
-
-              <div className="min-w-0 flex-1">
-                <div className="flex items-center gap-2">
-                  <span className="font-medium text-gray-900 dark:text-gray-100">{user.username}</span>
-                  {user.id === me?.id && (
-                    <span className="text-xs text-gray-400">you</span>
-                  )}
-                  {!user.active && (
-                    <span className="text-xs px-1.5 py-0.5 rounded bg-gray-200 dark:bg-slate-600 text-gray-600 dark:text-gray-300">
-                      deactivated
-                    </span>
-                  )}
-                </div>
-                <div className="text-xs text-gray-500 dark:text-gray-400 truncate">{user.email}</div>
-              </div>
-
-              <select
-                value={user.role === 'user' ? 'member' : user.role}
-                // The owner is the one account that cannot lose its power, or an
-                // installation could be left with nobody able to administer it.
-                disabled={isOwner(user) || updateMutation.isPending}
-                onChange={(e) => updateMutation.mutate({ id: user.id, changes: { role: e.target.value } })}
-                className="px-2 py-1 text-sm border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 disabled:opacity-50"
-                title={isOwner(user) ? 'The owner runs the installation and cannot be demoted' : undefined}
-              >
-                {ROLES.map((r) => (
-                  <option key={r.value} value={r.value}>{r.label}</option>
-                ))}
-              </select>
-
-              <button
-                onClick={() => { setResetting(user); setNewPassword(''); setError(null) }}
-                className="p-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded"
-                title="Set a new password"
-              >
-                <KeyRound className="w-4 h-4" />
-              </button>
-
-              <button
-                onClick={() => updateMutation.mutate({ id: user.id, changes: { active: !user.active } })}
-                disabled={isOwner(user) || user.id === me?.id}
-                className="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700 disabled:opacity-40"
-                title={user.id === me?.id ? 'You cannot lock yourself out' : 'A deactivated account cannot log in, and keeps its work'}
-              >
-                {user.active ? 'Deactivate' : 'Activate'}
-              </button>
-
-              <button
-                onClick={() => {
-                  if (confirm(`Delete ${user.username}? Their personal project and everything in it stays, and only an admin will be able to reach it.`)) {
-                    deleteMutation.mutate(user.id)
-                  }
-                }}
-                disabled={isOwner(user) || user.id === me?.id}
-                className="p-1.5 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded disabled:opacity-40"
-                title={user.id === me?.id ? 'You cannot delete your own account' : 'Delete this account'}
-              >
-                <Trash2 className="w-4 h-4" />
-              </button>
-            </div>
-          ))}
-        </div>
-      )}
-
-      {showNew && (
-        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => setShowNew(false)}>
-          <div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}>
-            <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-4">Add a user</h3>
-            {/* Shown here as well as in the section behind: with the dialog
-                open, an error underneath it is an error nobody reads, and the
-                button just looks broken. */}
-            {error && (
-              <div className="mb-3 px-3 py-2 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
-                {error}
-              </div>
-            )}
-            <div className="space-y-3 text-sm">
-              <input
-                autoFocus
-                placeholder="Username"
-                value={newUser.username}
-                onChange={(e) => setNewUser({ ...newUser, username: e.target.value })}
-                className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
-              />
-              <input
-                placeholder="Email"
-                value={newUser.email}
-                onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
-                className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
-              />
-              <input
-                type="password"
-                placeholder="Password"
-                value={newUser.password}
-                onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
-                className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
-              />
-              <div>
-                <select
-                  value={newUser.role}
-                  onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
-                  className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
-                >
-                  {ROLES.filter((r) => r.value !== 'owner').map((r) => (
-                    <option key={r.value} value={r.value}>{r.label}</option>
-                  ))}
-                </select>
-                <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
-                  {ROLES.find((r) => r.value === newUser.role)?.help}
-                </p>
-              </div>
-              <p className="text-xs text-gray-500 dark:text-gray-400">
-                They get a project of their own, and see nothing else until somebody adds them to a project.
-              </p>
-            </div>
-            <div className="flex justify-end gap-2 mt-5">
-              <button onClick={() => setShowNew(false)} className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400">
-                Cancel
-              </button>
-              <button
-                onClick={() => createMutation.mutate()}
-                disabled={!newUser.username.trim() || !newUser.password || createMutation.isPending}
-                className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-40"
-              >
-                {createMutation.isPending ? 'Adding...' : 'Add user'}
-              </button>
-            </div>
-          </div>
-        </div>
-      )}
-
-      {resetting && (
-        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => setResetting(null)}>
-          <div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}>
-            <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
-              New password for {resetting.username}
-            </h3>
-            <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
-              They are not told - pass it on yourself.
-            </p>
-            {error && (
-              <div className="mb-3 px-3 py-2 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
-                {error}
-              </div>
-            )}
-            <input
-              autoFocus
-              type="password"
-              value={newPassword}
-              onChange={(e) => setNewPassword(e.target.value)}
-              className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
-            />
-            <div className="flex justify-end gap-2 mt-4">
-              <button onClick={() => setResetting(null)} className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400">
-                Cancel
-              </button>
-              <button
-                onClick={() => passwordMutation.mutate({ id: resetting.id, password: newPassword })}
-                disabled={newPassword.length < 6 || passwordMutation.isPending}
-                className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-40"
-              >
-                {passwordMutation.isPending ? 'Setting...' : 'Set password'}
-              </button>
-            </div>
-          </div>
-        </div>
-      )}
-    </section>
-  )
-}

+ 265 - 0
webui/src/pages/UsersPage.tsx

@@ -0,0 +1,265 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { UserPlus, Trash2, ShieldCheck, X, Info } from 'lucide-react'
+import { usersApi, User } from '../api/users'
+import { useAuthStore } from '../stores/authStore'
+
+/**
+ * The accounts on this installation.
+ *
+ * Roles here are instance-wide. What somebody may do to a particular workflow
+ * is decided by the project it lives in, not here; this is the difference
+ * between "runs this installation" and "has been given a job in one project".
+ *
+ * Password resets for someone else are not offered here - see the note below
+ * the table. The change-password endpoint verifies the account's *current*
+ * password no matter who is calling it, so an admin who does not know that
+ * password cannot use it to set a new one. Only a user's own password (with
+ * their own current password) can be changed today, from Settings > Profile.
+ */
+
+const ROLES = [
+  { value: 'owner', label: 'Owner', help: 'Runs the installation. Cannot be demoted or deleted.' },
+  { value: 'admin', label: 'Admin', help: 'Everything, including every project.' },
+  { value: 'member', label: 'Member', help: 'Only the projects they belong to.' },
+]
+
+function formatLastLogin(ms: number): string {
+  if (!ms) return 'Never logged in'
+  try {
+    return new Date(ms).toLocaleString()
+  } catch {
+    return 'Unknown'
+  }
+}
+
+export default function UsersPage() {
+  const queryClient = useQueryClient()
+  const me = useAuthStore((s) => s.user)
+
+  const [showNew, setShowNew] = useState(false)
+  const [newUser, setNewUser] = useState({ username: '', password: '', email: '', role: 'member' })
+  const [error, setError] = useState<string | null>(null)
+
+  const { data: users = [], isLoading, isError } = useQuery({
+    queryKey: ['users'],
+    queryFn: () => usersApi.list(),
+  })
+
+  const refresh = () => queryClient.invalidateQueries({ queryKey: ['users'] })
+  const fail = (e: any) => setError(e?.response?.data?.error || e.message || 'That did not work')
+
+  const createMutation = useMutation({
+    mutationFn: () => usersApi.create(newUser),
+    onSuccess: () => {
+      refresh()
+      setShowNew(false)
+      setNewUser({ username: '', password: '', email: '', role: 'member' })
+      setError(null)
+    },
+    onError: fail,
+  })
+
+  const updateMutation = useMutation({
+    mutationFn: ({ id, changes }: { id: string; changes: any }) => usersApi.update(id, changes),
+    onSuccess: () => { refresh(); setError(null) },
+    onError: fail,
+  })
+
+  const deleteMutation = useMutation({
+    mutationFn: (id: string) => usersApi.remove(id),
+    onSuccess: () => { refresh(); setError(null) },
+    onError: fail,
+  })
+
+  const isOwner = (u: User) => u.role === 'owner'
+
+  if (isError) {
+    return (
+      <div className="h-full overflow-auto p-6 max-w-5xl">
+        <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">Users</h1>
+        <div className="bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 p-4 rounded-lg">
+          Could not load users. This page is admin-only - you may not have the role for it.
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <div className="h-full overflow-auto p-6 max-w-5xl">
+      <div className="flex items-center justify-between mb-6">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Users</h1>
+          <p className="text-gray-500 dark:text-gray-400">Accounts on this installation</p>
+        </div>
+        <button
+          onClick={() => { setShowNew(true); setError(null) }}
+          className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
+        >
+          <UserPlus className="w-4 h-4" />
+          Add user
+        </button>
+      </div>
+
+      {error && (
+        <div className="mb-4 px-3 py-2 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm flex items-center justify-between">
+          <span>{error}</span>
+          <button onClick={() => setError(null)}><X className="w-4 h-4" /></button>
+        </div>
+      )}
+
+      <section className="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700 p-6 mb-6">
+        <div className="flex items-center gap-2 mb-4">
+          <ShieldCheck className="w-5 h-5 text-gray-400" />
+          <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Accounts</h2>
+          <span className="text-sm text-gray-400">({users.length})</span>
+        </div>
+
+        {isLoading ? (
+          <p className="text-sm text-gray-500 dark:text-gray-400">Reading the accounts...</p>
+        ) : (
+          <div className="divide-y divide-gray-100 dark:divide-slate-700">
+            {users.map((user) => (
+              <div key={user.id} className="py-3 flex items-center gap-3">
+                <div className="w-9 h-9 rounded-full bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300 flex items-center justify-center uppercase text-sm shrink-0">
+                  {user.username.slice(0, 1)}
+                </div>
+
+                <div className="min-w-0 flex-1">
+                  <div className="flex items-center gap-2">
+                    <span className="font-medium text-gray-900 dark:text-gray-100">{user.username}</span>
+                    {user.id === me?.id && (
+                      <span className="text-xs text-gray-400">you</span>
+                    )}
+                    {!user.active && (
+                      <span className="text-xs px-1.5 py-0.5 rounded bg-gray-200 dark:bg-slate-600 text-gray-600 dark:text-gray-300">
+                        deactivated
+                      </span>
+                    )}
+                  </div>
+                  <div className="text-xs text-gray-500 dark:text-gray-400 truncate">{user.email}</div>
+                  <div className="text-xs text-gray-400 dark:text-gray-500">{formatLastLogin(user.lastLogin)}</div>
+                </div>
+
+                <select
+                  value={user.role === 'user' ? 'member' : user.role}
+                  // The owner is the one account that cannot lose its power, or
+                  // an installation could be left with nobody able to administer it.
+                  disabled={isOwner(user) || updateMutation.isPending}
+                  onChange={(e) => updateMutation.mutate({ id: user.id, changes: { role: e.target.value } })}
+                  className="px-2 py-1 text-sm border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 disabled:opacity-50"
+                  title={isOwner(user) ? 'The owner runs the installation and cannot be demoted' : 'Change role'}
+                >
+                  {ROLES.map((r) => (
+                    <option key={r.value} value={r.value}>{r.label}</option>
+                  ))}
+                </select>
+
+                <button
+                  onClick={() => updateMutation.mutate({ id: user.id, changes: { active: !user.active } })}
+                  disabled={isOwner(user) || user.id === me?.id || updateMutation.isPending}
+                  className="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700 disabled:opacity-40"
+                  title={user.id === me?.id ? 'You cannot lock yourself out' : 'A deactivated account cannot log in, and keeps its work'}
+                >
+                  {user.active ? 'Deactivate' : 'Activate'}
+                </button>
+
+                <button
+                  onClick={() => {
+                    if (confirm(
+                      `Delete ${user.username}? Their personal project stays if it holds any workflows or credentials - ` +
+                      `there is no owner left to delete it, and only an admin will be able to reach it. ` +
+                      `If it is empty, it is removed along with the account.`
+                    )) {
+                      deleteMutation.mutate(user.id)
+                    }
+                  }}
+                  disabled={isOwner(user) || user.id === me?.id || deleteMutation.isPending}
+                  className="p-1.5 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded disabled:opacity-40"
+                  title={user.id === me?.id ? 'You cannot delete your own account' : 'Delete this account'}
+                >
+                  <Trash2 className="w-4 h-4" />
+                </button>
+              </div>
+            ))}
+          </div>
+        )}
+      </section>
+
+      <div className="flex items-start gap-2 text-sm text-gray-500 dark:text-gray-400 px-1">
+        <Info className="w-4 h-4 shrink-0 mt-0.5" />
+        <p>
+          There is no password reset for somebody else's account here. The change-password endpoint always
+          verifies the account's current password, even when an admin is calling it, so it cannot be used to
+          set a new password for someone who has forgotten theirs. Only an account holder can change their
+          own password, from Settings, with their own current password. Resetting another user's password
+          needs a backend change - an endpoint that lets an admin set one without knowing the old one.
+        </p>
+      </div>
+
+      {showNew && (
+        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => setShowNew(false)}>
+          <div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}>
+            <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-4">Add a user</h3>
+            {error && (
+              <div className="mb-3 px-3 py-2 rounded bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
+                {error}
+              </div>
+            )}
+            <div className="space-y-3 text-sm">
+              <input
+                autoFocus
+                placeholder="Username"
+                value={newUser.username}
+                onChange={(e) => setNewUser({ ...newUser, username: e.target.value })}
+                className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
+              />
+              <input
+                placeholder="Email"
+                value={newUser.email}
+                onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
+                className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
+              />
+              <input
+                type="password"
+                placeholder="Password"
+                value={newUser.password}
+                onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
+                className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
+              />
+              <div>
+                <select
+                  value={newUser.role}
+                  onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
+                  className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
+                >
+                  {ROLES.filter((r) => r.value !== 'owner').map((r) => (
+                    <option key={r.value} value={r.value}>{r.label}</option>
+                  ))}
+                </select>
+                <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
+                  {ROLES.find((r) => r.value === newUser.role)?.help}
+                </p>
+              </div>
+              <p className="text-xs text-gray-500 dark:text-gray-400">
+                They get a project of their own, and see nothing else until somebody adds them to a project.
+              </p>
+            </div>
+            <div className="flex justify-end gap-2 mt-5">
+              <button onClick={() => setShowNew(false)} className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400">
+                Cancel
+              </button>
+              <button
+                onClick={() => createMutation.mutate()}
+                disabled={!newUser.username.trim() || !newUser.password || createMutation.isPending}
+                className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-40"
+              >
+                {createMutation.isPending ? 'Adding...' : 'Add user'}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}