retry a linux account in place instead of deleting the person
POST /users/:id/provision-linux, and a terminal button on each user row. One
operation covering three needs that were all previously answered by "delete the
account and make it again":
backfill an account created before the feature existed, or while the host was not
set up for it
retry the first attempt failed for something since fixed — the traversable
ancestor chmod being the one everybody hits once
re-key replace authorized_keys with a new public key
Deleting to redo a retryable side effect throws away the password, the dashboards and
everything else keyed to the row.
The provisioning block moves out of create-user into provisionOsAccount, shared by
both entry points for the same reason app-store/members.ts is shaped that way: two
moments, one piece of work.
Found by testing the retry rather than the create: provisionUserDirs re-chmods every
directory including home, and home belongs to the MEMBER after the first successful
run — chmod requires ownership, so it threw EPERM and took every retry down before it
started. Those chmods are now a default for directories being created, not an
assertion about ones that already exist; os-user.ts sets the home's mode through sudo
and is the authority for it.
The route answers 200 with the error in the body, because the interesting cases are
partial: "the account exists and is confined but the keys failed" is not nothing
having happened, and the row shows both halves.
Verified end to end: blocked ancestor reports the chmod and leaves osUser null, the
retry after that chmod succeeds and records the row, and a re-key replaces
authorized_keys without rotating the outbound key.
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 } from 'lucide-react';
|
||||
import { Crown, Trash2, Loader2, KeyRound, 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';
|
||||
@@ -32,6 +32,8 @@ type ManagedUser = {
|
||||
|
||||
type UsersResponse = {
|
||||
users: ManagedUser[];
|
||||
/** False on a host without per-user Linux accounts, where those controls would only ever refuse. */
|
||||
osUsersEnabled: boolean;
|
||||
/** Every role, for displaying the owner's own value. */
|
||||
roles: string[];
|
||||
/** Roles the server will accept in a write. Excludes the owner role — both write paths refuse it. */
|
||||
@@ -66,6 +68,45 @@ export const UsersSection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create or repair the account's Linux side in place.
|
||||
*
|
||||
* Prompts for a key rather than putting a whole form on the row: replacing it is the rarer of the two
|
||||
* reasons to press this, and an empty answer means "leave authorized_keys alone" rather than "remove it".
|
||||
*/
|
||||
const provisionLinux = async (user: ManagedUser) => {
|
||||
const key = window.prompt(
|
||||
`Linux account for ${user.email}.\n\n` +
|
||||
`Paste an SSH public key to allow them to SSH in, or leave empty to keep the current one.`,
|
||||
'',
|
||||
);
|
||||
// Cancel is null; empty string is a deliberate "no change".
|
||||
if (key === null) return;
|
||||
|
||||
setPendingId(user.id);
|
||||
try {
|
||||
const result = await client.post<{ osUser: string | null; sshPublicKey: string | null; error: string | null }>(
|
||||
`/users/${user.id}/provision-linux`,
|
||||
{ sshPublicKey: key.trim() },
|
||||
);
|
||||
await queryClient.invalidateQueries({ queryKey: USERS_KEY });
|
||||
// Partial success is the interesting case and must not read as a clean win: the account can exist and
|
||||
// be confined while the keys failed.
|
||||
if (result.error) {
|
||||
toast.warning(result.osUser ? `${result.osUser} created, but not finished` : 'Could not finish', {
|
||||
description: result.error,
|
||||
duration: 30_000,
|
||||
});
|
||||
} else {
|
||||
toast.success(`${result.osUser} is ready`);
|
||||
}
|
||||
} catch (ex) {
|
||||
toast.error(ex instanceof Error ? ex.message : 'Could not provision the Linux account');
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (user: ManagedUser) => {
|
||||
setPendingId(user.id);
|
||||
setConfirmDelete(null);
|
||||
@@ -139,6 +180,27 @@ export const UsersSection = () => {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Create or repair the Linux side. Offered for anyone lacking an account (backfill) and for
|
||||
anyone who has one (retry after fixing a host problem, or replace their key) — the
|
||||
underlying operation is idempotent, so there is no state where pressing it is wrong. */}
|
||||
{data.osUsersEnabled && !user.isOwner && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`shrink-0 ${user.osUser ? 'text-muted-foreground' : 'text-amber-500'}`}
|
||||
disabled={busy}
|
||||
aria-label={user.osUser ? `Repair ${user.email}'s Linux account` : `Create a Linux account`}
|
||||
title={
|
||||
user.osUser
|
||||
? `Linux account: ${user.osUser} — click to repair or replace their SSH key`
|
||||
: 'No Linux account — click to create one'
|
||||
}
|
||||
onClick={() => void provisionLinux(user)}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <TerminalIcon className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* The errand the create screen promised would still be here: this key has to end up on
|
||||
their Gitea account, and nothing else will remind anyone. */}
|
||||
{user.osSshPublicKey && (
|
||||
|
||||
Reference in New Issue
Block a user