OFFICER_OS_USERS is gone. The platform behaves as it always would have with the flag on, and there is nothing to enable. Six conditionals, five of which were dead weight — provisionOsAccount, deprovisionOsAccount and the create/delete paths each opened with an early "not enabled on this server" return, and the API told the frontend whether to render the Linux controls at all. Those go, along with the 'disabled' DeprovisionResult stage, which nothing can produce now. The sixth is the one with teeth. assertSecretsClosed opened with `if (!OS_USERS_ENABLED) return`, described in its own comment as "a no-op when the feature is off, so an existing install is unaffected until the owner opts in". It is now unconditional: the server refuses to boot while any .env in the project root is group- or world-readable. A member's shell reading .env and printing JWT_SECRET was confirmed exploitable when this check was written, and a prerequisite that only holds when somebody remembers to set a variable is not a prerequisite. Nothing to remove on the environment side — the flag was never in .env.example or in the setup script. Not typechecked: node_modules is empty in this tree and installs are frozen, so tsgo could not run. All six files parse under `bun build --no-bundle`, and the changes are deletions of dead branches plus one removed early return. Formatted with prettier 3.9.6 via bunx rather than the pinned resolution, for the same reason; its one unrelated reformat was reverted by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
260 lines
10 KiB
TypeScript
260 lines
10 KiB
TypeScript
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 { useClient } from 'hooks/useClient';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { CreateUserForm } from './CreateUserForm';
|
|
|
|
type ManagedUser = {
|
|
id: number;
|
|
email: string;
|
|
name: string | null;
|
|
username: string | null;
|
|
status: string;
|
|
role: string;
|
|
createdAt: string;
|
|
isOwner: boolean;
|
|
osUser: string | null;
|
|
osSshPublicKey: string | null;
|
|
};
|
|
|
|
type UsersResponse = {
|
|
users: ManagedUser[];
|
|
/** 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. */
|
|
assignableRoles: string[];
|
|
ownerId: number;
|
|
};
|
|
|
|
const USERS_KEY = ['MANAGED_USERS'];
|
|
|
|
export const UsersSection = () => {
|
|
const client = useClient();
|
|
const queryClient = useQueryClient();
|
|
const [pendingId, setPendingId] = useState<number | null>(null);
|
|
const [confirmDelete, setConfirmDelete] = useState<ManagedUser | null>(null);
|
|
|
|
const { data, isLoading, isError } = useQuery<UsersResponse>({
|
|
queryKey: USERS_KEY,
|
|
queryFn: () => client.get<UsersResponse>('/users'),
|
|
});
|
|
|
|
const changeRole = async (user: ManagedUser, role: string) => {
|
|
if (role === user.role) return;
|
|
setPendingId(user.id);
|
|
try {
|
|
await client.patch(`/users/${user.id}/role`, { role });
|
|
await queryClient.invalidateQueries({ queryKey: USERS_KEY });
|
|
toast.success(`${user.email} is now ${role}`);
|
|
} catch (ex) {
|
|
toast.error(ex instanceof Error ? ex.message : 'Could not change the role');
|
|
} finally {
|
|
setPendingId(null);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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);
|
|
try {
|
|
await client.delete(`/users/${user.id}`);
|
|
await queryClient.invalidateQueries({ queryKey: USERS_KEY });
|
|
toast.success(`${user.email} removed`);
|
|
} catch (ex) {
|
|
toast.error(ex instanceof Error ? ex.message : 'Could not remove the account');
|
|
} finally {
|
|
setPendingId(null);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading accounts…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isError || !data) {
|
|
return <div className="text-sm text-destructive">Could not load the accounts.</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Every account on this server. The owner is fixed — the database itself refuses to demote or remove it — so that
|
|
row cannot be changed from here, and no other account can be promoted into it.
|
|
</p>
|
|
|
|
<CreateUserForm roles={data.assignableRoles} usersKey={USERS_KEY} />
|
|
|
|
<div className="rounded-lg border divide-y">
|
|
{data.users.map((user) => {
|
|
const busy = pendingId === user.id;
|
|
return (
|
|
<div key={user.id} className="flex items-center gap-3 p-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="truncate text-sm font-medium">{user.name || user.username || user.email}</span>
|
|
{user.isOwner && <Crown className="h-3.5 w-3.5 shrink-0 text-duck-teal" aria-label="Server owner" />}
|
|
</div>
|
|
<div className="truncate text-xs text-muted-foreground">
|
|
{user.email}
|
|
{user.status !== 'Active' && ` · ${user.status}`}
|
|
{/* Shown because "does this person have a Linux account" is otherwise invisible, and it
|
|
decides whether their terminal and agent run as them or not at all. */}
|
|
{user.osUser && ` · ${user.osUser}`}
|
|
</div>
|
|
</div>
|
|
|
|
<Select
|
|
value={user.role}
|
|
disabled={user.isOwner || busy}
|
|
onValueChange={(role) => void changeRole(user, role)}
|
|
>
|
|
<SelectTrigger className="w-40 shrink-0">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{/* The owner's row needs its own value present to render at all, and it is disabled
|
|
anyway. Every other row offers only what the server will accept. */}
|
|
{(user.isOwner ? data.roles : data.assignableRoles).map((role) => (
|
|
<SelectItem key={role} value={role}>
|
|
{role}
|
|
</SelectItem>
|
|
))}
|
|
</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. */}
|
|
{!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 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="shrink-0 text-muted-foreground"
|
|
aria-label={`Copy ${user.email}'s SSH public key`}
|
|
title="Copy their SSH public key (add it to their Gitea account)"
|
|
onClick={() => {
|
|
void navigator.clipboard.writeText(user.osSshPublicKey!);
|
|
toast.success('Public key copied');
|
|
}}
|
|
>
|
|
<KeyRound className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
|
disabled={user.isOwner || busy}
|
|
onClick={() => setConfirmDelete(user)}
|
|
aria-label={`Remove ${user.email}`}
|
|
>
|
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Removal cascades — passkeys, dashboards, screens, email accounts, playlists — and there is no
|
|
undo, so it is worth one deliberate confirmation. */}
|
|
<AlertDialog open={!!confirmDelete} onOpenChange={(open) => !open && setConfirmDelete(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Remove {confirmDelete?.email}?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This deletes the account and everything belonging to it — passkeys, dashboards, saved layouts, email
|
|
accounts and playlists. It cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
onClick={() => confirmDelete && void remove(confirmDelete)}
|
|
>
|
|
Remove account
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
};
|