feat: admin set password for users + fix dashboard cache error
Admin Set Password: - New setPassword adminProcedure in user router (Argon2 hashing) - Audit log: "Password reset by admin" (no password value logged) - UI: per-user "Password" button with key icon in User Management - Modal: new password + confirm, min 8 chars, mismatch validation - Success toast + auto-close on completion Dashboard fix: - Corrupted .next cache causing "Cannot find module worker.js" - Fixed by clearing .next cache and restarting dev server Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, type PermissionOverrides } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||
import { SuccessToast } from "~/components/ui/SuccessToast.js";
|
||||
import { FilterChips } from "~/components/ui/FilterChips.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
@@ -90,6 +92,11 @@ export function UsersClient() {
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState<SystemRole | "">("");
|
||||
const [passwordTarget, setPasswordTarget] = useState<{ userId: string; userName: string } | null>(null);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [passwordSuccess, setPasswordSuccess] = useState(false);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
@@ -166,6 +173,53 @@ export function UsersClient() {
|
||||
onError: (err) => setActionError(err.message),
|
||||
});
|
||||
|
||||
const setPasswordMutation = trpc.user.setPassword.useMutation({
|
||||
onSuccess: () => {
|
||||
setPasswordSuccess(true);
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setPasswordError(null);
|
||||
setTimeout(() => {
|
||||
setPasswordTarget(null);
|
||||
setPasswordSuccess(false);
|
||||
}, 1500);
|
||||
},
|
||||
onError: (err) => setPasswordError(err.message),
|
||||
});
|
||||
|
||||
function openSetPassword(user: UserRow) {
|
||||
setPasswordTarget({ userId: user.id, userName: user.name ?? user.email });
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setPasswordError(null);
|
||||
setPasswordSuccess(false);
|
||||
}
|
||||
|
||||
function closeSetPassword() {
|
||||
setPasswordTarget(null);
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setPasswordError(null);
|
||||
setPasswordSuccess(false);
|
||||
}
|
||||
|
||||
async function handleSetPassword() {
|
||||
if (!passwordTarget) return;
|
||||
if (newPassword.length < 8) {
|
||||
setPasswordError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
setPasswordError(null);
|
||||
await setPasswordMutation.mutateAsync({
|
||||
userId: passwordTarget.userId,
|
||||
password: newPassword,
|
||||
});
|
||||
}
|
||||
|
||||
function openEdit(user: UserRow) {
|
||||
const role = (user.systemRole as SystemRole) ?? SystemRole.USER;
|
||||
const overrides = user.permissionOverrides as PermissionOverrides | null;
|
||||
@@ -291,7 +345,8 @@ export function UsersClient() {
|
||||
updateRoleMutation.isPending ||
|
||||
setPermissionsMutation.isPending ||
|
||||
resetPermissionsMutation.isPending ||
|
||||
createUserMutation.isPending;
|
||||
createUserMutation.isPending ||
|
||||
setPasswordMutation.isPending;
|
||||
|
||||
function clearAll() {
|
||||
setSearch("");
|
||||
@@ -474,13 +529,26 @@ export function UsersClient() {
|
||||
{new Date(user.createdAt).toLocaleDateString("en-GB")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(user)}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSetPassword(user)}
|
||||
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 font-medium"
|
||||
title="Set password"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
Password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(user)}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -488,6 +556,81 @@ export function UsersClient() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Set Password Modal */}
|
||||
<AnimatedModal open={!!passwordTarget} onClose={closeSetPassword} maxWidth="max-w-md">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Set Password for {passwordTarget?.userName}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
{passwordError && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-3 py-2 text-sm text-red-700 dark:text-red-400">
|
||||
{passwordError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{newPassword.length > 0 && newPassword.length < 8 && (
|
||||
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
{8 - newPassword.length} more character{8 - newPassword.length !== 1 ? "s" : ""} needed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Repeat password"
|
||||
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{confirmPassword.length > 0 && newPassword !== confirmPassword && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
Passwords do not match
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeSetPassword}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSetPassword()}
|
||||
disabled={setPasswordMutation.isPending || newPassword.length < 8 || newPassword !== confirmPassword}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{setPasswordMutation.isPending ? "Saving..." : "Set Password"}
|
||||
</button>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
|
||||
<SuccessToast show={passwordSuccess} message="Password updated successfully" />
|
||||
|
||||
{/* Create User Modal */}
|
||||
{createState && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
|
||||
Reference in New Issue
Block a user