diff --git a/web/messages/en/modal.json b/web/messages/en/modal.json index 8065a6ead5..4e807a5d9d 100644 --- a/web/messages/en/modal.json +++ b/web/messages/en/modal.json @@ -203,5 +203,12 @@ "modal_delete_api_token_success": "API token deleted", "modal_delete_api_token_error": "Failed to delete API token", "modal_delete_authorized_app_success": "Application removed", - "modal_delete_authorized_app_error": "Failed to remove application" + "modal_delete_authorized_app_error": "Failed to remove application", + "modal_app_update_title": "New update available", + "modal_app_update_critical_badge": "Critical", + "modal_app_update_full_changelog": "Full Changelog", + "modal_app_update_go_to_release": "Go to release page", + "modal_app_update_dismiss": "Dismiss", + "modal_app_update_snackbar_message": "New version {version} is available", + "modal_app_update_snackbar_action": "What's new" } diff --git a/web/src/routes/_authorized.tsx b/web/src/routes/_authorized.tsx index e20eff40ad..c512a26c30 100644 --- a/web/src/routes/_authorized.tsx +++ b/web/src/routes/_authorized.tsx @@ -1,5 +1,6 @@ import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'; import { DisplayListModal } from '../shared/components/DisplayListModal/DisplayListModal'; +import { AppUpdateModal } from '../shared/components/modals/AppUpdateModal/AppUpdateModal'; import { ConfirmActionModal } from '../shared/components/modals/ConfirmActionModal/ConfirmActionModal'; import { LicenseExpiredModal } from '../shared/components/modals/license/LicenseExpiredModal/LicenseExpiredModal'; import { LicenseLimitConflictModal } from '../shared/components/modals/license/LicenseLimitConflictModal/LicenseLimitConflictModal'; @@ -64,6 +65,7 @@ function RouteComponent() { + ); diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index f084413360..e229b02446 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -97,6 +97,7 @@ import type { StartEnrollmentResponse, TestDirectorySyncResponse, TotpInitResponse, + UpdateInfo, UploadCARequest, User, UserChangePasswordRequest, @@ -175,6 +176,7 @@ const api = { }, app: { info: () => client.get('/info'), + updates: () => client.get('/updates'), }, user: { addUser: (data: AddUserRequest) => client.post('/user', data), diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 273ffc384a..d26dac199c 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -394,6 +394,15 @@ export interface ApplicationInfo { ldap_info: LdapInfo; } +export interface UpdateInfo { + version: string; + release_date: string; + release_notes_url: string; + update_url: string; + critical: boolean; + notes: string; +} + export interface WebauthnRegisterStartResponse { publicKey: PublicKeyCredentialCreationOptionsJSON; } diff --git a/web/src/shared/components/modals/AppUpdateModal/AppUpdateModal.tsx b/web/src/shared/components/modals/AppUpdateModal/AppUpdateModal.tsx new file mode 100644 index 0000000000..b85b448d29 --- /dev/null +++ b/web/src/shared/components/modals/AppUpdateModal/AppUpdateModal.tsx @@ -0,0 +1,134 @@ +import './style.scss'; + +import { useEffect, useMemo, useState } from 'react'; + +import { m } from '../../../../paraglide/messages'; +import { DISMISSED_UPDATE_KEY } from '../../../constants'; +import { AppText } from '../../../defguard-ui/components/AppText/AppText'; +import { Badge } from '../../../defguard-ui/components/Badge/Badge'; +import { Button } from '../../../defguard-ui/components/Button/Button'; +import { Divider } from '../../../defguard-ui/components/Divider/Divider'; +import { Icon } from '../../../defguard-ui/components/Icon'; +import { ModalFoundation } from '../../../defguard-ui/components/ModalFoundation/ModalFoundation'; +import { RenderMarkdown } from '../../../defguard-ui/components/RenderMarkdown/RenderMarkdown'; +import { TextStyle, ThemeSpacing, ThemeVariable } from '../../../defguard-ui/types'; +import { isPresent } from '../../../defguard-ui/utils/isPresent'; +import { + closeModal, + subscribeCloseModal, + subscribeOpenModal, +} from '../../../hooks/modalControls/modalsSubjects'; +import { ModalName } from '../../../hooks/modalControls/modalTypes'; +import type { OpenAppUpdateModal } from '../../../hooks/modalControls/types'; +import { Controls } from '../../Controls/Controls'; +import updateImage from './update-image.png'; + +const modalNameKey = ModalName.AppUpdate; + +type ModalData = OpenAppUpdateModal; + +export const AppUpdateModal = () => { + const [isOpen, setOpen] = useState(false); + const [modalData, setModalData] = useState(null); + + useEffect(() => { + const openSub = subscribeOpenModal(modalNameKey, (data) => { + setModalData(data); + setOpen(true); + }); + const closeSub = subscribeCloseModal(modalNameKey, () => setOpen(false)); + return () => { + openSub.unsubscribe(); + closeSub.unsubscribe(); + }; + }, []); + + return ( + { + setModalData(null); + }} + > + + + {isPresent(modalData) && } + + + + + + + ); +}; + +const ModalContent = ({ data }: { data: ModalData }) => { + const handleDismiss = () => { + localStorage.setItem(DISMISSED_UPDATE_KEY, data.version); + closeModal(modalNameKey); + }; + + const { subtitle, body } = useMemo(() => { + const trimmed = data.notes.trim(); + const firstBlank = trimmed.search(/\n\s*\n/); + return { + subtitle: firstBlank === -1 ? trimmed : trimmed.slice(0, firstBlank).trim(), + body: firstBlank === -1 ? '' : trimmed.slice(firstBlank).trim(), + }; + }, [data.notes]); + + return ( + <> + + {data.critical && ( + + )} + + {m.modal_app_update_title()} + + + + + + + + + + {m.modal_app_update_full_changelog()} + + + + + + + + + > + ); +}; diff --git a/web/src/shared/components/modals/AppUpdateModal/style.scss b/web/src/shared/components/modals/AppUpdateModal/style.scss new file mode 100644 index 0000000000..d2b42ca9ca --- /dev/null +++ b/web/src/shared/components/modals/AppUpdateModal/style.scss @@ -0,0 +1,108 @@ +#modals-root .app-update-modal { + background-color: var(--bg-default); + border-radius: var(--radius-xxl); + width: 100%; + max-width: 1110px; + overflow: hidden; + + & > .tracks { + display: grid; + grid-template-columns: 667fr 443fr; + height: 657px; + + & > .content-track { + box-sizing: border-box; + padding: var(--spacing-4xl); + display: flex; + flex-flow: column; + background-color: var(--bg-default); + overflow: hidden; + + .header-section { + display: flex; + flex-flow: column; + row-gap: var(--spacing-sm); + + .markdown-render p { + font: var(--t-body-primary-600); + } + } + + .changelog-link { + text-decoration: none; + display: flex; + flex-flow: row; + align-items: center; + column-gap: var(--spacing-sm); + width: fit-content; + + p { + text-decoration: underline; + } + } + + .controls { + margin-top: auto; + padding-top: var(--spacing-2xl); + } + + & > .markdown-render { + overflow-y: auto; + flex: 1 1 0; + min-height: 0; + } + + .markdown-render { + display: flex; + flex-flow: column; + row-gap: var(--spacing-sm); + + p { + font: var(--t-body-sm-500); + color: var(--fg-default); + } + + span, + li { + font: var(--t-body-sm-400); + color: var(--fg-faded); + } + + a { + color: var(--fg-action); + text-decoration: underline; + } + + ul { + display: flex; + flex-flow: column; + row-gap: var(--spacing-xs); + padding-left: var(--spacing-lg); + } + + h1, + h2, + h3 { + font: var(--t-body-sm-500); + color: var(--fg-default); + } + + h2 { + padding-top: var(--spacing-lg); + margin-top: var(--spacing-lg); + border-top: 1px solid var(--bg-active); + } + } + } + + & > .media-track { + position: relative; + overflow: hidden; + background: linear-gradient(157deg, #e6e6ff -2.15%, #8cadff 102.56%); + + img { + position: absolute; + } + } + } +} diff --git a/web/src/shared/components/modals/AppUpdateModal/update-image.png b/web/src/shared/components/modals/AppUpdateModal/update-image.png new file mode 100644 index 0000000000..d820565b58 Binary files /dev/null and b/web/src/shared/components/modals/AppUpdateModal/update-image.png differ diff --git a/web/src/shared/constants.ts b/web/src/shared/constants.ts index f6e8afa748..a7320f1424 100644 --- a/web/src/shared/constants.ts +++ b/web/src/shared/constants.ts @@ -45,3 +45,5 @@ export const licenseGracePeriodDays = 14; export const edgeDefaultGrpcPort = 50051; export const gatewayDefaultGrpcPort = 50066; + +export const DISMISSED_UPDATE_KEY = 'dismissed-update-version'; diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index a9177dfb6e..95cb4f0d9c 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit a9177dfb6eec895bb3e20e91990abf35e440cd56 +Subproject commit 95cb4f0d9cd54cccce825a106fbb70d9c041d093 diff --git a/web/src/shared/hooks/modalControls/modalTypes.ts b/web/src/shared/hooks/modalControls/modalTypes.ts index 02ca861960..04012a971b 100644 --- a/web/src/shared/hooks/modalControls/modalTypes.ts +++ b/web/src/shared/hooks/modalControls/modalTypes.ts @@ -4,6 +4,7 @@ import type { OpenAddApiTokenModal, OpenAddLocationModal, OpenAddNetworkDeviceModal, + OpenAppUpdateModal, OpenAssignUserDeviceIPModal, OpenAssignUserIPModal, OpenAssignUsersToGroupsModal, @@ -67,6 +68,7 @@ export const ModalName = { AssignUserIP: 'assignUserIP', AssignUserDeviceIP: 'assignUserDeviceIP', ConfirmAction: 'confirmAction', + AppUpdate: 'appUpdate', } as const; export type ModalNameValue = (typeof ModalName)[keyof typeof ModalName]; @@ -216,6 +218,10 @@ const modalOpenArgsSchema = z.discriminatedUnion('name', [ name: z.literal(ModalName.ConfirmAction), data: z.custom(), }), + z.object({ + name: z.literal(ModalName.AppUpdate), + data: z.custom(), + }), ]); export type ModalOpenEvent = z.infer; diff --git a/web/src/shared/hooks/modalControls/types.ts b/web/src/shared/hooks/modalControls/types.ts index 67e57b4ca9..76b3c7d3c0 100644 --- a/web/src/shared/hooks/modalControls/types.ts +++ b/web/src/shared/hooks/modalControls/types.ts @@ -12,6 +12,7 @@ import type { NetworkLocation, OpenIdClient, StartEnrollmentResponse, + UpdateInfo, User, Webhook, } from '../../api/types'; @@ -165,3 +166,5 @@ export interface OpenAssignUserDeviceIPModal { username: string; locationData: DeviceLocationIpsResponse; } + +export interface OpenAppUpdateModal extends UpdateInfo {} diff --git a/web/src/shared/providers/AppInfoProvider.tsx b/web/src/shared/providers/AppInfoProvider.tsx index ac6a58e760..3e36c2ac63 100644 --- a/web/src/shared/providers/AppInfoProvider.tsx +++ b/web/src/shared/providers/AppInfoProvider.tsx @@ -1,29 +1,68 @@ import { useQuery } from '@tanstack/react-query'; -import { type PropsWithChildren, useEffect } from 'react'; +import { type PropsWithChildren, useEffect, useRef } from 'react'; +import { m } from '../../paraglide/messages'; import api from '../api/api'; +import { DISMISSED_UPDATE_KEY } from '../constants'; +import { Snackbar } from '../defguard-ui/providers/snackbar/snackbar'; import { isPresent } from '../defguard-ui/utils/isPresent'; +import { openModal } from '../hooks/modalControls/modalsSubjects'; +import { ModalName } from '../hooks/modalControls/modalTypes'; import { useApp } from '../hooks/useApp'; import { useAuth } from '../hooks/useAuth'; export const AppInfoProvider = ({ children }: PropsWithChildren) => { const isAuthenticated = useAuth((s) => isPresent(s.user)); + const isAdmin = useAuth((s) => s.isAdmin); + const updateModalOpenedRef = useRef(false); - const { data: appInfoResponse } = useQuery({ + const { data: appInfo } = useQuery({ queryFn: api.app.info, queryKey: ['info'], enabled: isAuthenticated, refetchOnWindowFocus: true, refetchOnReconnect: true, refetchOnMount: true, + select: (resp) => resp.data, + }); + + const { data: update } = useQuery({ + queryFn: api.app.updates, + queryKey: ['app-updates'], + enabled: isAuthenticated, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: true, + select: (resp) => resp.data, }); useEffect(() => { - if (isPresent(appInfoResponse)) { - useApp.setState({ - appInfo: appInfoResponse.data, - }); + if (appInfo) { + useApp.setState({ appInfo }); } - }, [appInfoResponse]); + }, [appInfo]); + + useEffect(() => { + if (!update || !isAdmin || updateModalOpenedRef.current) return; + + const dismissedVersion = localStorage.getItem(DISMISSED_UPDATE_KEY); + if (dismissedVersion === update.version) return; + + updateModalOpenedRef.current = true; + const anchor = Snackbar.custom({ + id: 'app-update-available', + icon: 'download', + variant: 'success', + text: m.modal_app_update_snackbar_message({ version: update.version }), + action: { + text: m.modal_app_update_snackbar_action(), + onClick: () => { + anchor.dismiss(); + openModal(ModalName.AppUpdate, update); + }, + }, + dismissible: true, + }); + }, [update, isAdmin]); return <>{children}>; };