diff --git a/Cargo.lock b/Cargo.lock
index 5c744740c..e57b4231e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4057,9 +4057,9 @@ dependencies = [
[[package]]
name = "openssl"
-version = "0.10.77"
+version = "0.10.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f"
+checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
@@ -4089,9 +4089,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
-version = "0.9.113"
+version = "0.9.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644"
+checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
dependencies = [
"cc",
"libc",
@@ -5276,9 +5276,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
-version = "0.103.12"
+version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring",
@@ -5657,9 +5657,9 @@ dependencies = [
[[package]]
name = "sha3"
-version = "0.10.8"
+version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
+checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
dependencies = [
"digest",
"keccak",
@@ -6725,9 +6725,9 @@ dependencies = [
[[package]]
name = "typenum"
-version = "1.19.0"
+version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "uaparser"
@@ -7009,11 +7009,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
-version = "1.0.2+wasi-0.2.9"
+version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
- "wit-bindgen",
+ "wit-bindgen 0.57.1",
]
[[package]]
@@ -7022,7 +7022,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
- "wit-bindgen",
+ "wit-bindgen 0.51.0",
]
[[package]]
@@ -7594,9 +7594,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
-version = "1.0.1"
+version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
+checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
dependencies = [
"memchr",
]
@@ -7610,6 +7610,12 @@ dependencies = [
"wit-bindgen-rust-macro",
]
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
diff --git a/web/src/shared/video-tutorials/README.md b/web/src/shared/video-tutorials/README.md
index 4f08e91d9..c03024928 100644
--- a/web/src/shared/video-tutorials/README.md
+++ b/web/src/shared/video-tutorials/README.md
@@ -156,6 +156,10 @@ endpoint on the same server.
"title": "Defguard overview",
"description": "A high-level walkthrough of Defguard.",
"appRoute": "/vpn-overview",
+ "contextAppRoutes": [
+ "/vpn-overview/$locationId",
+ "/settings/"
+ ],
"docsUrl": "https://docs.defguard.net/introduction"
}
]
@@ -252,7 +256,8 @@ Route tutorial video fields:
| `title` | Yes | Non-empty string. Displayed in the section list and as the heading above the player. |
| `description` | Yes | Non-empty string. Displayed as body text below the player in the tutorials modal. |
| `appRoute` | Yes | Must start with `/`. Use TanStack Router route definition paths (e.g. `/vpn-overview`, `/vpn-overview/$locationId`), not runtime URLs with concrete param values. |
-| `docsUrl` | Yes | Valid URL. Shown as the external documentation link in the tutorials modal. |
+| `contextAppRoutes` | No | Optional non-empty array of additional in-app route definition paths where the tutorial should also appear. Each entry must start with `/`. |
+| `docsUrl` | No | Optional valid URL. When present, shown as the external documentation link in the tutorials modal. |
Wizard placement fields:
@@ -359,12 +364,19 @@ route definition string, never an instantiated URL with real param values.
For example, when the user is on `/vpn-overview/42`, TanStack Router reports
`fullPath` as `/vpn-overview/$locationId` (the template). Canonicalization trims
whitespace, ensures a leading `/`, and strips a trailing `/`. The same
-canonicalization is applied to every `video.appRoute` value before comparison.
+canonicalization is applied to every `video.appRoute` and `video.contextAppRoutes`
+value before comparison.
+
+A route tutorial is shown when the current route matches either:
+
+- `appRoute`
+- any entry in `contextAppRoutes`
### Parameterized routes
-A video with `appRoute: "/vpn-overview/$locationId"` matches whenever the user
-is on any location detail page, regardless of the concrete `locationId` in the
+A video with `appRoute: "/vpn-overview"` and
+`contextAppRoutes: ["/vpn-overview/$locationId"]` matches both the overview page
+and any location detail page, regardless of the concrete `locationId` in the
URL. Tutorials are associated with route shapes, not specific records.
Do not use runtime URLs as `appRoute`. A value like `/vpn-overview/42` will
diff --git a/web/src/shared/video-tutorials/components/modal/ModalContent/ModalContent.tsx b/web/src/shared/video-tutorials/components/modal/ModalContent/ModalContent.tsx
index 9792b5296..2209629d2 100644
--- a/web/src/shared/video-tutorials/components/modal/ModalContent/ModalContent.tsx
+++ b/web/src/shared/video-tutorials/components/modal/ModalContent/ModalContent.tsx
@@ -3,6 +3,7 @@ import { m } from '../../../../../paraglide/messages';
import { Icon } from '../../../../defguard-ui/components/Icon/Icon';
import { IconButton } from '../../../../defguard-ui/components/IconButton/IconButton';
import { Direction } from '../../../../defguard-ui/types';
+import { isPresent } from '../../../../defguard-ui/utils/isPresent';
import { getNavRoot } from '../../../route-key';
import { getRouteLabel } from '../../../route-label';
import { useVideoTutorialsModal } from '../../../store';
@@ -67,16 +68,22 @@ export const ModalContent = ({
})}
-
-
- {m.cmp_video_tutorials_modal_learn_more()}
-
-
+ {isPresent(selectedVideo.docsUrl) && (
+
+
+ {m.cmp_video_tutorials_modal_learn_more()}
+
+
+ )}
diff --git a/web/src/shared/video-tutorials/data.ts b/web/src/shared/video-tutorials/data.ts
index 1e498173e..eb70cc408 100644
--- a/web/src/shared/video-tutorials/data.ts
+++ b/web/src/shared/video-tutorials/data.ts
@@ -35,7 +35,11 @@ const videoTutorialSchema = z
title: z.string().min(1, 'title must be non-empty'),
description: z.string().min(1, 'description must be non-empty'),
appRoute: z.string().regex(/^\//, 'appRoute must start with "/"'),
- docsUrl: z.string().url('docsUrl must be a valid URL'),
+ contextAppRoutes: z
+ .array(z.string().regex(/^\//, 'contextAppRoutes entries must start with "/"'))
+ .min(1, 'contextAppRoutes must contain at least one item')
+ .optional(),
+ docsUrl: z.string().url('docsUrl must be a valid URL').optional(),
})
.strip();
diff --git a/web/src/shared/video-tutorials/resolved.tsx b/web/src/shared/video-tutorials/resolved.tsx
index a9a65589b..5b69e0373 100644
--- a/web/src/shared/video-tutorials/resolved.tsx
+++ b/web/src/shared/video-tutorials/resolved.tsx
@@ -14,6 +14,21 @@ const EMPTY_VIDEO_TUTORIALS: VideoTutorial[] = [];
const EMPTY_SECTIONS: VideoTutorialsSection[] = [];
const EMPTY_VIDEO_GUIDE_PLACEMENT: VideoGuidePlacement | null = null;
+export function matchesVideoRouteContext(
+ video: VideoTutorial,
+ routeKey: string,
+): boolean {
+ if (canonicalizeRouteKey(video.appRoute) === routeKey) {
+ return true;
+ }
+
+ return Boolean(
+ video.contextAppRoutes?.some(
+ (contextRoute) => canonicalizeRouteKey(contextRoute) === routeKey,
+ ),
+ );
+}
+
/**
* Derives the canonical route key for the current page from TanStack Router
* matches, skipping pathless shell/layout routes.
@@ -73,5 +88,5 @@ export function useResolvedVideoTutorials(): VideoTutorial[] {
if (!routeKey) return EMPTY_VIDEO_TUTORIALS;
return sections
.flatMap((s) => s.videos)
- .filter((v) => canonicalizeRouteKey(v.appRoute) === routeKey);
+ .filter((video) => matchesVideoRouteContext(video, routeKey));
}
diff --git a/web/src/shared/video-tutorials/types.ts b/web/src/shared/video-tutorials/types.ts
index 9ab031bf5..010c11128 100644
--- a/web/src/shared/video-tutorials/types.ts
+++ b/web/src/shared/video-tutorials/types.ts
@@ -7,8 +7,10 @@ export interface VideoTutorial extends PlayableVideo {
description: string;
/** In-app route this video is associated with (must start with "/"). */
appRoute: string;
+ /** Additional in-app routes where this tutorial should also be shown. */
+ contextAppRoutes?: string[];
/** External documentation URL. */
- docsUrl: string;
+ docsUrl?: string;
}
export interface VideoGuideDocLink {
diff --git a/web/tests/video-tutorials.test.ts b/web/tests/video-tutorials.test.ts
index 2ac26d2f3..904d37131 100644
--- a/web/tests/video-tutorials.test.ts
+++ b/web/tests/video-tutorials.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { parseVideoTutorials } from '../src/shared/video-tutorials/data';
+import { matchesVideoRouteContext } from '../src/shared/video-tutorials/resolved';
import {
resolveSections,
resolveVideoGuidePlacement,
@@ -89,12 +90,18 @@ describe('parseVersion', () => {
// Shared fixture helpers
// ---------------------------------------------------------------------------
-const makeVideo = (id: string, appRoute: string) => ({
+const makeVideo = (
+ id: string,
+ appRoute: string,
+ contextAppRoutes?: string[],
+ docsUrl = 'https://docs.defguard.net/test',
+) => ({
youtubeVideoId: id,
title: `Video ${id}`,
description: `Description for ${id}`,
appRoute,
- docsUrl: 'https://docs.defguard.net/test',
+ contextAppRoutes,
+ docsUrl,
});
const makeMappings = (): VideoTutorialsMappings => ({
@@ -271,6 +278,30 @@ describe('resolveSections', () => {
});
});
+// ---------------------------------------------------------------------------
+// matchesVideoRouteContext
+// ---------------------------------------------------------------------------
+
+describe('matchesVideoRouteContext', () => {
+ it('should match the primary appRoute', () => {
+ const video = makeVideo('abcDEFghiJK', '/users');
+
+ expect(matchesVideoRouteContext(video, '/users')).toBe(true);
+ });
+
+ it('should match a contextAppRoutes entry', () => {
+ const video = makeVideo('abcDEFghiJK', '/users', ['/groups', '/settings/']);
+
+ expect(matchesVideoRouteContext(video, '/settings')).toBe(true);
+ });
+
+ it('should return false when neither appRoute nor contextAppRoutes match', () => {
+ const video = makeVideo('abcDEFghiJK', '/users', ['/groups']);
+
+ expect(matchesVideoRouteContext(video, '/settings')).toBe(false);
+ });
+});
+
// ---------------------------------------------------------------------------
// resolveVideoGuidePlacement
// ---------------------------------------------------------------------------
@@ -523,6 +554,63 @@ describe('parseVideoTutorials', () => {
);
});
+ it('should accept missing docsUrl', () => {
+ const raw = {
+ versions: {
+ '2.2': {
+ sections: [
+ {
+ name: 'Test',
+ videos: [
+ {
+ youtubeVideoId: 'abcDEFghiJK',
+ title: 'Test video',
+ description: 'A test description',
+ appRoute: '/users',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ };
+
+ const result = parseVideoTutorials(raw);
+
+ expect(result['2.2'].sections[0].videos[0].docsUrl).toBeUndefined();
+ });
+
+ it('should accept contextAppRoutes when present', () => {
+ const raw = {
+ versions: {
+ '2.2': {
+ sections: [
+ {
+ name: 'Test',
+ videos: [
+ {
+ youtubeVideoId: 'abcDEFghiJK',
+ title: 'Test video',
+ description: 'A test description',
+ appRoute: '/users',
+ contextAppRoutes: ['/groups', '/settings/'],
+ docsUrl: 'https://docs.defguard.net/users',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ };
+
+ const result = parseVideoTutorials(raw);
+
+ expect(result['2.2'].sections[0].videos[0].contextAppRoutes).toEqual([
+ '/groups',
+ '/settings/',
+ ]);
+ });
+
it('should reject an invalid youtubeVideoId (not 11 chars)', () => {
const raw = {
versions: {
@@ -619,6 +707,58 @@ describe('parseVideoTutorials', () => {
expect(() => parseVideoTutorials(raw)).toThrow();
});
+ it('should reject contextAppRoutes entries missing a leading slash', () => {
+ const raw = {
+ versions: {
+ '2.2': {
+ sections: [
+ {
+ name: 'Test',
+ videos: [
+ {
+ youtubeVideoId: 'abcDEFghiJK',
+ title: 'Title',
+ description: 'Desc',
+ appRoute: '/users',
+ contextAppRoutes: ['groups'],
+ docsUrl: 'https://docs.defguard.net',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ };
+
+ expect(() => parseVideoTutorials(raw)).toThrow();
+ });
+
+ it('should reject an empty contextAppRoutes array', () => {
+ const raw = {
+ versions: {
+ '2.2': {
+ sections: [
+ {
+ name: 'Test',
+ videos: [
+ {
+ youtubeVideoId: 'abcDEFghiJK',
+ title: 'Title',
+ description: 'Desc',
+ appRoute: '/users',
+ contextAppRoutes: [],
+ docsUrl: 'https://docs.defguard.net',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ };
+
+ expect(() => parseVideoTutorials(raw)).toThrow();
+ });
+
it('should reject an invalid docsUrl', () => {
const raw = {
versions: {
@@ -698,6 +838,37 @@ describe('parseVideoTutorials', () => {
).toBeUndefined();
});
+ it('should preserve contextAppRoutes while stripping unknown video fields', () => {
+ const raw = {
+ versions: {
+ '2.2': {
+ sections: [
+ {
+ name: 'Test',
+ videos: [
+ {
+ youtubeVideoId: 'abcDEFghiJK',
+ title: 'Test',
+ description: 'Desc',
+ appRoute: '/users',
+ contextAppRoutes: ['/groups'],
+ docsUrl: 'https://docs.defguard.net',
+ unknownField: 'ignored',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ };
+ const result = parseVideoTutorials(raw);
+
+ expect(result['2.2'].sections[0].videos[0].contextAppRoutes).toEqual(['/groups']);
+ expect(
+ (result['2.2'].sections[0].videos[0] as Record)['unknownField'],
+ ).toBeUndefined();
+ });
+
it('should strip unknown fields from sections', () => {
const raw = {
versions: {