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()} + + + + +