mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-05 17:42:12 +00:00
* feat: dashboard backend — GitHub OAuth, profile CRUD, admin endpoints Adds agent/dashboard/ FastAPI router mounted at /dashboard/api covering: - GitHub App OAuth login → JWT cookie session (cross-domain ready) - profile CRUD against LangGraph Store with model+effort validation - admin gate via CONFIGURED_ADMINS - /repos via /user/installations using the user's encrypted OAuth token CORS allowlist on webapp.py is opt-in via DASHBOARD_ALLOWED_ORIGINS so the Vercel-hosted frontend can call the LangSmith deployment with credentials. * feat: apply dashboard profile model/effort overrides in get_agent Look up the triggering user's GitHub login from config (direct field or GITHUB_USER_EMAIL_MAP reverse lookup), read their profile from the Store, and apply default_model + reasoning_effort to make_model when both are valid. Effort 'max' is captured on the profile but not yet wired through — the OpenAI Reasoning Literal doesn't accept it. * feat: ui/ TanStack Start dashboard for profile config Scaffolded with the shadcn b7CScJIjA preset (TanStack Start template, base-ui primitives, Tailwind v4). Three routes: - /login — Sign in with GitHub (links to /dashboard/api/auth/login) - /profile — Edit default model, reasoning effort, default repo - /admin — Admin-only: list users and edit other profiles API client (src/lib/api.ts) uses credentials: include so the osw_session cookie set by the OAuth callback rides cross-origin. VITE_DASHBOARD_API_BASE_URL points at the LangSmith deployment. Effort options re-render when the model changes; 'max' on Opus 4.7 is captured on the profile but ignored downstream until anthropic reasoning is wired through make_model. * feat: searchable Combobox for default repo picker Replaces the Select with a base-ui Combobox so users can filter by typing, the popup is wider than the trigger so full owner/repo names are readable, and the list caps at max-h-80 to stay on screen. * fix: address review comments + wire default_repo and Anthropic thinking Security/correctness fixes from PR review: * Open redirect: validate `redirect_to` in `/auth/login` against `DASHBOARD_BASE_URL` + `DASHBOARD_ALLOWED_ORIGINS` before signing it into the state JWT. Anything off-allowlist falls back to the dashboard base URL. (PR #1302 r3250054386) * Login CSRF: bind the OAuth `state` to the requesting browser. At `/auth/login` we generate a fresh nonce, set it as a short-lived HttpOnly SameSite=Lax cookie scoped to `/dashboard/api/auth`, and embed `hash_state_nonce(nonce)` in the state JWT. At `/auth/callback` we require the cookie nonce to hash-match the state JWT's nonce_hash (constant-time compare). (PR #1302 r3250054395) * RMW race in profile vs token writes: split storage into two namespaces — `["profiles"]` for user-editable settings and `["oauth_tokens"]` for the encrypted GitHub token. Each upsert now only writes its own namespace so an in-flight profile save can no longer clobber a fresh token from a concurrent re-login (and vice versa). (PR #1302 r3250054393) * /repos pagination: follow `Link: rel="next"` for both `/user/installations` and per-installation `/repositories` with per_page=100, capped at 1000 items. (PR #1302 r3250054401) Feature wires: * default_repo: applied as a fallback in `get_slack_repo_config` (after explicit-repo / thread metadata, before the env defaults) and in the Linear webhook (after comment-body extraction, before team mapping). Both paths resolve the triggering user's GitHub login via GITHUB_USER_EMAIL_MAP and read the profile's default_repo. * Anthropic "thinking" effort: `make_model` now accepts a `thinking` kwarg; `get_agent` maps profile effort {low,medium,high,xhigh,max} to budget_tokens {1k,4k,12k,32k,60k} when the chosen model is anthropic. OpenAI path still ignores "max" since the Literal doesn't accept it.
111 lines
3.7 KiB
TypeScript
111 lines
3.7 KiB
TypeScript
import { Navigate, createFileRoute } from "@tanstack/react-router";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useState } from "react";
|
|
|
|
import type {Profile, ProfileUpdate} from "@/lib/api";
|
|
import { AppHeader } from "@/components/AppHeader";
|
|
import { ProfileForm } from "@/components/ProfileForm";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { api } from "@/lib/api";
|
|
import { useSession } from "@/lib/session";
|
|
|
|
export const Route = createFileRoute("/admin")({ component: AdminPage });
|
|
|
|
function AdminPage() {
|
|
const session = useSession();
|
|
const qc = useQueryClient();
|
|
const [selected, setSelected] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const options = useQuery({
|
|
queryKey: ["options"],
|
|
queryFn: api.options,
|
|
enabled: !!session.data?.is_admin,
|
|
});
|
|
|
|
const profiles = useQuery({
|
|
queryKey: ["adminProfiles"],
|
|
queryFn: api.adminListProfiles,
|
|
enabled: !!session.data?.is_admin,
|
|
});
|
|
|
|
const save = useMutation({
|
|
mutationFn: ({ login, body }: { login: string; body: ProfileUpdate }) =>
|
|
api.adminSaveProfile(login, body),
|
|
onSuccess: () => {
|
|
void qc.invalidateQueries({ queryKey: ["adminProfiles"] });
|
|
setError(null);
|
|
},
|
|
onError: (e: Error) => setError(e.message),
|
|
});
|
|
|
|
if (session.isLoading) {
|
|
return (
|
|
<main className="container mx-auto p-6">
|
|
<Skeleton className="h-64 w-full" />
|
|
</main>
|
|
);
|
|
}
|
|
if (!session.data) return <Navigate to="/login" />;
|
|
if (!session.data.is_admin) return <Navigate to="/profile" />;
|
|
|
|
const activeProfile: Profile | null =
|
|
(selected && profiles.data?.find((p) => p.login === selected)) || null;
|
|
|
|
return (
|
|
<div className="min-h-svh">
|
|
<AppHeader user={session.data} />
|
|
<main className="container mx-auto grid grid-cols-1 gap-6 p-6 md:grid-cols-[280px_1fr]">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>All users</CardTitle>
|
|
<CardDescription>{profiles.data?.length ?? 0} profiles</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-1">
|
|
{profiles.isLoading ? (
|
|
<Skeleton className="h-32" />
|
|
) : (
|
|
profiles.data?.map((p) => (
|
|
<Button
|
|
key={p.login}
|
|
variant={selected === p.login ? "secondary" : "ghost"}
|
|
className="justify-start"
|
|
onClick={() => setSelected(p.login ?? null)}
|
|
>
|
|
<span className="truncate">{p.login}</span>
|
|
</Button>
|
|
))
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{activeProfile?.login ?? "Select a user"}</CardTitle>
|
|
<CardDescription>{activeProfile?.email ?? ""}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!activeProfile ? (
|
|
<p className="text-muted-foreground text-sm">Pick a user on the left to edit.</p>
|
|
) : options.isLoading ? (
|
|
<Skeleton className="h-48" />
|
|
) : (
|
|
<ProfileForm
|
|
models={options.data?.models ?? []}
|
|
repos={[]}
|
|
initial={activeProfile}
|
|
onSubmit={(body) =>
|
|
save.mutateAsync({ login: activeProfile.login!, body })
|
|
}
|
|
saving={save.isPending}
|
|
error={error}
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|