Bladeren bron

feat: a screen for the accounts on this installation

The endpoints for creating and managing users existed from the start and
had no screen at all, so adding somebody meant a curl command - which is
why this installation has only ever had one account.

Settings now lists the accounts with their instance role, and can add one,
change a role, set a password, deactivate and delete. The section hides
itself when the endpoint refuses, because a member opening Settings is not
somebody who failed to load the users - they are somebody who does not
manage users.

The roles here are instance-wide, and the screen says so: what somebody
may do to a particular workflow is decided by the project it lives in.

Guards, and each says why rather than just refusing:
- The owner cannot be demoted, deactivated or deleted. An installation
  with nobody able to administer it has no way back.
- Nobody can deactivate or delete their own account and lock themselves
  out.
- Deleting an account says plainly what happens to their work: it stays,
  and only an admin will be able to reach it.

Errors are drawn inside the open dialog as well as the section behind it.
The first version only had the one behind, so a refused create left the
dialog sitting there looking like a button that did nothing.

A new account gets its own project, and the dialog says so - otherwise
somebody logs in to an empty screen and reasonably concludes the
installation is broken rather than empty.

Verified in the browser with three accounts: the list renders, adding one
works, a duplicate name is refused with the reason shown inside the
dialog, and a role change survives a reload.
fszontagh 1 maand geleden
bovenliggende
commit
3c60e18c53
3 gewijzigde bestanden met toevoegingen van 367 en 0 verwijderingen
  1. 82 0
      webui/src/api/users.ts
  2. 280 0
      webui/src/components/settings/UsersSection.tsx
  3. 5 0
      webui/src/pages/SettingsPage.tsx

+ 82 - 0
webui/src/api/users.ts

@@ -18,4 +18,86 @@ export const usersApi = {
     const { data } = await api.get('/users', { params: { page, pageSize } })
     return (data.users || []) as User[]
   },
+
+  create: async (user: { username: string; password: string; email?: string; role?: string }) => {
+    const { data } = await api.post('/users', user)
+    return data as User & { personalProjectId?: string }
+  },
+
+  update: async (id: string, changes: { role?: string; active?: boolean; email?: string }) => {
+    const { data } = await api.put(`/users/${id}`, changes)
+    return data as User
+  },
+
+  remove: async (id: string) => {
+    const { data } = await api.delete(`/users/${id}`)
+    return data
+  },
+
+  setPassword: async (id: string, newPassword: string) => {
+    const { data } = await api.post(`/users/${id}/change-password`, { newPassword })
+    return data
+  },
+}
+
+export interface ProjectMember {
+  userId: string
+  role: 'admin' | 'editor' | 'viewer'
+  username?: string
+  email?: string
+  addedAt?: number
+}
+
+export interface Project {
+  _id: string
+  name: string
+  description?: string
+  type: 'personal' | 'team'
+  ownerId: string
+  members: ProjectMember[]
+  myRole: 'admin' | 'editor' | 'viewer' | 'none'
+  workflowCount: number
+  createdAt?: number
+}
+
+export const projectsApi = {
+  list: async () => {
+    const { data } = await api.get('/projects')
+    return (data.projects || []) as Project[]
+  },
+
+  get: async (id: string) => {
+    const { data } = await api.get(`/projects/${id}`)
+    return data as Project
+  },
+
+  create: async (project: { name: string; description?: string }) => {
+    const { data } = await api.post('/projects', project)
+    return data as Project
+  },
+
+  update: async (id: string, changes: { name?: string; description?: string }) => {
+    const { data } = await api.put(`/projects/${id}`, changes)
+    return data as Project
+  },
+
+  remove: async (id: string) => {
+    const { data } = await api.delete(`/projects/${id}`)
+    return data
+  },
+
+  addMember: async (id: string, userId: string, role: string) => {
+    const { data } = await api.post(`/projects/${id}/members`, { userId, role })
+    return data as Project
+  },
+
+  setMemberRole: async (id: string, userId: string, role: string) => {
+    const { data } = await api.put(`/projects/${id}/members/${userId}`, { role })
+    return data as Project
+  },
+
+  removeMember: async (id: string, userId: string) => {
+    const { data } = await api.delete(`/projects/${id}/members/${userId}`)
+    return data as Project
+  },
 }

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

@@ -0,0 +1,280 @@
+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>
+  )
+}

+ 5 - 0
webui/src/pages/SettingsPage.tsx

@@ -1,3 +1,4 @@
+import { UsersSection } from '../components/settings/UsersSection'
 import { useQuery } from '@tanstack/react-query'
 import { api } from '../api/client'
 import { useAuthStore } from '../stores/authStore'
@@ -62,6 +63,10 @@ export default function SettingsPage() {
         </div>
       </section>
 
+      {/* Users - only shown to somebody who can read them; the section hides
+          itself when the endpoint refuses. */}
+      <UsersSection />
+
       {/* Runners section */}
       <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-3 mb-4">