diff --git a/packages/docs/content/connections/vault.mdx b/packages/docs/content/connections/vault.mdx index 8374fa18..452ab78c 100644 --- a/packages/docs/content/connections/vault.mdx +++ b/packages/docs/content/connections/vault.mdx @@ -20,9 +20,9 @@ written. ### Add an entry -Open **Vault** in the sidebar and choose **Add entry**. Once anyone else is in the organization, or -invited to it, **Just me** and **Everyone in** the organization pick which vault is open, and a new -entry goes in that one. +Open **Connections** in the sidebar, then **Vault**, first in the list, and choose **Add entry**. +Once anyone else is in the organization, or invited to it, **Just me** and **Everyone in** the +organization pick which vault is open, and a new entry goes in that one. ### Choose what agents can do diff --git a/packages/docs/content/organizations.mdx b/packages/docs/content/organizations.mdx index 11d80d99..ce3bdfe9 100644 --- a/packages/docs/content/organizations.mdx +++ b/packages/docs/content/organizations.mdx @@ -8,8 +8,8 @@ with everyone in it or one member's own, its activity log, its vault and each me starts with one of their own, and can make more, such as one for work beside a personal one, or join someone else's. Every page is one organization's, named first in its address (`/acme/connections`), so a link to a page is a link to it in that organization; the mark at the -top of the sidebar is the one you are in: hover it to switch, or to create another. Your agents -there reach what is connected there. +top of the sidebar is the one you are in: hover it to switch, to open its **Members** or +**Settings**, or to create another. Your agents there reach what is connected there. ## Your own or everyone's @@ -56,9 +56,10 @@ page on a shared connection is logged with their name. ## Members -**Members** lists who is in the organization and who is invited. Every organization has one owner, -whoever made it, for good; everyone else is a member. The owner invites people by email, removes -members, renames the organization and deletes it. Members use everything in it and can leave. +**Members**, under the organization's mark at the top of the sidebar, lists who is in the +organization and who is invited. Every organization has one owner, whoever made it, for good; +everyone else is a member. The owner invites people by email, removes members, renames the +organization and deletes it. Members use everything in it and can leave. An invitation is a link that works for a week, for the address it was sent to: the person signs in, or creates an account, as that address to join. Inviting follows the same rules as signing up, so diff --git a/packages/ui/src/components/separator.tsx b/packages/ui/src/components/separator.tsx deleted file mode 100644 index e0bb631d..00000000 --- a/packages/ui/src/components/separator.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import {Separator as SeparatorPrimitive} from '@base-ui/react/separator'; -import {cn} from 'cn'; - -function Separator({className, orientation = 'horizontal', ...props}: SeparatorPrimitive.Props) { - return ( - - ); -} - -export {Separator}; diff --git a/packages/ui/src/components/sidebar.tsx b/packages/ui/src/components/sidebar.tsx index 56f081de..5c97496b 100644 --- a/packages/ui/src/components/sidebar.tsx +++ b/packages/ui/src/components/sidebar.tsx @@ -8,8 +8,6 @@ import {cn} from 'cn'; import {useIsMobile} from '@proxy/ui/hooks/use-mobile'; import {Button} from '@proxy/ui/components/button'; -import {Input} from '@proxy/ui/components/input'; -import {Separator} from '@proxy/ui/components/separator'; import { Sheet, SheetContent, @@ -17,25 +15,18 @@ import { SheetHeader, SheetTitle, } from '@proxy/ui/components/sheet'; -import {Skeleton} from '@proxy/ui/components/skeleton'; import {Tooltip, TooltipContent, TooltipTrigger} from '@proxy/ui/components/tooltip'; import {PanelLeftIcon} from 'lucide-react'; -const SIDEBAR_COOKIE_NAME = 'sidebar_state'; -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; const SIDEBAR_WIDTH = '16rem'; const SIDEBAR_WIDTH_MOBILE = '18rem'; const SIDEBAR_WIDTH_ICON = '3rem'; -const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; type SidebarContextProps = { state: 'expanded' | 'collapsed'; - open: boolean; - setOpen: (open: boolean) => void; openMobile: boolean; setOpenMobile: (open: boolean) => void; isMobile: boolean; - toggleSidebar: () => void; }; const SidebarContext = React.createContext(null); @@ -49,74 +40,24 @@ function useSidebar() { return context; } +// On desktop the sidebar is either always collapsed or always expanded, as `collapsed` says: there +// is no toggling it. On phones it is a sheet, opened by the trigger and closed on every pick. function SidebarProvider({ - defaultOpen = true, - open: openProp, - onOpenChange: setOpenProp, + collapsed = false, className, style, children, ...props }: React.ComponentProps<'div'> & { - defaultOpen?: boolean; - open?: boolean; - onOpenChange?: (open: boolean) => void; + collapsed?: boolean; }) { const isMobile = useIsMobile(); const [openMobile, setOpenMobile] = React.useState(false); - - // This is the internal state of the sidebar. - // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(defaultOpen); - const open = openProp ?? _open; - const setOpen = React.useCallback( - (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === 'function' ? value(open) : value; - if (setOpenProp) { - setOpenProp(openState); - } else { - _setOpen(openState); - } - - // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; - }, - [setOpenProp, open], - ); - - // Helper to toggle the sidebar. - const toggleSidebar = React.useCallback(() => { - return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); - }, [isMobile, setOpen, setOpenMobile]); - - // Adds a keyboard shortcut to toggle the sidebar. - React.useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { - event.preventDefault(); - toggleSidebar(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [toggleSidebar]); - - // We add a state so that we can do data-state="expanded" or "collapsed". - // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? 'expanded' : 'collapsed'; + const state = collapsed ? 'collapsed' : 'expanded'; const contextValue = React.useMemo( - () => ({ - state, - open, - setOpen, - isMobile, - openMobile, - setOpenMobile, - toggleSidebar, - }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], + () => ({state, isMobile, openMobile, setOpenMobile}), + [state, isMobile, openMobile], ); return ( @@ -244,8 +185,9 @@ function Sidebar({ ); } +// Opens the sidebar's sheet on phones. function SidebarTrigger({className, onClick, ...props}: React.ComponentProps) { - const {toggleSidebar} = useSidebar(); + const {openMobile, setOpenMobile} = useSidebar(); return ( ); } -function SidebarRail({className, ...props}: React.ComponentProps<'button'>) { - const {toggleSidebar} = useSidebar(); - - return ( - + ))} + + ); +} diff --git a/packages/web/src/components/theme.test.tsx b/packages/web/src/components/theme.test.tsx index 227ca380..82910747 100644 --- a/packages/web/src/components/theme.test.tsx +++ b/packages/web/src/components/theme.test.tsx @@ -2,9 +2,7 @@ import {afterEach, beforeEach, describe, expect, test} from 'bun:test'; import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; -import {SidebarProvider} from '@proxy/ui/components/sidebar'; -import {TooltipProvider} from '@proxy/ui/components/tooltip'; -import {ThemeMenu} from '@/components/theme-menu'; +import {ThemePicker} from '@/components/theme-picker'; import {ThemeProvider} from '@proxy/ui/components/theme-provider'; import { storeTheme, @@ -166,20 +164,16 @@ describe('ThemeProvider', () => { }); }); -test('the sidebar menu selects and persists each theme', async () => { +test("the account's picker selects and persists each theme", async () => { localStorage.setItem(THEME_STORAGE_KEY, 'dark'); - renderTheme( - - - - - , - ); + renderTheme(); - fireEvent.click(screen.getByRole('button', {name: 'Theme: Dark'})); - expect(screen.getByRole('group', {name: 'Theme'})).toBeTruthy(); - fireEvent.click(await screen.findByRole('menuitemradio', {name: 'Light'})); + const group = screen.getByRole('group', {name: 'Theme'}); + expect(group).toBeTruthy(); + expect(screen.getByRole('button', {name: 'Dark'}).getAttribute('aria-pressed')).toBe('true'); + fireEvent.click(screen.getByRole('button', {name: 'Light'})); await waitFor(() => expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('light')); expect(document.documentElement.classList.contains('light')).toBe(true); + expect(screen.getByRole('button', {name: 'Light'}).getAttribute('aria-pressed')).toBe('true'); }); diff --git a/packages/web/src/pages/account-page.tsx b/packages/web/src/pages/account-page.tsx index d4456266..8d6b3ffb 100644 --- a/packages/web/src/pages/account-page.tsx +++ b/packages/web/src/pages/account-page.tsx @@ -8,13 +8,15 @@ import {OrgTile} from '@/components/org-switcher'; import {RowList} from '@/components/row-list'; import {Section} from '@/components/section'; import {SettingRow} from '@/components/setting-row'; +import {ThemePicker} from '@/components/theme-picker'; import {TypeToConfirmDialog} from '@/components/type-to-confirm-dialog'; import {useHuman} from '@/hooks/use-human'; import {describeApiError} from '@/lib/loader-utils'; import {hasOtherPeople} from '@/lib/orgs'; -// The person's account, in no organization: who is signed in, the organizations they are in, each -// to its own settings, and deleting the account, which deletes every one they own. +// The person's account, in no organization: who is signed in, how the app looks to them, the +// organizations they are in, each to its own settings, and deleting the account, which deletes +// every one they own. export function AccountPage() { const human = useHuman(); const [deleting, setDeleting] = useState(false); @@ -34,6 +36,13 @@ export function AccountPage() { {human.user.name && {human.user.name}} +
+ + + + + +
{human.orgs.map((org) => ( diff --git a/packages/web/src/pages/connections-page.test.tsx b/packages/web/src/pages/connections-page.test.tsx new file mode 100644 index 00000000..54561adc --- /dev/null +++ b/packages/web/src/pages/connections-page.test.tsx @@ -0,0 +1,92 @@ +/// + +import {afterEach, describe, expect, test} from 'bun:test'; +import {cleanup, render, screen} from '@testing-library/react'; +import {createMemoryRouter, RouterProvider} from 'react-router'; +import {ThemeProvider} from '@proxy/ui/components/theme-provider'; +import {TooltipProvider} from '@proxy/ui/components/tooltip'; +import type {Org} from '@/client/orgs-client'; +import {HumanSide} from '@/components/human-side'; +import type {Connection} from '@/lib/types'; +import {ConnectionsPage} from '@/pages/connections-page'; +import {testHuman, testOrg} from '@/test-org'; + +afterEach(() => { + cleanup(); +}); + +function connection(id: string, integrationId: string, shared: boolean): Connection { + return { + id, + integrationId, + account: 'Acme', + shared, + connectedById: 'user-1', + connectedBy: null, + defaults: [], + tools: null, + name: null, + unavailable: [], + signInNeeded: false, + }; +} + +// The organization's two vaults and a shared Google account; alone in it unless `org` says +// otherwise. +function renderConnectionsPage(entry: string, org: Org = testOrg) { + const router = createMemoryRouter( + [ + { + id: 'human', + loader: () => testHuman(org), + element: , + children: [ + { + path: '/:org/connections', + loader: () => ({ + connections: [ + connection('vault-own', 'vault', false), + connection('vault-shared', 'vault', true), + connection('google-1', 'google', true), + ], + agents: [], + }), + element: , + }, + ], + }, + ], + {initialEntries: [entry]}, + ); + + return render( + + + + + , + ); +} + +describe('ConnectionsPage', () => { + test('alone in the organization, lists the vault once, first, opening both', async () => { + renderConnectionsPage('/acme/connections'); + + const [first] = await screen.findAllByRole('link', {name: /Vault|Google/}); + expect(first?.textContent).toContain('Vault'); + expect(first?.getAttribute('href')).toBe('/acme/connections/vault'); + expect(screen.getAllByRole('link', {name: /Vault/})).toHaveLength(1); + }); + + test('with others in the organization, opens the vault of the side shown', async () => { + const shared = {...testOrg, memberCount: 2}; + renderConnectionsPage('/acme/connections', shared); + const everyone = await screen.findByRole('link', {name: /Vault/}); + expect(everyone.getAttribute('href')).toBe('/acme/connections/vault?for=everyone'); + cleanup(); + + renderConnectionsPage('/acme/connections?for=me', shared); + const own = await screen.findByRole('link', {name: /Vault/}); + expect(own.getAttribute('href')).toBe('/acme/connections/vault?for=me'); + }); +}); diff --git a/packages/web/src/pages/connections-page.tsx b/packages/web/src/pages/connections-page.tsx index 91c3dc2d..b0b31b8d 100644 --- a/packages/web/src/pages/connections-page.tsx +++ b/packages/web/src/pages/connections-page.tsx @@ -1,3 +1,4 @@ +import {VAULT_INTEGRATION_ID} from '@proxy/integrations'; import {PencilIcon, PlusIcon, Trash2Icon} from 'lucide-react'; import {useState} from 'react'; import {Link, useLoaderData, useRevalidator} from 'react-router'; @@ -16,7 +17,7 @@ import {useSide} from '@/hooks/use-side'; import {agentsWithAccess, integrationOf} from '@/lib/access'; import {describeApiError, describeFetchError} from '@/lib/loader-utils'; import {actsAs, manages} from '@/lib/orgs'; -import type {Connection} from '@/lib/types'; +import type {AgentLogin, Connection} from '@/lib/types'; import type {connectionsLoader} from '@/loaders'; import {useOrgSlug} from '@/hooks/use-org'; @@ -40,9 +41,10 @@ function titleOf(connection: Connection): string { // The services connected in the organization open: its shared ones and the person's own, never // another member's. With anyone else in it or invited, a toggle picks the list, in the address as // `?for=me` or `?for=everyone`, opening on the shared one unless only the person's own has any, the -// shared one saying who the provider sees when an agent uses each. The vault is kept on a page of -// its own. A right click on a row renames or deletes a connection the person may change: their own, -// or a shared one they connected or whose organization they own. +// shared one saying who the provider sees when an agent uses each. The vault comes first, the +// person's own or the organization's as the list shows, both alone, opening its own page. A right +// click on a row renames or deletes a connection the person may change: their own, or a shared one +// they connected or whose organization they own. export function ConnectionsPage() { const org = useOrgSlug(); const {connections: all, agents} = useLoaderData(); @@ -56,6 +58,12 @@ export function ConnectionsPage() { return null; } + const vaults = all.filter( + (connection) => + connection.integrationId === VAULT_INTEGRATION_ID && + (!side.others || connection.shared === side.shared), + ); + const activeOrg = human.org; const connections = side.shown; const showingShared = side.shared; @@ -96,6 +104,7 @@ export function ConnectionsPage() { } > + {connections.length === 0 && ( ); } + +// The vault the list shows, or both alone, as one row: what agents know, kept in Personal Agent +// Proxy, so never renamed or deleted. Its page opens on the same side. +function VaultRow({ + vaults, + agents, + showingShared, +}: { + vaults: Connection[]; + agents: AgentLogin[]; + showingShared: boolean; +}) { + const org = useOrgSlug(); + const human = useHuman(); + const [first] = vaults; + const integration = first && integrationOf(first); + if (!first || !integration || !human) { + return null; + } + + const withAccess = new Set( + vaults.flatMap((vault) => agentsWithAccess(agents, vault).map((agent) => agent.id)), + ); + const side = vaults.length > 1 ? '' : `?for=${first.shared ? 'everyone' : 'me'}`; + + return ( + } + title={integration.name} + cells={ + <> + {first.account} + {showingShared && {actsAs(first, human)}} + {withAccess.size} + + } + /> + ); +} diff --git a/packages/web/src/pages/vault-page.test.tsx b/packages/web/src/pages/vault-page.test.tsx index dbeeb1de..93c7179a 100644 --- a/packages/web/src/pages/vault-page.test.tsx +++ b/packages/web/src/pages/vault-page.test.tsx @@ -35,7 +35,7 @@ function renderVaultPage(entries: Entry[], options: {shared?: Entry[]; org?: Org element: , children: [ { - path: '/:org/vault', + path: '/:org/connections/vault', loader: () => ({ own: vault('vault-1', entries), shared: vault('vault-2', options.shared ?? []), @@ -45,7 +45,7 @@ function renderVaultPage(entries: Entry[], options: {shared?: Entry[]; org?: Org ], }, ], - {initialEntries: ['/acme/vault']}, + {initialEntries: ['/acme/connections/vault']}, ); return render( diff --git a/packages/web/src/pages/vault-page.tsx b/packages/web/src/pages/vault-page.tsx index 6a38062b..42c1c7ea 100644 --- a/packages/web/src/pages/vault-page.tsx +++ b/packages/web/src/pages/vault-page.tsx @@ -13,6 +13,7 @@ import {useState} from 'react'; import {useLoaderData} from 'react-router'; import {createRecord, deleteRecord, updateRecord} from '@/client/records-client'; import {AppShell, PageTitle} from '@/components/app-shell'; +import {BackButton} from '@/components/back-button'; import {RecordForm} from '@/components/record-form'; import {Row, RowHeader, RowList} from '@/components/row-list'; import {SideEmpty, SideToggle} from '@/components/share-toggle'; @@ -70,7 +71,8 @@ type Editing = {listed: Listed | null}; // everyone's agents should know about the organization: a table of the entries, each opened for // editing. With anyone else in the organization or invited, a toggle picks the vault, the person's // own or the organization's, in the address as `?for=me` or `?for=everyone`, opening on their own; -// alone, both vaults' entries are one list, and a new entry is the person's own. +// alone, both vaults' entries are one list, and a new entry is the person's own. It is one of the +// connections, opened from theirs. export function VaultPage() { const org = useOrgSlug(); const {own, shared} = useLoaderData(); @@ -101,7 +103,12 @@ export function VaultPage() { return ( Vault} + title={ + <> + + Vault + + } actions={