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",