|
|
@@ -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>
|
|
|
- )
|
|
|
-}
|