Skip to content

Commit

Permalink
feat: add protected routes feature
Browse files Browse the repository at this point in the history
  • Loading branch information
mosch committed Jan 15, 2025
1 parent 476fa93 commit 0d86edf
Show file tree
Hide file tree
Showing 9 changed files with 413 additions and 27 deletions.
27 changes: 27 additions & 0 deletions docs/pages/configuration/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,33 @@ If you use a managed authentication service, such as Auth0, Clerk or OpenID you

To implement the authentication option for your site, add the `authentication` property to the [Zudoku Configuration](./overview.md) file. The configuration is slightly different depending on the authentication provider you use.

## Protected Routes

You can protect specific routes in your documentation by adding the `protectedRoutes` property to your configuration. This property takes an array of glob patterns that match the routes you want to protect. When a user tries to access a protected route without being authenticated, they will be redirected to the login page.

```typescript
{
// ...
protectedRoutes: [
"/admin/*", // Protect all routes under /admin
"/settings", // Protect the settings page
"/api/**", // Protect all API-related routes
"/*.secret.*" // Protect any route with 'secret' in the name
],
// ...
}
```

The glob patterns support the following special characters:

- `*` matches any number of characters except `/`
- `**` matches any number of characters including `/`
- `?` matches a single character except `/`
- `[...]` matches any character in the brackets
- `{...}` matches any pattern in the braces

After logging in, users will be automatically redirected back to the protected route they were trying to access.

### Auth0

For Auth0, you will need the `clientId` associated with the domain you are using.
Expand Down
5 changes: 5 additions & 0 deletions docs/pages/configuration/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ const config: ZudokuConfig = {
},
],
},
// Protect specific routes that require authentication
protectedRoutes: [
"/admin/*", // Protect all routes under /admin
"/api/private", // Protect private API documentation
],
redirects: [{ from: "/", to: "/documentation" }],
apis: {
type: "url",
Expand Down
6 changes: 5 additions & 1 deletion examples/with-auth0/zudoku.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type ZudokuConfig } from "zudoku";
const config: ZudokuConfig = {
topNavigation: [
{ id: "documentation", label: "Documentation" },
{ id: "api", label: "Rick & Morty API" },
{ id: "api", label: "Rick & Morty API", display: "auth" },
],
sidebar: {
documentation: [
Expand All @@ -14,6 +14,10 @@ const config: ZudokuConfig = {
},
],
},
protectedRoutes: [
"/documentation/installation",
"/api/**",
],
redirects: [{ from: "/", to: "/documentation/introduction" }],
docs: {
files: "/pages/**/*.mdx",
Expand Down
6 changes: 4 additions & 2 deletions packages/zudoku/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,8 @@
"yargs": "17.7.2",
"zod": "3.23.8",
"zod-validation-error": "3.4.0",
"zustand": "5.0.2"
"zustand": "5.0.2",
"minimatch": "^9.0.0"
},
"devDependencies": {
"@graphql-codegen/cli": "5.0.3",
Expand All @@ -279,7 +280,8 @@
"react-dom": "catalog:",
"rollup-plugin-visualizer": "5.12.0",
"typescript": "5.7.2",
"vitest": "2.1.8"
"vitest": "2.1.8",
"@types/minimatch": "^5.1.2"
},
"peerDependencies": {
"react": ">=19",
Expand Down
11 changes: 9 additions & 2 deletions packages/zudoku/src/app/main.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type RouteObject } from "react-router";
import { Outlet, type RouteObject } from "react-router";
import { configuredApiKeysPlugin } from "virtual:zudoku-api-keys-plugin";
import {
configuredApiCatalogPlugins,
Expand All @@ -13,8 +13,9 @@ import { configuredSidebar } from "virtual:zudoku-sidebar";
import "virtual:zudoku-theme.css";
import { Layout, RouterError, Zudoku } from "zudoku/components";
import type { ZudokuConfig } from "../config/config.js";
import type { ZudokuContextOptions } from "../lib/core/ZudokuContext.js";
import { isNavigationPlugin } from "../lib/core/plugins.js";
import { ProtectedRoute } from "../lib/core/ProtectedRoute.js";
import type { ZudokuContextOptions } from "../lib/core/ZudokuContext.js";

export const convertZudokuConfigToOptions = (
config: ZudokuConfig,
Expand All @@ -32,6 +33,7 @@ export const convertZudokuConfigToOptions = (
!config.page?.logo?.src?.dark;

return {
protectedRoutes: config.protectedRoutes,
page: {
...config.page,
logo: {
Expand Down Expand Up @@ -102,6 +104,11 @@ export const getRoutesByConfig = (config: ZudokuConfig): RouteObject[] => {
),
children: [
{
element: (
<ProtectedRoute>
<Outlet />
</ProtectedRoute>
),
errorElement: <RouterError />,
children: routes,
},
Expand Down
1 change: 1 addition & 0 deletions packages/zudoku/src/config/validators/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ const ApiCatalogSchema = z.object({
* formats.
*/
export const CommonConfigSchema = z.object({
protectedRoutes: z.array(z.string()).optional(),
basePath: z.string().optional(),
page: PageSchema,
topNavigation: z.array(TopNavigationItemSchema),
Expand Down
45 changes: 45 additions & 0 deletions packages/zudoku/src/lib/core/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { minimatch } from "minimatch";
import { useEffect } from "react";
import { useLocation } from "react-router";
import { useAuth } from "../authentication/hook.js";
import { useZudoku } from "../components/context/ZudokuContext.js";
import { ZudokuError } from "../util/invariant.js";

export function isProtectedRoute(
path: string,
protectedPatterns?: string[],
): boolean {
if (!protectedPatterns?.length) return false;
return protectedPatterns.some((pattern) => minimatch(path, pattern));
}

export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const auth = useAuth();
const zudoku = useZudoku();
const location = useLocation();

const isProtected = isProtectedRoute(
location.pathname,
zudoku.options.protectedRoutes,
);

useEffect(() => {
if (isProtected && !auth.isAuthenticated) {
void zudoku.authentication?.signIn();
}
}, [isProtected, auth.isAuthenticated, zudoku.authentication]);

if (isProtected && !auth.isAuthenticated) {
return null;
}

if (isProtected && !auth.isAuthEnabled) {
throw new ZudokuError("Authentication is not enabled", {
title: "Authentication is not enabled",
developerHint:
"To use protectedRoutes you need authentication to be enabled",
});
}

return children;
}
17 changes: 9 additions & 8 deletions packages/zudoku/src/lib/core/ZudokuContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export type ZudokuContextOptions = {
components?: MdxComponentsType;
};
overrides?: ComponentsContextType;
protectedRoutes?: string[];
};

export class ZudokuContext {
Expand All @@ -74,16 +75,16 @@ export class ZudokuContext {
public meta: ZudokuContextOptions["metadata"];
public page: ZudokuContextOptions["page"];
public authentication?: ZudokuContextOptions["authentication"];
private navigationPlugins: NavigationPlugin[];
private readonly navigationPlugins: NavigationPlugin[];

constructor(config: ZudokuContextOptions) {
this.plugins = config.plugins ?? [];
this.topNavigation = config.topNavigation ?? [];
this.sidebars = config.sidebars ?? {};
constructor(public readonly options: ZudokuContextOptions) {
this.plugins = options.plugins ?? [];
this.topNavigation = options.topNavigation ?? [];
this.sidebars = options.sidebars ?? {};
this.navigationPlugins = this.plugins.filter(isNavigationPlugin);
this.authentication = config.authentication;
this.meta = config.metadata;
this.page = config.page;
this.authentication = options.authentication;
this.meta = options.metadata;
this.page = options.page;
}

initialize = async (): Promise<void> => {
Expand Down
Loading

0 comments on commit 0d86edf

Please sign in to comment.