feat(server-s3): one Linux user per account (S3) — experimental
Server mode can now run each account as its own Linux user (ah-<name>) when `agenthub server install-system` has set S3 up. The dashboard reaches a member only through sudo -u / write-as / pane-launch, reads a member's files only through owner- and type-checked opens, and hooks are authenticated by peer uid. Local mode and server mode without S3 are unchanged. EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not yet been verified on a real install with real member accounts, sudo or a real Codex login. Do not offer it to real users yet. - S3 core: provisioning, per-member tmux servers, the proxy key gateway, shared tools in /opt, dropped capabilities for children (P0–P5) - P4: plan and quota per member home - P6: ~/.claude.json seed; opt-in plugin baseline installed as the member; managed-settings spike script (not run) - P7: Codex account pool (`agenthub accounts add --tool codex`) - Roles merged with main's admin / analyst / developer: analysts write only their own rows, under S3 as well - Per-account prefs: one implementation (Chat UI + onboarding) - Security reviews F1–F49 (five rounds), each fix with a regression test: symlink/TOCTOU-safe member reads, no host writes in a member home, credentials and the data dir never served, owner and folder account must agree, pane tokens bound to the pane's uid, bounded sudo/tmux/git calls and member reads (no hang or crash from a member), async route rejections answer 500 instead of exiting - Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box); client 1162/1162; tsc clean; headers check passes. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @file OnboardingTour.tsx
|
||||
* @description The step-by-step spotlight tour after the first-run setup
|
||||
* (docs/plans/onboarding): it dims the page around ONE real control and says
|
||||
* what to do with it — Add workspace → the folder → Add → the prompt box →
|
||||
* the live session → done. The overlay never takes a click (the user works
|
||||
* the real UI); a step moves on when what it asked for has happened (the
|
||||
* dialog opened, the workspace exists, the session started), and steps back
|
||||
* when the user backed out (the dialog closed with nothing made). Targets are
|
||||
* existing `data-testid`s plus three `data-tour` marks in AddLaneModal. The
|
||||
* step reached is `localStorage["agenthub.tour"]`, so it is synced to the
|
||||
* account and survives a reload; `done` ends it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
const KEY = "agenthub.tour";
|
||||
const EVENT = "agenthub-tour";
|
||||
const POLL_MS = 250;
|
||||
|
||||
type StepId = "add" | "folder" | "create" | "prompt" | "session" | "finish";
|
||||
const ORDER: StepId[] = ["add", "folder", "create", "prompt", "session", "finish"];
|
||||
|
||||
const $ = (sel: string) => document.querySelector(sel);
|
||||
/** An element that is on screen (a console collapsed with CSS has no box). */
|
||||
function shown(sel: string): Element | null {
|
||||
const el = $(sel);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 ? el : null;
|
||||
}
|
||||
|
||||
const modalOpen = () => !!$('[data-tour="add-lane"]');
|
||||
const composer = () => shown('[data-testid="workspace-home-composer"]');
|
||||
const sessionLive = () =>
|
||||
!!new URLSearchParams(window.location.search).get("run") && !!shown('[data-testid="pane-grid"]');
|
||||
|
||||
/** Where each step points, and what moves it on (or back). */
|
||||
const STEPS: Record<
|
||||
StepId,
|
||||
{ target: () => Element | null; next?: () => StepId | null; info?: boolean }
|
||||
> = {
|
||||
add: {
|
||||
target: () =>
|
||||
shown('[data-testid="lane-empty"]') || shown('[data-testid="workspace-tree-add"]'),
|
||||
next: () => (modalOpen() ? "folder" : null),
|
||||
},
|
||||
folder: {
|
||||
target: () => $('[data-tour="add-lane-folder"]'),
|
||||
next: () => (modalOpen() ? null : composer() ? "prompt" : "add"),
|
||||
info: true,
|
||||
},
|
||||
create: {
|
||||
target: () => $('[data-tour="add-lane-create"]'),
|
||||
next: () => (modalOpen() ? null : composer() ? "prompt" : "add"),
|
||||
},
|
||||
prompt: {
|
||||
target: composer,
|
||||
next: () => (sessionLive() ? "session" : null),
|
||||
},
|
||||
session: { target: () => shown('[data-testid="pane-grid"]'), info: true },
|
||||
finish: { target: () => null, info: true },
|
||||
};
|
||||
|
||||
function read(): StepId | null {
|
||||
try {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return (ORDER as string[]).includes(v ?? "") ? (v as StepId) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function write(v: StepId | "done") {
|
||||
try {
|
||||
localStorage.setItem(KEY, v);
|
||||
} catch {
|
||||
/* storage blocked: the tour just ends with the page */
|
||||
}
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
}
|
||||
|
||||
/** Begin the tour at its first step (the setup's last button). */
|
||||
export const startTour = () => write("add");
|
||||
|
||||
/** Mounted once in the layout; renders nothing unless a tour is running. */
|
||||
export function OnboardingTour() {
|
||||
const { t } = useTranslation("onboarding");
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [step, setStep] = useState<StepId | null>(read);
|
||||
const [rect, setRect] = useState<DOMRect | null>(null);
|
||||
// The dialog the target sits in, if any: the card goes beside the dialog.
|
||||
const [box, setBox] = useState<DOMRect | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setStep(read());
|
||||
window.addEventListener(EVENT, sync);
|
||||
window.addEventListener("storage", sync);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT, sync);
|
||||
window.removeEventListener("storage", sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!step) return;
|
||||
const tick = () => {
|
||||
const def = STEPS[step];
|
||||
const moved = def.next?.();
|
||||
if (moved && moved !== step) return write(moved);
|
||||
const el = def.target();
|
||||
const r = el ? el.getBoundingClientRect() : null;
|
||||
const d = el?.closest("[role=dialog]")?.getBoundingClientRect() ?? null;
|
||||
setBox((prev) => (prev && d && prev.right === d.right ? prev : d));
|
||||
// Only when it moved: a new DOMRect every tick would re-render forever.
|
||||
setRect((prev) =>
|
||||
prev &&
|
||||
r &&
|
||||
prev.x === r.x &&
|
||||
prev.y === r.y &&
|
||||
prev.width === r.width &&
|
||||
prev.height === r.height
|
||||
? prev
|
||||
: r
|
||||
);
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, POLL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [step, location]);
|
||||
|
||||
if (!step) return null;
|
||||
const n = ORDER.indexOf(step) + 1;
|
||||
const end = () => write("done");
|
||||
const advance = () => {
|
||||
if (step === "folder") return write("create");
|
||||
if (step === "session") return write("finish");
|
||||
end();
|
||||
};
|
||||
const lost = !rect && step !== "finish";
|
||||
|
||||
// Beside a narrow target (a field in a dialog: below it, the card would
|
||||
// cover the rest of the dialog), else under it when there is room, else
|
||||
// above; centred when there is no target (the finish, or the page moved).
|
||||
const W = 320;
|
||||
let style: React.CSSProperties = { left: "50%", top: "50%", transform: "translate(-50%, -50%)" };
|
||||
if (rect) {
|
||||
const right = (box ?? rect).right;
|
||||
const top = Math.min(Math.max(12, rect.top), window.innerHeight - 220);
|
||||
const left = Math.min(Math.max(12, rect.left), window.innerWidth - W - 12);
|
||||
if (rect.width < window.innerWidth / 2 && right + W + 24 < window.innerWidth) {
|
||||
style = { left: right + 16, top };
|
||||
} else if (rect.bottom + 180 < window.innerHeight) {
|
||||
style = { left, top: rect.bottom + 12 };
|
||||
} else {
|
||||
style = { left, top: Math.max(12, rect.top - 12), transform: "translateY(-100%)" };
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed inset-0 z-[70]" data-testid="onboarding-tour">
|
||||
{rect ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute rounded-lg ring-2 ring-accent transition-all duration-200"
|
||||
style={{
|
||||
left: rect.left - 6,
|
||||
top: rect.top - 6,
|
||||
width: rect.width + 12,
|
||||
height: rect.height + 12,
|
||||
boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.55)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
step === "finish" && <div aria-hidden className="absolute inset-0 bg-black/55" />
|
||||
)}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-live="polite"
|
||||
aria-label={t(`tour.${step}.title`)}
|
||||
className="pointer-events-auto absolute w-80 space-y-2 rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
||||
style={style}
|
||||
>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-fg-muted">
|
||||
{t("tour.progress", { n, total: ORDER.length })}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-fg-primary">{t(`tour.${step}.title`)}</p>
|
||||
<p className="text-xs leading-relaxed text-fg-secondary">{t(`tour.${step}.body`)}</p>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{step !== "finish" && (
|
||||
<button type="button" onClick={end} className="btn-ghost px-0 text-xs">
|
||||
{t("tour.skip")}
|
||||
</button>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{lost && !location.pathname.startsWith("/workspace") && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/workspace")}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{t("tour.goWorkspaces")}
|
||||
</button>
|
||||
)}
|
||||
{STEPS[step].info && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={advance}
|
||||
data-testid="onboarding-tour-next"
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{step === "finish" ? t("tour.done") : t("tour.next")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user