fix(desktop): Rename links go by account id and open once, a started add refreshes the list
The chat notice's Rename link carries the account id; a link whose account is gone opens nothing and says so. A docked Capabilities page takes each new link's scope, and of two mounted pages only one acts on a link. A started add or reconnect refreshes the account list at once.
This commit is contained in:
committed by
Siddharth Balyan
parent
e22e4517ad
commit
f38da142bc
@@ -15,6 +15,7 @@ import { useMcpServers } from '../mcp/use-mcp-servers'
|
||||
import { AddServerDialog } from './add-dialog'
|
||||
import { ConnectorsDirectory } from './connectors-directory'
|
||||
import { $abandonedConnects, $accountOperations, abandonConnect, accountOperationFor } from './data/account-operations'
|
||||
import { claimRouteLink } from './data/deep-link'
|
||||
import { accountAddress, joinBundledEntries, joinLocalServers, pickAccount } from './data/join'
|
||||
import { useConnectConnector, useConnectorSwitch, useDisconnectAccount, useRenameAccount } from './data/mutations'
|
||||
import { seedLocalServers, startConnectorPersistence, storeLocalServers } from './data/persist'
|
||||
@@ -117,7 +118,7 @@ export function ConnectorsTab({ gateway, profile }: ConnectorsTabProps) {
|
||||
|
||||
const openCard = useMemo(() => cards.find(card => cardKey(card) === openKey) ?? null, [cards, openKey])
|
||||
|
||||
useOpenFromRoute(cards, setOpenKey, setPendingRename)
|
||||
useOpenFromRoute(cards, profile, setOpenKey, setPendingRename)
|
||||
|
||||
const write = async (pending: Promise<{ error?: unknown; ok: boolean }>) => {
|
||||
const outcome = await pending
|
||||
@@ -378,10 +379,11 @@ function HostedNotice({ hasGuest, phase }: { hasGuest: boolean; phase: HostedPha
|
||||
|
||||
function useOpenFromRoute(
|
||||
cards: readonly ConnectorCardModel[],
|
||||
scope: ProfileScope,
|
||||
open: (key: string) => void,
|
||||
rename: (target: PendingRename) => void
|
||||
): void {
|
||||
const { hash, pathname, search } = useLocation()
|
||||
const { hash, key: locationKey, pathname, search } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -397,7 +399,9 @@ function useOpenFromRoute(
|
||||
? cards.find(card => card.residency === 'local' && card.slug === server)
|
||||
: cards.find(card => card.slug === slug)
|
||||
|
||||
if (!target) {
|
||||
// A tab for another profile leaves the link to the page once it takes the link's scope, and of two
|
||||
// mounted pages (one docked) only one acts on it.
|
||||
if (!target || !claimRouteLink(locationKey, params, scope)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -405,10 +409,10 @@ function useOpenFromRoute(
|
||||
openToolsList(toolsListKey(target))
|
||||
}
|
||||
|
||||
const alias = params.get('rename')
|
||||
const connectionId = params.get('rename')
|
||||
|
||||
if (alias) {
|
||||
rename({ alias, slug: target.slug })
|
||||
if (connectionId) {
|
||||
rename({ connectionId, slug: target.slug })
|
||||
}
|
||||
|
||||
open(cardKey(target))
|
||||
@@ -421,5 +425,5 @@ function useOpenFromRoute(
|
||||
|
||||
const query = params.toString()
|
||||
navigate({ hash, pathname, search: query ? `?${query}` : '' }, { replace: true })
|
||||
}, [cards, hash, navigate, open, pathname, rename, search])
|
||||
}, [cards, hash, locationKey, navigate, open, pathname, rename, scope, search])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
import { setPrimaryGateway } from '@/store/gateway'
|
||||
|
||||
import { renameLinkTarget } from '../hosted-dialog'
|
||||
import type { AccountRow } from '../types'
|
||||
|
||||
import { accountRenameRoute, claimRouteLink, resetRouteLinksForTests } from './deep-link'
|
||||
import { connectorsAccountsQueryKey } from './keys'
|
||||
import { useConnectConnector } from './mutations'
|
||||
|
||||
const row = (connectionId: string, alias: string): AccountRow => ({
|
||||
active: true,
|
||||
alias,
|
||||
connection_id: connectionId,
|
||||
connector: 'gmail',
|
||||
created_at: '2026-10-01T00:00:00Z',
|
||||
label: 'Gmail account',
|
||||
status: 'active',
|
||||
updated_at: '2026-10-01T00:00:00Z'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetRouteLinksForTests()
|
||||
setPrimaryGateway(null)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('the chat Rename link goes by account id', () => {
|
||||
it('carries the id, not the name the account had then', () => {
|
||||
const params = new URLSearchParams(
|
||||
accountRenameRoute('gmail', 'ca_work', { connectionId: null, profile: 'a' }).split('?')[1]
|
||||
)
|
||||
|
||||
expect(params.get('rename')).toBe('ca_work')
|
||||
})
|
||||
|
||||
it('opens the account with that id, never one that took its old name, and reports a missing one', () => {
|
||||
const pending = { connectionId: 'ca_work', slug: 'gmail' }
|
||||
const reused = [row('ca_new', 'work'), row('ca_work', 'office')]
|
||||
|
||||
expect(renameLinkTarget(pending, 'gmail', reused, true)).toEqual(reused[1])
|
||||
expect(renameLinkTarget(pending, 'gmail', [row('ca_new', 'work')], true)).toBe('gone')
|
||||
expect(renameLinkTarget(pending, 'gmail', [], false)).toBeNull()
|
||||
expect(renameLinkTarget(pending, 'slack', reused, true)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a link is acted on once, by the page on its scope', () => {
|
||||
it('leaves a link for another profile alone and lets only the first mounted page take it', () => {
|
||||
const params = new URLSearchParams({ connector: 'gmail', profile: 'b', rename: 'ca_b' })
|
||||
|
||||
expect(claimRouteLink('k1', params, 'a')).toBe(false)
|
||||
expect(claimRouteLink('k1', params, 'b')).toBe(true)
|
||||
expect(claimRouteLink('k1', params, 'b')).toBe(false)
|
||||
expect(claimRouteLink('k2', params, 'b')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('starting an add or a reconnect', () => {
|
||||
it('refreshes the account list when the start returns, before the operation settles', async () => {
|
||||
const request = vi.fn(async () => ({ deadline_at: 0, op_id: 'op1', seq: 1, settled: false, targets: [] }))
|
||||
|
||||
// SAFETY: the connector RPC layer calls only `request` on the primary socket here.
|
||||
setPrimaryGateway({ request } as never)
|
||||
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
const { result } = renderHook(() => useConnectConnector('default'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect('gmail', { alias: 'work' })
|
||||
})
|
||||
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: connectorsAccountsQueryKey('default') })
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,6 @@
|
||||
import { type ProfileScope, profileScopeKey } from '@/hermes'
|
||||
import { activeGatewayConnectionId } from '@/store/gateway'
|
||||
|
||||
import { CAPABILITIES_ROUTE } from '../../../routes'
|
||||
|
||||
import { $accountOperations } from './account-operations'
|
||||
@@ -6,14 +9,14 @@ import { wakeAccountOperation } from './rpc'
|
||||
const connectorRoute = (slug: string): string =>
|
||||
`${CAPABILITIES_ROUTE}?tab=connectors&connector=${encodeURIComponent(slug)}`
|
||||
|
||||
/** The Connectors tab, scoped to the chat's own profile and connection, with one app's dialog and the named
|
||||
* account's rename editor open. */
|
||||
/** The Connectors tab, scoped to the chat's own profile and connection, with one app's dialog and that
|
||||
* account's rename editor open. The account goes by id: its name can change and be reused by another. */
|
||||
export function accountRenameRoute(
|
||||
slug: string,
|
||||
alias: string,
|
||||
connectionId: string,
|
||||
owner: { connectionId: null | string; profile: string }
|
||||
) {
|
||||
const params = new URLSearchParams({ profile: owner.profile, rename: alias })
|
||||
const params = new URLSearchParams({ profile: owner.profile, rename: connectionId })
|
||||
|
||||
if (owner.connectionId) {
|
||||
params.set('connection', owner.connectionId)
|
||||
@@ -43,3 +46,43 @@ export async function resumeAccountConnect(opId: string, navigate: (to: string)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** The scope a link names: the profile, pinned to its connection when that is not the active one. */
|
||||
export function linkedScope(params: URLSearchParams): null | string | { connectionId: string; profile: string } {
|
||||
const profile = params.get('profile')?.trim()
|
||||
|
||||
if (!profile) {
|
||||
return null
|
||||
}
|
||||
|
||||
const connectionId = params.get('connection')?.trim()
|
||||
|
||||
// The active connection is the ambient route; only another connection needs a pinned scope.
|
||||
return connectionId && connectionId !== (activeGatewayConnectionId() ?? 'local') ? { connectionId, profile } : profile
|
||||
}
|
||||
|
||||
const claimedLinks = new Set<string>()
|
||||
|
||||
/** Whether this mounted Connectors tab handles the link at `locationKey`. A tab for another scope waits for
|
||||
* the page to switch to the link's scope, and of several mounted pages only the first takes it. */
|
||||
export function claimRouteLink(locationKey: string, params: URLSearchParams, scope: ProfileScope): boolean {
|
||||
const linked = linkedScope(params)
|
||||
|
||||
if (linked !== null && profileScopeKey(linked) !== profileScopeKey(scope)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const claim = `${locationKey}?${params.toString()}`
|
||||
|
||||
if (claimedLinks.has(claim)) {
|
||||
return false
|
||||
}
|
||||
|
||||
claimedLinks.add(claim)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function resetRouteLinksForTests(): void {
|
||||
claimedLinks.clear()
|
||||
}
|
||||
|
||||
@@ -276,8 +276,12 @@ export function useConnectConnector(scope: ProfileScope): ConnectorConnect {
|
||||
connectionId: options?.connectionId,
|
||||
reconnect: options?.reconnect ?? false
|
||||
}
|
||||
|
||||
const snapshot = await connectAccountConnectors(scope, [slug], request.reconnect, request)
|
||||
|
||||
// The start already lists the new or repaired account as pending; the list must not wait for the settle.
|
||||
invalidateConnectors(scope, 'accounts')
|
||||
|
||||
return { ok: true, operation: startAccountOperation(scope, [slug], snapshot, request) }
|
||||
} catch (error) {
|
||||
return { error: asConnectorError(error), ok: false }
|
||||
|
||||
@@ -61,6 +61,8 @@ const read = (of: keyof typeof CONNECTOR_LIFETIMES) => ({
|
||||
|
||||
export interface HostedConnectorsView {
|
||||
accounts: readonly ConnectorAccountRow[]
|
||||
/** The account list has answered at least once, so an account missing from it is gone. */
|
||||
accountsLoaded: boolean
|
||||
listSlugs: ReadonlySet<string>
|
||||
phase: HostedPhase
|
||||
policy: ConnectorPolicyView
|
||||
@@ -150,6 +152,7 @@ export function useHostedConnectors(scope: ProfileScope): HostedConnectorsView {
|
||||
|
||||
return {
|
||||
accounts: accounts.data?.accounts ?? [],
|
||||
accountsLoaded: accounts.data !== undefined,
|
||||
listSlugs,
|
||||
phase,
|
||||
policy: policyView,
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { ProfileScope } from '@/hermes'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { openFreeTierSignIn } from '@/store/free-tier-sign-in'
|
||||
import { notify } from '@/store/notifications'
|
||||
|
||||
import type { McpServersController } from '../mcp/use-mcp-servers'
|
||||
|
||||
@@ -50,10 +52,26 @@ export interface HostedConnectorDialogProps {
|
||||
}
|
||||
|
||||
export interface PendingRename {
|
||||
alias: string
|
||||
/** The account goes by id: a stale link must not open another account that took its old name. */
|
||||
connectionId: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
/** The account a pending rename link opens in this app's dialog: the row with that id, 'gone' once the list
|
||||
* has answered without it, or null while there is nothing to do yet. Never a row matched by name. */
|
||||
export function renameLinkTarget(
|
||||
pending: null | PendingRename,
|
||||
slug: string,
|
||||
accounts: readonly AccountRow[],
|
||||
loaded: boolean
|
||||
): 'gone' | AccountRow | null {
|
||||
if (pending?.slug !== slug) {
|
||||
return null
|
||||
}
|
||||
|
||||
return accounts.find(row => row.connection_id === pending.connectionId) ?? (loaded ? 'gone' : null)
|
||||
}
|
||||
|
||||
export interface HostedAccountActions {
|
||||
add: (alias: string) => Promise<WriteOutcome>
|
||||
/** An account a deep link asked to rename; that app's dialog opens its editor once the account is listed. */
|
||||
@@ -101,6 +119,7 @@ export function HostedConnectorDialog({
|
||||
|
||||
const accountList = useAccountList(
|
||||
card,
|
||||
hosted.accountsLoaded,
|
||||
accountActions,
|
||||
operation !== null && !operation.settled,
|
||||
retryableAccount(operation)
|
||||
@@ -211,6 +230,7 @@ function menuReconnect(
|
||||
* rename opens that account's editor once this app lists it. */
|
||||
function useAccountList(
|
||||
card: ConnectorCardModel,
|
||||
accountsLoaded: boolean,
|
||||
actions: HostedAccountActions,
|
||||
connecting: boolean,
|
||||
retryable: null | string
|
||||
@@ -219,15 +239,20 @@ function useAccountList(
|
||||
const accounts = card.ways.hosted?.accounts ?? []
|
||||
const { pendingRename, renameOpened } = actions
|
||||
|
||||
const renameTarget =
|
||||
pendingRename?.slug === card.slug ? accounts.find(row => row.alias === pendingRename.alias) : undefined
|
||||
const linked = renameLinkTarget(pendingRename, card.slug, accounts, accountsLoaded)
|
||||
const renameTarget = linked === 'gone' ? undefined : (linked ?? undefined)
|
||||
const renameGone = linked === 'gone'
|
||||
|
||||
useEffect(() => {
|
||||
if (renameTarget) {
|
||||
setEdit({ connectionId: renameTarget.connection_id, kind: 'rename' })
|
||||
renameOpened()
|
||||
} else if (renameGone) {
|
||||
// Removed or replaced by a reconnect since the link was made: open nothing and say so.
|
||||
notify({ kind: 'info', message: translateNow('connectorsPage.accounts.renameGone') })
|
||||
renameOpened()
|
||||
}
|
||||
}, [renameOpened, renameTarget])
|
||||
}, [renameGone, renameOpened, renameTarget])
|
||||
|
||||
const props: AccountsSectionProps = {
|
||||
accounts,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
@@ -13,6 +13,8 @@ import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
|
||||
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
|
||||
|
||||
import { linkedScope } from './connectors/data/deep-link'
|
||||
|
||||
interface ScopeOption {
|
||||
key: string
|
||||
label: string
|
||||
@@ -53,13 +55,23 @@ export function useCapabilityScope({
|
||||
fixedProfile?: string
|
||||
}): CapabilityScope {
|
||||
const activeProfile = useStore($activeGatewayProfile)
|
||||
const { search } = useLocation()
|
||||
const { key: locationKey, search } = useLocation()
|
||||
|
||||
// A link from a chat names the chat's own profile (and connection), so the page opens on that scope.
|
||||
const [scopeOverride, setScopeOverride] = useState<null | string | { connectionId: string; profile: string }>(() =>
|
||||
linkedScope(new URLSearchParams(search))
|
||||
const linked = useMemo(() => linkedScope(new URLSearchParams(search)), [search])
|
||||
|
||||
const [scopeOverride, setScopeOverride] = useState<null | string | { connectionId: string; profile: string }>(
|
||||
() => linked
|
||||
)
|
||||
|
||||
// A page that stays mounted (docked) takes each new link's scope before any tab acts on the link.
|
||||
useEffect(() => {
|
||||
if (linked !== null) {
|
||||
setScopeOverride(linked)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [locationKey])
|
||||
|
||||
const scopeProfile: ProfileScope = useMemo(
|
||||
() =>
|
||||
fixedProfile
|
||||
@@ -237,16 +249,3 @@ export function CapabilityScopeSelector({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function linkedScope(params: URLSearchParams): null | string | { connectionId: string; profile: string } {
|
||||
const profile = params.get('profile')?.trim()
|
||||
|
||||
if (!profile) {
|
||||
return null
|
||||
}
|
||||
|
||||
const connectionId = params.get('connection')?.trim()
|
||||
|
||||
// The active connection is the ambient route; only another connection needs a pinned scope.
|
||||
return connectionId && connectionId !== (activeGatewayConnectionId() ?? 'local') ? { connectionId, profile } : profile
|
||||
}
|
||||
|
||||
@@ -401,7 +401,13 @@ function NamedAccountNotices({ owner, targets }: { owner: ConnectionOwner; targe
|
||||
return (
|
||||
<div className="grid gap-1 px-3.5" data-slot="connector-named-account">
|
||||
{named.map(target => (
|
||||
<NamedAccountNotice alias={target.alias ?? ''} key={target.name} name={target.name} owner={owner} />
|
||||
<NamedAccountNotice
|
||||
alias={target.alias ?? ''}
|
||||
connectionId={target.connectionId}
|
||||
key={target.name}
|
||||
name={target.name}
|
||||
owner={owner}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -409,29 +415,33 @@ function NamedAccountNotices({ owner, targets }: { owner: ConnectionOwner; targe
|
||||
|
||||
interface NamedAccountProps {
|
||||
alias: string
|
||||
/** The account the line is about; the Rename link goes by it, since the name can change hands. */
|
||||
connectionId: string
|
||||
name: string
|
||||
owner: ConnectionOwner
|
||||
}
|
||||
|
||||
function NamedAccountNotice({ alias, name, owner }: NamedAccountProps) {
|
||||
function NamedAccountNotice({ alias, connectionId, name, owner }: NamedAccountProps) {
|
||||
const { t } = useI18n()
|
||||
const inRouter = useInRouterContext()
|
||||
|
||||
return (
|
||||
<p className="flex flex-wrap items-center gap-x-1.5 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-secondary)">
|
||||
{t.connectors.namedAccount(connectorTitle(name), alias)}
|
||||
{inRouter ? <RenameAccountLink alias={alias} name={name} owner={owner} /> : null}
|
||||
{inRouter && connectionId ? (
|
||||
<RenameAccountLink alias={alias} connectionId={connectionId} name={name} owner={owner} />
|
||||
) : null}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// useNavigate() throws outside a Router; the parent mounts this only inside one.
|
||||
function RenameAccountLink({ alias, name, owner }: NamedAccountProps) {
|
||||
function RenameAccountLink({ connectionId, name, owner }: NamedAccountProps) {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<Button onClick={() => navigate(accountRenameRoute(name, alias, owner))} size="inline" variant="textStrong">
|
||||
<Button onClick={() => navigate(accountRenameRoute(name, connectionId, owner))} size="inline" variant="textStrong">
|
||||
{t.connectors.renameAccount}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ export const deConnectorAccounts: NonNullable<TranslationOverrides['connectorsPa
|
||||
add: 'Verbinden',
|
||||
rename: 'Umbenennen',
|
||||
renameLabel: (name: string) => `Neuer Name für ${name}`,
|
||||
renameGone: 'Dieses Konto gibt es nicht mehr. Es wurde entfernt oder durch eine neue Verbindung ersetzt.',
|
||||
remove: 'Entfernen',
|
||||
removeTitle: (app: string, name: string) => `Das ${app}-Konto ${name} entfernen?`,
|
||||
removeBody: 'Hermes handelt nicht mehr über dieses Konto. Ihre anderen Konten bleiben verbunden.',
|
||||
|
||||
@@ -11,6 +11,7 @@ export const enConnectorAccounts: Translations['connectorsPage']['accounts'] = {
|
||||
add: 'Connect',
|
||||
rename: 'Rename',
|
||||
renameLabel: (name: string) => `New name for ${name}`,
|
||||
renameGone: 'That account is no longer there. It was removed or replaced by a reconnect.',
|
||||
remove: 'Remove',
|
||||
removeTitle: (app: string, name: string) => `Remove the ${name} ${app} account?`,
|
||||
removeBody: 'Hermes stops acting as this account. Your other accounts stay connected.',
|
||||
|
||||
@@ -11,6 +11,7 @@ export const esConnectorAccounts: NonNullable<TranslationOverrides['connectorsPa
|
||||
add: 'Conectar',
|
||||
rename: 'Cambiar nombre',
|
||||
renameLabel: (name: string) => `Nuevo nombre para ${name}`,
|
||||
renameGone: 'Esa cuenta ya no existe. Se quitó o una reconexión la sustituyó.',
|
||||
remove: 'Quitar',
|
||||
removeTitle: (app: string, name: string) => `¿Quitar la cuenta ${name} de ${app}?`,
|
||||
removeBody: 'Hermes deja de actuar con esta cuenta. Tus otras cuentas siguen conectadas.',
|
||||
|
||||
@@ -12,6 +12,7 @@ export const frConnectorAccounts: NonNullable<TranslationOverrides['connectorsPa
|
||||
add: 'Connecter',
|
||||
rename: 'Renommer',
|
||||
renameLabel: (name: string) => `Nouveau nom pour ${name}`,
|
||||
renameGone: 'Ce compte n’existe plus. Il a été supprimé ou remplacé par une reconnexion.',
|
||||
remove: 'Retirer',
|
||||
removeTitle: (app: string, name: string) => `Retirer le compte ${app} ${name} ?`,
|
||||
removeBody: "Hermes cesse d'agir avec ce compte. Vos autres comptes restent connectés.",
|
||||
|
||||
@@ -354,6 +354,7 @@
|
||||
"connectorsPage.accounts.removeBody": ["ar","ja","ru","zh","zh-hant"],
|
||||
"connectorsPage.accounts.removeTitle": ["ar","ja","ru","zh","zh-hant"],
|
||||
"connectorsPage.accounts.rename": ["ar","ja","ru","zh","zh-hant"],
|
||||
"connectorsPage.accounts.renameGone": ["ar","ja","ru","zh","zh-hant"],
|
||||
"connectorsPage.accounts.renameLabel": ["ar","ja","ru","zh","zh-hant"],
|
||||
"connectorsPage.accounts.retiredCount": ["ar","ja","ru","zh","zh-hant"],
|
||||
"connectorsPage.accounts.status.expired": ["ar","ja","ru","zh","zh-hant"],
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface ConnectorAccountsTranslations {
|
||||
add: string
|
||||
rename: string
|
||||
renameLabel: (name: string) => string
|
||||
renameGone: string
|
||||
remove: string
|
||||
removeTitle: (app: string, name: string) => string
|
||||
removeBody: string
|
||||
|
||||
Reference in New Issue
Block a user