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>
);
};
@@ -0,0 +1,121 @@
import type { Handler } from 'hono';
import { getUserById, updateUser, OWNER_USER_ID } from 'officerdb';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { validatePassword } from '../auth/validate-password';
// The owner resetting somebody else's platform password.
//
// Named for what it is, and NOT to be confused with `api/auth/reset-password.ts`, which is the
// token-from-an-email flow the person runs themselves. This one is the owner acting on another account,
// and the only thing the two share is the column they write.
//
// ── Why the platform generates it rather than the owner typing one ──
//
// Because the failure this exists to fix is "I created the account and forgot to copy the password down".
// An owner-typed reset can lose it exactly the same way on the second attempt. A generated one is shown
// once, in a dialog built to be copied, and is never recoverable afterwards — which is the honest state,
// since only the argon2 hash is stored.
//
// ── The Linux account is deliberately untouched ──
//
// A member's Linux account has no password and never had one: `ensureOsUser` runs `useradd` with no `-p`,
// so it is created password-locked. Nothing needs one — their terminal runs through `setpriv`, which does
// not authenticate; their SSH access is the key the owner pasted; `su - <member>` as root does not ask.
// And machine-setup sets `PasswordAuthentication no`, so a Linux password could not be used to log in even
// if it existed. Setting one here would be creating a new way in, not repairing a lost one.
/**
* Characters the generated password is built from.
*
* Split by class because `validatePassword` requires ALL FOUR — twelve characters, a lowercase, an
* uppercase, a digit and a special. A uniform draw over the union satisfies that only with high
* probability, and "the reset button fails once a month" is a worse bug than it looks, because it happens
* to somebody who is already locked out.
*
* The specials are a subset of the class `validatePassword` accepts, chosen to survive being copied,
* pasted, quoted in a shell and read aloud: no quotes, no backslash, no backtick.
*/
const CLASSES = [
'abcdefghijkmnopqrstuvwxyz',
'ABCDEFGHJKLMNPQRSTUVWXYZ',
'23456789',
'!@#$%^&*()-_=+',
] as const;
const PASSWORD_LENGTH = 20;
/** Uniform over `alphabet` by rejection sampling — `% n` on a byte biases the first characters. */
function pick(alphabet: string): string {
const max = 256 - (256 % alphabet.length);
const byte = new Uint8Array(1);
for (;;) {
crypto.getRandomValues(byte);
if (byte[0]! < max) return alphabet[byte[0]! % alphabet.length]!;
}
}
/**
* A password that satisfies `validatePassword` by construction, not by luck.
*
* One character from each class first, the remainder from the union, then shuffled — otherwise the first
* four positions would always be lower, upper, digit, special, which is a pattern worth not having.
*
* Fisher-Yates with the same rejection sampling, for the same reason.
*
* `l`, `I`, `1`, `O` and `0` are absent from the alphabets above. This gets read off a screen and typed
* somewhere else often enough that the ambiguous pairs are worth losing.
*/
export function generatePassword(length = PASSWORD_LENGTH): string {
const union = CLASSES.join('');
const chars = CLASSES.map((cls) => pick(cls));
while (chars.length < length) chars.push(pick(union));
for (let i = chars.length - 1; i > 0; i--) {
const bound = i + 1;
const max = 256 - (256 % bound);
const byte = new Uint8Array(1);
let j: number;
do {
crypto.getRandomValues(byte);
} while (byte[0]! >= max);
j = byte[0]! % bound;
[chars[i], chars[j]] = [chars[j]!, chars[i]!];
}
const password = chars.join('');
// Generated then CHECKED against the same validator every other path uses. If the rules ever gain a
// requirement the alphabets above do not cover, this throws here — at the one call site, loudly — rather
// than minting passwords the login form would go on to reject.
validatePassword(password);
return password;
}
export const resetUserPasswordHandler: Handler = async function (ctx) {
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id');
// The owner has `PUT /auth/change-password`, which asks for the current one. Resetting themselves from
// here would sign them out of the session they are doing it from, for no gain.
if (id === OWNER_USER_ID) {
throw errors.BAD_REQUEST('Change your own password from Settings, which asks for the current one.');
}
const existing = await getUserById(id);
if (!existing) throw errors.NOT_FOUND('User not found');
const password = generatePassword();
// `passwordChangedAt: new Date()` is what signs them out everywhere. `userMiddleware` refuses any token
// whose `iat` predates it, so every session they have — browser, phone, an agent holding a token —
// stops at the next request. That is the point: a reset happens because the old password was lost or
// leaked, and leaving live sessions behind would defeat both reasons.
//
// It also overwrites the null that `create-user.ts` leaves to mean "the owner chose this, not them".
// Nothing reads that; the only consumer of this column is the token check above.
await updateUser(id, { password: await argon2.hash(password), passwordChangedAt: new Date() });
// Returned exactly once. Only the hash is stored, so there is no second chance to fetch it and the UI
// says so — which is the whole reason it is generated rather than typed.
return ctx.json({ email: existing.email, password });
};
+4
View File
@@ -7,6 +7,7 @@ import { updateUserHandler } from './update-user';
import { listUsersHandler, updateUserRoleHandler, deleteUserHandler } from './manage-users';
import { createUserHandler } from './create-user';
import { provisionLinuxHandler } from './provision-linux-route';
import { resetUserPasswordHandler } from './reset-user-password';
import { capabilityAdminRouter } from './capabilities-routes';
export const usersRouter = createRouter();
@@ -31,6 +32,9 @@ usersRouter.get('/', ownerGate, listUsersHandler);
usersRouter.post('/', ownerGate, createUserHandler);
usersRouter.patch('/:id/role', ownerGate, updateUserRoleHandler);
usersRouter.post('/:id/provision-linux', ownerGate, provisionLinuxHandler);
// Generates a new platform password, returns it once, and signs the account out everywhere. Not the same
// thing as auth/reset-password.ts, which is the token flow the person runs on themselves.
usersRouter.post('/:id/password', ownerGate, resetUserPasswordHandler);
usersRouter.delete('/:id', ownerGate, deleteUserHandler);
// Which capabilities each role holds. Owner-gated inside its own router.