mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-06 14:42:11 +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**:
|
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).
|
- **Add / update** a single mapping (GitHub login + work email, plus an optional Slack user ID). The list is paged (20 per page).
|
||||||
- **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.
|
|
||||||
- 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.
|
- 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:
|
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
|
### 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 Linear: ensure the comment contains `@openswe` (case-insensitive)
|
||||||
- For Slack: ensure the bot is invited to the channel and the message is an `@mention`
|
- For Slack: ensure the bot is invited to the channel and the message is an `@mention`
|
||||||
- Check server logs for webhook processing errors
|
- Check server logs for webhook processing errors
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ from .profiles import (
|
||||||
ProfileUpdate,
|
ProfileUpdate,
|
||||||
get_profile,
|
get_profile,
|
||||||
get_valid_access_token,
|
get_valid_access_token,
|
||||||
list_profiles,
|
|
||||||
upsert_access_token_from_github_response,
|
upsert_access_token_from_github_response,
|
||||||
upsert_profile,
|
upsert_profile,
|
||||||
)
|
)
|
||||||
|
|
@ -76,7 +75,6 @@ from .thread_api import (
|
||||||
stream_dashboard_thread,
|
stream_dashboard_thread,
|
||||||
)
|
)
|
||||||
from .user_mappings import (
|
from .user_mappings import (
|
||||||
bulk_import,
|
|
||||||
delete_mapping,
|
delete_mapping,
|
||||||
list_mappings,
|
list_mappings,
|
||||||
upsert_mapping,
|
upsert_mapping,
|
||||||
|
|
@ -285,37 +283,6 @@ async def put_my_profile(
|
||||||
return await upsert_profile(session["sub"], session.get("email") or "", update)
|
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")
|
@router.get("/team-settings")
|
||||||
async def api_get_team_settings(
|
async def api_get_team_settings(
|
||||||
session: dict[str, Any] = _SESSION_DEP,
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
|
@ -360,9 +327,22 @@ class UserMappingUpsert(BaseModel):
|
||||||
|
|
||||||
@router.get("/admin/user-mappings")
|
@router.get("/admin/user-mappings")
|
||||||
async def admin_list_user_mappings(
|
async def admin_list_user_mappings(
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
_admin: dict[str, Any] = _ADMIN_DEP,
|
_admin: dict[str, Any] = _ADMIN_DEP,
|
||||||
) -> list[dict[str, Any]]:
|
) -> dict[str, Any]:
|
||||||
return await list_mappings()
|
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")
|
@router.put("/admin/user-mappings")
|
||||||
|
|
@ -391,17 +371,6 @@ async def admin_delete_user_mapping(
|
||||||
return {"deleted": deleted}
|
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:
|
def _next_link_url(link_header: str | None) -> str | None:
|
||||||
if not link_header:
|
if not link_header:
|
||||||
return None
|
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;
|
slack_user_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserMappingsPage {
|
||||||
|
items: Array<UserMapping>;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Repository {
|
export interface Repository {
|
||||||
full_name: string;
|
full_name: string;
|
||||||
private: boolean;
|
private: boolean;
|
||||||
|
|
@ -205,13 +212,10 @@ export const api = {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ full_name, enabled }),
|
body: JSON.stringify({ full_name, enabled }),
|
||||||
}),
|
}),
|
||||||
adminListProfiles: () => request<Array<Profile>>("/admin/profiles"),
|
adminListUserMappings: (page = 1, pageSize = 20) =>
|
||||||
adminSaveProfile: (login: string, body: ProfileUpdate & { email?: string }) =>
|
request<UserMappingsPage>(
|
||||||
request<Profile>(`/admin/profiles/${encodeURIComponent(login)}`, {
|
`/admin/user-mappings?page=${page}&page_size=${pageSize}`,
|
||||||
method: "PUT",
|
),
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
adminListUserMappings: () => request<Array<UserMapping>>("/admin/user-mappings"),
|
|
||||||
adminSaveUserMapping: (body: UserMappingUpsert) =>
|
adminSaveUserMapping: (body: UserMappingUpsert) =>
|
||||||
request<UserMapping>("/admin/user-mappings", {
|
request<UserMapping>("/admin/user-mappings", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
|
|
@ -222,8 +226,6 @@ export const api = {
|
||||||
`/admin/user-mappings/${encodeURIComponent(github_login)}`,
|
`/admin/user-mappings/${encodeURIComponent(github_login)}`,
|
||||||
{ method: "DELETE" },
|
{ method: "DELETE" },
|
||||||
),
|
),
|
||||||
adminImportUserMappings: () =>
|
|
||||||
request<{ created: number }>("/admin/user-mappings/import", { method: "POST" }),
|
|
||||||
logout: () => request<void>("/auth/logout", { 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type {
|
import type { ModelOption, TeamSettings, UserMapping } from "@/lib/api";
|
||||||
ModelOption,
|
|
||||||
Profile,
|
|
||||||
ProfileUpdate,
|
|
||||||
TeamSettings,
|
|
||||||
UserMapping,
|
|
||||||
} from "@/lib/api";
|
|
||||||
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
|
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
|
||||||
import { ProfileForm } from "@/components/ProfileForm";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
|
|
@ -28,9 +21,6 @@ export const Route = createFileRoute("/admin")({ component: AdminPage });
|
||||||
|
|
||||||
function AdminPage() {
|
function AdminPage() {
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const qc = useQueryClient();
|
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const options = useQuery({
|
const options = useQuery({
|
||||||
queryKey: ["options"],
|
queryKey: ["options"],
|
||||||
|
|
@ -38,22 +28,6 @@ function AdminPage() {
|
||||||
enabled: !!session.data?.is_admin,
|
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) {
|
if (session.isLoading) {
|
||||||
return (
|
return (
|
||||||
<main className="p-6">
|
<main className="p-6">
|
||||||
|
|
@ -64,77 +38,44 @@ function AdminPage() {
|
||||||
if (!session.data) return <Navigate to="/login" />;
|
if (!session.data) return <Navigate to="/login" />;
|
||||||
if (!session.data.is_admin) return <Navigate to="/my-settings" />;
|
if (!session.data.is_admin) return <Navigate to="/my-settings" />;
|
||||||
|
|
||||||
const activeProfile: Profile | null =
|
|
||||||
(selected && profiles.data?.find((p) => p.login === selected)) || null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
user={session.data}
|
user={session.data}
|
||||||
title="Admin"
|
title="Admin"
|
||||||
description="Workspace-wide defaults and per-user profile edits."
|
description="Workspace-wide defaults and user mappings."
|
||||||
>
|
>
|
||||||
<GlobalDefaultsSection models={options.data?.models ?? []} />
|
<GlobalDefaultsSection models={options.data?.models ?? []} />
|
||||||
|
|
||||||
<UserMappingsSection enabled={!!session.data.is_admin} />
|
<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>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
function UserMappingsSection({ enabled }: { enabled: boolean }) {
|
function UserMappingsSection({ enabled }: { enabled: boolean }) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [login, setLogin] = useState("");
|
const [login, setLogin] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [slackId, setSlackId] = useState("");
|
const [slackId, setSlackId] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
const mappings = useQuery({
|
const mappings = useQuery({
|
||||||
queryKey: ["adminUserMappings"],
|
queryKey: ["adminUserMappings", page],
|
||||||
queryFn: api.adminListUserMappings,
|
queryFn: () => api.adminListUserMappings(page, PAGE_SIZE),
|
||||||
enabled,
|
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 = () =>
|
const invalidate = () =>
|
||||||
void qc.invalidateQueries({ queryKey: ["adminUserMappings"] });
|
void qc.invalidateQueries({ queryKey: ["adminUserMappings"] });
|
||||||
|
|
||||||
|
|
@ -150,7 +91,6 @@ function UserMappingsSection({ enabled }: { enabled: boolean }) {
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setSlackId("");
|
setSlackId("");
|
||||||
setError(null);
|
setError(null);
|
||||||
setNotice(null);
|
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
onError: (e: Error) => setError(e.message),
|
onError: (e: Error) => setError(e.message),
|
||||||
|
|
@ -162,15 +102,7 @@ function UserMappingsSection({ enabled }: { enabled: boolean }) {
|
||||||
onError: (e: Error) => setError(e.message),
|
onError: (e: Error) => setError(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const importLegacy = useMutation({
|
const items = mappings.data?.items ?? [];
|
||||||
mutationFn: api.adminImportUserMappings,
|
|
||||||
onSuccess: (res) => {
|
|
||||||
setNotice(`Imported ${res.created} legacy mapping(s).`);
|
|
||||||
setError(null);
|
|
||||||
invalidate();
|
|
||||||
},
|
|
||||||
onError: (e: Error) => setError(e.message),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
|
|
@ -202,25 +134,15 @@ function UserMappingsSection({ enabled }: { enabled: boolean }) {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
{mappings.isLoading ? (
|
{mappings.isLoading ? (
|
||||||
<Skeleton className="h-32" />
|
<Skeleton className="h-32" />
|
||||||
) : !mappings.data?.length ? (
|
) : !items.length ? (
|
||||||
<p className="text-xs text-muted-foreground">No mappings yet.</p>
|
<p className="text-xs text-muted-foreground">No mappings yet.</p>
|
||||||
) : (
|
) : (
|
||||||
mappings.data.map((m: UserMapping) => (
|
items.map((m: UserMapping) => (
|
||||||
<div
|
<div
|
||||||
key={m.github_login}
|
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"
|
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>
|
</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>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue