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 { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; 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 { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -47,6 +47,9 @@ export const UsersSection = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [pendingId, setPendingId] = useState<number | null>(null); const [pendingId, setPendingId] = useState<number | null>(null);
const [confirmDelete, setConfirmDelete] = useState<ManagedUser | 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>({ const { data, isLoading, isError } = useQuery<UsersResponse>({
queryKey: USERS_KEY, 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) => { const remove = async (user: ManagedUser) => {
setPendingId(user.id); setPendingId(user.id);
setConfirmDelete(null); setConfirmDelete(null);
@@ -218,6 +242,22 @@ export const UsersSection = () => {
</Button> </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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -255,6 +295,69 @@ export const UsersSection = () => {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </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> </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 { listUsersHandler, updateUserRoleHandler, deleteUserHandler } from './manage-users';
import { createUserHandler } from './create-user'; import { createUserHandler } from './create-user';
import { provisionLinuxHandler } from './provision-linux-route'; import { provisionLinuxHandler } from './provision-linux-route';
import { resetUserPasswordHandler } from './reset-user-password';
import { capabilityAdminRouter } from './capabilities-routes'; import { capabilityAdminRouter } from './capabilities-routes';
export const usersRouter = createRouter(); export const usersRouter = createRouter();
@@ -31,6 +32,9 @@ usersRouter.get('/', ownerGate, listUsersHandler);
usersRouter.post('/', ownerGate, createUserHandler); usersRouter.post('/', ownerGate, createUserHandler);
usersRouter.patch('/:id/role', ownerGate, updateUserRoleHandler); usersRouter.patch('/:id/role', ownerGate, updateUserRoleHandler);
usersRouter.post('/:id/provision-linux', ownerGate, provisionLinuxHandler); 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); usersRouter.delete('/:id', ownerGate, deleteUserHandler);
// Which capabilities each role holds. Owner-gated inside its own router. // Which capabilities each role holds. Owner-gated inside its own router.