|
|
@@ -1,6 +1,6 @@
|
|
|
import { useState } from 'react'
|
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
|
-import { UserPlus, Trash2, ShieldCheck, X, Info } from 'lucide-react'
|
|
|
+import { UserPlus, Trash2, ShieldCheck, X, KeyRound } from 'lucide-react'
|
|
|
import { usersApi, User } from '../api/users'
|
|
|
import { useAuthStore } from '../stores/authStore'
|
|
|
|
|
|
@@ -11,11 +11,11 @@ import { useAuthStore } from '../stores/authStore'
|
|
|
* 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.
|
|
|
+ * Resetting someone else's password is an admin action: it sets a new
|
|
|
+ * password without knowing the old one, and ends every session the account
|
|
|
+ * currently holds. That is deliberately different from Settings > Profile,
|
|
|
+ * which only ever changes your own password and still asks for the current
|
|
|
+ * one - self-service password changes stay there.
|
|
|
*/
|
|
|
|
|
|
const ROLES = [
|
|
|
@@ -40,6 +40,9 @@ export default function UsersPage() {
|
|
|
const [showNew, setShowNew] = useState(false)
|
|
|
const [newUser, setNewUser] = useState({ username: '', password: '', email: '', role: 'member' })
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
+ const [resetTarget, setResetTarget] = useState<User | null>(null)
|
|
|
+ const [resetPasswordValue, setResetPasswordValue] = useState('')
|
|
|
+ const [resetError, setResetError] = useState<string | null>(null)
|
|
|
|
|
|
const { data: users = [], isLoading, isError } = useQuery({
|
|
|
queryKey: ['users'],
|
|
|
@@ -72,6 +75,17 @@ export default function UsersPage() {
|
|
|
onError: fail,
|
|
|
})
|
|
|
|
|
|
+ const resetMutation = useMutation({
|
|
|
+ mutationFn: ({ id, newPassword }: { id: string; newPassword: string }) =>
|
|
|
+ usersApi.resetPassword(id, newPassword),
|
|
|
+ onSuccess: () => {
|
|
|
+ setResetTarget(null)
|
|
|
+ setResetPasswordValue('')
|
|
|
+ setResetError(null)
|
|
|
+ },
|
|
|
+ onError: (e: any) => setResetError(e?.response?.data?.error || e.message || 'That did not work'),
|
|
|
+ })
|
|
|
+
|
|
|
const isOwner = (u: User) => u.role === 'owner'
|
|
|
|
|
|
if (isError) {
|
|
|
@@ -164,6 +178,15 @@ export default function UsersPage() {
|
|
|
{user.active ? 'Deactivate' : 'Activate'}
|
|
|
</button>
|
|
|
|
|
|
+ <button
|
|
|
+ onClick={() => { setResetTarget(user); setResetPasswordValue(''); setResetError(null) }}
|
|
|
+ disabled={user.id === me?.id}
|
|
|
+ className="p-1.5 text-gray-500 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/30 rounded disabled:opacity-40"
|
|
|
+ title={user.id === me?.id ? 'Change your own password from Settings instead' : "Reset this account's password"}
|
|
|
+ >
|
|
|
+ <KeyRound className="w-4 h-4" />
|
|
|
+ </button>
|
|
|
+
|
|
|
<button
|
|
|
onClick={() => {
|
|
|
if (confirm(
|
|
|
@@ -186,17 +209,6 @@ export default function UsersPage() {
|
|
|
)}
|
|
|
</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()}>
|
|
|
@@ -260,6 +272,58 @@ export default function UsersPage() {
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
+
|
|
|
+ {resetTarget && (
|
|
|
+ <div
|
|
|
+ className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
|
|
+ onClick={() => { setResetTarget(null); setResetError(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">
|
|
|
+ Reset password for {resetTarget.username}
|
|
|
+ </h3>
|
|
|
+ <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
|
|
+ This is an administrative action. It sets a new password without knowing the old one, and
|
|
|
+ immediately signs {resetTarget.username} out everywhere - they will need the new password to
|
|
|
+ log in again.
|
|
|
+ </p>
|
|
|
+ {resetError && (
|
|
|
+ <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">
|
|
|
+ {resetError}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <input
|
|
|
+ autoFocus
|
|
|
+ type="password"
|
|
|
+ placeholder="New password"
|
|
|
+ value={resetPasswordValue}
|
|
|
+ onChange={(e) => setResetPasswordValue(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 text-sm"
|
|
|
+ />
|
|
|
+ <div className="flex justify-end gap-2 mt-5">
|
|
|
+ <button
|
|
|
+ onClick={() => { setResetTarget(null); setResetError(null) }}
|
|
|
+ className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400"
|
|
|
+ >
|
|
|
+ Cancel
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ if (confirm(
|
|
|
+ `Reset ${resetTarget.username}'s password? They will be signed out of every session immediately.`
|
|
|
+ )) {
|
|
|
+ resetMutation.mutate({ id: resetTarget.id, newPassword: resetPasswordValue })
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ disabled={!resetPasswordValue || resetMutation.isPending}
|
|
|
+ className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-40"
|
|
|
+ >
|
|
|
+ {resetMutation.isPending ? 'Resetting...' : 'Reset password'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
)
|
|
|
}
|