mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
chore: remove legacy mapping + admin profiles section, page user mappings (#1371)
* chore: remove legacy mapping + admin profiles section, page user mappings
- Delete the hardcoded github_user_email_map.py and the one-time
POST /admin/user-mappings/import endpoint + Import legacy mapping UI
button (the Store is now the sole source of truth post-import).
- Remove the per-user profiles admin section and its GET/PUT
/admin/profiles endpoints + ProfileForm component; users still manage
their own profile via My Settings.
- Page the user mappings list: /admin/user-mappings now takes
page/page_size and returns {items,total,page,page_size}; the admin UI
shows 20 rows per page with Previous/Next controls.
* Address review: fix stale import-button doc + clamp mappings page on shrink
This commit is contained in:
parent
427bfe4f56
commit
c41185a3ca
6 changed files with 74 additions and 487 deletions
|
|
@ -209,8 +209,7 @@ GitHub triggering works automatically once your GitHub App is set up (step 3). U
|
|||
|
||||
Which GitHub users can trigger the agent is controlled by the **user mapping** (GitHub login ⇄ work email ⇄ optional Slack ID), stored in the LangGraph Store rather than in code. Manage it in the dashboard under **Admin → User mappings**:
|
||||
|
||||
- **Add / update** a single mapping (GitHub login + work email, plus an optional Slack user ID).
|
||||
- **Import legacy mapping** — a one-time button that seeds the Store from the historical `agent/utils/github_user_email_map.py` dict (records already present are left untouched). Run this once after deploying so existing users keep working; the dict is no longer read at runtime.
|
||||
- **Add / update** a single mapping (GitHub login + work email, plus an optional Slack user ID). The list is paged (20 per page).
|
||||
- Users can also **self-onboard**: when an unmapped person tags Open SWE in Slack, the agent runs with limited (GitHub App installation) permissions and posts a "link your GitHub account" prompt. Completing the org-gated GitHub OAuth login records a `self` mapping (carrying the originating Slack ID and work email). Self-signup is therefore bounded by the same `ALLOWED_GITHUB_ORGS` gate as dashboard login.
|
||||
|
||||
You should also configure which GitHub organizations and/or repositories the agent is allowed to operate on. You can specify allowed orgs, specific `owner/repo` pairs, or both:
|
||||
|
|
@ -545,7 +544,7 @@ The `langgraph.json` at the project root already defines the graph entry point a
|
|||
|
||||
### Agent not responding to comments
|
||||
|
||||
- For GitHub: ensure the comment or issue contains `@openswe` (case-insensitive), and the commenter has a user mapping (Admin → User mappings; see "Configure triggering surfaces"). If you just migrated, run the one-time **Import legacy mapping** button.
|
||||
- For GitHub: ensure the comment or issue contains `@openswe` (case-insensitive), and the commenter has a user mapping (Admin → User mappings; see "Configure triggering surfaces"). Add any missing user with **Add / update** in that section.
|
||||
- For Linear: ensure the comment contains `@openswe` (case-insensitive)
|
||||
- For Slack: ensure the bot is invited to the channel and the message is an `@mention`
|
||||
- Check server logs for webhook processing errors
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ from .profiles import (
|
|||
ProfileUpdate,
|
||||
get_profile,
|
||||
get_valid_access_token,
|
||||
list_profiles,
|
||||
upsert_access_token_from_github_response,
|
||||
upsert_profile,
|
||||
)
|
||||
|
|
@ -76,7 +75,6 @@ from .thread_api import (
|
|||
stream_dashboard_thread,
|
||||
)
|
||||
from .user_mappings import (
|
||||
bulk_import,
|
||||
delete_mapping,
|
||||
list_mappings,
|
||||
upsert_mapping,
|
||||
|
|
@ -285,37 +283,6 @@ async def put_my_profile(
|
|||
return await upsert_profile(session["sub"], session.get("email") or "", update)
|
||||
|
||||
|
||||
@router.get("/admin/profiles")
|
||||
async def admin_list_profiles(
|
||||
_admin: dict[str, Any] = _ADMIN_DEP,
|
||||
) -> list[dict[str, Any]]:
|
||||
return await list_profiles()
|
||||
|
||||
|
||||
class AdminProfileUpdate(ProfileUpdate):
|
||||
email: str | None = None
|
||||
|
||||
|
||||
@router.put("/admin/profiles/{login}")
|
||||
async def admin_put_profile(
|
||||
login: str,
|
||||
update: AdminProfileUpdate,
|
||||
_admin: dict[str, Any] = _ADMIN_DEP,
|
||||
) -> dict[str, Any]:
|
||||
update.validate_pairing()
|
||||
existing = await get_profile(login) or {}
|
||||
email = update.email or existing.get("email") or ""
|
||||
# Overlay only fields that were explicitly sent so the admin form (which
|
||||
# only sends model/effort/repo) can't reset other fields the target user
|
||||
# configured via My Settings / Cloud Agents to ProfileUpdate's defaults.
|
||||
incoming = update.model_dump(exclude={"email"}, exclude_unset=True)
|
||||
merged = {**existing, **incoming}
|
||||
base = ProfileUpdate(
|
||||
**{k: v for k, v in merged.items() if k in ProfileUpdate.model_fields},
|
||||
)
|
||||
return await upsert_profile(login, email, base)
|
||||
|
||||
|
||||
@router.get("/team-settings")
|
||||
async def api_get_team_settings(
|
||||
session: dict[str, Any] = _SESSION_DEP,
|
||||
|
|
@ -360,9 +327,22 @@ class UserMappingUpsert(BaseModel):
|
|||
|
||||
@router.get("/admin/user-mappings")
|
||||
async def admin_list_user_mappings(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
_admin: dict[str, Any] = _ADMIN_DEP,
|
||||
) -> list[dict[str, Any]]:
|
||||
return await list_mappings()
|
||||
) -> dict[str, Any]:
|
||||
page = max(page, 1)
|
||||
page_size = max(1, min(page_size, 100))
|
||||
records = await list_mappings()
|
||||
total = len(records)
|
||||
start = (page - 1) * page_size
|
||||
items = records[start : start + page_size]
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/admin/user-mappings")
|
||||
|
|
@ -391,17 +371,6 @@ async def admin_delete_user_mapping(
|
|||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@router.post("/admin/user-mappings/import")
|
||||
async def admin_import_user_mappings(
|
||||
_admin: dict[str, Any] = _ADMIN_DEP,
|
||||
) -> dict[str, int]:
|
||||
"""One-time seed of the legacy hardcoded GitHub→email map into the Store."""
|
||||
from ..utils.github_user_email_map import GITHUB_USER_EMAIL_MAP
|
||||
|
||||
created = await bulk_import(GITHUB_USER_EMAIL_MAP, source="hardcoded")
|
||||
return {"created": created}
|
||||
|
||||
|
||||
def _next_link_url(link_header: str | None) -> str | None:
|
||||
if not link_header:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
"""Legacy GitHub-username → LangSmith-email mapping.
|
||||
|
||||
This dict is **no longer read at runtime**. The live mapping is the
|
||||
Store-backed bidirectional record managed in
|
||||
``agent/dashboard/user_mappings.py`` (Admin → User mappings in the
|
||||
dashboard). This file is retained only as the payload for the one-time
|
||||
``POST /dashboard/api/admin/user-mappings/import`` bulk import (the
|
||||
"Import legacy mapping" admin button), which seeds the Store with
|
||||
``source="hardcoded"`` entries. It can be deleted once every deployment has
|
||||
run that import.
|
||||
"""
|
||||
|
||||
GITHUB_USER_EMAIL_MAP: dict[str, str] = {
|
||||
"aran-yogesh": "yogesh.mahendran@langchain.dev",
|
||||
"AaryanPotdar": "aaryan.potdar@langchain.dev",
|
||||
"agola11": "ankush@langchain.dev",
|
||||
"akira": "alex@langchain.dev",
|
||||
"amal-irgashev": "amal.irgashev@langchain.dev",
|
||||
"andrew-langchain-gh": "andrew.selden@langchain.dev",
|
||||
"andrewnguonly": "andrew@langchain.dev",
|
||||
"andrewrreed": "andrew@langchain.dev",
|
||||
"angus-langchain": "angus@langchain.dev",
|
||||
"ArthurLangChain": "arthur@langchain.dev",
|
||||
"asatish-langchain": "asatish@langchain.dev",
|
||||
"ashwinamardeep-ashwin": "ashwin.amardeep@langchain.dev",
|
||||
"asrira428": "siri.arun@langchain.dev",
|
||||
"ayoung19": "andy@langchain.dev",
|
||||
"baskaryan": "bagatur@langchain.dev",
|
||||
"bastiangerstner": "bastian.gerstner@langchain.dev",
|
||||
"bees": "arian@langchain.dev",
|
||||
"bentanny": "ben.tannyhill@langchain.dev",
|
||||
"bracesproul": "brace@langchain.dev",
|
||||
"brianto-langchain": "brian.to@langchain.dev",
|
||||
"bscott449": "brandon@langchain.dev",
|
||||
"bvs-langchain": "brian@langchain.dev",
|
||||
"bwhiting2356": "brendan.whiting@langchain.dev",
|
||||
"carolinedivittorio": "caroline.divittorio@langchain.dev",
|
||||
"casparb": "caspar@langchain.dev",
|
||||
"catherine-langchain": "catherine@langchain.dev",
|
||||
"ccurme": "chester@langchain.dev",
|
||||
"christian-bromann": "christian@langchain.dev",
|
||||
"christineastoria": "christine@langchain.dev",
|
||||
"colifran": "colin.francis@langchain.dev",
|
||||
"conradcorbett-crypto": "conrad.corbett@langchain.dev",
|
||||
"cstanlee": "carlos.stanley@langchain.dev",
|
||||
"cwaddingham": "chris.waddingham@langchain.dev",
|
||||
"cwlbraa": "cwlbraa@langchain.dev",
|
||||
"dahlke": "neil@langchain.dev",
|
||||
"DanielKneipp": "daniel@langchain.dev",
|
||||
"danielrlambert3": "daniel@langchain.dev",
|
||||
"DavoCoder": "davidc@langchain.dev",
|
||||
"ddzmitry": "dzmitry.dubarau@langchain.dev",
|
||||
"denis-at-langchain": "denis@langchain.dev",
|
||||
"dqbd": "david@langchain.dev",
|
||||
"elibrosen": "eli@langchain.dev",
|
||||
"emil-lc": "emil@langchain.dev",
|
||||
"emily-langchain": "emily@langchain.dev",
|
||||
"ericdong-langchain": "ericdong@langchain.dev",
|
||||
"ericjohanson-langchain": "eric.johanson@langchain.dev",
|
||||
"eyurtsev": "eugene@langchain.dev",
|
||||
"gethin-langchain": "gethin.dibben@langchain.dev",
|
||||
"gladwig2": "geoff@langchain.dev",
|
||||
"GowriH-1": "gowri@langchain.dev",
|
||||
"hanalodi": "hana@langchain.dev",
|
||||
"hari-dhanushkodi": "hari@langchain.dev",
|
||||
"hinthornw": "will@langchain.dev",
|
||||
"hntrl": "hunter@langchain.dev",
|
||||
"hwchase17": "harrison@langchain.dev",
|
||||
"iakshay": "akshay@langchain.dev",
|
||||
"sydney-runkle": "sydney@langchain.dev",
|
||||
"tanushree-sharma": "tanushree@langchain.dev",
|
||||
"victorm-lc": "victor@langchain.dev",
|
||||
"vishnu-ssuresh": "vishnu.suresh@langchain.dev",
|
||||
"vtrivedy": "vivek.trivedy@langchain.dev",
|
||||
"will-langchain": "will.anderson@langchain.dev",
|
||||
"xuro-langchain": "xuro@langchain.dev",
|
||||
"yumuzi234": "zhen@langchain.dev",
|
||||
"j-broekhuizen": "jb@langchain.dev",
|
||||
"jacobalbert3": "jacob.albert@langchain.dev",
|
||||
"jacoblee93": "jacob@langchain.dev",
|
||||
"jdrogers940 ": "josh@langchain.dev",
|
||||
"jeeyoonhyun": "jeeyoon@langchain.dev",
|
||||
"jessieibarra": "jessie.ibarra@langchain.dev",
|
||||
"jfglanc": "jan.glanc@langchain.dev",
|
||||
"jkennedyvz": "john@langchain.dev",
|
||||
"joaquin-borggio-lc": "joaquin@langchain.dev",
|
||||
"joel-at-langchain": "joel.johnson@langchain.dev",
|
||||
"johannes117": "johannes@langchain.dev",
|
||||
"joshuatagoe": "joshua.tagoe@langchain.dev",
|
||||
"katmayb": "kathryn@langchain.dev",
|
||||
"kenvora": "kvora@langchain.dev",
|
||||
"kevinbfrank": "kevin.frank@langchain.dev",
|
||||
"KiewanVillatel": "kiewan@langchain.dev",
|
||||
"l2and": "randall@langchain.dev",
|
||||
"langchain-infra": "mukil@langchain.dev",
|
||||
"langchain-karan": "karan@langchain.dev",
|
||||
"lc-arjun": "arjun@langchain.dev",
|
||||
"lc-chad": "chad@langchain.dev",
|
||||
"lcochran400": "logan.cochran@langchain.dev",
|
||||
"lnhsingh": "lauren@langchain.dev",
|
||||
"longquanzheng": "long@langchain.dev",
|
||||
"loralee90": "lora.lee@langchain.dev",
|
||||
"lunevalex": "alunev@langchain.dev",
|
||||
"maahir30": "maahir.sachdev@langchain.dev",
|
||||
"madams0013": "maddy@langchain.dev",
|
||||
"mdrxy": "mason@langchain.dev",
|
||||
"mhk197": "katz@langchain.dev",
|
||||
"mwalker5000": "mike.walker@langchain.dev",
|
||||
"mlo20030": "morgan.lo@langchain.dev",
|
||||
"natasha-langchain": "nwhitney@langchain.dev",
|
||||
"nhuang-lc": "nick@langchain.dev",
|
||||
"niilooy": "niloy@langchain.dev",
|
||||
"nitboss": "nithin@langchain.dev",
|
||||
"npentrel": "naomi@langchain.dev",
|
||||
"nrc": "nick.cameron@langchain.dev",
|
||||
"Palashio": "palash@langchain.dev",
|
||||
"PeriniM": "marco@langchain.dev",
|
||||
"pjrule": "parker@langchain.dev",
|
||||
"QuentinBrosse": "quentin@langchain.dev",
|
||||
"rahul-langchain": "rahul@langchain.dev",
|
||||
"ramonpetgrave64": "ramon@langchain.dev",
|
||||
"rx5ad": "rafid.saad@langchain.dev",
|
||||
"saad-supports-langchain": "saad@langchain.dev",
|
||||
"samecrowder": "scrowder@langchain.dev",
|
||||
"samnoyes": "sam@langchain.dev",
|
||||
"seanderoiste": "sean@langchain.dev",
|
||||
"simon-langchain": "simon@langchain.dev",
|
||||
"sriputhucode-ops": "sri.puthucode@langchain.dev",
|
||||
"stephen-chu": "stephen.chu@langchain.dev",
|
||||
"sthm": "steffen@langchain.dev",
|
||||
"steve-langchain": "steve@langchain.dev",
|
||||
"SumedhArani": "sumedh@langchain.dev",
|
||||
"suraj-langchain": "suraj@langchain.dev",
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { ModelOption, Profile, ProfileUpdate, Repository } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface ProfileFormProps {
|
||||
models: Array<ModelOption>;
|
||||
repos: Array<Repository>;
|
||||
initial: Profile;
|
||||
onSubmit: (body: ProfileUpdate) => Promise<unknown>;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function ProfileForm({ models, repos, initial, onSubmit, saving, error }: ProfileFormProps) {
|
||||
const first: ModelOption | undefined = models[0];
|
||||
const [modelId, setModelId] = useState<string>(initial.default_model ?? first?.id ?? "");
|
||||
const currentModel: ModelOption | undefined = models.find((m) => m.id === modelId) ?? first;
|
||||
const [effort, setEffort] = useState<string>(
|
||||
initial.reasoning_effort ?? currentModel?.default_effort ?? "",
|
||||
);
|
||||
const [subagentModelId, setSubagentModelId] = useState<string>(
|
||||
initial.default_subagent_model ?? initial.default_model ?? first?.id ?? "",
|
||||
);
|
||||
const currentSubagentModel: ModelOption | undefined =
|
||||
models.find((m) => m.id === subagentModelId) ?? first;
|
||||
const [subagentEffort, setSubagentEffort] = useState<string>(
|
||||
initial.subagent_reasoning_effort ??
|
||||
initial.reasoning_effort ??
|
||||
currentSubagentModel?.default_effort ??
|
||||
"",
|
||||
);
|
||||
const [defaultRepo, setDefaultRepo] = useState<string>(initial.default_repo ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
if (currentModel !== undefined && !currentModel.efforts.includes(effort)) {
|
||||
setEffort(currentModel.default_effort);
|
||||
}
|
||||
}, [modelId, currentModel, effort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentSubagentModel !== undefined &&
|
||||
!currentSubagentModel.efforts.includes(subagentEffort)
|
||||
) {
|
||||
setSubagentEffort(currentSubagentModel.default_effort);
|
||||
}
|
||||
}, [subagentModelId, currentSubagentModel, subagentEffort]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
void onSubmit({
|
||||
default_model: modelId,
|
||||
reasoning_effort: effort,
|
||||
default_subagent_model: subagentModelId,
|
||||
subagent_reasoning_effort: subagentEffort,
|
||||
default_repo: defaultRepo || null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="model">Default model</Label>
|
||||
<Select value={modelId} onValueChange={(v) => v && setModelId(v)}>
|
||||
<SelectTrigger id="model">
|
||||
<SelectValue placeholder="Pick a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="effort">Reasoning effort</Label>
|
||||
<Select value={effort} onValueChange={(v) => v && setEffort(v)}>
|
||||
<SelectTrigger id="effort">
|
||||
<SelectValue placeholder="Pick an effort level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currentModel?.efforts.map((e) => (
|
||||
<SelectItem key={e} value={e}>
|
||||
{e}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="subagent-model">Default subagent model</Label>
|
||||
<Select
|
||||
value={subagentModelId}
|
||||
onValueChange={(v) => v && setSubagentModelId(v)}
|
||||
>
|
||||
<SelectTrigger id="subagent-model">
|
||||
<SelectValue placeholder="Pick a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="subagent-effort">Subagent reasoning effort</Label>
|
||||
<Select
|
||||
value={subagentEffort}
|
||||
onValueChange={(v) => v && setSubagentEffort(v)}
|
||||
>
|
||||
<SelectTrigger id="subagent-effort">
|
||||
<SelectValue placeholder="Pick an effort level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currentSubagentModel?.efforts.map((e) => (
|
||||
<SelectItem key={e} value={e}>
|
||||
{e}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="repo">Default repo</Label>
|
||||
{repos.length > 0 ? (
|
||||
<Combobox
|
||||
items={repos.map((r) => r.full_name)}
|
||||
value={defaultRepo}
|
||||
onValueChange={(v) => setDefaultRepo(typeof v === "string" ? v : "")}
|
||||
>
|
||||
<ComboboxInput id="repo" placeholder="Search repos…" showClear />
|
||||
<ComboboxContent className="min-w-[28rem]">
|
||||
<ComboboxList className="max-h-80">
|
||||
<ComboboxEmpty>No repos match</ComboboxEmpty>
|
||||
{repos.map((r) => (
|
||||
<ComboboxItem key={r.full_name} value={r.full_name}>
|
||||
<span className="truncate">{r.full_name}</span>
|
||||
{r.private && (
|
||||
<span className="text-muted-foreground ml-auto pr-5 text-[10px]">private</span>
|
||||
)}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
) : (
|
||||
<Input
|
||||
id="repo"
|
||||
placeholder="owner/repo"
|
||||
value={defaultRepo}
|
||||
onChange={(e) => setDefaultRepo(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-destructive text-sm">{error}</p>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
saving || !modelId || !effort || !subagentModelId || !subagentEffort
|
||||
}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -127,6 +127,13 @@ export interface UserMappingUpsert {
|
|||
slack_user_id?: string | null;
|
||||
}
|
||||
|
||||
export interface UserMappingsPage {
|
||||
items: Array<UserMapping>;
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface Repository {
|
||||
full_name: string;
|
||||
private: boolean;
|
||||
|
|
@ -205,13 +212,10 @@ export const api = {
|
|||
method: "PUT",
|
||||
body: JSON.stringify({ full_name, enabled }),
|
||||
}),
|
||||
adminListProfiles: () => request<Array<Profile>>("/admin/profiles"),
|
||||
adminSaveProfile: (login: string, body: ProfileUpdate & { email?: string }) =>
|
||||
request<Profile>(`/admin/profiles/${encodeURIComponent(login)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
adminListUserMappings: () => request<Array<UserMapping>>("/admin/user-mappings"),
|
||||
adminListUserMappings: (page = 1, pageSize = 20) =>
|
||||
request<UserMappingsPage>(
|
||||
`/admin/user-mappings?page=${page}&page_size=${pageSize}`,
|
||||
),
|
||||
adminSaveUserMapping: (body: UserMappingUpsert) =>
|
||||
request<UserMapping>("/admin/user-mappings", {
|
||||
method: "PUT",
|
||||
|
|
@ -222,8 +226,6 @@ export const api = {
|
|||
`/admin/user-mappings/${encodeURIComponent(github_login)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
adminImportUserMappings: () =>
|
||||
request<{ created: number }>("/admin/user-mappings/import", { method: "POST" }),
|
||||
logout: () => request<void>("/auth/logout", { method: "POST" }),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,15 +2,8 @@ import { Navigate, createFileRoute } from "@tanstack/react-router";
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type {
|
||||
ModelOption,
|
||||
Profile,
|
||||
ProfileUpdate,
|
||||
TeamSettings,
|
||||
UserMapping,
|
||||
} from "@/lib/api";
|
||||
import type { ModelOption, TeamSettings, UserMapping } from "@/lib/api";
|
||||
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
|
||||
import { ProfileForm } from "@/components/ProfileForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
|
|
@ -28,9 +21,6 @@ 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"],
|
||||
|
|
@ -38,22 +28,6 @@ function AdminPage() {
|
|||
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="p-6">
|
||||
|
|
@ -64,77 +38,44 @@ function AdminPage() {
|
|||
if (!session.data) return <Navigate to="/login" />;
|
||||
if (!session.data.is_admin) return <Navigate to="/my-settings" />;
|
||||
|
||||
const activeProfile: Profile | null =
|
||||
(selected && profiles.data?.find((p) => p.login === selected)) || null;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
user={session.data}
|
||||
title="Admin"
|
||||
description="Workspace-wide defaults and per-user profile edits."
|
||||
description="Workspace-wide defaults and user mappings."
|
||||
>
|
||||
<GlobalDefaultsSection models={options.data?.models ?? []} />
|
||||
|
||||
<UserMappingsSection enabled={!!session.data.is_admin} />
|
||||
|
||||
<SettingsSection title="Per-user profiles">
|
||||
<div className="grid grid-cols-1 gap-0 md:grid-cols-[260px_1fr]">
|
||||
<div className="flex flex-col gap-0.5 border-b border-border p-2 md:border-b-0 md:border-r">
|
||||
{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>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{!activeProfile ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pick a user on the left to edit their profile.
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function UserMappingsSection({ enabled }: { enabled: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [login, setLogin] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [slackId, setSlackId] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const mappings = useQuery({
|
||||
queryKey: ["adminUserMappings"],
|
||||
queryFn: api.adminListUserMappings,
|
||||
queryKey: ["adminUserMappings", page],
|
||||
queryFn: () => api.adminListUserMappings(page, PAGE_SIZE),
|
||||
enabled,
|
||||
});
|
||||
|
||||
const total = mappings.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
useEffect(() => {
|
||||
if (!mappings.isFetching && page > pageCount) {
|
||||
setPage(pageCount);
|
||||
}
|
||||
}, [mappings.isFetching, page, pageCount]);
|
||||
|
||||
const invalidate = () =>
|
||||
void qc.invalidateQueries({ queryKey: ["adminUserMappings"] });
|
||||
|
||||
|
|
@ -150,7 +91,6 @@ function UserMappingsSection({ enabled }: { enabled: boolean }) {
|
|||
setEmail("");
|
||||
setSlackId("");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
|
|
@ -162,15 +102,7 @@ function UserMappingsSection({ enabled }: { enabled: boolean }) {
|
|||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const importLegacy = useMutation({
|
||||
mutationFn: api.adminImportUserMappings,
|
||||
onSuccess: (res) => {
|
||||
setNotice(`Imported ${res.created} legacy mapping(s).`);
|
||||
setError(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
const items = mappings.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
|
|
@ -202,25 +134,15 @@ function UserMappingsSection({ enabled }: { enabled: boolean }) {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => importLegacy.mutate()}
|
||||
disabled={importLegacy.isPending}
|
||||
>
|
||||
{importLegacy.isPending ? "Importing…" : "Import legacy mapping"}
|
||||
</Button>
|
||||
{notice && <span className="text-xs text-muted-foreground">{notice}</span>}
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
</div>
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{mappings.isLoading ? (
|
||||
<Skeleton className="h-32" />
|
||||
) : !mappings.data?.length ? (
|
||||
) : !items.length ? (
|
||||
<p className="text-xs text-muted-foreground">No mappings yet.</p>
|
||||
) : (
|
||||
mappings.data.map((m: UserMapping) => (
|
||||
items.map((m: UserMapping) => (
|
||||
<div
|
||||
key={m.github_login}
|
||||
className="flex items-center justify-between gap-2 border-b border-border py-1.5 text-sm last:border-b-0"
|
||||
|
|
@ -245,6 +167,32 @@ function UserMappingsSection({ enabled }: { enabled: boolean }) {
|
|||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between pt-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{total} mapping{total === 1 ? "" : "s"} · page {page} of {pageCount}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || mappings.isFetching}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
disabled={page >= pageCount || mappings.isFetching}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue