diff --git a/apps/apollo-vertex/content/docs/patterns/shell.mdx b/apps/apollo-vertex/content/docs/patterns/shell.mdx index ba6e070f7..9d57eff84 100644 --- a/apps/apollo-vertex/content/docs/patterns/shell.mdx +++ b/apps/apollo-vertex/content/docs/patterns/shell.mdx @@ -69,6 +69,8 @@ const projects = { Labels are translation keys. A key in the app's default catalog is used first, including keys that are not in Apollo Vertex. Vertex keys such as `dashboard` still resolve when the app catalog does not define them. +When one item's path is a prefix of another's, only the most specific match is highlighted. A landing page such as `/acme` stays inactive while `/acme/projects` is open. + ### App translations Pass `configureI18n` to `ApolloShell` or `LocaleProvider` when the app initializes i18next itself. The shell then runs that function instead of replacing the app catalog with the Vertex one. @@ -121,6 +123,50 @@ function App() { } ``` +## Bringing your own authentication + +Apps that already authenticate another way, such as with a session cookie set by their platform, can provide `AuthContext` themselves instead of mounting `ShellAuthProvider`. The shell gate reads `isLoading` and `isAuthenticated`, the user profile reads `user`, and sign-out calls `logout`. Leave `accessToken` as `null` when the app has no bearer token to share. + +Pass `unauthenticatedElement` to replace the sign-in screen. For example, render an element that redirects to the platform login: + +```tsx +import { useEffect } from 'react'; +import { ApolloShell, AuthContext, type AuthContextValue } from '@uipath/apollo-vertex'; + +function RedirectToLogin() { + useEffect(() => { + window.location.assign(`/login?returnTo=${encodeURIComponent(window.location.href)}`); + }, []); + return null; +} + +function App() { + const session = useSession(); // the app's own session hook + + const auth: AuthContextValue = { + user: session.user, + isAuthenticated: session.user != null, + isLoading: session.isLoading, + login: async () => session.login(), + logout: session.logout, + accessToken: null, + }; + + return ( + + } + > + + + + ); +} +``` + ## Gating access by group membership Use `GroupMembershipGuard` to restrict the application to members of one or more diff --git a/packages/apollo-vertex/registry/shell/shell-auth-provider.tsx b/packages/apollo-vertex/registry/shell/shell-auth-provider.tsx index b4aa366f6..53c87b25a 100644 --- a/packages/apollo-vertex/registry/shell/shell-auth-provider.tsx +++ b/packages/apollo-vertex/registry/shell/shell-auth-provider.tsx @@ -11,6 +11,10 @@ import { import { z } from "zod"; import { ensureValidToken, getTokenQueryKey, login, logout } from "@/lib/auth"; +// Apps that authenticate outside the shell (a session cookie, their own IdP) +// can provide this context themselves instead of mounting ShellAuthProvider. +// The shell gate reads isAuthenticated; accessToken stays null when the app +// has no bearer token to share. export interface AuthContextValue { user: UserInfo | null; isAuthenticated: boolean; diff --git a/packages/apollo-vertex/registry/shell/shell-nav-active.test.tsx b/packages/apollo-vertex/registry/shell/shell-nav-active.test.tsx index 6b9e4ba1b..3ee6eaa02 100644 --- a/packages/apollo-vertex/registry/shell/shell-nav-active.test.tsx +++ b/packages/apollo-vertex/registry/shell/shell-nav-active.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { VERTEX_NAMESPACE } from "@/lib/vertex-translation"; import { + activeNavItemPath, mostSpecificActivePath, navItemIsCurrent, navItemState, @@ -106,6 +107,37 @@ describe("mostSpecificActivePath", () => { }); }); +describe("activeNavItemPath", () => { + const sidebar = [ + { path: "/acme" }, + { path: "/acme/projects", childPaths: ["/acme/projects/archive"] }, + { path: "/work", childPaths: ["/invoices"] }, + ]; + + it("keeps a landing page off while a section under it is open", () => { + expect(activeNavItemPath("/acme/projects", sidebar)).toBe("/acme/projects"); + expect(activeNavItemPath("/acme/projects/42", sidebar)).toBe( + "/acme/projects", + ); + }); + + it("selects the landing page on its own route", () => { + expect(activeNavItemPath("/acme", sidebar)).toBe("/acme"); + expect(activeNavItemPath("/acme/unlisted", sidebar)).toBe("/acme"); + }); + + it("gives a child page to its group", () => { + expect(activeNavItemPath("/acme/projects/archive", sidebar)).toBe( + "/acme/projects", + ); + expect(activeNavItemPath("/invoices/abc", sidebar)).toBe("/work"); + }); + + it("returns null when no item matches", () => { + expect(activeNavItemPath("/elsewhere", sidebar)).toBeNull(); + }); +}); + describe("navItemState", () => { it("links a group to its default child and keeps the prefix active", () => { expect( diff --git a/packages/apollo-vertex/registry/shell/shell-nav-active.ts b/packages/apollo-vertex/registry/shell/shell-nav-active.ts index 8c5639991..18298c35a 100644 --- a/packages/apollo-vertex/registry/shell/shell-nav-active.ts +++ b/packages/apollo-vertex/registry/shell/shell-nav-active.ts @@ -44,3 +44,24 @@ export function navItemState( current: navItemIsCurrent(pathname, item.path, childPaths), }; } + +// The sidebar keeps one item active: the one that owns the most specific match +// among item and child paths. A landing page such as `/` prefixes +// every section under it and would otherwise stay active on all of them. +export function activeNavItemPath( + pathname: string, + items: readonly { path: string; childPaths?: readonly string[] }[], +): string | null { + let owner: string | null = null; + let matchLength = -1; + for (const item of items) { + for (const path of [item.path, ...(item.childPaths ?? [])]) { + if (!navPathIsActive(pathname, path)) continue; + if (path.length > matchLength) { + owner = item.path; + matchLength = path.length; + } + } + } + return owner; +} diff --git a/packages/apollo-vertex/registry/shell/shell-sidebar.tsx b/packages/apollo-vertex/registry/shell/shell-sidebar.tsx index 58e1e3f93..eb35d78ee 100644 --- a/packages/apollo-vertex/registry/shell/shell-sidebar.tsx +++ b/packages/apollo-vertex/registry/shell/shell-sidebar.tsx @@ -35,7 +35,11 @@ import { import { Company } from "./shell-company"; import { MinimalCompany } from "./shell-minimal-company"; import { MinimalNavItem } from "./shell-minimal-nav-item"; -import { mostSpecificActivePath, navItemState } from "./shell-nav-active"; +import { + activeNavItemPath, + mostSpecificActivePath, + navItemState, +} from "./shell-nav-active"; import { navCatalogFromHook, readNavLabel } from "./shell-nav-label"; import { Text } from "./shell-text"; import type { ShellNavLabel } from "./shell-translation-key"; @@ -174,6 +178,14 @@ function SidebarNav({ }); }; + const activePath = activeNavItemPath( + pathname, + navItems.map((item) => ({ + path: item.path, + childPaths: item.subItems?.map((sub) => sub.path), + })), + ); + const getTooltipText = (label: ShellNavLabel): string => { if (typeof label === "string") return readNavLabel(catalog, label); return t(label.i18nKey, label.values); @@ -204,11 +216,15 @@ function SidebarNav({ const Icon = item.icon; const isExpanded = expandedItems.has(item.path); const childPaths = item.subItems?.map((sub) => sub.path) ?? []; - const row = navItemState(pathname, { + const isActiveItem = item.path === activePath; + const itemState = navItemState(pathname, { path: item.path, to: item.to, childPaths, }); + const row = isActiveItem + ? itemState + : { ...itemState, prefixActive: false, current: false }; if (item.subItems && item.subItems.length > 0) { return ( diff --git a/packages/apollo-vertex/registry/shell/shell.test.tsx b/packages/apollo-vertex/registry/shell/shell.test.tsx new file mode 100644 index 000000000..c3259c3f8 --- /dev/null +++ b/packages/apollo-vertex/registry/shell/shell.test.tsx @@ -0,0 +1,111 @@ +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render, screen } from "@testing-library/react"; +import { Home } from "lucide-react"; +import type { ReactNode } from "react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { ApolloShell } from "./shell"; +import { AuthContext, type AuthContextValue } from "./shell-auth-provider"; +import { ThemeProvider } from "./shell-theme-provider"; + +afterEach(cleanup); + +beforeAll(() => { + // jsdom has no matchMedia; the sidebar reads it to detect mobile. + window.matchMedia = (query: string) => + ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: () => false, + }) satisfies MediaQueryList; +}); + +const signedOut: AuthContextValue = { + user: null, + isAuthenticated: false, + isLoading: false, + login: () => Promise.resolve(), + logout: vi.fn(), + accessToken: null, +}; + +const configureI18n = () => Promise.resolve(); + +function renderShell( + auth: AuthContextValue, + props: { unauthenticatedElement?: ReactNode } = {}, +) { + // The shell layout renders router links, so mount it as a route. + const rootRoute = createRootRoute({ + component: () => ( + + + +

{"app content"}

+
+
+
+ ), + }); + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + return render(); +} + +describe("ApolloShell auth gate", () => { + it("shows the sign-in screen while signed out", async () => { + renderShell(signedOut); + + expect(await screen.findByRole("button")).toBeDefined(); + expect(screen.queryByText("app content")).toBeNull(); + }); + + it("renders unauthenticatedElement instead of the sign-in screen", async () => { + renderShell(signedOut, { + unauthenticatedElement:

{"redirecting"}

, + }); + + expect(await screen.findByText("redirecting")).toBeDefined(); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("renders the app for a session without an access token", async () => { + renderShell({ + ...signedOut, + isAuthenticated: true, + user: { name: "Ada Lovelace", email: "ada@example.com", sub: "ada" }, + }); + + expect(await screen.findByText("app content")).toBeDefined(); + expect(screen.getByText("ada@example.com")).toBeDefined(); + }); + + it("does not show the sign-in screen while loading", async () => { + renderShell( + { ...signedOut, isLoading: true }, + { unauthenticatedElement:

{"redirecting"}

}, + ); + + // Let LocaleProvider finish configuring before asserting. + await Promise.resolve(); + expect(screen.queryByText("redirecting")).toBeNull(); + expect(screen.queryByRole("button")).toBeNull(); + }); +}); diff --git a/packages/apollo-vertex/registry/shell/shell.tsx b/packages/apollo-vertex/registry/shell/shell.tsx index f7fef7543..5a443cd7c 100644 --- a/packages/apollo-vertex/registry/shell/shell.tsx +++ b/packages/apollo-vertex/registry/shell/shell.tsx @@ -1,7 +1,7 @@ "use client"; import type { LucideIcon } from "lucide-react"; -import type { FC, PropsWithChildren } from "react"; +import type { FC, PropsWithChildren, ReactNode } from "react"; import { useContext } from "react"; import { Skeleton } from "@/components/ui/skeleton"; import { AuthContext, useAuth } from "./shell-auth-provider"; @@ -48,6 +48,12 @@ export interface ApolloShellProps extends PropsWithChildren { companyLogo?: CompanyLogo; navItems: ShellNavItem[]; loginDescription?: string; + /** + * Rendered instead of the shell while an auth provider reports the user as + * signed out. Defaults to the "Sign in with UiPath" screen. Apps whose + * provider signs in by redirecting can render an element that does that. + */ + unauthenticatedElement?: ReactNode; configureI18n?: () => Promise; } @@ -59,16 +65,21 @@ const ApolloShellContent: FC = ({ variant, navItems, loginDescription, + unauthenticatedElement, }) => { const authContext = useContext(AuthContext); - const { accessToken, isLoading } = useAuth(); + const { isAuthenticated, isLoading } = useAuth(); if (authContext && isLoading) { return ; } - if (authContext && !accessToken) { - return ; + if (authContext && !isAuthenticated) { + return ( + unauthenticatedElement ?? ( + + ) + ); } return ( @@ -94,6 +105,7 @@ export const ApolloShell: FC = ({ variant, navItems, loginDescription, + unauthenticatedElement, configureI18n, }) => { return ( @@ -108,6 +120,7 @@ export const ApolloShell: FC = ({ variant={variant} navItems={navItems} loginDescription={loginDescription} + unauthenticatedElement={unauthenticatedElement} > {children} diff --git a/packages/apollo-vertex/vitest.config.ts b/packages/apollo-vertex/vitest.config.ts index 5f63b1ed2..4ceb2d933 100644 --- a/packages/apollo-vertex/vitest.config.ts +++ b/packages/apollo-vertex/vitest.config.ts @@ -1,9 +1,10 @@ -import { resolve } from "node:path"; import { defineConfig } from "vitest/config"; export default defineConfig({ resolve: { - alias: { "@": resolve(__dirname, ".") }, + // tsconfig maps `@/*` and each `@/components/ui/*` import to its registry + // folder, so component tests can render components that import siblings. + tsconfigPaths: true, }, test: { environment: "jsdom",