owner can reset a member's platform password

The gap at the other end of create-user.ts: the owner could set a password once, at
creation, and never again. Losing it meant a hand-written UPDATE with an argon2
hash — the same "edit Postgres by hand" hole that creating accounts used to have.

POST /api/users/:id/password, owner-gated, with a button on the row.

GENERATED, not typed. The failure this exists for is "I created the account and
forgot to copy the password down", and an owner typing a replacement can lose it the
same way on the second go. Shown once in a dialog built to be copied — a dialog and
not a toast, because a toast that times out while somebody finds a pen loses the one
thing they came for.

The generator satisfies validatePassword BY CONSTRUCTION rather than by luck: one
character drawn from each of the four required classes, the rest from the union,
then Fisher-Yates shuffled so the first four positions are not always
lower/upper/digit/special. Rejection sampling throughout — `% n` on a byte biases
the early characters. Then it runs validatePassword on its own output, so if the
rules ever gain a requirement the alphabets do not cover it throws at the one call
site instead of minting passwords the login form rejects. Measured: 20,000
generations, all four classes present every time.

l, I, 1, O and 0 are absent from the alphabets. This gets read off a screen and
typed somewhere else.

Signs them out everywhere, as asked: passwordChangedAt = now, and userMiddleware
already refuses any token whose iat predates it. That overwrites the null
create-user leaves to mean "the owner chose this, not them" — checked, nothing reads
that column except the token check.

The Linux account is deliberately untouched, and the dialog says so. Members have no
Linux password and never had one: ensureOsUser runs useradd with no -p, so it is
created locked. Their terminal goes through setpriv, which does not authenticate;
their SSH is the key the owner pasted; `su - <member>` as root does not ask. And
machine-setup sets PasswordAuthentication no — verified on this host — so one could
not be used to log in even if it existed. Setting one would be a new way in, not a
repair.

The owner is excluded: they have change-password, which asks for the current one,
and resetting themselves here would end the session doing it.

Verified: transpiles, all lucide icons exist, 20k generator runs. tsgo next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 12:07:43 +00:00
co-authored by Claude Opus 5
parent 1d95ad3d1b
commit b6feca8350
3 changed files with 229 additions and 1 deletions
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Crown, Trash2, Loader2, KeyRound, SquareTerminal as TerminalIcon } from 'lucide-react';
import { Crown, Trash2, Loader2, KeyRound, RotateCcw, Copy, SquareTerminal as TerminalIcon } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -47,6 +47,9 @@ export const UsersSection = () => {
const queryClient = useQueryClient();
const [pendingId, setPendingId] = useState<number | null>(null);
const [confirmDelete, setConfirmDelete] = useState<ManagedUser | null>(null);
const [confirmReset, setConfirmReset] = useState<ManagedUser | null>(null);
/** The one and only sighting of a generated password. Cleared when the dialog closes, and gone for good. */
const [newPassword, setNewPassword] = useState<{ email: string; password: string } | null>(null);
const { data, isLoading, isError } = useQuery<UsersResponse>({
queryKey: USERS_KEY,
@@ -106,6 +109,27 @@ export const UsersSection = () => {
}
};
/**
* A new platform password, generated by the server and shown once.
*
* Generated rather than typed because the failure this exists for is "I forgot to copy it down", and an
* owner typing a replacement can lose it the same way twice. Only the argon2 hash is stored, so the
* dialog below really is the only time anyone sees it — which is why it is a dialog and not a toast.
*/
const resetPassword = async (user: ManagedUser) => {
setPendingId(user.id);
setConfirmReset(null);
try {
const result = await client.post<{ email: string; password: string }>(`/users/${user.id}/password`, {});
setNewPassword(result);
await queryClient.invalidateQueries({ queryKey: USERS_KEY });
} catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not reset the password');
} finally {
setPendingId(null);
}
};
const remove = async (user: ManagedUser) => {
setPendingId(user.id);
setConfirmDelete(null);
@@ -218,6 +242,22 @@ export const UsersSection = () => {
</Button>
)}
{/* The owner is excluded because they have change-password, which asks for the current one —
and resetting themselves from here would sign them out of the session doing it. */}
{!user.isOwner && (
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground"
disabled={busy}
aria-label={`Reset ${user.email}'s password`}
title="Generate a new password — shown once, and signs them out everywhere"
onClick={() => setConfirmReset(user)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCcw className="h-4 w-4" />}
</Button>
)}
<Button
variant="ghost"
size="icon"
@@ -255,6 +295,69 @@ export const UsersSection = () => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Confirmed rather than immediate: this ends every session the account has, including one they may
be in the middle of using. Not destructive enough for the red button, so it keeps the default. */}
<AlertDialog open={!!confirmReset} onOpenChange={(open) => !open && setConfirmReset(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset the password for {confirmReset?.email}?</AlertDialogTitle>
<AlertDialogDescription>
A new password is generated and shown to you once it is not stored anywhere and cannot be looked up
afterwards. Their existing password stops working immediately, and they are signed out everywhere.
{confirmReset?.osUser ? (
<>
{' '}
Their Linux account ({confirmReset.osUser}) is not affected: it has no password, and SSH keys are
unchanged.
</>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => confirmReset && void resetPassword(confirmReset)}>
Generate a new password
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* The only time this password is ever visible. A dialog rather than a toast for exactly that reason:
a toast that times out while somebody is finding a pen loses the thing they came for. */}
<AlertDialog open={!!newPassword} onOpenChange={(open) => !open && setNewPassword(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>New password for {newPassword?.email}</AlertDialogTitle>
<AlertDialogDescription>
Copy it now and give it to them. Only its hash is stored, so closing this dialog is the last anyone sees
of it if it is lost, generate another one.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex items-center gap-2 rounded-md border bg-muted/50 p-3">
<code className="flex-1 select-all break-all font-mono text-sm">{newPassword?.password}</code>
<Button
variant="ghost"
size="icon"
className="shrink-0"
aria-label="Copy the new password"
onClick={() => {
if (!newPassword) return;
void copyToClipboard(newPassword.password).then((ok) =>
ok ? toast.success('Password copied') : toast.error('Could not copy — select it and copy by hand'),
);
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setNewPassword(null)}>Done</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};