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
+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.