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:
2026-03-23 09:32:38 +01:00
parent 208f866d68
commit bc6afefeae
3 changed files with 226 additions and 150 deletions
+151 -8
View File
@@ -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">