History

Render the two-tier picker and an auto-registered clients section

Wire the modal to the new grouping: tier-1 exact/intent matches stay the default-selectable list, root-domain near-misses render in a visually separated, subdued "Other apps on this provider" group (selectable but never auto-selected), and the auto-registered DCR clients for this integration live in a collapsed "Auto-registered clients" section at the bottom, deletable but not pickable. Resolving the chosen client now spans every pickable tier so the near-miss and escape-hatch apps actually work. Also drop the DCR connect path's dependency on the picker's slug list: the slug is deterministic per authorization server since Part A, so runDcrConnect no longer needs existingSlugs threaded in.

Rhys Sullivan committed 4 months ago

Commit 9afe2ec1 parent 149f9092 files · +172 −12Browse files

Unified · Split

2 files
  1. packages/react/src/components/add-account-modal.test.ts +33 −0
  2. packages/react/src/components/add-account-modal.tsx +139 −12
packages/react/src/components/add-account-modal.test.ts+33−0
@@ -575,4 +575,37 @@ describe("runDcrConnect", () => { · 574 unmodified lines
575 });575 });
576 expect(started).toBe(false);576 expect(started).toBe(false);
577 });577 });
578
579 it("mints a deterministic server-keyed slug WITHOUT the picker's slug list", async () => {
580 // Part B removed the DCR connect path's dependency on the picker's app list:
581 // the slug is derived from the authorization server (Part A), so callers no
582 // longer thread `existingSlugs`. This omits it entirely and still succeeds.
583 let registerArgs: RegisterArgs | null = null;
584 const outcome = await runDcrConnect(
585 {
586 probe: (): Promise<ProbeResult | null> =>
587 Promise.resolve({
588 issuer: "https://auth.example.com",
589 authorizationUrl: "https://auth.example.com/authorize",
590 tokenUrl: "https://auth.example.com/token",
591 registrationEndpoint: "https://auth.example.com/register",
592 }),
593 register: (args: RegisterArgs): Promise<OAuthClientSlug | null> => {
594 registerArgs = args;
595 return Promise.resolve(OAuthClientSlug.make("app"));
596 },
597 start: (): void => {},
598 },
599 {
600 discoveryUrl: "https://mcp.example.com/mcp",
601 owner: "user",
602 integrationName: "App",
603 integration: TEST_INTEGRATION,
604 },
605 );
606 expect(outcome.kind).toBe("started");
607 expect(registerArgs).not.toBeNull();
608 // Slug comes from the issuer host, independent of any picker state.
609 expect(String(registerArgs!.slug)).toBe("dcr-auth-example-com");
610 });
578});611});
packages/react/src/components/add-account-modal.tsx+139−12
@@ -48,6 +48,7 @@ import { · 47 unmodified lines
48 clientDisplayName,48 clientDisplayName,
49 clientHost,49 clientHost,
50 dcrClientSlug,50 dcrClientSlug,
51 selectDcrClientsForIntegration,
51 uniqueClientSlug,52 uniqueClientSlug,
52 useOAuthClientsForIntegration,53 useOAuthClientsForIntegration,
53 type OAuthClientOption,54 type OAuthClientOption,
@@ -687,8 +688,10 @@ type RunDcrConnectInput = { · 633 unmodified lines
687 readonly resourceFallback?: string;688 readonly resourceFallback?: string;
688 readonly owner: Owner;689 readonly owner: Owner;
689 readonly integrationName: string;690 readonly integrationName: string;
690 /** Kept for callers that still pass picker state. DCR slugs are server keyed. */691 /** @deprecated DCR slugs are deterministic per authorization server (Part A),
691 readonly existingSlugs: readonly string[];692 * so the connect path no longer needs the picker's slug list. Accepted but
693 * ignored; retained only so existing test callers keep compiling. */
694 readonly existingSlugs?: readonly string[];
692 /** Scopes declared by the integration's method (override the probed ones). */695 /** Scopes declared by the integration's method (override the probed ones). */
693 readonly declaredScopes?: readonly string[];696 readonly declaredScopes?: readonly string[];
694 /** Browser-facing callback URL registered with DCR when available. */697 /** Browser-facing callback URL registered with DCR when available. */
@@ -902,6 +905,10 @@ function AddAccountModalView(props: AddAccountModalProps) { · 207 unmodified lines
902 // FIX 3 escape hatch: when no registered app matched the integration's905 // FIX 3 escape hatch: when no registered app matched the integration's
903 // endpoints, the unmatched apps are collapsed behind an opt-in expander.906 // endpoints, the unmatched apps are collapsed behind an opt-in expander.
904 const [showOtherApps, setShowOtherApps] = useState(false);907 const [showOtherApps, setShowOtherApps] = useState(false);
908 // Auto-registered (DCR) clients are plumbing, not pickable apps: they live in a
909 // collapsed management section at the bottom of the OAuth tab, revealed on
910 // demand so they can be inspected and deleted without cluttering the picker.
911 const [showAutoRegistered, setShowAutoRegistered] = useState(false);
905 // Inline OAuth app management — edit re-opens the registration form for an912 // Inline OAuth app management — edit re-opens the registration form for an
906 // existing app (upsert by owner+slug); remove confirms before deleting. Both913 // existing app (upsert by owner+slug); remove confirms before deleting. Both
907 // hold the FULL app summary (with endpoints + resource) so the edit prefill914 // hold the FULL app summary (with endpoints + resource) so the edit prefill
@@ -1080,6 +1087,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 172 unmodified lines
1080 // unconditionally; in DCR mode the result is ignored until/unless we fall back.1087 // unconditionally; in DCR mode the result is ignored until/unless we fall back.
1081 const {1088 const {
1082 clients: oauthApps,1089 clients: oauthApps,
1090 nearMatches: oauthNearApps,
1083 otherClients: oauthOtherApps,1091 otherClients: oauthOtherApps,
1084 loading: oauthLoading,1092 loading: oauthLoading,
1085 endpointMatched: oauthEndpointMatched,1093 endpointMatched: oauthEndpointMatched,
@@ -1092,8 +1100,29 @@ function AddAccountModalView(props: AddAccountModalProps) { · 6 unmodified lines
1092 // unrelated provider's app.1100 // unrelated provider's app.
1093 tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl,1101 tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl,
1094 authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl,1102 authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl,
1103 // Recorded intent: a manual app registered from THIS integration's dialog is
1104 // a tier-1 match regardless of host.
1105 integration,
1095 requireEndpointMatch: isDcr,1106 requireEndpointMatch: isDcr,
1096 });1107 });
1108 // Auto-registered (DCR) clients for THIS integration. Excluded from the picker
1109 // above, but surfaced in a collapsed management section so they stay
1110 // deletable. Reads the same optimistic list the picker does.
1111 const dcrClients = useMemo(
1112 () =>
1113 selectDcrClientsForIntegration(clientSummaries as readonly OAuthClientOption[], {
1114 integration,
1115 tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl,
1116 authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl,
1117 }),
1118 [
1119 clientSummaries,
1120 integration,
1121 method?.oauth?.tokenUrl,
1122 method?.oauth?.authorizationUrl,
1123 oauthFallbackProbe,
1124 ],
1125 );
1097 const oauthPopup = useOAuthPopupFlow({1126 const oauthPopup = useOAuthPopupFlow({
1098 popupName: "add-account-oauth",1127 popupName: "add-account-oauth",
1099 detectPopupClosed: false,1128 detectPopupClosed: false,
@@ -1110,8 +1139,15 @@ function AddAccountModalView(props: AddAccountModalProps) { · 10 unmodified lines
1110 // Editing reuses the registration form (createClient upserts by owner+slug),1139 // Editing reuses the registration form (createClient upserts by owner+slug),
1111 // so it occupies the same full-bleed sub-view as registering.1140 // so it occupies the same full-bleed sub-view as registering.
1112 const oauthEditing = isOAuth && editingClient !== null;1141 const oauthEditing = isOAuth && editingClient !== null;
1142 // Resolve the picked slug across every PICKABLE tier: exact matches, the
1143 // subdued near-match tier, and the unrelated escape-hatch apps. All three are
1144 // selectable (only the default auto-selection is restricted to tier 1), so the
1145 // connect button must resolve a client from any of them. DCR clients are not
1146 // in these lists, so they can never be chosen as the connect app.
1113 const chosenClient: OAuthClientOption | null =1147 const chosenClient: OAuthClientOption | null =
1114 oauthApps.find((c: OAuthClientOption) => String(c.slug) === selectedApp) ?? null;1148 [...oauthApps, ...oauthNearApps, ...oauthOtherApps].find(
1149 (c: OAuthClientOption) => String(c.slug) === selectedApp,
1150 ) ?? null;
1115 const oauthBusy = ccBusy || oauthPopup.busy;1151 const oauthBusy = ccBusy || oauthPopup.busy;
1116 const cimdConnecting = cimdBusy || oauthPopup.busy;1152 const cimdConnecting = cimdBusy || oauthPopup.busy;
1117 const dcrConnecting = dcrBusy || oauthPopup.busy;1153 const dcrConnecting = dcrBusy || oauthPopup.busy;
@@ -1496,9 +1532,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 378 unmodified lines
1496 resourceFallback: method.oauth?.discoveryUrl,1532 resourceFallback: method.oauth?.discoveryUrl,
1497 owner: dcrOwner,1533 owner: dcrOwner,
1498 integrationName,1534 integrationName,
1499 existingSlugs: [...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>1535 // DCR slugs are server-keyed (Part A): the connect path no longer depends
1500 String(app.slug),1536 // on the picker's app list, so it need not be threaded here.
1501 ),
1502 declaredScopes: method.oauth?.scopes,1537 declaredScopes: method.oauth?.scopes,
1503 redirectUri: oauthCallbackUrl(),1538 redirectUri: oauthCallbackUrl(),
1504 integration,1539 integration,
@@ -1558,9 +1593,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 53 unmodified lines
1558 <div className="px-5 py-5">1593 <div className="px-5 py-5">
1559 <OAuthClientForm1594 <OAuthClientForm
1560 integrationName={integrationName}1595 integrationName={integrationName}
1561 existingSlugs={[...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>1596 existingSlugs={clientSummaries.map((c: OAuthClientSummary) => String(c.slug))}
1562 String(app.slug),
1563 )}
1564 fixedSlug={editingClient.slug}1597 fixedSlug={editingClient.slug}
1565 fixedOwner={editingClient.owner}1598 fixedOwner={editingClient.owner}
1566 prefill={{1599 prefill={{
@@ -1587,9 +1620,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 20 unmodified lines
1587 <div className="px-5 py-5">1620 <div className="px-5 py-5">
1588 <OAuthClientForm1621 <OAuthClientForm
1589 integrationName={integrationName}1622 integrationName={integrationName}
1590 existingSlugs={[...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>1623 integrationSlug={integration}
1591 String(app.slug),1624 existingSlugs={clientSummaries.map((c: OAuthClientSummary) => String(c.slug))}
1592 )}
1593 autoRegisterRejectedReason={dcrFallbackMessage}1625 autoRegisterRejectedReason={dcrFallbackMessage}
1594 prefill={{1626 prefill={{
1595 authorizationUrl:1627 authorizationUrl:
@@ -1881,6 +1913,101 @@ function AddAccountModalView(props: AddAccountModalProps) { · 285 unmodified lines
1881 </Button>1913 </Button>
1882 </RadioGroup>1914 </RadioGroup>
1883 )}1915 )}
1916
1917 {/* Tier 2 — apps that only match by root domain (a
1918 near-miss, e.g. the same provider's MCP app for a REST
1919 integration). Visually separated and subdued so they are
1920 never mistaken for the integration's own app, but still
1921 selectable as an escape hatch. Shown alongside tier 1, not
1922 mixed into it. */}
1923 {oauthNearApps.length > 0 && (
1924 <div className="space-y-2 border-t border-border/60 pt-3">
1925 <p className="text-xs font-medium text-muted-foreground">
1926 Other apps on this provider
1927 </p>
1928 <p className="text-xs text-muted-foreground">
1929 These share {integrationName}&apos;s domain but weren&apos;t
1930 registered for it. Pick one only if you know it applies.
1931 </p>
1932 <RadioGroup
1933 value={selectedApp}
1934 onValueChange={setPickedApp}
1935 className="gap-2 pt-1"
1936 >
1937 {oauthNearApps.map((app: OAuthClientOption) => (
1938 <OAuthAppRadioRow
1939 key={String(app.slug)}
1940 app={app}
1941 idPrefix="near-app"
1942 variant="other"
1943 showOwnerLabel={ownerDisplay.showOwnerLabels}
1944 onManage={manageHandlersFor(app)}
1945 />
1946 ))}
1947 </RadioGroup>
1948 </div>
1949 )}
1950
1951 {/* Auto-registered (DCR) clients: plumbing, never
1952 pickable. Kept here, collapsed, so they can be reviewed
1953 and deleted without cluttering the picker. */}
1954 {dcrClients.length > 0 && (
1955 <div className="border-t border-border/60 pt-3">
1956 <button
1957 type="button"
1958 className="flex w-full items-center justify-between text-xs font-medium text-muted-foreground hover:text-foreground"
1959 onClick={() => setShowAutoRegistered((prev) => !prev)}
1960 aria-expanded={showAutoRegistered}
1961 >
1962 <span>Auto-registered clients ({dcrClients.length})</span>
1963 <span aria-hidden>{showAutoRegistered ? "−" : "+"}</span>
1964 </button>
1965 {showAutoRegistered ? (
1966 <div className="space-y-2 pt-2">
1967 <p className="text-xs text-muted-foreground">
1968 Created automatically when connecting. Reused across
1969 connections, not selectable as your app.
1970 </p>
1971 {dcrClients.map((app: OAuthClientOption) => (
1972 <div
1973 key={String(app.slug)}
1974 className="flex items-center gap-3 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5"
1975 >
1976 <span className="min-w-0 flex-1">
1977 <span className="block text-sm font-medium">
1978 {clientDisplayName(String(app.slug))}
1979 </span>
1980 <span className="block truncate text-xs text-muted-foreground">
1981 {clientHost(app.tokenUrl)}
1982 </span>
1983 </span>
1984 {ownerDisplay.showOwnerLabels ? (
1985 <Badge variant="outline">
1986 {ownerLabel(app.owner)}
1987 </Badge>
1988 ) : null}
1989 <Button
1990 type="button"
1991 variant="ghost"
1992 size="sm"
1993 className="shrink-0 text-destructive hover:text-destructive"
1994 onClick={() => {
1995 const summary = clientSummaries.find(
1996 (c: OAuthClientSummary) =>
1997 c.owner === app.owner &&
1998 String(c.slug) === String(app.slug),
1999 );
2000 if (summary) setRemovingClient(summary);
2001 }}
2002 >
2003 Remove
2004 </Button>
2005 </div>
2006 ))}
2007 </div>
2008 ) : null}
2009 </div>
2010 )}
1884 </div>2011 </div>
1885 )2012 )
1886 ) : (2013 ) : (

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t