Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions apps/apollo-vertex/content/docs/patterns/shell.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
<AuthContext.Provider value={auth}>
<ApolloShell
companyName="Your Company"
productName="Your Product"
navItems={navItems}
unauthenticatedElement={<RedirectToLogin />}
>
<YourApp />
</ApolloShell>
</AuthContext.Provider>
);
}
```

## Gating access by group membership

Use `GroupMembershipGuard` to restrict the application to members of one or more
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions packages/apollo-vertex/registry/shell/shell-nav-active.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { VERTEX_NAMESPACE } from "@/lib/vertex-translation";
import {
activeNavItemPath,
mostSpecificActivePath,
navItemIsCurrent,
navItemState,
Expand Down Expand Up @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions packages/apollo-vertex/registry/shell/shell-nav-active.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/<workspace>` 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;
}
20 changes: 18 additions & 2 deletions packages/apollo-vertex/registry/shell/shell-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 };
Comment on lines +225 to +227

if (item.subItems && item.subItems.length > 0) {
return (
Expand Down
111 changes: 111 additions & 0 deletions packages/apollo-vertex/registry/shell/shell.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => (
<ThemeProvider>
<AuthContext.Provider value={auth}>
<ApolloShell
companyName="UiPath"
productName="Invoices"
navItems={[{ path: "/", label: "dashboard", icon: Home }]}
configureI18n={configureI18n}
{...props}
>
<p>{"app content"}</p>
</ApolloShell>
</AuthContext.Provider>
</ThemeProvider>
),
});
const router = createRouter({
routeTree: rootRoute,
history: createMemoryHistory({ initialEntries: ["/"] }),
});
return render(<RouterProvider router={router} />);
}

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: <p>{"redirecting"}</p>,
});

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: <p>{"redirecting"}</p> },
);

// Let LocaleProvider finish configuring before asserting.
await Promise.resolve();
expect(screen.queryByText("redirecting")).toBeNull();
expect(screen.queryByRole("button")).toBeNull();
});
});
21 changes: 17 additions & 4 deletions packages/apollo-vertex/registry/shell/shell.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<void>;
}

Expand All @@ -59,16 +65,21 @@ const ApolloShellContent: FC<ApolloShellProps> = ({
variant,
navItems,
loginDescription,
unauthenticatedElement,
}) => {
const authContext = useContext(AuthContext);
const { accessToken, isLoading } = useAuth();
const { isAuthenticated, isLoading } = useAuth();

if (authContext && isLoading) {
return <ShellLoadingSkeleton />;
}

if (authContext && !accessToken) {
return <ShellLogin title={productName} description={loginDescription} />;
if (authContext && !isAuthenticated) {
return (
unauthenticatedElement ?? (
<ShellLogin title={productName} description={loginDescription} />
)
);
}

return (
Expand All @@ -94,6 +105,7 @@ export const ApolloShell: FC<ApolloShellProps> = ({
variant,
navItems,
loginDescription,
unauthenticatedElement,
configureI18n,
}) => {
return (
Expand All @@ -108,6 +120,7 @@ export const ApolloShell: FC<ApolloShellProps> = ({
variant={variant}
navItems={navItems}
loginDescription={loginDescription}
unauthenticatedElement={unauthenticatedElement}
>
{children}
</ApolloShellContent>
Expand Down
5 changes: 3 additions & 2 deletions packages/apollo-vertex/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading