Repository navigation
🔌 feat: Add a Connected Account Setting for Pull Requests #16990
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
30 commits
Select commit
Hold shift + click to select a range
e15e495
feat: Add a Connected Account Setting for Pull Requests
berry-13 889bddf
fix: Watch an Open Account Sign-In and Keep the Retry Button Named
berry-13 b7a8088
fix: Name the Disconnect Confirmation and Show the Provider the Serve…
berry-13 1653795
feat: Tell the Client How Long a Pull Request Account Sign-In Lasts
berry-13 aa1646a
fix: Follow the Sign-In Lifetime and Keep Account Actions Settled Pas…
berry-13 d520033
fix: Watch an Account Sign-In Until Its Window Closes, Even After Set…
berry-13 0ad83ed
fix: Refresh Pull Requests Once per Change of the Credential They Wer…
berry-13 85a51eb
fix: Record Every Status the Sign-In Watch Reads
berry-13 7466b50
fix: Keep Watching a Severed Sign-In Through Its Landing Page and End…
berry-13 483418c
fix: Drop a Sign-In Begun Before the Session Ended
berry-13 cc3064f
fix: Keep a Surviving Grant Removable and Tie a Sign-In Watch to Its …
berry-13 d07ef6b
fix: Ask for the Pull Request Account Wherever Pull Requests Are On
berry-13 a4e8fca
fix: Refresh Pull Requests on a Session's First Status and End a Fail…
berry-13 44e31ad
fix: Ignore an OAuth Failure That Names No Sign-In Flow
berry-13 6c05b16
fix: Start No Sign-In for a Window Closed Before the Server Answered
berry-13 3a67b57
fix: Keep a Known Grant Removable While the Status Check Fails
berry-13 91af4d1
fix: Name a Grant Without a Current App Only as an Account
berry-13 26441a4
fix: Keep the Pull Request Account Row Readable on a Phone
berry-13 1d59756
fix: Show a Grant Whose App Is Off as Not in Use
berry-13 cc4a4fc
fix: Apply a Sign-In Status Asked For Before the Flow Expired
berry-13 24b4966
fix: Keep Focus When Removing the Last Pull Request Grant and Reuse I…
berry-13 8a31140
fix: Resync Pull Request Credentials After Failed Sign-Ins and Discon…
berry-13 bc22099
fix: Ask for the Connection Status Afresh Once a Sign-In Settles
berry-13 50298e1
fix: Resync Pull Requests From the Settings Visibility Status
berry-13 44b9b55
fix: Reconcile Pull Request Credentials Until a Status Read Succeeds
berry-13 9500dc0
fix: Keep Pull Request Sign-In State Accurate Across Failures and Acc…
berry-13 d298038
fix: Drop Replaced Sign-In Answers and Reset Status When the Account …
berry-13 63967db
fix: Reset Unwatched Connection Status and Keep Focus When a Retry Hi…
berry-13 4e81e14
fix: Hear Late Sign-In Settlements and Explain Connect Failures After…
berry-13 e223d57
fix: Listen Until a Sign-In Settles and Keep Focus After a Dismissed …
berry-13 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
396 changes: 396 additions & 0 deletions
396
client/src/components/Nav/Settings/ConnectedAccounts.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,396 @@ | ||
| import { useCallback, useEffect, useId, useRef, useState } from 'react'; | ||
| import { ExternalLink, Gitlab } from 'lucide-react'; | ||
| import { apiBaseUrl } from 'librechat-data-provider'; | ||
| import { useQueryClient } from '@tanstack/react-query'; | ||
| import { | ||
| AlertDialog, | ||
| AlertDialogCancel, | ||
| AlertDialogContent, | ||
| AlertDialogDescription, | ||
| AlertDialogFooter, | ||
| AlertDialogHeader, | ||
| AlertDialogTitle, | ||
| AlertDialogTrigger, | ||
| Button, | ||
| Chip, | ||
| GithubIcon, | ||
| Skeleton, | ||
| Spinner, | ||
| } from '@librechat/client'; | ||
| import type { PullRequestProvider } from 'librechat-data-provider'; | ||
| import type { ComponentType } from 'react'; | ||
| import type { TranslationKeys } from '~/hooks'; | ||
| import { | ||
| useGetStartupConfig, | ||
| usePullRequestConnectionQuery, | ||
| useConnectPullRequestAccountMutation, | ||
| useDisconnectPullRequestAccountMutation, | ||
| watchPullRequestSignIn, | ||
| pullRequestSignInSession, | ||
| } from '~/data-provider'; | ||
| import { useLocalize } from '~/hooks'; | ||
|
|
||
| const PROVIDERS: Record< | ||
| PullRequestProvider, | ||
| { Icon: ComponentType<{ className?: string }>; name: TranslationKeys } | ||
| > = { | ||
| github: { Icon: GithubIcon, name: 'com_ui_pr_provider_github' }, | ||
| gitlab: { Icon: Gitlab, name: 'com_ui_pr_provider_gitlab' }, | ||
| bitbucket: { Icon: ExternalLink, name: 'com_ui_pr_provider_bitbucket' }, | ||
| }; | ||
|
|
||
| /** A grant whose app the server no longer has, so no provider is named for it. */ | ||
| const UNNAMED_PROVIDER: (typeof PROVIDERS)[PullRequestProvider] = { | ||
| Icon: ExternalLink, | ||
| name: 'com_ui_settings_label_pull_request_account', | ||
| }; | ||
|
|
||
| type ConnectError = 'com_ui_pr_account_connect_error' | 'com_ui_pr_account_popup_blocked'; | ||
|
|
||
| /** How long a started sign-in is watched for when the server does not say how long it lasts. */ | ||
| const DEFAULT_CONNECT_FLOW_SECONDS = 10 * 60; | ||
|
|
||
| /** | ||
| * The user's own account for pull request lookups. One row for the provider the operator | ||
| * configured: the status, and a single action to connect or disconnect it. The action button is | ||
| * swapped as the state changes, so focus is carried over to whichever button replaces it. | ||
| */ | ||
| export default function ConnectedAccounts() { | ||
| const localize = useLocalize(); | ||
| const rowId = useId(); | ||
| const { data: startupConfig } = useGetStartupConfig(); | ||
| const queryClient = useQueryClient(); | ||
| const status = usePullRequestConnectionQuery(); | ||
| const connectMutation = useConnectPullRequestAccountMutation(); | ||
| const disconnectMutation = useDisconnectPullRequestAccountMutation(); | ||
| const [confirmOpen, setConfirmOpen] = useState(false); | ||
| const [connectError, setConnectError] = useState<ConnectError | null>(null); | ||
| const [disconnectFailed, setDisconnectFailed] = useState(false); | ||
| const actionRef = useRef<HTMLButtonElement | null>(null); | ||
| const groupRef = useRef<HTMLDivElement | null>(null); | ||
| /** Where focus goes when a disconnect removes the row along with its trigger. */ | ||
| const fallbackRef = useRef<HTMLElement | null>(null); | ||
| const keepFocus = useRef(false); | ||
| /** Whether the row is still shown, so a failure after Settings closed is reported elsewhere. */ | ||
| const mounted = useRef(true); | ||
| useEffect(() => { | ||
| mounted.current = true; | ||
| return () => { | ||
| mounted.current = false; | ||
| /** A status that hides the row while a handoff is armed takes the focused button with it. */ | ||
| if (keepFocus.current) { | ||
| const fallback = fallbackRef.current; | ||
| setTimeout(() => { | ||
| if (document.activeElement == null || document.activeElement === document.body) { | ||
| fallback?.focus(); | ||
| } | ||
| }, 0); | ||
| } | ||
| }; | ||
| }, []); | ||
|
|
||
| /** | ||
| * Callback ref, so a replacement button takes focus as it mounts after an action, but only | ||
| * when the button it replaced took focus away with it. | ||
| */ | ||
| const bindAction = useCallback((button: HTMLButtonElement | null) => { | ||
| actionRef.current = button; | ||
| if (button == null || !keepFocus.current) { | ||
| return; | ||
| } | ||
| keepFocus.current = false; | ||
| if (document.activeElement == null || document.activeElement === document.body) { | ||
| button.focus(); | ||
| } | ||
| }, []); | ||
|
|
||
| /** A disconnect that failed may still have ended the connection: once the status read after it | ||
| * says so, the confirmation closes as on success, and focus moves on the same way. */ | ||
| const disconnected = status.data?.connected === false; | ||
| useEffect(() => { | ||
| if (!confirmOpen || !disconnectFailed || !disconnected) { | ||
| return; | ||
| } | ||
| keepFocus.current = true; | ||
| setConfirmOpen(false); | ||
| setDisconnectFailed(false); | ||
| }, [confirmOpen, disconnectFailed, disconnected]); | ||
|
|
||
| /** | ||
| * The server is the authority: an app it no longer has is nothing to connect to, and once the | ||
| * status loads its provider is the one a connection goes to, whatever the cached startup says. | ||
| * A grant still stored stays removable after the app is turned off, or turning it back on would | ||
| * revive it. | ||
| */ | ||
| const revocable = status.data?.connected === true; | ||
|
berry-13 marked this conversation as resolved.
|
||
| /** A grant whose app is turned off: kept until removed, but no lookup uses it. */ | ||
| const unused = revocable && status.data?.configured === false; | ||
| /** The startup config only until the status loads: after that a grant whose app is gone may | ||
| * belong to any provider, so the row names it only as an account. */ | ||
| const provider = status.data != null ? status.data.provider : startupConfig?.pullRequestsConnect; | ||
| if (!revocable && (provider == null || status.data?.configured === false)) { | ||
| return null; | ||
|
berry-13 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| const { Icon, name: nameKey } = provider != null ? PROVIDERS[provider] : UNNAMED_PROVIDER; | ||
| const name = localize(nameKey); | ||
| const labelId = `${rowId}-label`; | ||
| const descriptionId = `${rowId}-description`; | ||
|
|
||
| const connect = () => { | ||
| if (connectMutation.isLoading) { | ||
| return; | ||
| } | ||
| setConnectError(null); | ||
| /** Opened inside the click so the browser treats it as user initiated. */ | ||
| const popup = window.open('', '_blank'); | ||
| if (popup == null) { | ||
| setConnectError('com_ui_pr_account_popup_blocked'); | ||
| return; | ||
| } | ||
| popup.opener = null; | ||
| /** | ||
| * The promise, not `mutate` callbacks, so the window opened in the click is still sent to the | ||
| * sign-in page (or closed) when Settings is closed before the server answers. The watch on the | ||
| * sign-in lives outside this row for the same reason. | ||
| */ | ||
| const startedIn = pullRequestSignInSession(); | ||
| connectMutation.mutateAsync().then( | ||
| ({ url, expiresInSeconds, flowId }) => { | ||
| /** Signed out meanwhile: the sign-in belongs to a session that is over. */ | ||
| if (startedIn !== pullRequestSignInSession()) { | ||
| popup.close(); | ||
| return; | ||
| } | ||
| /** | ||
| * Closed while the server answered: the user cancelled, and nothing will sign in. Still the | ||
| * blank page this window opened, so unlike after navigating, `closed` can be trusted here. | ||
| */ | ||
| if (popup.closed) { | ||
| return; | ||
| } | ||
| popup.location.href = url; | ||
| keepFocus.current = true; | ||
| watchPullRequestSignIn(queryClient, { | ||
| startedIn, | ||
| flowId, | ||
| expiresAt: Date.now() + (expiresInSeconds ?? DEFAULT_CONNECT_FLOW_SECONDS) * 1000, | ||
| }); | ||
| }, | ||
| () => { | ||
| /** With Settings closed the row cannot explain the failure, so the window it opened does, | ||
| * on the sign-in error page. */ | ||
| if (!mounted.current) { | ||
| popup.location.href = `${apiBaseUrl()}/oauth/error?error=connect_failed`; | ||
| return; | ||
| } | ||
| popup.close(); | ||
| setConnectError('com_ui_pr_account_connect_error'); | ||
|
berry-13 marked this conversation as resolved.
|
||
| }, | ||
| ); | ||
| }; | ||
|
|
||
| const disconnect = () => { | ||
| setDisconnectFailed(false); | ||
| /** Taken before the request: removing a grant whose app is off removes this row too. */ | ||
| fallbackRef.current = | ||
| groupRef.current?.closest<HTMLElement>('[role="tabpanel"], [role="dialog"]') ?? null; | ||
| disconnectMutation.mutate(undefined, { | ||
| onSuccess: () => { | ||
| keepFocus.current = true; | ||
| setConfirmOpen(false); | ||
| }, | ||
|
berry-13 marked this conversation as resolved.
|
||
| onError: () => setDisconnectFailed(true), | ||
|
berry-13 marked this conversation as resolved.
berry-13 marked this conversation as resolved.
|
||
| }); | ||
| }; | ||
|
|
||
| const retry = () => { | ||
| keepFocus.current = true; | ||
| /** Taken before the request: an answer that the app is gone hides this row, Retry with it. */ | ||
| const fallback = groupRef.current?.closest<HTMLElement>('[role="tabpanel"], [role="dialog"]'); | ||
| status.refetch().then((result) => { | ||
|
berry-13 marked this conversation as resolved.
|
||
| if (result.isError) { | ||
| keepFocus.current = false; | ||
| } | ||
| /** After React has applied the answer: focus left on nothing goes to the panel. */ | ||
| setTimeout(() => { | ||
| const lost = document.activeElement == null || document.activeElement === document.body; | ||
| if (groupRef.current == null && lost) { | ||
| fallback?.focus(); | ||
| } | ||
| }, 0); | ||
| }); | ||
| }; | ||
|
|
||
| let statusContent = <Chip tone="neutral">{localize('com_ui_pr_account_checking')}</Chip>; | ||
| let action = <Skeleton className="h-9 w-24" aria-hidden="true" />; | ||
|
|
||
| const statusError = ( | ||
| <span className="text-text-destructive text-xs"> | ||
| {localize('com_ui_pr_account_status_error')} | ||
| </span> | ||
| ); | ||
|
|
||
| /** A grant known to be removable stays removable while a later status check fails. */ | ||
| if (status.isError && !revocable) { | ||
| statusContent = statusError; | ||
| action = ( | ||
| <Button | ||
| ref={bindAction} | ||
| type="button" | ||
| variant="outline" | ||
| size="sm" | ||
| aria-label={localize('com_ui_retry')} | ||
| aria-busy={status.isFetching} | ||
| onClick={retry} | ||
| > | ||
| {status.isFetching ? <Spinner className="h-4 w-4" /> : localize('com_ui_retry')} | ||
|
berry-13 marked this conversation as resolved.
|
||
| </Button> | ||
| ); | ||
| } else if (status.data?.connected === true) { | ||
| statusContent = unused ? ( | ||
| <Chip tone="neutral">{localize('com_ui_pr_account_not_in_use')}</Chip> | ||
| ) : ( | ||
| <Chip tone="success">{localize('com_ui_pr_account_connected')}</Chip> | ||
| ); | ||
| action = ( | ||
| <AlertDialogTrigger asChild> | ||
| <Button | ||
| ref={bindAction} | ||
| type="button" | ||
| variant="outline" | ||
| size="sm" | ||
| aria-label={localize('com_ui_pr_account_disconnect_aria', { 0: name })} | ||
| > | ||
| {localize('com_ui_pr_account_disconnect')} | ||
| </Button> | ||
| </AlertDialogTrigger> | ||
| ); | ||
| } else if (status.data != null) { | ||
| statusContent = <Chip tone="neutral">{localize('com_ui_pr_account_not_connected')}</Chip>; | ||
| action = ( | ||
| <Button | ||
| ref={bindAction} | ||
| type="button" | ||
| variant="outline" | ||
| size="sm" | ||
| aria-label={localize('com_ui_pr_account_connect_aria', { 0: name })} | ||
| aria-busy={connectMutation.isLoading} | ||
| aria-disabled={connectMutation.isLoading} | ||
| onClick={connect} | ||
|
berry-13 marked this conversation as resolved.
|
||
| > | ||
| {connectMutation.isLoading ? ( | ||
| <Spinner className="h-4 w-4" /> | ||
| ) : ( | ||
| localize('com_ui_pr_account_connect') | ||
| )} | ||
| </Button> | ||
| ); | ||
| } | ||
|
|
||
| if (status.isError && revocable) { | ||
| statusContent = statusError; | ||
| } | ||
|
|
||
| return ( | ||
| <AlertDialog | ||
| open={confirmOpen} | ||
| onOpenChange={(open) => { | ||
| /** A disconnect already sent cannot be taken back, so it is not dismissed mid-request. */ | ||
| if (!open && disconnectMutation.isLoading) { | ||
| return; | ||
| } | ||
| /** Dismissed after a failed disconnect whose status read is still out: that read may | ||
| * replace or hide the trigger focus returns to, so the handoff stays armed. */ | ||
| if (!open && disconnectFailed) { | ||
| keepFocus.current = true; | ||
| } | ||
| setConfirmOpen(open); | ||
| setDisconnectFailed(false); | ||
| }} | ||
| > | ||
| <div | ||
| ref={groupRef} | ||
| role="group" | ||
| aria-labelledby={labelId} | ||
| aria-describedby={descriptionId} | ||
| className="flex flex-col gap-2" | ||
| > | ||
| <div className="flex items-center gap-3"> | ||
| <span className="text-text-secondary shrink-0" aria-hidden="true"> | ||
| <Icon className="h-5 w-5" /> | ||
| </span> | ||
| <div className="min-w-0 flex-1"> | ||
| {/** The status sits beside the name, not in the row, so the description keeps the | ||
| * column's width on a phone. */} | ||
| <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | ||
| <p id={labelId} className="text-text-primary text-sm font-medium"> | ||
| {name} | ||
| </p> | ||
| <div role="status">{statusContent}</div> | ||
| </div> | ||
| <p id={descriptionId} className="text-text-secondary text-xs"> | ||
| {localize( | ||
| unused ? 'com_ui_pr_account_unused_description' : 'com_ui_pr_account_description', | ||
| )} | ||
| </p> | ||
| </div> | ||
| <div className="shrink-0">{action}</div> | ||
| </div> | ||
| {connectError != null && ( | ||
| <p role="alert" className="text-text-destructive text-xs"> | ||
| {localize(connectError)} | ||
| </p> | ||
| )} | ||
| </div> | ||
|
|
||
| <AlertDialogContent | ||
| className="w-11/12 max-w-md" | ||
| onCloseAutoFocus={(event) => { | ||
| event.preventDefault(); | ||
| const action = actionRef.current; | ||
| if (action?.isConnected === true) { | ||
| action.focus(); | ||
| } else { | ||
| fallbackRef.current?.focus(); | ||
| } | ||
| }} | ||
| > | ||
| <AlertDialogHeader> | ||
| <AlertDialogTitle> | ||
| {localize('com_ui_pr_account_disconnect_title', { 0: name })} | ||
| </AlertDialogTitle> | ||
| <AlertDialogDescription> | ||
| {localize('com_ui_pr_account_disconnect_description')} | ||
| </AlertDialogDescription> | ||
| </AlertDialogHeader> | ||
| {disconnectFailed && ( | ||
| <p role="alert" className="text-text-destructive text-xs"> | ||
| {localize('com_ui_pr_account_disconnect_error')} | ||
| </p> | ||
| )} | ||
| <AlertDialogFooter className="mt-2"> | ||
| <AlertDialogCancel asChild> | ||
| <Button type="button" variant="outline" disabled={disconnectMutation.isLoading}> | ||
| {localize('com_ui_cancel')} | ||
| </Button> | ||
| </AlertDialogCancel> | ||
|
berry-13 marked this conversation as resolved.
|
||
| <Button | ||
| type="button" | ||
| variant="destructive" | ||
| aria-label={localize('com_ui_pr_account_disconnect')} | ||
| aria-busy={disconnectMutation.isLoading} | ||
| disabled={disconnectMutation.isLoading} | ||
| onClick={disconnect} | ||
| > | ||
| {disconnectMutation.isLoading ? ( | ||
| <Spinner className="h-4 w-4" /> | ||
| ) : ( | ||
| localize('com_ui_pr_account_disconnect') | ||
|
berry-13 marked this conversation as resolved.
|
||
| )} | ||
| </Button> | ||
| </AlertDialogFooter> | ||
| </AlertDialogContent> | ||
| </AlertDialog> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.