fa156d6a59
Chat UI: - Each Claude/Codex session keeps its own chat/terminal view; Settings only sets how new sessions open. - Composer + menu: upload, project files and folders (#54, also dragged from the Files/Changes tree), context files, recent uploads, skills, plugins. - Activity side panel (progress, outputs, files read, uploads, memory, skills, connectors) with a file preview; edited-file counts per turn. - Permission-mode chip (Shift+Tab steps, read back from the pane: server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode). - Stop (Esc) while the main agent works; the status is checked per agent (with session_id /api/agents ignores its status filter) and re-read every 5 s while Stop shows. - Dropped images shown in the conversation (GET /api/run/drop/:name, the requester's own drop dir), full-size viewer. - A plain-text paste over 5,000 characters is attached as pasted-text.txt, so no prompt reaches tmux/argv limits (#68). Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the whole panel to a workspace's extra folder (#70). Server mode S3 (one Linux user per account) — experimental, verified with fake sudo plus a P6 managed-settings spike on a test account: - member tmux listing runs in the background (cached, 15 s => unknown); - transcript snapshot copy is async, chunked, temp file + rename; - Express Layer patch only on Express 4; - tmux capturePane/sendKey go to the member's own server. Docs: the multi-user design moved in as docs/plans/server-mode/README.md (the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
/**
|
|
* @file AuthGate.tsx
|
|
* @description Server mode's login gate around the whole app (docs/SERVER-MODE.md).
|
|
* Asks `GET /api/auth/me` (again every 2 s while it gets no answer): in local
|
|
* mode — the default — it renders the app and nothing else changes, at once
|
|
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
|
|
* someone logs in, and again whenever a request answers 401 `EAUTH`
|
|
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
|
|
* previous user's data. `useAuth()` gives the sidebar the user and `logout`;
|
|
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
|
* Once an account is known (local mode is one), its own UI preferences are
|
|
* loaded into this browser (`lib/userPrefs.ts`).
|
|
* The login page follows the OS's light/dark (`applySystemTheme`).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import {
|
|
createContext,
|
|
useContext,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useState,
|
|
type FormEvent,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Eye, EyeOff } from "lucide-react";
|
|
import { BRAND_LOGO } from "../lib/brand";
|
|
import { applySystemTheme } from "../hooks/useTheme";
|
|
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
|
import { hydrate } from "../lib/userPrefs";
|
|
|
|
type AuthValue = { state: AuthState; logout: () => Promise<void> };
|
|
|
|
const AuthContext = createContext<AuthValue>({ state: { mode: "local" }, logout: async () => {} });
|
|
|
|
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
|
|
export const useAuth = () => useContext(AuthContext);
|
|
|
|
/**
|
|
* What the logged-in user may do — the server's rules (server/lib/auth.js,
|
|
* scope.js) mirrored so the UI hides what would only be refused. Local mode:
|
|
* everything. `isAdmin`: the host's own setup (Settings → System, updates,
|
|
* AI tools, proxies, users, alert rules…). `seesAll`: every user's rows
|
|
* (admin, analyst). `canEditLane`: an admin any workspace, everyone else
|
|
* only their own — the one that matters is an analyst looking at another
|
|
* user's.
|
|
*/
|
|
export function usePermissions() {
|
|
const { state } = useAuth();
|
|
const user = state.mode === "server" ? state.user : null;
|
|
const local = state.mode !== "server";
|
|
const isAdmin = local || user?.role === "admin";
|
|
return {
|
|
isAdmin,
|
|
seesAll: local || (!!user && user.role !== "developer"),
|
|
// A developer only ever sees their own rows, so everything shown is theirs.
|
|
canEditLane: (lane?: { owner_id?: number | null } | null) =>
|
|
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
|
|
};
|
|
}
|
|
|
|
/** The mode this browser last saw, so a local dashboard paints at once. */
|
|
const MODE_KEY = "agenthub.authMode";
|
|
const RETRY_MS = 2000;
|
|
|
|
function cachedState(): AuthState | null {
|
|
try {
|
|
return localStorage.getItem(MODE_KEY) === "local" ? { mode: "local" } : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function AuthGate({ children }: { children: ReactNode }) {
|
|
// null = still asking the server. A browser that last saw local mode starts
|
|
// there and is corrected by the answer (or by a 401) if that changed.
|
|
const [state, setState] = useState<AuthState | null>(cachedState);
|
|
|
|
useEffect(() => {
|
|
let timer: number | undefined;
|
|
let alive = true;
|
|
const ask = () => {
|
|
api.auth
|
|
.me()
|
|
.then((next) => {
|
|
if (!alive) return;
|
|
try {
|
|
localStorage.setItem(MODE_KEY, next.mode);
|
|
} catch {
|
|
/* storage blocked */
|
|
}
|
|
setState(next);
|
|
})
|
|
.catch((err: { status?: number; code?: string }) => {
|
|
if (!alive) return;
|
|
// An answer that is not this build's: an older server still running
|
|
// under a freshly built UI (404, or its index.html → EBADRESPONSE), or
|
|
// DASHBOARD_TOKEN's own 401 — neither is server mode, so render the
|
|
// app as before and let it show its own state.
|
|
if (
|
|
err?.status === 404 ||
|
|
err?.code === "EBADRESPONSE" ||
|
|
(err?.status === 401 && err?.code !== "EAUTH")
|
|
) {
|
|
setState({ mode: "local" });
|
|
return;
|
|
}
|
|
// No answer at all (a restart, a proxy's 502): ask again rather than
|
|
// guess — "local" would hide a server-mode dashboard's login page.
|
|
timer = window.setTimeout(ask, RETRY_MS);
|
|
});
|
|
};
|
|
ask();
|
|
// Any request answering 401 EAUTH means server mode with no valid login.
|
|
const onRequired = () => setState({ mode: "server", user: null });
|
|
window.addEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
|
return () => {
|
|
alive = false;
|
|
window.clearTimeout(timer);
|
|
window.removeEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
|
};
|
|
}, []);
|
|
|
|
// The signed-in account's own preferences (lib/userPrefs.ts): loaded once
|
|
// per account, so a second login in this browser brings that account's.
|
|
const account =
|
|
state?.mode === "local"
|
|
? "local"
|
|
: state?.mode === "server" && state.user
|
|
? `u${state.user.id}`
|
|
: null;
|
|
useEffect(() => {
|
|
if (account) void hydrate();
|
|
}, [account]);
|
|
|
|
if (!state) return null;
|
|
if (state.mode === "server" && !state.user) {
|
|
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
|
}
|
|
const logout = async () => {
|
|
await api.auth.logout().catch(() => {});
|
|
setState({ mode: "server", user: null });
|
|
};
|
|
return <AuthContext.Provider value={{ state, logout }}>{children}</AuthContext.Provider>;
|
|
}
|
|
|
|
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
|
const { t } = useTranslation();
|
|
const [username, setUsername] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Nobody is signed in, so no account's theme applies: follow the OS, live.
|
|
useLayoutEffect(() => {
|
|
applySystemTheme();
|
|
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
|
|
mq?.addEventListener?.("change", applySystemTheme);
|
|
return () => mq?.removeEventListener?.("change", applySystemTheme);
|
|
}, []);
|
|
|
|
const submit = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
onLoggedIn((await api.auth.login(username, password)).user);
|
|
} catch (err) {
|
|
const code = (err as { code?: string }).code;
|
|
setError(code === "ETOOMANY" ? t("auth.tooMany") : t("auth.failed"));
|
|
setPassword("");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
|
<form
|
|
onSubmit={submit}
|
|
className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
|
|
>
|
|
{/* Accent glow and a grid fading out from the top, drawn from the
|
|
theme's own accent so it holds in every theme. */}
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute inset-x-0 top-0 h-56"
|
|
style={{
|
|
backgroundImage:
|
|
"linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
|
|
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
|
|
backgroundPosition: "center top",
|
|
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
|
|
}}
|
|
/>
|
|
<div className="relative space-y-5">
|
|
<div className="text-center">
|
|
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
|
|
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">
|
|
{t("auth.title")}
|
|
</h1>
|
|
<p className="mt-2 text-sm text-fg-secondary">{t("auth.subtitle")}</p>
|
|
</div>
|
|
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
|
|
<span>{t("auth.username")}</span>
|
|
<input
|
|
className="input h-11 w-full px-4"
|
|
autoComplete="username"
|
|
autoFocus
|
|
required
|
|
placeholder={t("auth.username")}
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
/>
|
|
</label>
|
|
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
|
|
<label htmlFor="login-password">{t("auth.password")}</label>
|
|
<div className="relative">
|
|
<input
|
|
id="login-password"
|
|
className="input h-11 w-full pl-4 pr-11"
|
|
type={showPassword ? "text" : "password"}
|
|
autoComplete="current-password"
|
|
required
|
|
placeholder="••••••••"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword((v) => !v)}
|
|
aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
|
|
aria-pressed={showPassword}
|
|
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
|
>
|
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{error && (
|
|
<p role="alert" className="text-sm text-status-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
{/* Inverted fill (text colour behind the surface colour): black in a
|
|
light theme, light in a dark one, like the reference design. */}
|
|
<button
|
|
type="submit"
|
|
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
|
|
disabled={busy}
|
|
>
|
|
{busy ? t("auth.submitting") : t("auth.submit")}
|
|
</button>
|
|
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
|
|
</div>
|
|
</form>
|
|
</main>
|
|
);
|
|
}
|