22de5009ff
- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the Artifact tool's hook), pinned workspace files, skills and plugins, each kept as a snapshot; shared ones appear in a Hub and a skill or plugin installs into the viewer's own account (server/lib/artifacts.js, /api/artifacts, docs/plans/artifacts/). - Workspace extra folders: clone one or more related repos beside the workspace and add them in one step (POST /api/lanes/:id/sources/clone). - A pane that ended by itself while tmux kept running is no longer offered for resume (tmux.serverStartedAt in reconcileOrphans). - Changes panel: a folder that is not a git repo opens on Files, and its files open from there. - Plus the parallel sessions' work in this tree: File Explorer button in the panel header, user delete, RSA SSH keys, chat/terminal and pet fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
559 lines
21 KiB
TypeScript
559 lines
21 KiB
TypeScript
/**
|
|
* @file UserEditDialog.tsx
|
|
* @description The Users page's ONE account editor (admin only): profile
|
|
* (display name, email), access (role, active/disabled — neither for your own
|
|
* account), a new password (typed or generated, copyable), the workspaces the
|
|
* account owns (remove, add from every workspace), and "sign out everywhere".
|
|
* Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only
|
|
* what changed, confirmed with the admin's OWN password (`currentPassword`) —
|
|
* the server validates the whole change before it applies any of it. A
|
|
* refusal is shown inside the dialog, which stays open.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react";
|
|
import { confirmDialog } from "../../lib/confirm";
|
|
import { api, type AuthUser, type ServerUserUsage, type UserPatch } from "../../lib/api";
|
|
import type { Lane } from "../../lib/types";
|
|
import { fmtCost, formatDateTime } from "../../lib/format";
|
|
import { copyText } from "../../lib/clipboard";
|
|
import { isSubmitEnter } from "../../lib/keys";
|
|
import { useDialog } from "../../hooks/useDialog";
|
|
import { Switch } from "../Switch";
|
|
import { Checkbox } from "../Checkbox";
|
|
|
|
type Role = AuthUser["role"];
|
|
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
|
|
|
export const ROLE_BADGE: Record<Role, string> = {
|
|
admin: "bg-accent/15 text-accent border-accent/20",
|
|
analyst: "bg-status-warning/10 text-status-warning border-status-warning/20",
|
|
developer: "bg-surface-4 text-fg-secondary border-border",
|
|
};
|
|
|
|
/** A random password of `n` characters, no look-alikes (0/O, 1/l/I). */
|
|
export function generatePassword(n = 16): string {
|
|
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789-_!@#%";
|
|
const bytes = new Uint32Array(n);
|
|
crypto.getRandomValues(bytes);
|
|
return Array.from(bytes, (b) => chars[b % chars.length]).join("");
|
|
}
|
|
|
|
type OwnedWorkspace = { id: number; title: string; cwd: string };
|
|
|
|
export function UserEditDialog({
|
|
user,
|
|
self,
|
|
users,
|
|
onClose,
|
|
onDone,
|
|
}: {
|
|
user: ServerUserUsage;
|
|
/** The account being edited is the signed-in admin's own. */
|
|
self: boolean;
|
|
users: ServerUserUsage[];
|
|
onClose: () => void;
|
|
onDone: (message: string) => void;
|
|
}) {
|
|
const { t } = useTranslation("users");
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
useDialog(true, panelRef, onClose);
|
|
|
|
const [displayName, setDisplayName] = useState(user.displayName ?? "");
|
|
const [email, setEmail] = useState(user.email ?? "");
|
|
const [role, setRole] = useState<Role>(user.role);
|
|
const [active, setActive] = useState(!user.disabled);
|
|
const [password, setPassword] = useState("");
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [owned, setOwned] = useState<OwnedWorkspace[] | null>(null);
|
|
const [lanes, setLanes] = useState<Lane[] | null>(null);
|
|
const [removed, setRemoved] = useState<Set<number>>(new Set());
|
|
const [added, setAdded] = useState<number[]>([]);
|
|
const [pick, setPick] = useState("");
|
|
const [signOut, setSignOut] = useState(false);
|
|
const [current, setCurrent] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
api.users
|
|
.get(user.id)
|
|
.then((r) => setOwned(r.workspaces))
|
|
.catch(() => setOwned([]));
|
|
api.lanes
|
|
.list()
|
|
.then((r) => setLanes(r.lanes))
|
|
.catch(() => setLanes([]));
|
|
}, [user.id]);
|
|
|
|
const ownerName = (id: number) => {
|
|
const u = users.find((x) => x.id === id);
|
|
return u ? u.displayName || u.username : `#${id}`;
|
|
};
|
|
const ownedIds = useMemo(() => new Set((owned ?? []).map((w) => w.id)), [owned]);
|
|
const addable = (lanes ?? []).filter((l) => !ownedIds.has(l.id) && !added.includes(l.id));
|
|
const laneLabel = (id: number) => {
|
|
const l = (lanes ?? []).find((x) => x.id === id);
|
|
return l ? l.title || l.cwd : `#${id}`;
|
|
};
|
|
|
|
const patch: UserPatch = {};
|
|
const norm = (v: string) => (v.trim() === "" ? null : v.trim());
|
|
if (norm(displayName) !== (user.displayName ?? null)) patch.displayName = norm(displayName);
|
|
if (norm(email) !== (user.email ?? null)) patch.email = norm(email);
|
|
if (!self && role !== user.role) patch.role = role;
|
|
if (!self && active === user.disabled) patch.disabled = !active;
|
|
if (password) patch.password = password;
|
|
if (added.length) patch.addWorkspaces = added;
|
|
if (removed.size) patch.removeWorkspaces = [...removed];
|
|
if (signOut) patch.signOut = true;
|
|
const changed = Object.keys(patch).length > 0;
|
|
// No minimum length (server/lib/auth.js) — a blank field keeps the password.
|
|
const ready = changed && current.length > 0 && !busy;
|
|
|
|
const submit = async () => {
|
|
if (!ready) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await api.users.update(user.id, patch, current);
|
|
onDone(t("done.updated", { name: user.displayName || user.username }));
|
|
} catch (e) {
|
|
const err = e as Error & { code?: string };
|
|
setError(
|
|
err.code && t(`errors.${err.code}`, { defaultValue: "" })
|
|
? t(`errors.${err.code}`)
|
|
: err.message
|
|
);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
/** Delete the user for good — asked first, with what goes, in red. */
|
|
const remove = async () => {
|
|
if (self || busy) return;
|
|
if (!current) {
|
|
setError(t("delete.needsPassword"));
|
|
return;
|
|
}
|
|
const name = user.displayName || user.username;
|
|
const ok = await confirmDialog({
|
|
title: t("delete.title", { name }),
|
|
message: t("delete.body", { name, count: owned?.length ?? 0, user: user.username }),
|
|
confirmLabel: t("delete.confirm"),
|
|
destructive: true,
|
|
});
|
|
if (!ok) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await api.users.remove(user.id, current);
|
|
onDone(t("done.deleted", { name }));
|
|
} catch (e) {
|
|
const err = e as Error & { code?: string };
|
|
setError(
|
|
err.code && t(`errors.${err.code}`, { defaultValue: "" })
|
|
? t(`errors.${err.code}`)
|
|
: err.message
|
|
);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
if (isSubmitEnter(e)) {
|
|
e.preventDefault();
|
|
void submit();
|
|
}
|
|
};
|
|
|
|
const shown = user.displayName || user.username;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
|
|
role="presentation"
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="user-edit-title"
|
|
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
|
|
>
|
|
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
|
|
<span
|
|
aria-hidden
|
|
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-sm font-semibold uppercase text-accent"
|
|
>
|
|
{shown.slice(0, 2)}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<h2 id="user-edit-title" className="text-base font-semibold text-fg-primary">
|
|
{t("edit.title", { name: user.displayName || user.username })}
|
|
</h2>
|
|
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
|
<span className={`badge py-0.5 text-[11px] ${ROLE_BADGE[user.role]}`}>
|
|
{t(`roles.${user.role}`)}
|
|
</span>
|
|
{self && (
|
|
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
|
|
{t("you")}
|
|
</span>
|
|
)}
|
|
{user.disabled && (
|
|
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
|
{t("disabled")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label={t("common:close")}
|
|
className="btn-icon"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 space-y-5 overflow-y-auto px-5 py-4">
|
|
<dl
|
|
className="grid grid-cols-2 gap-3 rounded-lg border border-border bg-surface-2 p-3 text-xs sm:grid-cols-3"
|
|
data-testid="user-edit-info"
|
|
>
|
|
<Info label={t("edit.created")}>
|
|
{user.createdAt ? formatDateTime(user.createdAt) : "—"}
|
|
</Info>
|
|
<Info label={t("edit.lastLogin")}>
|
|
{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : t("edit.never")}
|
|
</Info>
|
|
<Info label={t("edit.logins")}>{user.logins ?? 0}</Info>
|
|
<Info label={t("workspaces")}>{user.workspaces}</Info>
|
|
<Info label={t("sessions")}>{user.sessions}</Info>
|
|
<Info label={t("cost30d")}>{fmtCost(user.last30d.cost)}</Info>
|
|
</dl>
|
|
|
|
<Section title={t("edit.profile")}>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<Field label={t("displayName")} hint={t("displayNameHint")}>
|
|
<input
|
|
className="input w-full text-sm"
|
|
value={displayName}
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
|
onKeyDown={onKeyDown}
|
|
maxLength={100}
|
|
aria-label={t("displayName")}
|
|
/>
|
|
</Field>
|
|
<Field label={t("email")}>
|
|
<input
|
|
type="email"
|
|
className="input w-full text-sm"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
onKeyDown={onKeyDown}
|
|
maxLength={254}
|
|
autoComplete="off"
|
|
aria-label={t("email")}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title={t("edit.access")}>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<Field label={t("role")} hint={self ? t("edit.selfRole") : t(`roleHint.${role}`)}>
|
|
<select
|
|
className="input w-full text-sm"
|
|
value={role}
|
|
disabled={self}
|
|
onChange={(e) => setRole(e.target.value as Role)}
|
|
aria-label={t("role")}
|
|
>
|
|
{ROLES.map((r) => (
|
|
<option key={r} value={r}>
|
|
{t(`roles.${r}`)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
<div>
|
|
<span className="text-xs font-medium text-fg-secondary">{t("edit.status")}</span>
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<Switch
|
|
checked={active}
|
|
onChange={setActive}
|
|
disabled={self}
|
|
label={t("edit.active")}
|
|
/>
|
|
<span className="text-sm text-fg-secondary">
|
|
{active ? t("edit.active") : t("disabled")}
|
|
</span>
|
|
</div>
|
|
<span className="mt-0.5 block text-[11px] text-fg-muted">
|
|
{self ? t("edit.selfDisable") : t("edit.statusHint")}
|
|
</span>
|
|
{!active && !user.disabled && (
|
|
<p className="mt-1 text-[11px] text-status-danger">
|
|
{t("dialog.disableBody", { name: user.displayName || user.username })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title={t("edit.password")}>
|
|
<Field label={t("newPassword")} hint={t("edit.passwordHint")}>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type={showPassword ? "text" : "password"}
|
|
className="input w-full font-mono text-sm"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
onKeyDown={onKeyDown}
|
|
autoComplete="new-password"
|
|
aria-label={t("newPassword")}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn-icon"
|
|
onClick={() => setShowPassword((v) => !v)}
|
|
aria-label={showPassword ? t("edit.hide") : t("edit.show")}
|
|
title={showPassword ? t("edit.hide") : t("edit.show")}
|
|
>
|
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn-icon"
|
|
disabled={!password}
|
|
onClick={() => void copyText(password)}
|
|
aria-label={t("edit.copy")}
|
|
title={t("edit.copy")}
|
|
>
|
|
<Copy className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn-ghost whitespace-nowrap text-xs"
|
|
onClick={() => {
|
|
setPassword(generatePassword());
|
|
setShowPassword(true);
|
|
}}
|
|
>
|
|
<Sparkles className="h-3.5 w-3.5" /> {t("edit.generate")}
|
|
</button>
|
|
</div>
|
|
</Field>
|
|
</Section>
|
|
|
|
<Section title={t("workspaces")}>
|
|
{owned === null ? (
|
|
<p className="text-xs text-fg-muted">{t("edit.loading")}</p>
|
|
) : (
|
|
<ul className="space-y-1.5" data-testid="user-edit-workspaces">
|
|
{owned.length === 0 && added.length === 0 && (
|
|
<li className="text-xs text-fg-muted">{t("edit.noOwned")}</li>
|
|
)}
|
|
{owned.map((w) => {
|
|
const gone = removed.has(w.id);
|
|
return (
|
|
<li
|
|
key={w.id}
|
|
className="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm"
|
|
>
|
|
<span
|
|
className={`min-w-0 flex-1 truncate ${gone ? "text-fg-muted line-through" : "text-fg-primary"}`}
|
|
title={w.cwd}
|
|
>
|
|
{w.title || w.cwd}
|
|
</span>
|
|
{gone ? (
|
|
<button
|
|
type="button"
|
|
className="text-xs text-accent hover:underline"
|
|
onClick={() =>
|
|
setRemoved((s) => {
|
|
const n = new Set(s);
|
|
n.delete(w.id);
|
|
return n;
|
|
})
|
|
}
|
|
>
|
|
{t("edit.undo")}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="btn-icon"
|
|
aria-label={t("edit.removeWorkspace", { name: w.title || w.cwd })}
|
|
title={t("edit.removeWorkspaceHint")}
|
|
onClick={() => setRemoved((s) => new Set(s).add(w.id))}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
{added.map((id) => (
|
|
<li
|
|
key={`add-${id}`}
|
|
className="flex items-center gap-2 rounded-md border border-accent/30 bg-accent/5 px-3 py-1.5 text-sm"
|
|
>
|
|
<Plus className="h-3.5 w-3.5 text-accent" />
|
|
<span className="min-w-0 flex-1 truncate text-fg-primary">{laneLabel(id)}</span>
|
|
<button
|
|
type="button"
|
|
className="btn-icon"
|
|
aria-label={t("edit.undo")}
|
|
onClick={() => setAdded((a) => a.filter((x) => x !== id))}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{addable.length > 0 && (
|
|
<div className="mt-2 flex gap-2">
|
|
<select
|
|
className="input w-full text-sm"
|
|
value={pick}
|
|
onChange={(e) => setPick(e.target.value)}
|
|
aria-label={t("edit.addWorkspace")}
|
|
>
|
|
<option value="">{t("edit.chooseWorkspace")}</option>
|
|
{addable.map((l) => (
|
|
<option key={l.id} value={l.id}>
|
|
{(l.title || l.cwd) +
|
|
" — " +
|
|
(l.owner_id == null
|
|
? t("noOwner")
|
|
: t("ownedBy", { name: ownerName(l.owner_id) }))}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
className="btn-ghost whitespace-nowrap text-xs"
|
|
disabled={!pick}
|
|
onClick={() => {
|
|
setAdded((a) => [...a, Number(pick)]);
|
|
setPick("");
|
|
}}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" /> {t("edit.addWorkspace")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{(added.length > 0 || removed.size > 0) && (
|
|
<p className="mt-1 text-[11px] text-fg-muted">
|
|
{t("dialog.workspaceBody", { name: user.displayName || user.username })}
|
|
</p>
|
|
)}
|
|
</Section>
|
|
|
|
<Section title={t("edit.sessions")}>
|
|
<Checkbox
|
|
checked={signOut}
|
|
onChange={setSignOut}
|
|
tone="warning"
|
|
label={t("edit.signOut")}
|
|
labelClassName="text-sm text-fg-secondary"
|
|
/>
|
|
<p className="ml-6 mt-0.5 text-[11px] text-fg-muted">
|
|
{t("edit.signOutHint", { n: user.logins ?? 0 })}
|
|
</p>
|
|
</Section>
|
|
</div>
|
|
|
|
<div className="space-y-3 border-t border-border bg-surface-2/40 px-5 py-4">
|
|
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
|
<input
|
|
type="password"
|
|
className="input w-full text-sm"
|
|
value={current}
|
|
onChange={(e) => setCurrent(e.target.value)}
|
|
onKeyDown={onKeyDown}
|
|
autoComplete="current-password"
|
|
aria-label={t("yourPassword")}
|
|
/>
|
|
</Field>
|
|
{error && (
|
|
<p role="alert" className="text-xs text-status-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
<div className="flex items-center justify-end gap-2">
|
|
{!self && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void remove()}
|
|
disabled={busy}
|
|
data-testid="user-delete"
|
|
className="mr-auto rounded-md border border-status-danger/50 px-3 py-1.5 text-sm text-status-danger hover:bg-status-danger/10 disabled:opacity-50"
|
|
>
|
|
{t("delete.button")}
|
|
</button>
|
|
)}
|
|
{!changed && (
|
|
<span className={`text-xs text-fg-muted ${self ? "mr-auto" : ""}`}>
|
|
{t("edit.noChanges")}
|
|
</span>
|
|
)}
|
|
<button type="button" onClick={onClose} className="btn-ghost">
|
|
{t("common:cancel")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void submit()}
|
|
disabled={!ready}
|
|
aria-busy={busy}
|
|
className="btn-primary disabled:opacity-50"
|
|
>
|
|
{t("save")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Section({ title, children }: { title: string; children: ReactNode }) {
|
|
return (
|
|
<section>
|
|
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-fg-muted">
|
|
{title}
|
|
</h3>
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Info({ label, children }: { label: string; children: ReactNode }) {
|
|
return (
|
|
<div className="min-w-0">
|
|
<dt className="text-[11px] text-fg-muted">{label}</dt>
|
|
<dd className="truncate text-sm tabular-nums text-fg-primary">{children}</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
|
return (
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
|
<div className="mt-1">{children}</div>
|
|
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
|
</label>
|
|
);
|
|
}
|