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:
Johannes du Plessis 2026-06-01 15:10:55 -07:00 • committed by GitHub
parent 427bfe4f56
commit c41185a3ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 74 additions and 487 deletions

View file

@ -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

View file

@ -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

View file

@ -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",
}

View file

@ -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>
);
}

View file

@ -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" }),
};

View file

@ -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>
);