diff --git a/crates/defguard_setup/src/handlers/mod.rs b/crates/defguard_setup/src/handlers/mod.rs index bad0ae0ef3..85afba4bde 100644 --- a/crates/defguard_setup/src/handlers/mod.rs +++ b/crates/defguard_setup/src/handlers/mod.rs @@ -2,3 +2,4 @@ pub mod auto_wizard; pub mod initial_wizard; pub mod migration; pub mod session_info; +pub mod version; diff --git a/crates/defguard_setup/src/handlers/version.rs b/crates/defguard_setup/src/handlers/version.rs new file mode 100644 index 0000000000..0888fa6566 --- /dev/null +++ b/crates/defguard_setup/src/handlers/version.rs @@ -0,0 +1,14 @@ +use axum::{Extension, Json}; +use semver::Version; +use serde::Serialize; + +#[derive(Serialize)] +pub struct VersionResponse { + version: String, +} + +pub async fn get_version(Extension(version): Extension) -> Json { + Json(VersionResponse { + version: version.to_string(), + }) +} diff --git a/crates/defguard_setup/src/migration.rs b/crates/defguard_setup/src/migration.rs index ae2f50cbcb..0f8b9cf359 100644 --- a/crates/defguard_setup/src/migration.rs +++ b/crates/defguard_setup/src/migration.rs @@ -20,7 +20,6 @@ use defguard_core::{ grpc::GatewayEvent, handle_404, handlers::{ - app_info::get_app_info, auth::{ authenticate, email_mfa_code, email_mfa_enable, email_mfa_init, logout, mfa_disable, mfa_enable, recovery_code, request_email_mfa_code, totp_code, totp_enable, totp_secret, @@ -30,7 +29,6 @@ use defguard_core::{ resource_display::get_locations_display, session_info::get_session_info, settings::{get_settings, get_settings_essentials, patch_settings}, - user::me, wireguard::{count_networks, list_networks}, }, health_check, @@ -52,6 +50,7 @@ use crate::handlers::{ finish_setup, get_migration_state, migration_set_external_url_settings, migration_set_internal_url_settings, update_migration_state, }, + version::get_version, }; /// FIXME: This is a workaround which enables us to reuse the same API handlers @@ -104,8 +103,7 @@ pub fn build_migration_webapp( "/api/v1", Router::new() .route("/health", get(health_check)) - .route("/info", get(get_app_info)) - .route("/me", get(me)) + .route("/version", get(get_version)) .route("/session-info", get(get_session_info)) .route("/settings_essentials", get(get_settings_essentials)) .route("/settings", get(get_settings).patch(patch_settings)) diff --git a/crates/defguard_setup/src/setup_server.rs b/crates/defguard_setup/src/setup_server.rs index 5ee6afa5d2..15c549fbd8 100644 --- a/crates/defguard_setup/src/setup_server.rs +++ b/crates/defguard_setup/src/setup_server.rs @@ -36,6 +36,7 @@ use crate::handlers::{ setup_login, setup_session, upload_ca, }, session_info::get_session_info, + version::get_version, }; pub fn build_setup_webapp( @@ -54,6 +55,7 @@ pub fn build_setup_webapp( "/api/v1", Router::<()>::new() .route("/health", get(health_check)) + .route("/version", get(get_version)) .route("/settings_essentials", get(get_settings_essentials)) .route("/session-info", get(get_session_info)) .route("/network/display", get(get_locations_display)) diff --git a/web/src/pages/SetupPage/autoAdoption/AutoAdoptionSetupPage.tsx b/web/src/pages/SetupPage/autoAdoption/AutoAdoptionSetupPage.tsx index cec07481d2..b5fb3d4e76 100644 --- a/web/src/pages/SetupPage/autoAdoption/AutoAdoptionSetupPage.tsx +++ b/web/src/pages/SetupPage/autoAdoption/AutoAdoptionSetupPage.tsx @@ -262,6 +262,7 @@ export const AutoAdoptionSetupPage = () => { subtitle={m.initial_setup_auto_adoption_wizard_subtitle()} title={m.initial_setup_auto_adoption_wizard_title()} steps={stepsConfig} + videoGuidePlacementKey="autoAdoptionWizard" isOnWelcomePage={!isAutoAdoptionFlowStarted} welcomePageConfig={{ title: m.initial_setup_auto_adoption_welcome_title(), diff --git a/web/src/pages/SetupPage/initial/SetupPage.tsx b/web/src/pages/SetupPage/initial/SetupPage.tsx index 2aae3a7886..63745ed81d 100644 --- a/web/src/pages/SetupPage/initial/SetupPage.tsx +++ b/web/src/pages/SetupPage/initial/SetupPage.tsx @@ -159,6 +159,7 @@ export const SetupPage = () => { title={m.initial_setup_wizard_title()} steps={stepsConfig} id="setup-wizard" + videoGuidePlacementKey="initialSetupWizard" isOnWelcomePage={isOnWelcomePage} welcomePageConfig={{ title: m.initial_setup_welcome_title(), diff --git a/web/src/routes/_wizard/migration/index.tsx b/web/src/routes/_wizard/migration/index.tsx index fe5b8e4903..2c241ca42e 100644 --- a/web/src/routes/_wizard/migration/index.tsx +++ b/web/src/routes/_wizard/migration/index.tsx @@ -4,26 +4,14 @@ import { MigrationWizardPage } from '../../../pages/MigrationWizardPage/Migratio import { useMigrationWizardStore } from '../../../pages/MigrationWizardPage/store/useMigrationWizardStore'; import { ActiveWizard } from '../../../shared/api/types'; import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; -import { AppInfoProvider } from '../../../shared/providers/AppInfoProvider'; -import { AppUserProvider } from '../../../shared/providers/AppUserProvider'; import { getMigrationStateQueryOptions, getSessionInfoQueryOptions, getSettingsQueryOptions, } from '../../../shared/query'; -const MigrationWizardRoute = () => { - return ( - - - - - - ); -}; - export const Route = createFileRoute('/_wizard/migration/')({ - component: MigrationWizardRoute, + component: MigrationWizardPage, pendingComponent: AppLoaderPage, beforeLoad: async ({ context }) => { const sessionInfo = (await context.queryClient.fetchQuery(getSessionInfoQueryOptions)) diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index f445185d39..6d4819df69 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -115,6 +115,7 @@ import type { UserProfileResponse, ValidateDeviceIpsRequest, ValidateIpAssignmentRequest, + VersionResponse, WebauthnLoginStartResponse, WebauthnRegisterFinishRequest, WebauthnRegisterStartResponse, @@ -191,6 +192,7 @@ const api = { }, app: { info: () => client.get('/info'), + version: () => client.get('/version'), updates: () => client.get('/updates'), }, user: { @@ -565,7 +567,6 @@ const api = { getSessionInfo: () => client.get(`/session-info`), getActivityLog: (data?: ActivityLogRequestParams) => fetchPage(`/activity_log`, data), - info: () => client.get('/info'), getLicenseInfo: () => client.get(`/enterprise_info`), support: { getSupportData: () => client.get('/support/configuration'), diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index d49056fe0a..af5b755901 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -488,6 +488,10 @@ export interface ApplicationInfo { ldap_info: LdapInfo; } +export interface VersionResponse { + version: string; +} + export interface UpdateInfo { version: string; release_date: string; diff --git a/web/src/shared/components/wizard/WizardPage/WizardPage.tsx b/web/src/shared/components/wizard/WizardPage/WizardPage.tsx index ae316521c3..afbbf70efd 100644 --- a/web/src/shared/components/wizard/WizardPage/WizardPage.tsx +++ b/web/src/shared/components/wizard/WizardPage/WizardPage.tsx @@ -41,7 +41,7 @@ export const WizardPage = ({ ...containerProps }: Props) => { const activeStep = steps[activeStepId]; - const videoGuide = useWizardVideoGuidePlacement(videoGuidePlacementKey); + const videoGuide = useWizardVideoGuidePlacement(videoGuidePlacementKey, activeStepId); const visibleSteps = useMemo( () => diff --git a/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx b/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx index 446a89a857..86dc4573a0 100644 --- a/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx +++ b/web/src/shared/components/wizard/WizardVideoGuide/WizardVideoGuide.tsx @@ -18,55 +18,82 @@ type Props = { export const WizardVideoGuide = ({ videoGuide }: Props) => { const [isVideoOpen, setIsVideoOpen] = useState(false); + const video = videoGuide.video; + const hasDocs = Boolean(videoGuide.docs?.length); + + if (!video && !hasDocs) { + return null; + } return ( <>
-
- {m.migration_wizard_support_video_guide_helper()} - - {m.migration_wizard_support_video_guide()} - -
- -
- - -
- - - {m.migration_wizard_support_related_documentation()} - -
- -
- - {videoGuide.docsTitle} - -
+ +
+ + {video?.title} + +
+ + + )} + + {video && hasDocs && } + + {hasDocs && ( + <> +
+ + + {m.migration_wizard_support_related_documentation()} + +
+ +
+ {videoGuide.docs?.map((doc) => ( +
+ + {doc.docsTitle} + +
+ ))} +
+ + )} + - setIsVideoOpen(false)} - afterClose={() => setIsVideoOpen(false)} - /> + {video && ( + setIsVideoOpen(false)} + afterClose={() => setIsVideoOpen(false)} + /> + )} ); }; diff --git a/web/src/shared/components/wizard/WizardVideoGuide/style.scss b/web/src/shared/components/wizard/WizardVideoGuide/style.scss index b2aed5220d..f121751a87 100644 --- a/web/src/shared/components/wizard/WizardVideoGuide/style.scss +++ b/web/src/shared/components/wizard/WizardVideoGuide/style.scss @@ -31,13 +31,20 @@ } .doc-card { + display: flex; + flex-direction: column; padding: var(--spacing-lg); border: var(--border-1) solid var(--border-default); border-radius: var(--radius-lg); background-color: var(--bg-default); + gap: var(--spacing-md); .external-link { font: var(--t-body-sm-400); } } + + .doc-link-row { + display: flex; + } } diff --git a/web/src/shared/components/wizard/types.ts b/web/src/shared/components/wizard/types.ts index f42de4f0da..db27a987cb 100644 --- a/web/src/shared/components/wizard/types.ts +++ b/web/src/shared/components/wizard/types.ts @@ -3,7 +3,7 @@ import type { HTMLProps } from 'react'; export interface WizardPageConfig { title: string; subtitle: string; - activeStep: string | number; + activeStep: string; steps: WizardPageStepsConfig; videoGuidePlacementKey?: string; relatedDocs?: WizardDocsLink[]; @@ -17,7 +17,7 @@ export interface WizardDocsLink { } export interface WizardPageStep { - id: number | string; + id: string; order: number; label: string; description?: string; diff --git a/web/src/shared/query.ts b/web/src/shared/query.ts index 627f6916f5..60757406f3 100644 --- a/web/src/shared/query.ts +++ b/web/src/shared/query.ts @@ -271,6 +271,15 @@ export const getSessionInfoQueryOptions = queryOptions({ refetchOnWindowFocus: false, }); +export const getVersionQueryOptions = queryOptions({ + queryFn: api.app.version, + queryKey: ['version'], + select: (resp) => resp.data.version, + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: false, +}); + export const getSettingsEssentialsQueryOptions = queryOptions({ queryFn: api.settings.getSettingsEssentials, queryKey: ['settings_essentials'], diff --git a/web/src/shared/video-tutorials/README.md b/web/src/shared/video-tutorials/README.md index 8e1f11f762..4f08e91d9b 100644 --- a/web/src/shared/video-tutorials/README.md +++ b/web/src/shared/video-tutorials/README.md @@ -4,14 +4,15 @@ The video tutorials module displays YouTube-based help content sourced from the update service. It now powers two kinds of UI: - route-bound tutorials inside the authenticated app shell -- placement-specific help content, currently the migration wizard sidebar block +- placement-specific help content in wizard sidebars The configuration is fetched as static JSON from `https://pkgs.defguard.net/api/content/video-tutorials` by default. Route-based tutorials are mounted in `src/routes/_authorized/_default.tsx` and -remain available across the authenticated layout. Placement-based migration -content is rendered inside the migration wizard flow under `/_wizard/migration`. +remain available across the authenticated layout. Placement-based content is +rendered inside wizard step layouts under `/_wizard/migration` and +`/_wizard/setup`. A launcher button (`NavTutorialsButton`) is shown in the navigation only when the resolved app version contains at least one tutorial section with videos. @@ -25,9 +26,10 @@ when at least one route-matched video is available for the current page. Clicking it opens a floating list of video cards with thumbnails and titles. Clicking on a specific card opens a modal with an embedded YouTube player. -The migration wizard uses the same JSON source, but reads a dedicated -`placements.migrationWizard` entry from the resolved version and renders a -thumbnail plus documentation card in the sidebar. +Wizards use the same JSON source, but read a dedicated placement entry from the +resolved version and render a thumbnail plus documentation card in the sidebar. +Current placement keys are `migrationWizard`, `initialSetupWizard`, and +`autoAdoptionWizard`. While a video is loading a skeleton placeholder is shown; if the video fails to load within 8 seconds, a "Video unavailable" message is displayed instead with @@ -159,16 +161,84 @@ endpoint on the same server. ] } ], - "placements": { - "migrationWizard": { - "youtubeVideoId": "xyz987GHI12", - "title": "Migration wizard guide", - "docsTitle": "Defguard Configuration Guide", - "docsUrl": "https://docs.defguard.net/migration" + "placements": { + "migrationWizard": { + "default": { + "video": { + "youtubeVideoId": "xyz987GHI12", + "title": "Migration wizard guide" + }, + "docs": [ + { + "docsTitle": "Defguard Configuration Guide", + "docsUrl": "https://docs.defguard.net/migration" + } + ] + }, + "steps": { + "ca": { + "video": { + "youtubeVideoId": "aaaBBBccc11", + "title": "Certificate authority guide" + }, + "docs": [ + { + "docsTitle": "Certificate authority documentation", + "docsUrl": "https://docs.defguard.net/migration/ca" + }, + { + "docsTitle": "Certificate authority troubleshooting", + "docsUrl": "https://docs.defguard.net/migration/ca/troubleshooting" + } + ] + } + } + }, + "initialSetupWizard": { + "default": { + "video": { + "youtubeVideoId": "bbbCCCddd22", + "title": "Initial setup guide" + }, + "docs": [ + { + "docsTitle": "Initial setup documentation", + "docsUrl": "https://docs.defguard.net/setup" + } + ] + }, + "steps": { + "adminUser": { + "docs": [ + { + "docsTitle": "Admin user documentation", + "docsUrl": "https://docs.defguard.net/setup/admin-user" + } + ] + } + } + }, + "autoAdoptionWizard": { + "default": { + "docs": [ + { + "docsTitle": "Auto adoption documentation", + "docsUrl": "https://docs.defguard.net/auto-adoption" + } + ] + }, + "steps": { + "vpnSettings": { + "video": { + "youtubeVideoId": "eeeFFFggg55", + "title": "VPN settings guide" + } + } + } + } } } - } - } + } } ``` @@ -184,14 +254,48 @@ Route tutorial video fields: | `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. | -Migration placement fields: +Wizard placement fields: | Field | Required | Description | |---|---|---| -| `youtubeVideoId` | Yes | Used to render the thumbnail and embedded player in the migration sidebar. | +| `video` | No | Optional video block shown in the wizard sidebar. | +| `docs` | No | Optional non-empty array of documentation links shown one under another in the wizard sidebar. | + +`video` fields: + +| Field | Required | Description | +|---|---|---| +| `youtubeVideoId` | Yes | Used to render the thumbnail and embedded player in the wizard sidebar. | | `title` | Yes | Displayed next to the thumbnail and used as the iframe title. | -| `docsTitle` | Yes | Text shown in the migration documentation card. | -| `docsUrl` | Yes | External URL opened from the migration documentation card. | + +`docs` item fields: + +| Field | Required | Description | +|---|---|---| +| `docsTitle` | Yes | Text shown in the wizard documentation card. | +| `docsUrl` | Yes | External URL opened from the wizard documentation card. | + +Wizard placement structure: + +- `default`: optional fallback guide used when the current step has no dedicated entry +- `steps`: optional map of step key to guide data + +Each placement object must define at least one of `video` or `docs`. +If `docs` is present, it must contain at least one item. + +`placements` is a string-keyed record, so placement keys are not hardcoded in the +schema. The application currently uses these keys: + +- `migrationWizard` +- `initialSetupWizard` +- `autoAdoptionWizard` + +The keys in each placement's `steps` map should match frontend step IDs. +Examples: + +- Migration: `general`, `ca`, `caSummary`, `edgeDeployment`, `edge`, `edgeAdoption`, `internalUrlSettings`, `internalUrlSslConfig`, `externalUrlSettings`, `externalUrlSslConfig`, `confirmation`, `welcome` +- Initial setup: `adminUser`, `generalConfig`, `certificateAuthority`, `certificateAuthoritySummary`, `edgeDeploy`, `edgeComponent`, `edgeAdoption`, `internalUrlSettings`, `internalUrlSslConfig`, `externalUrlSettings`, `externalUrlSslConfig`, `confirmation` +- Auto adoption: `adminUser`, `internalUrlSettings`, `internalUrlSslConfig`, `externalUrlSettings`, `externalUrlSslConfig`, `vpnSettings`, `mfaSetup`, `summary` ### Section structure @@ -200,6 +304,8 @@ Each version value is an object with: - `sections`: ordered route-based tutorial sections - `placements`: optional surface-specific content entries +`sections` is required for every version entry, but it may be an empty array. + Sections are displayed in the order they appear in the array; videos within a section are displayed in their array order. @@ -225,11 +331,21 @@ Rules: Consumers built on top of that selected version: - `resolveSections()` returns `selectedVersion.sections` -- `resolveVideoGuidePlacement()` returns `selectedVersion.placements?[placementKey]` +- `resolveVideoGuidePlacement()` returns a step-aware placement from the selected version There is no fallback to older versions once a newer eligible version has been -selected. If `2.2` is selected and omits `placements.migrationWizard`, the -migration wizard shows nothing even if `2.1` defined that placement. +selected. If `2.2` is selected and omits a placement key, that wizard shows +nothing even if `2.1` defined that placement. + +Within the selected version, wizard guide resolution uses this fallback order: + +1. `placements[placementKey].steps[currentStep]` +2. `placements[placementKey].default` +3. `null` + +Fallback to `default` only happens when the whole step entry is missing. +If a step entry exists with only `docs` or only `video`, it is used as-is and is +not merged with `default`. --- diff --git a/web/src/shared/video-tutorials/data.ts b/web/src/shared/video-tutorials/data.ts index d18e076269..1e498173e1 100644 --- a/web/src/shared/video-tutorials/data.ts +++ b/web/src/shared/video-tutorials/data.ts @@ -46,26 +46,46 @@ const sectionSchema = z }) .strip(); -const migrationWizardPlacementSchema = z +const placementSchema = z .object({ - youtubeVideoId: z - .string() - .regex( - /^[A-Za-z0-9_-]{11}$/, - 'youtubeVideoId must be exactly 11 alphanumeric/-/_ chars', - ), - title: z.string().min(1, 'title must be non-empty'), - docsTitle: z.string().min(1, 'docsTitle must be non-empty'), - docsUrl: z.string().url('docsUrl must be a valid URL'), + video: z + .object({ + youtubeVideoId: z + .string() + .regex( + /^[A-Za-z0-9_-]{11}$/, + 'youtubeVideoId must be exactly 11 alphanumeric/-/_ chars', + ), + title: z.string().min(1, 'title must be non-empty'), + }) + .strip() + .optional(), + docs: z + .array( + z + .object({ + docsTitle: z.string().min(1, 'docsTitle must be non-empty'), + docsUrl: z.string().url('docsUrl must be a valid URL'), + }) + .strip(), + ) + .min(1, 'docs must contain at least one item') + .optional(), + }) + .refine((value) => Boolean(value.video || value.docs), { + message: 'placement must define at least one of video or docs', }) .strip(); -const placementsSchema = z +const placementGroupSchema = z .object({ - migrationWizard: migrationWizardPlacementSchema.optional(), + default: placementSchema.optional(), + steps: z.record(z.string(), placementSchema).optional(), }) .strip(); +const placementsSchema = z.record(z.string(), placementGroupSchema); + const versionEntrySchema = z .object({ sections: z.array(sectionSchema), diff --git a/web/src/shared/video-tutorials/resolved.tsx b/web/src/shared/video-tutorials/resolved.tsx index efa2591706..a9a65589b0 100644 --- a/web/src/shared/video-tutorials/resolved.tsx +++ b/web/src/shared/video-tutorials/resolved.tsx @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { useMatches } from '@tanstack/react-router'; import { useApp } from '../hooks/useApp'; -import { videoTutorialsQueryOptions } from '../query'; +import { getVersionQueryOptions, videoTutorialsQueryOptions } from '../query'; import { resolveSections, resolveVideoGuidePlacement } from './resolver'; import { canonicalizeRouteKey } from './route-key'; import type { VideoGuidePlacement, VideoTutorial, VideoTutorialsSection } from './types'; @@ -45,12 +45,20 @@ export function useVideoTutorialsSections(): VideoTutorialsSection[] { export function useWizardVideoGuidePlacement( placementKey: string | undefined, + stepKey?: string, ): VideoGuidePlacement | null { - const { data } = useQuery(videoTutorialsQueryOptions); - const appVersion = useApp((s) => s.appInfo.version); + const isEnabled = Boolean(placementKey); + const { data } = useQuery({ + ...videoTutorialsQueryOptions, + enabled: isEnabled, + }); + const { data: appVersion } = useQuery({ + ...getVersionQueryOptions, + enabled: isEnabled, + }); if (!placementKey || !data || !appVersion) return EMPTY_VIDEO_GUIDE_PLACEMENT; - return resolveVideoGuidePlacement(data, appVersion, placementKey); + return resolveVideoGuidePlacement(data, appVersion, placementKey, stepKey); } /** diff --git a/web/src/shared/video-tutorials/resolver.ts b/web/src/shared/video-tutorials/resolver.ts index 7f5ad3bb08..7dfc19a100 100644 --- a/web/src/shared/video-tutorials/resolver.ts +++ b/web/src/shared/video-tutorials/resolver.ts @@ -53,10 +53,22 @@ export function resolveVideoGuidePlacement( mappings: VideoTutorialsMappings, appVersionRaw: string, placementKey: string | undefined, + stepKey?: string, ): VideoGuidePlacement | null { if (!placementKey) { return null; } - return resolveVersion(mappings, appVersionRaw)?.placements?.[placementKey] ?? null; + const placementGroup = resolveVersion(mappings, appVersionRaw)?.placements?.[ + placementKey + ]; + if (!placementGroup) { + return null; + } + + if (stepKey && placementGroup.steps?.[stepKey]) { + return placementGroup.steps[stepKey] ?? null; + } + + return placementGroup.default ?? null; } diff --git a/web/src/shared/video-tutorials/types.ts b/web/src/shared/video-tutorials/types.ts index 0d2687ce85..9ab031bf53 100644 --- a/web/src/shared/video-tutorials/types.ts +++ b/web/src/shared/video-tutorials/types.ts @@ -11,20 +11,30 @@ export interface VideoTutorial extends PlayableVideo { docsUrl: string; } -export interface VideoGuidePlacement extends PlayableVideo { - /** Documentation link title shown in the migration wizard card. */ +export interface VideoGuideDocLink { + /** Documentation link title shown in the wizard card. */ docsTitle: string; /** External documentation URL. */ docsUrl: string; } +export interface VideoGuidePlacement { + video?: PlayableVideo; + docs?: VideoGuideDocLink[]; +} + +export interface VideoGuidePlacementGroup { + default?: VideoGuidePlacement; + steps?: Record; +} + export interface VideoTutorialsSection { name: string; videos: VideoTutorial[]; } export interface VideoTutorialsPlacements { - [key: string]: VideoGuidePlacement | undefined; + [key: string]: VideoGuidePlacementGroup | undefined; } export interface VideoTutorialsVersionEntry { diff --git a/web/tests/video-tutorials.test.ts b/web/tests/video-tutorials.test.ts index 2b0d5e31a9..2ac26d2f36 100644 --- a/web/tests/video-tutorials.test.ts +++ b/web/tests/video-tutorials.test.ts @@ -119,10 +119,88 @@ const makeMappings = (): VideoTutorialsMappings => ({ ], placements: { migrationWizard: { - youtubeVideoId: 'abcDEFghiJK', - title: 'Migration wizard guide', - docsTitle: 'Migration wizard documentation', - docsUrl: 'https://docs.defguard.net/migration', + default: { + video: { + youtubeVideoId: 'abcDEFghiJK', + title: 'Migration wizard guide', + }, + docs: [ + { + docsTitle: 'Migration wizard documentation', + docsUrl: 'https://docs.defguard.net/migration', + }, + ], + }, + steps: { + ca: { + video: { + youtubeVideoId: 'caGuide220a', + title: 'Certificate authority guide', + }, + docs: [ + { + docsTitle: 'Certificate authority documentation', + docsUrl: 'https://docs.defguard.net/migration/ca', + }, + ], + }, + }, + }, + initialSetupWizard: { + default: { + video: { + youtubeVideoId: 'setGuide220', + title: 'Initial setup guide', + }, + docs: [ + { + docsTitle: 'Initial setup documentation', + docsUrl: 'https://docs.defguard.net/setup', + }, + ], + }, + steps: { + adminUser: { + video: { + youtubeVideoId: 'admGuide220', + title: 'Admin user guide', + }, + docs: [ + { + docsTitle: 'Admin user documentation', + docsUrl: 'https://docs.defguard.net/setup/admin-user', + }, + ], + }, + }, + }, + autoAdoptionWizard: { + default: { + video: { + youtubeVideoId: 'autoGuid220', + title: 'Auto adoption guide', + }, + docs: [ + { + docsTitle: 'Auto adoption documentation', + docsUrl: 'https://docs.defguard.net/auto-adoption', + }, + ], + }, + steps: { + vpnSettings: { + video: { + youtubeVideoId: 'vpnGuide220', + title: 'VPN settings guide', + }, + docs: [ + { + docsTitle: 'VPN settings documentation', + docsUrl: 'https://docs.defguard.net/auto-adoption/vpn-settings', + }, + ], + }, + }, }, }, }, @@ -137,7 +215,12 @@ describe('resolveVersion', () => { const result = resolveVersion(makeMappings(), '2.3.0'); expect(result?.sections).toHaveLength(2); - expect(result?.placements?.migrationWizard?.youtubeVideoId).toBe('abcDEFghiJK'); + expect(result?.placements?.migrationWizard?.default?.video?.youtubeVideoId).toBe( + 'abcDEFghiJK', + ); + expect(result?.placements?.initialSetupWizard?.default?.video?.youtubeVideoId).toBe( + 'setGuide220', + ); }); it('should return null for an unparseable app version', () => { @@ -193,10 +276,21 @@ describe('resolveSections', () => { // --------------------------------------------------------------------------- describe('resolveVideoGuidePlacement', () => { - it('should return the placement from the newest eligible version', () => { - const result = resolveVideoGuidePlacement(makeMappings(), '2.3.0', 'migrationWizard'); + it('should return the step-specific placement from the newest eligible version', () => { + const result = resolveVideoGuidePlacement(makeMappings(), '2.3.0', 'migrationWizard', 'ca'); - expect(result?.title).toBe('Migration wizard guide'); + expect(result?.video?.title).toBe('Certificate authority guide'); + }); + + it('should fall back to the default placement when step-specific entry is missing', () => { + const result = resolveVideoGuidePlacement( + makeMappings(), + '2.3.0', + 'migrationWizard', + 'general', + ); + + expect(result?.video?.title).toBe('Migration wizard guide'); }); it('should not fall back to an older placement once a newer eligible version is selected', () => { @@ -205,10 +299,18 @@ describe('resolveVideoGuidePlacement', () => { sections: [], placements: { migrationWizard: { - youtubeVideoId: 'abcDEFghiJK', - title: 'Migration wizard guide', - docsTitle: 'Migration wizard documentation', - docsUrl: 'https://docs.defguard.net/migration', + default: { + video: { + youtubeVideoId: 'abcDEFghiJK', + title: 'Migration wizard guide', + }, + docs: [ + { + docsTitle: 'Migration wizard documentation', + docsUrl: 'https://docs.defguard.net/migration', + }, + ], + }, }, }, }, @@ -217,7 +319,7 @@ describe('resolveVideoGuidePlacement', () => { }, }; - const result = resolveVideoGuidePlacement(mappings, '2.2', 'migrationWizard'); + const result = resolveVideoGuidePlacement(mappings, '2.2', 'migrationWizard', 'ca'); expect(result).toBeNull(); }); @@ -229,11 +331,105 @@ describe('resolveVideoGuidePlacement', () => { }, }; - expect(resolveVideoGuidePlacement(mappings, '2.2', 'migrationWizard')).toBeNull(); + expect(resolveVideoGuidePlacement(mappings, '2.2', 'migrationWizard', 'ca')).toBeNull(); + }); + + it('should return null when neither default nor step-specific placement exists', () => { + const mappings: VideoTutorialsMappings = { + '2.2': { + sections: [], + placements: { + migrationWizard: { + steps: {}, + }, + }, + }, + }; + + expect(resolveVideoGuidePlacement(mappings, '2.2', 'migrationWizard', 'ca')).toBeNull(); }); it('should return null for an unsupported placement key', () => { - expect(resolveVideoGuidePlacement(makeMappings(), '2.3.0', 'unknownPlacement')).toBeNull(); + expect( + resolveVideoGuidePlacement(makeMappings(), '2.3.0', 'unknownPlacement', 'ca'), + ).toBeNull(); + }); + + it('should resolve a step-specific placement for initial setup wizard', () => { + const result = resolveVideoGuidePlacement( + makeMappings(), + '2.3.0', + 'initialSetupWizard', + 'adminUser', + ); + + expect(result?.video?.title).toBe('Admin user guide'); + }); + + it('should resolve a default placement for auto adoption wizard when step is missing', () => { + const result = resolveVideoGuidePlacement( + makeMappings(), + '2.3.0', + 'autoAdoptionWizard', + 'summary', + ); + + expect(result?.video?.title).toBe('Auto adoption guide'); + }); + + it('should resolve a step-specific placement for auto adoption wizard', () => { + const result = resolveVideoGuidePlacement( + makeMappings(), + '2.3.0', + 'autoAdoptionWizard', + 'vpnSettings', + ); + + expect(result?.video?.title).toBe('VPN settings guide'); + }); + + it('should not merge a step placement with the default placement', () => { + const mappings: VideoTutorialsMappings = { + '2.2': { + sections: [], + placements: { + migrationWizard: { + default: { + video: { + youtubeVideoId: 'abcDEFghiJK', + title: 'Migration wizard guide', + }, + docs: [ + { + docsTitle: 'Migration wizard documentation', + docsUrl: 'https://docs.defguard.net/migration', + }, + ], + }, + steps: { + ca: { + docs: [ + { + docsTitle: 'Certificate authority documentation', + docsUrl: 'https://docs.defguard.net/migration/ca', + }, + ], + }, + }, + }, + }, + }, + }; + + const result = resolveVideoGuidePlacement(mappings, '2.2', 'migrationWizard', 'ca'); + + expect(result?.video).toBeUndefined(); + expect(result?.docs).toEqual([ + { + docsTitle: 'Certificate authority documentation', + docsUrl: 'https://docs.defguard.net/migration/ca', + }, + ]); }); }); @@ -260,10 +456,46 @@ const validRaw = { ], placements: { migrationWizard: { - youtubeVideoId: 'xyz987GHI12', - title: 'Migration guide', - docsTitle: 'Defguard Configuration Guide', - docsUrl: 'https://docs.defguard.net/migration', + default: { + video: { + youtubeVideoId: 'xyz987GHI12', + title: 'Migration guide', + }, + docs: [ + { + docsTitle: 'Defguard Configuration Guide', + docsUrl: 'https://docs.defguard.net/migration', + }, + ], + }, + steps: { + general: { + video: { + youtubeVideoId: 'genGuide220', + title: 'General configuration guide', + }, + docs: [ + { + docsTitle: 'General configuration documentation', + docsUrl: 'https://docs.defguard.net/migration/general', + }, + ], + }, + }, + }, + initialSetupWizard: { + default: { + video: { + youtubeVideoId: 'setGuide220', + title: 'Setup guide', + }, + docs: [ + { + docsTitle: 'Setup docs', + docsUrl: 'https://docs.defguard.net/setup', + }, + ], + }, }, }, }, @@ -277,10 +509,18 @@ describe('parseVideoTutorials', () => { expect(result['2.2'].sections).toHaveLength(1); expect(result['2.2'].sections[0].name).toBe('Identity'); expect(result['2.2'].sections[0].videos[0].youtubeVideoId).toBe('abcDEFghiJK'); - expect(result['2.2'].placements?.migrationWizard?.youtubeVideoId).toBe('xyz987GHI12'); - expect(result['2.2'].placements?.migrationWizard?.docsTitle).toBe( + expect(result['2.2'].placements?.migrationWizard?.default?.video?.youtubeVideoId).toBe( + 'xyz987GHI12', + ); + expect(result['2.2'].placements?.migrationWizard?.default?.docs?.[0]?.docsTitle).toBe( 'Defguard Configuration Guide', ); + expect(result['2.2'].placements?.migrationWizard?.steps?.general?.video?.youtubeVideoId).toBe( + 'genGuide220', + ); + expect(result['2.2'].placements?.initialSetupWizard?.default?.video?.youtubeVideoId).toBe( + 'setGuide220', + ); }); it('should reject an invalid youtubeVideoId (not 11 chars)', () => { @@ -522,17 +762,21 @@ describe('parseVideoTutorials', () => { expect(result['2.2'].sections[0].videos).toHaveLength(0); }); - it('should reject invalid migrationWizard docsUrl', () => { + it('should reject invalid placement docsUrl', () => { const raw = { versions: { '2.2': { sections: [], placements: { - migrationWizard: { - youtubeVideoId: 'xyz987GHI12', - title: 'Migration guide', - docsTitle: 'Defguard Configuration Guide', - docsUrl: 'not-a-url', + initialSetupWizard: { + default: { + docs: [ + { + docsTitle: 'Setup Guide', + docsUrl: 'not-a-url', + }, + ], + }, }, }, }, @@ -550,10 +794,20 @@ describe('parseVideoTutorials', () => { extraVersionField: 'ignored', placements: { migrationWizard: { - youtubeVideoId: 'xyz987GHI12', - title: 'Migration guide', - docsTitle: 'Defguard Configuration Guide', - docsUrl: 'https://docs.defguard.net/migration', + default: { + video: { + youtubeVideoId: 'xyz987GHI12', + title: 'Migration guide', + ignoredVideoField: 'ignored', + }, + docs: [ + { + docsTitle: 'Defguard Configuration Guide', + docsUrl: 'https://docs.defguard.net/migration', + ignoredDocsField: 'ignored', + }, + ], + }, extraPlacementField: 'ignored', }, }, @@ -567,19 +821,219 @@ describe('parseVideoTutorials', () => { expect( (result['2.2'].placements?.migrationWizard as Record)['extraPlacementField'], ).toBeUndefined(); + expect( + (result['2.2'].placements?.migrationWizard?.default?.video as Record)[ + 'ignoredVideoField' + ], + ).toBeUndefined(); + expect( + (result['2.2'].placements?.migrationWizard?.default?.docs?.[0] as Record)[ + 'ignoredDocsField' + ], + ).toBeUndefined(); }); - it('should reject an empty migrationWizard docsTitle', () => { + it('should reject an empty placement docsTitle', () => { const raw = { versions: { '2.2': { sections: [], placements: { - migrationWizard: { - youtubeVideoId: 'xyz987GHI12', - title: 'Migration guide', - docsTitle: '', - docsUrl: 'https://docs.defguard.net/migration', + autoAdoptionWizard: { + default: { + docs: [ + { + docsTitle: '', + docsUrl: 'https://docs.defguard.net/auto-adoption', + }, + ], + }, + }, + }, + }, + }, + }; + + expect(() => parseVideoTutorials(raw)).toThrow(); + }); + + it('should reject invalid generic placement step docsUrl', () => { + const raw = { + versions: { + '2.2': { + sections: [], + placements: { + autoAdoptionWizard: { + steps: { + vpnSettings: { + docs: [ + { + docsTitle: 'VPN settings guide', + docsUrl: 'not-a-url', + }, + ], + }, + }, + }, + }, + }, + }, + }; + + expect(() => parseVideoTutorials(raw)).toThrow(); + }); + + it('should accept generic placement keys', () => { + const raw = { + versions: { + '2.2': { + sections: [], + placements: { + anyWizardKey: { + default: { + video: { + youtubeVideoId: 'xyz987GHI12', + title: 'Generic guide', + }, + docs: [ + { + docsTitle: 'Generic docs', + docsUrl: 'https://docs.defguard.net/generic', + }, + ], + }, + }, + }, + }, + }, + }; + + const result = parseVideoTutorials(raw); + + expect(result['2.2'].placements?.anyWizardKey?.default?.video?.title).toBe( + 'Generic guide', + ); + }); + + it('should accept a placement with only video', () => { + const raw = { + versions: { + '2.2': { + sections: [], + placements: { + anyWizardKey: { + default: { + video: { + youtubeVideoId: 'xyz987GHI12', + title: 'Generic guide', + }, + }, + }, + }, + }, + }, + }; + + const result = parseVideoTutorials(raw); + + expect(result['2.2'].placements?.anyWizardKey?.default).toEqual({ + video: { + youtubeVideoId: 'xyz987GHI12', + title: 'Generic guide', + }, + }); + }); + + it('should accept a placement with only docs', () => { + const raw = { + versions: { + '2.2': { + sections: [], + placements: { + anyWizardKey: { + default: { + docs: [ + { + docsTitle: 'Generic docs', + docsUrl: 'https://docs.defguard.net/generic', + }, + ], + }, + }, + }, + }, + }, + }; + + const result = parseVideoTutorials(raw); + + expect(result['2.2'].placements?.anyWizardKey?.default).toEqual({ + docs: [ + { + docsTitle: 'Generic docs', + docsUrl: 'https://docs.defguard.net/generic', + }, + ], + }); + }); + + it('should accept multiple docs links in a placement', () => { + const raw = { + versions: { + '2.2': { + sections: [], + placements: { + anyWizardKey: { + default: { + docs: [ + { + docsTitle: 'Generic docs', + docsUrl: 'https://docs.defguard.net/generic', + }, + { + docsTitle: 'More docs', + docsUrl: 'https://docs.defguard.net/generic/more', + }, + ], + }, + }, + }, + }, + }, + }; + + const result = parseVideoTutorials(raw); + + expect(result['2.2'].placements?.anyWizardKey?.default?.docs).toHaveLength(2); + }); + + it('should reject an empty docs array when docs is present', () => { + const raw = { + versions: { + '2.2': { + sections: [], + placements: { + anyWizardKey: { + default: { + docs: [], + }, + }, + }, + }, + }, + }; + + expect(() => parseVideoTutorials(raw)).toThrow(); + }); + + it('should reject a placement with neither video nor docs', () => { + const raw = { + versions: { + '2.2': { + sections: [], + placements: { + anyWizardKey: { + default: {}, }, }, },