Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* User management page (admin only): user list + create / reset password / delete.
|
||||
* Registration is closed: new users are created here, with the initial password set by the admin and
|
||||
* communicated offline; deleting a user also deletes all their Projects (including data directories),
|
||||
* with a confirmation dialog.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Navigate } from "react-router";
|
||||
import type { UserInfo } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { USERNAME_PATTERN } from "../../lib/semantic-id";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { useDocumentTitle } from "../../lib/use-document-title";
|
||||
import { useAuth } from "../../state/auth";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { PasswordInput } from "../../components/ui/password-input";
|
||||
import { Modal } from "../../components/ui/modal";
|
||||
|
||||
export function AdminUsersPage() {
|
||||
useDocumentTitle(S.admin.users);
|
||||
const { user } = useAuth();
|
||||
const [users, setUsers] = useState<UserInfo[] | null>(null);
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [resetting, setResetting] = useState<UserInfo | null>(null);
|
||||
const [deleting, setDeleting] = useState<UserInfo | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
setUsers((await api.adminListUsers()).users);
|
||||
setListError(null);
|
||||
} catch (e) {
|
||||
setListError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.isAdmin) void reload();
|
||||
}, [user?.isAdmin, reload]);
|
||||
|
||||
// Route guard fallback: non-admins are redirected back to the chat page (the sidebar has no entry point anyway).
|
||||
if (user && !user.isAdmin) return <Navigate to="/chat" replace />;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 md:p-6">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">{S.admin.users}</h1>
|
||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>
|
||||
{S.admin.createUser}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{users === null ? (
|
||||
<p className="text-sm text-gray-400">{S.common.loading}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900/60 dark:text-gray-400">
|
||||
<th className="whitespace-nowrap px-3 py-2 font-medium">{S.admin.userId}</th>
|
||||
<th className="whitespace-nowrap px-3 py-2 font-medium">{S.admin.role}</th>
|
||||
<th className="whitespace-nowrap px-3 py-2 font-medium">{S.admin.createdAt}</th>
|
||||
<th className="whitespace-nowrap px-3 py-2 text-right font-medium">
|
||||
{S.admin.actions}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800/60">
|
||||
{users.map((u) => (
|
||||
<tr key={u.userId}>
|
||||
<td className="whitespace-nowrap px-3 py-2 font-medium">
|
||||
{u.userId}
|
||||
{u.passwordIsInitial && (
|
||||
<span className="ml-2 align-middle">
|
||||
<Badge tone="gray">{S.admin.initialPasswordFlag}</Badge>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2">
|
||||
<Badge tone="gray">{u.isAdmin ? S.admin.roleAdmin : S.admin.roleUser}</Badge>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-gray-500 dark:text-gray-400">
|
||||
{formatDateTime(u.createdAt)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-1.5 text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => setResetting(u)}>
|
||||
{S.admin.resetPassword}
|
||||
</Button>
|
||||
{!u.isAdmin && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setDeleting(u)}>
|
||||
{S.admin.deleteUser}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{listError && <p className="mt-2 text-xs text-red-600 dark:text-red-400">{listError}</p>}
|
||||
</div>
|
||||
|
||||
<CreateUserDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onDone={() => {
|
||||
setCreateOpen(false);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
<ResetPasswordDialog user={resetting} onClose={() => setResetting(null)} />
|
||||
<DeleteUserDialog
|
||||
user={deleting}
|
||||
onClose={() => setDeleting(null)}
|
||||
onDone={() => {
|
||||
setDeleting(null);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateUserDialog({
|
||||
open,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [userId, setUserId] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setUserId("");
|
||||
setPassword("");
|
||||
setError(null);
|
||||
}, [open]);
|
||||
|
||||
const submit = async () => {
|
||||
const id = userId.trim();
|
||||
if (!id || !password) {
|
||||
setError(S.common.requiredField);
|
||||
return;
|
||||
}
|
||||
if (!USERNAME_PATTERN.test(id)) {
|
||||
setError(S.auth.usernameHint);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.adminCreateUser({ userId: id, password });
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={S.admin.createUser}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={busy}>
|
||||
{S.common.cancel}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={busy} onClick={() => void submit()}>
|
||||
{S.common.create}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
label={S.admin.userId}
|
||||
size="sm"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
hint={S.auth.usernameHint}
|
||||
autoFocus
|
||||
/>
|
||||
<PasswordInput
|
||||
label={S.admin.initialPassword}
|
||||
size="sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
hint={S.auth.passwordHint}
|
||||
/>
|
||||
{USERNAME_PATTERN.test(userId.trim()) && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{S.admin.defaultProjectNote(`${userId.trim()}-default_project`)}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetPasswordDialog({ user, onClose }: { user: UserInfo | null; onClose: () => void }) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setPassword("");
|
||||
setError(null);
|
||||
}, [user]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!user) return;
|
||||
if (!password) {
|
||||
setError(S.common.requiredField);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.adminResetPassword(user.userId, { password });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={user !== null}
|
||||
title={user ? S.admin.resetPasswordTitle(user.userId) : ""}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={busy}>
|
||||
{S.common.cancel}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={busy} onClick={() => void submit()}>
|
||||
{S.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<PasswordInput
|
||||
label={S.admin.initialPassword}
|
||||
size="sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
hint={S.auth.passwordHint}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">{S.admin.resetPasswordNote}</p>
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteUserDialog({
|
||||
user,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
user: UserInfo | null;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setConfirmed(false);
|
||||
setError(null);
|
||||
}, [user]);
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!user) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.adminDeleteUser(user.userId);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={user !== null}
|
||||
title={user ? S.admin.deleteUserTitle(user.userId) : ""}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={busy}>
|
||||
{S.common.cancel}
|
||||
</Button>
|
||||
{confirmed ? (
|
||||
<Button variant="danger" disabled={busy} onClick={() => void doDelete()}>
|
||||
{S.common.confirm}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="danger" onClick={() => setConfirmed(true)}>
|
||||
{S.common.delete}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{user ? S.admin.deleteUserConfirm(user.userId) : ""}
|
||||
</p>
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user