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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user