From 225c53e9a0d1005cc0b9384d8d26ca7e7bad052a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Thu, 25 Sep 2025 13:04:43 +0200 Subject: [PATCH 1/3] add modal component --- webnext/messages/en.json | 2 + webnext/package.json | 3 + webnext/pnpm-lock.yaml | 33 ++++ .../defguard-ui/components/Icon/Icon.tsx | 5 +- .../components/Icon/icons/IconClose.tsx | 19 ++ .../InteractionBox/InteractionBox.tsx | 32 ++++ .../components/InteractionBox/style.scss | 29 ++++ .../defguard-ui/components/Modal/Modal.tsx | 164 ++++++++++++++++++ .../defguard-ui/components/Modal/style.scss | 69 ++++++++ .../defguard-ui/components/Modal/types.ts | 14 ++ .../ModalControls/ModalControls.tsx | 37 ++++ .../components/ModalControls/style.scss | 30 ++++ .../src/shared/defguard-ui/scss/_base.scss | 8 + webnext/src/test_components/page/TestPage.tsx | 32 ++++ webnext/vite.config.ts | 2 +- 15 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 webnext/src/shared/defguard-ui/components/Icon/icons/IconClose.tsx create mode 100644 webnext/src/shared/defguard-ui/components/InteractionBox/InteractionBox.tsx create mode 100644 webnext/src/shared/defguard-ui/components/InteractionBox/style.scss create mode 100644 webnext/src/shared/defguard-ui/components/Modal/Modal.tsx create mode 100644 webnext/src/shared/defguard-ui/components/Modal/style.scss create mode 100644 webnext/src/shared/defguard-ui/components/Modal/types.ts create mode 100644 webnext/src/shared/defguard-ui/components/ModalControls/ModalControls.tsx create mode 100644 webnext/src/shared/defguard-ui/components/ModalControls/style.scss diff --git a/webnext/messages/en.json b/webnext/messages/en.json index fce4b1cd..325ad7a1 100644 --- a/webnext/messages/en.json +++ b/webnext/messages/en.json @@ -2,6 +2,8 @@ "$schema": "https://inlang.com/schema/inlang-message-format", "controls_back": "Back", "controls_continue": "Continue", + "controls_cancel": "Cancel", + "controls_submit": "Submit", "form_error_min_len": "Minimum length of {length}", "form_error_email": "Enter valid email", "form_error_required": "Field is required", diff --git a/webnext/package.json b/webnext/package.json index f78eb4d3..a8bb3c40 100644 --- a/webnext/package.json +++ b/webnext/package.json @@ -26,10 +26,12 @@ "@uidotdev/usehooks": "^2.4.1", "axios": "^1.12.2", "clsx": "^2.1.1", + "lodash-es": "^4.17.21", "motion": "^12.23.21", "qs": "^6.14.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "rxjs": "^7.8.2", "zod": "^4.1.11", "zustand": "^5.0.8" }, @@ -37,6 +39,7 @@ "@biomejs/biome": "2.2.4", "@inlang/paraglide-js": "2.3.2", "@tanstack/router-plugin": "^1.132.2", + "@types/lodash-es": "^4.17.12", "@types/node": "^24.5.2", "@types/qs": "^6.14.0", "@types/react": "^19.1.13", diff --git a/webnext/pnpm-lock.yaml b/webnext/pnpm-lock.yaml index 3e9c69a7..e965e3ab 100644 --- a/webnext/pnpm-lock.yaml +++ b/webnext/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 motion: specifier: ^12.23.21 version: 12.23.21(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -56,6 +59,9 @@ importers: react-dom: specifier: ^19.1.1 version: 19.1.1(react@19.1.1) + rxjs: + specifier: ^7.8.2 + version: 7.8.2 zod: specifier: ^4.1.11 version: 4.1.11 @@ -69,6 +75,9 @@ importers: '@tanstack/router-plugin': specifier: ^1.132.2 version: 1.132.2(@tanstack/react-router@1.132.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(vite@7.1.7(@types/node@24.5.2)(sass@1.93.2)(tsx@4.20.5)) + '@types/lodash-es': + specifier: ^4.17.12 + version: 4.17.12 '@types/node': specifier: ^24.5.2 version: 24.5.2 @@ -1194,6 +1203,12 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.20': + resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} + '@types/node@24.5.2': resolution: {integrity: sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==} @@ -1916,6 +1931,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -2160,6 +2178,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + sass@1.93.2: resolution: {integrity: sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==} engines: {node: '>=14.0.0'} @@ -3515,6 +3536,12 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.20 + + '@types/lodash@4.17.20': {} + '@types/node@24.5.2': dependencies: undici-types: 7.12.0 @@ -4242,6 +4269,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.17.21: {} + lodash.merge@4.6.2: {} lodash.truncate@4.4.2: {} @@ -4456,6 +4485,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + sass@1.93.2: dependencies: chokidar: 4.0.3 diff --git a/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx b/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx index 53ea8180..97b57a18 100644 --- a/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx +++ b/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx @@ -9,6 +9,7 @@ import { IconArrowBig } from './icons/IconArrowBig'; import { IconArrowSmall } from './icons/IconArrowSmall'; import { IconCheckCircle } from './icons/IconCheckCircle'; import { IconCheckFilled } from './icons/IconCheckFilled'; +import { IconClose } from './icons/IconClose'; import { IconDesktop } from './icons/IconDesktop'; import { IconEmptyPoint } from './icons/IconEmptyPoint'; import { IconLinux } from './icons/IconLinux'; @@ -89,8 +90,10 @@ export const Icon = ({ return IconApple; case 'android': return IconAndroid; + case 'close': + return IconClose; default: - throw Error('Unimplemented icon kind'); + throw Error(`Unimplemented icon kind: ${iconKind}`); } }, [iconKind]); diff --git a/webnext/src/shared/defguard-ui/components/Icon/icons/IconClose.tsx b/webnext/src/shared/defguard-ui/components/Icon/icons/IconClose.tsx new file mode 100644 index 00000000..cb8e9d1a --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Icon/icons/IconClose.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconClose = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/webnext/src/shared/defguard-ui/components/InteractionBox/InteractionBox.tsx b/webnext/src/shared/defguard-ui/components/InteractionBox/InteractionBox.tsx new file mode 100644 index 00000000..81531e4f --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/InteractionBox/InteractionBox.tsx @@ -0,0 +1,32 @@ +import type { Ref } from 'react'; +import { Icon } from '../Icon'; +import type { IconKindValue } from '../Icon/icon-types'; +import './style.scss'; +import clsx from 'clsx'; + +type Props = { + iconSize: number; + icon: IconKindValue; + onClick?: () => void; + id?: string; + className?: string; + ref?: Ref; + disabled?: boolean; +}; + +export const InteractionBox = ({ + iconSize, + icon, + onClick, + className, + id, + ref, + disabled = false, +}: Props) => { + return ( +
+ + +
+ ); +}; diff --git a/webnext/src/shared/defguard-ui/components/InteractionBox/style.scss b/webnext/src/shared/defguard-ui/components/InteractionBox/style.scss new file mode 100644 index 00000000..b18b3125 --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/InteractionBox/style.scss @@ -0,0 +1,29 @@ +.interaction-box { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: center; + flex: none; + position: relative; + user-select: none; + + & > button { + display: block; + position: absolute; + content: ' '; + width: 36px; + height: 36px; + background-color: transparent; + border: none; + cursor: pointer; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + padding: 0; + margin: 0; + + &:disabled { + cursor: not-allowed; + } + } +} diff --git a/webnext/src/shared/defguard-ui/components/Modal/Modal.tsx b/webnext/src/shared/defguard-ui/components/Modal/Modal.tsx new file mode 100644 index 00000000..5419f3c5 --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Modal/Modal.tsx @@ -0,0 +1,164 @@ +import './style.scss'; +import clsx from 'clsx'; +import { AnimatePresence, motion } from 'motion/react'; +import { useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { BehaviorSubject } from 'rxjs'; +import { motionTransitionStandard } from '../../../consts'; +import { isPresent } from '../../utils/isPresent'; +import { InteractionBox } from '../InteractionBox/InteractionBox'; +import type { ModalProps } from './types'; + +const portalTarget = document.getElementById('modals-root') as HTMLElement; +const rootElement = document.getElementById('root') as HTMLElement; + +type MouseObserverState = { + press?: React.MouseEvent; + release?: React.MouseEvent; +}; + +export const Modal = ({ + id, + isOpen, + contentClassName, + positionerClassName, + hideBackdrop, + title, + children, + size, + afterClose, + onClose, +}: ModalProps) => { + const openRef = useRef(isOpen); + const contentRef = useRef(null); + const mouseObserver = useRef(new BehaviorSubject({})); + + const checkEventOutside = useCallback( + (event: React.MouseEvent): boolean => { + const domRect = contentRef.current?.getBoundingClientRect(); + if (domRect) { + const start_x = domRect?.x; + const start_y = domRect?.y; + const end_x = start_x + domRect?.width; + const end_y = start_y + domRect.height; + if ( + event.clientX < start_x || + event.clientX > end_x || + event.clientY < start_y || + event.clientY > end_y + ) { + return true; + } + } + return false; + }, + [], + ); + + useEffect(() => { + if (mouseObserver && contentRef && isOpen) { + const sub = mouseObserver.current.subscribe(({ press, release }) => { + if (release && press) { + const target = press.target as Element; + const validParent = target.closest('#modals-root'); + const checkPress = checkEventOutside(press); + const checkRelease = checkEventOutside(release); + if (checkPress && checkRelease && isPresent(onClose) && validParent !== null) { + onClose(); + } + } + }); + return () => { + sub.unsubscribe(); + }; + } + }, [isOpen, onClose, checkEventOutside]); + + useEffect(() => { + // clear observer after closing modal + if (!isOpen) { + mouseObserver.current.next({}); + } + if (isOpen) { + rootElement.setAttribute('aria-hidden', 'true'); + rootElement.style.overflowY = 'hidden'; + } else { + rootElement.removeAttribute('aria-hidden'); + rootElement.style.overflowY = 'auto'; + } + }, [isOpen]); + + return createPortal( + + {isOpen && ( + + {!hideBackdrop && ( + + )} + { + if (event) { + const { press } = mouseObserver.current.getValue(); + mouseObserver.current.next({ press: press, release: event }); + } + }} + onMouseDown={(event) => { + if (event) { + mouseObserver.current.next({ press: event, release: undefined }); + } + }} + > + { + if (!openRef.current) { + afterClose?.(); + } + }} + transition={motionTransitionStandard} + > +
+

{title}

+ +
+
{children}
+
+
+
+ )} +
, + portalTarget, + ); +}; diff --git a/webnext/src/shared/defguard-ui/components/Modal/style.scss b/webnext/src/shared/defguard-ui/components/Modal/style.scss new file mode 100644 index 00000000..df84c0cb --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Modal/style.scss @@ -0,0 +1,69 @@ +#modals-root { + position: relative; +} + +#modals-root .modal-root { + display: block; + + .backdrop { + position: fixed; + inset: 0; + display: block; + content: ' '; + width: 100%; + height: 100%; + z-index: 4; + } + + .modal-positioner { + overflow: auto; + position: fixed; + inset: 0; + z-index: 4; + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding: var(--spacing-xl); + + .modal { + --max-width: var(--modal-size-md); + + background-color: var(--bg-default); + border-radius: var(--radius-lg); + width: 100%; + max-width: var(--max-width); + + &.size-small { + --max-width: var(--modal-size-sm); + } + + &.size-primary { + --max-width: var(--modal-size-md); + } + + & > .modal-header { + box-sizing: border-box; + padding: var(--spacing-md) var(--spacing-xl); + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); + display: grid; + grid-template-columns: 1fr 20px; + grid-template-rows: 1fr; + align-items: center; + user-select: none; + border-bottom: 1px solid var(--border-default); + + .title { + font: var(--t-body-primary-600); + } + } + + & > .modal-content { + box-sizing: border-box; + padding: var(--spacing-xl); + } + } + } +} diff --git a/webnext/src/shared/defguard-ui/components/Modal/types.ts b/webnext/src/shared/defguard-ui/components/Modal/types.ts new file mode 100644 index 00000000..359ab6c6 --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Modal/types.ts @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; + +export interface ModalProps { + title: string; + children: ReactNode; + isOpen: boolean; + hideBackdrop?: boolean; + size?: 'small' | 'primary'; + onClose?: () => void; + afterClose?: () => void; + id?: string; + positionerClassName?: string; + contentClassName?: string; +} diff --git a/webnext/src/shared/defguard-ui/components/ModalControls/ModalControls.tsx b/webnext/src/shared/defguard-ui/components/ModalControls/ModalControls.tsx new file mode 100644 index 00000000..c7cdc725 --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/ModalControls/ModalControls.tsx @@ -0,0 +1,37 @@ +import './style.scss'; +import clsx from 'clsx'; +import type { ReactNode } from 'react'; +import { m } from '../../../../paraglide/messages'; +import { isPresent } from '../../utils/isPresent'; +import { Button } from '../Button/Button'; +import type { ButtonProps } from '../Button/types'; + +type Props = { + submitProps?: ButtonProps; + cancelProps?: ButtonProps; + children?: ReactNode; +}; + +export const ModalControls = ({ submitProps, cancelProps, children }: Props) => { + return ( +
+ {isPresent(children) &&
{children}
} +
+
+
+ ); +}; diff --git a/webnext/src/shared/defguard-ui/components/ModalControls/style.scss b/webnext/src/shared/defguard-ui/components/ModalControls/style.scss new file mode 100644 index 00000000..ca454734 --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/ModalControls/style.scss @@ -0,0 +1,30 @@ +.modal-controls { + width: 100%; + padding-top: var(--spacing-2xl); + + &:not(.extras) { + display: flex; + flex-flow: row nowrap; + width: 100%; + align-items: center; + justify-content: flex-end; + } + + // todo: extend when will be needed + &.extras { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: 1fr; + column-gap: var(--spacing-xl); + align-items: center; + justify-content: end; + } + + .buttons { + display: flex; + flex-flow: row nowrap; + align-items: flex-end; + justify-content: flex-end; + column-gap: var(--spacing-md); + } +} diff --git a/webnext/src/shared/defguard-ui/scss/_base.scss b/webnext/src/shared/defguard-ui/scss/_base.scss index 29412a26..407b8a20 100644 --- a/webnext/src/shared/defguard-ui/scss/_base.scss +++ b/webnext/src/shared/defguard-ui/scss/_base.scss @@ -14,6 +14,11 @@ li { padding: 0; } +body, +html { + overflow: hidden; +} + h1 { font: var(--t-title-h1); } @@ -37,7 +42,10 @@ h5 { #root { position: relative; min-height: 100dvh; + max-height: 100dvh; width: 100%; + overflow: hidden auto; + scrollbar-gutter: stable; } #root, diff --git a/webnext/src/test_components/page/TestPage.tsx b/webnext/src/test_components/page/TestPage.tsx index 52284719..1339e679 100644 --- a/webnext/src/test_components/page/TestPage.tsx +++ b/webnext/src/test_components/page/TestPage.tsx @@ -9,6 +9,8 @@ import { CounterLabel } from '../../shared/defguard-ui/components/CounterLabel/C import { Divider } from '../../shared/defguard-ui/components/Divider/Divider'; import { EmptyState } from '../../shared/defguard-ui/components/EmptyState/EmptyState'; import { IconButton } from '../../shared/defguard-ui/components/IconButton/IconButton'; +import { Modal } from '../../shared/defguard-ui/components/Modal/Modal'; +import { ModalControls } from '../../shared/defguard-ui/components/ModalControls/ModalControls'; import { Radio } from '../../shared/defguard-ui/components/Radio/Radio'; export const TestPage = () => { @@ -117,6 +119,9 @@ export const TestPage = () => { + + + ); }; @@ -141,3 +146,30 @@ const TestButtonTransition = () => { /> ); }; + +const TestModalButton = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + + +
{}
+

+
+
+
+

+ +
+
+
+
+

{m.client_setup_footer_extra()}

+

{m.start_footer_contact()}

+
+ + ); +}; diff --git a/webnext/src/pages/enrollment/ConfigureClient/style.scss b/webnext/src/pages/enrollment/ConfigureClient/style.scss new file mode 100644 index 00000000..6751cc72 --- /dev/null +++ b/webnext/src/pages/enrollment/ConfigureClient/style.scss @@ -0,0 +1,5 @@ +#configure-client-page .page-content { + .container-with-icon { + width: 100%; + } +} diff --git a/webnext/src/pages/enrollment/EnrollmentStart/EnrollmentStartPage.tsx b/webnext/src/pages/enrollment/EnrollmentStart/EnrollmentStartPage.tsx new file mode 100644 index 00000000..6a484c93 --- /dev/null +++ b/webnext/src/pages/enrollment/EnrollmentStart/EnrollmentStartPage.tsx @@ -0,0 +1,98 @@ +import { useNavigate } from '@tanstack/react-router'; +import { m } from '../../../paraglide/messages'; +import { ContainerWithIcon } from '../../../shared/components/ContainerWithIcon/ContainerWithIcon'; +import { Page } from '../../../shared/components/Page/Page'; +import { PageNavigation } from '../../../shared/components/PageNavigation/PageNavigation'; +import { EnrollmentStep } from '../../../shared/components/Step/Step'; +import { Divider } from '../../../shared/defguard-ui/components/Divider/Divider'; +import './style.scss'; +import { revalidateLogic } from '@tanstack/react-form'; +import z from 'zod'; +import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { useAppForm } from '../../../shared/defguard-ui/form'; +import { ThemeSpacing } from '../../../shared/defguard-ui/types'; + +const formSchema = z.object({ + token: z.string().trim().min(1, m.form_error_required()), +}); + +type FormFields = z.infer; + +const defaultValues: FormFields = { + token: '', +}; + +export const EnrollmentStartPage = () => { + const navigate = useNavigate(); + + const form = useAppForm({ + defaultValues, + validationLogic: revalidateLogic({ + mode: 'change', + modeAfterSubmission: 'change', + }), + validators: { + onDynamic: formSchema, + onSubmit: formSchema, + }, + onSubmit: (values) => { + console.table(values); + navigate({ + to: '/client-setup', + replace: true, + }); + }, + onSubmitInvalid: (props) => { + console.log(props); + }, + }); + + return ( + + +
+

{m.enrollment_start_title()}

+

{m.enrollment_start_subtitle()}

+
+ + +
+
{m.enrollment_start_external_title()}
+

{m.enrollment_start_external_subtitle()}

+
+
+ + +
+
{m.enrollment_start_internal_title()}
+

{m.enrollment_start_internal_subtitle()}

+
+ +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + + {(field) => } + +
+
+
+ { + navigate({ + to: '/', + }); + }} + nextText={m.controls_continue()} + onNext={() => { + form.handleSubmit(); + }} + /> +
+ ); +}; diff --git a/webnext/src/pages/enrollment/EnrollmentStart/style.scss b/webnext/src/pages/enrollment/EnrollmentStart/style.scss new file mode 100644 index 00000000..94d4a473 --- /dev/null +++ b/webnext/src/pages/enrollment/EnrollmentStart/style.scss @@ -0,0 +1,18 @@ +#enrollment-start-page .page-content { + .container-with-icon { + width: 100%; + + header { + padding-bottom: var(--spacing-2xl); + + p { + font: var(--t-body-sm-400); + color: var(--fg-neutral); + } + } + } + + .divider { + padding: var(--spacing-lg) 0; + } +} diff --git a/webnext/src/routeTree.gen.ts b/webnext/src/routeTree.gen.ts index ec646ef8..83667cd6 100644 --- a/webnext/src/routeTree.gen.ts +++ b/webnext/src/routeTree.gen.ts @@ -10,7 +10,9 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as TestRouteImport } from './routes/test' +import { Route as EnrollmentStartRouteImport } from './routes/enrollment-start' import { Route as DownloadRouteImport } from './routes/download' +import { Route as ClientSetupRouteImport } from './routes/client-setup' import { Route as IndexRouteImport } from './routes/index' import { Route as PasswordIndexRouteImport } from './routes/password/index' import { Route as PasswordSentRouteImport } from './routes/password/sent' @@ -22,11 +24,21 @@ const TestRoute = TestRouteImport.update({ path: '/test', getParentRoute: () => rootRouteImport, } as any) +const EnrollmentStartRoute = EnrollmentStartRouteImport.update({ + id: '/enrollment-start', + path: '/enrollment-start', + getParentRoute: () => rootRouteImport, +} as any) const DownloadRoute = DownloadRouteImport.update({ id: '/download', path: '/download', getParentRoute: () => rootRouteImport, } as any) +const ClientSetupRoute = ClientSetupRouteImport.update({ + id: '/client-setup', + path: '/client-setup', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -55,7 +67,9 @@ const PasswordFormFinishRoute = PasswordFormFinishRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/client-setup': typeof ClientSetupRoute '/download': typeof DownloadRoute + '/enrollment-start': typeof EnrollmentStartRoute '/test': typeof TestRoute '/password/sent': typeof PasswordSentRoute '/password': typeof PasswordIndexRoute @@ -64,7 +78,9 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/client-setup': typeof ClientSetupRoute '/download': typeof DownloadRoute + '/enrollment-start': typeof EnrollmentStartRoute '/test': typeof TestRoute '/password/sent': typeof PasswordSentRoute '/password': typeof PasswordIndexRoute @@ -74,7 +90,9 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/client-setup': typeof ClientSetupRoute '/download': typeof DownloadRoute + '/enrollment-start': typeof EnrollmentStartRoute '/test': typeof TestRoute '/password/sent': typeof PasswordSentRoute '/password/': typeof PasswordIndexRoute @@ -85,7 +103,9 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/client-setup' | '/download' + | '/enrollment-start' | '/test' | '/password/sent' | '/password' @@ -94,7 +114,9 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/client-setup' | '/download' + | '/enrollment-start' | '/test' | '/password/sent' | '/password' @@ -103,7 +125,9 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/client-setup' | '/download' + | '/enrollment-start' | '/test' | '/password/sent' | '/password/' @@ -113,7 +137,9 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + ClientSetupRoute: typeof ClientSetupRoute DownloadRoute: typeof DownloadRoute + EnrollmentStartRoute: typeof EnrollmentStartRoute TestRoute: typeof TestRoute PasswordSentRoute: typeof PasswordSentRoute PasswordIndexRoute: typeof PasswordIndexRoute @@ -130,6 +156,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof TestRouteImport parentRoute: typeof rootRouteImport } + '/enrollment-start': { + id: '/enrollment-start' + path: '/enrollment-start' + fullPath: '/enrollment-start' + preLoaderRoute: typeof EnrollmentStartRouteImport + parentRoute: typeof rootRouteImport + } '/download': { id: '/download' path: '/download' @@ -137,6 +170,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DownloadRouteImport parentRoute: typeof rootRouteImport } + '/client-setup': { + id: '/client-setup' + path: '/client-setup' + fullPath: '/client-setup' + preLoaderRoute: typeof ClientSetupRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -177,7 +217,9 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + ClientSetupRoute: ClientSetupRoute, DownloadRoute: DownloadRoute, + EnrollmentStartRoute: EnrollmentStartRoute, TestRoute: TestRoute, PasswordSentRoute: PasswordSentRoute, PasswordIndexRoute: PasswordIndexRoute, diff --git a/webnext/src/routes/client-setup.tsx b/webnext/src/routes/client-setup.tsx new file mode 100644 index 00000000..34fa19b4 --- /dev/null +++ b/webnext/src/routes/client-setup.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { ConfigureClientPage } from '../pages/enrollment/ConfigureClient/ConfigureClientPage'; + +export const Route = createFileRoute('/client-setup')({ + component: ConfigureClientPage, +}); diff --git a/webnext/src/routes/enrollment-start.tsx b/webnext/src/routes/enrollment-start.tsx new file mode 100644 index 00000000..835b7ebc --- /dev/null +++ b/webnext/src/routes/enrollment-start.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { EnrollmentStartPage } from '../pages/enrollment/EnrollmentStart/EnrollmentStartPage'; + +export const Route = createFileRoute('/enrollment-start')({ + component: EnrollmentStartPage, +}); diff --git a/webnext/src/shared/components/ContainerWithIcon/ContainerWithIcon.tsx b/webnext/src/shared/components/ContainerWithIcon/ContainerWithIcon.tsx new file mode 100644 index 00000000..1b2a4488 --- /dev/null +++ b/webnext/src/shared/components/ContainerWithIcon/ContainerWithIcon.tsx @@ -0,0 +1,28 @@ +import type { PropsWithChildren } from 'react'; +import { Container } from '../Container/Container'; +import './style.scss'; +import clsx from 'clsx'; +import { Icon } from '../../defguard-ui/components/Icon'; +import type { IconKindValue } from '../../defguard-ui/components/Icon/icon-types'; + +export const ContainerWithIcon = ({ + children, + className, + id, + icon, +}: PropsWithChildren & { + icon: IconKindValue; + className?: string; + id?: string; +}) => { + return ( + +
+
+ +
+
{children}
+
+
+ ); +}; diff --git a/webnext/src/shared/components/ContainerWithIcon/style.scss b/webnext/src/shared/components/ContainerWithIcon/style.scss new file mode 100644 index 00000000..51bb293b --- /dev/null +++ b/webnext/src/shared/components/ContainerWithIcon/style.scss @@ -0,0 +1,25 @@ +.container-with-icon { + & > .track { + display: grid; + grid-template-columns: 32px 1fr; + grid-template-rows: 1fr; + column-gap: var(--spacing-2xl); + align-items: start; + + & > .container-icon { + user-select: none; + display: flex; + flex-flow: row; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding: 6px; + border-radius: var(--radius-full); + background-color: var(--bg-action-muted); + + svg path { + fill: var(--fg-action); + } + } + } +} diff --git a/webnext/src/shared/defguard-ui/components/Button/Button.tsx b/webnext/src/shared/defguard-ui/components/Button/Button.tsx index a73c6913..5737584b 100644 --- a/webnext/src/shared/defguard-ui/components/Button/Button.tsx +++ b/webnext/src/shared/defguard-ui/components/Button/Button.tsx @@ -14,6 +14,7 @@ export const Button = ({ iconRight, onClick, ref, + iconRightRotation, size = 'primary', variant = 'primary', type = 'button', @@ -42,7 +43,9 @@ export const Button = ({ > {isPresent(iconLeft) && } {text} - {isPresent(iconRight) && } + {isPresent(iconRight) && ( + + )} {loading && !disabled && ( ; @@ -14,6 +15,7 @@ export type ButtonProps = { type?: DefaultButtonProps['type']; iconLeft?: IconKindValue; iconRight?: IconKindValue; + iconRightRotation?: Direction; testId?: string; disabled?: boolean; loading?: boolean; diff --git a/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx b/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx index 97b57a18..d652cf48 100644 --- a/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx +++ b/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx @@ -12,6 +12,8 @@ import { IconCheckFilled } from './icons/IconCheckFilled'; import { IconClose } from './icons/IconClose'; import { IconDesktop } from './icons/IconDesktop'; import { IconEmptyPoint } from './icons/IconEmptyPoint'; +import { IconFile } from './icons/IconFile'; +import { IconGlobe } from './icons/IconGlobe'; import { IconLinux } from './icons/IconLinux'; import { IconLoader } from './icons/IconLoader'; import { IconLockOpen } from './icons/IconLock'; @@ -92,6 +94,10 @@ export const Icon = ({ return IconAndroid; case 'close': return IconClose; + case 'file': + return IconFile; + case 'globe': + return IconGlobe; default: throw Error(`Unimplemented icon kind: ${iconKind}`); } diff --git a/webnext/src/shared/defguard-ui/components/Icon/icons/IconFile.tsx b/webnext/src/shared/defguard-ui/components/Icon/icons/IconFile.tsx new file mode 100644 index 00000000..0f4266cf --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Icon/icons/IconFile.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconFile = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/webnext/src/shared/defguard-ui/components/Icon/icons/IconGlobe.tsx b/webnext/src/shared/defguard-ui/components/Icon/icons/IconGlobe.tsx new file mode 100644 index 00000000..c7d51e12 --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Icon/icons/IconGlobe.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconGlobe = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/webnext/src/shared/defguard-ui/components/Menu/Menu.tsx b/webnext/src/shared/defguard-ui/components/Menu/Menu.tsx new file mode 100644 index 00000000..c3ebed3d --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Menu/Menu.tsx @@ -0,0 +1,80 @@ +import { Fragment, useEffect } from 'react'; +import { MenuItem } from './components/MenuItem'; +import './style.scss'; +import { + autoUpdate, + FloatingPortal, + offset, + size, + useDismiss, + useFloating, + useInteractions, +} from '@floating-ui/react'; +import { isPresent } from '../../utils/isPresent'; +import { MenuHeader } from './components/MenuHeader'; +import { MenuSpacer } from './components/MenuSpacer'; + +import type { MenuProps } from './types'; + +export const Menu = ({ + itemGroups, + isOpen, + referenceRef, + setOpen, + placement, + floatingOffset = 5, +}: MenuProps) => { + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: setOpen, + whileElementsMounted: autoUpdate, + placement: placement ?? 'bottom-start', + middleware: [ + offset(floatingOffset), + size({ + apply({ rects, elements }) { + const w = `${rects.reference.width}px`; + (elements.floating as HTMLElement).style.minWidth = w; + }, + }), + ], + }); + + const dismiss = useDismiss(context, { + ancestorScroll: true, + outsidePress: (event) => !(event.target as HTMLElement | null)?.closest('.menu'), + }); + + const { getFloatingProps } = useInteractions([dismiss]); + + useEffect(() => { + if (referenceRef) { + refs.setReference(referenceRef.current); + } + }, [referenceRef, refs.setReference]); + + if (!isOpen) return null; + + return ( + +
+ {itemGroups.map((group, groupIndex) => ( + + {isPresent(group.header) && } + {group.items.map((item) => ( + + ))} + {groupIndex !== 0 && groupIndex !== itemGroups.length - 1 && } + + ))} +
+
+ ); +}; diff --git a/webnext/src/shared/defguard-ui/components/Menu/components/MenuHeader.tsx b/webnext/src/shared/defguard-ui/components/Menu/components/MenuHeader.tsx new file mode 100644 index 00000000..e836810e --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Menu/components/MenuHeader.tsx @@ -0,0 +1,24 @@ +import clsx from 'clsx'; +import { isPresent } from '../../../utils/isPresent'; +import { InteractionBox } from '../../InteractionBox/InteractionBox'; +import type { MenuHeaderProps } from '../types'; + +export const MenuHeader = ({ text, onHelp }: MenuHeaderProps) => { + return ( +
+

{text}

+ {isPresent(onHelp) && ( + + )} +
+ ); +}; diff --git a/webnext/src/shared/defguard-ui/components/Menu/components/MenuItem.tsx b/webnext/src/shared/defguard-ui/components/Menu/components/MenuItem.tsx new file mode 100644 index 00000000..51af2e47 --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Menu/components/MenuItem.tsx @@ -0,0 +1,42 @@ +import clsx from 'clsx'; +import { isPresent } from '../../../utils/isPresent'; +import { Icon } from '../../Icon'; +import type { MenuItemProps } from '../types'; + +export const MenuItem = ({ + disabled, + text, + icon, + items, + onClick, + variant, +}: MenuItemProps) => { + const hasItems = isPresent(items) && items.length > 0; + const hasIcon = isPresent(icon); + + return ( +
{ + if (!disabled) { + onClick?.(); + } + }} + > + {isPresent(icon) && } +

{text}

+ {hasItems && ( +
+ +
+ )} +
+ ); +}; diff --git a/webnext/src/shared/defguard-ui/components/Menu/components/MenuSpacer.tsx b/webnext/src/shared/defguard-ui/components/Menu/components/MenuSpacer.tsx new file mode 100644 index 00000000..4510f86e --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Menu/components/MenuSpacer.tsx @@ -0,0 +1,7 @@ +export const MenuSpacer = () => { + return ( +
+
+
+ ); +}; diff --git a/webnext/src/shared/defguard-ui/components/Menu/style.scss b/webnext/src/shared/defguard-ui/components/Menu/style.scss new file mode 100644 index 00000000..229f99cb --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Menu/style.scss @@ -0,0 +1,94 @@ +.menu { + display: flex; + flex-flow: column; + box-sizing: border-box; + padding: var(--spacing-sm); + border-radius: var(--radius-lg); + border: 1px solid var(--border-disabled); + + .menu-spacer { + user-select: none; + padding: var(--spacing-sm); + + & > .line { + display: block; + content: ' '; + background-color: var(--bg-active); + height: 1px; + width: 100%; + } + } + + .menu-header { + display: flex; + flex-flow: row nowrap; + column-gap: var(--spacing-md); + + p { + font: var(--t-menu-title); + color: var(--fg-muted); + } + } + + .menu-item { + --bg-color: var(--bg-default); + --color: var(--fg-default); + --icon-fill: var(--fg-muted); + + display: flex; + flex-flow: row nowrap; + align-items: center; + border-radius: var(--radius-md); + padding: 0 var(--spacing-sm); + column-gap: var(--spacing-md); + background-color: var(--bg-color); + cursor: pointer; + height: 36px; + color: var(--color); + position: relative; + + @include animate(background-color); + + &.disabled { + cursor: not-allowed; + } + + &.nested { + // account for positioned icon on the right side + padding: 0 calc(var(--spacing-sm) + var(--spacing-md) + 20px) 0 var(--spacing-sm); + } + + &.variant-danger { + --color: var(--fg-critical); + --icon-fill: var(--fg-critical); + } + + &:not(.disabled) { + &:hover { + --bg-color: var(--bg-muted); + } + } + + p { + font: var(--t-menu-text); + color: inherit; + } + + & > .icon svg path { + fill: var(--icon-fill); + } + + & > .suffix { + position: absolute; + height: 20px; + width: 20px; + top: 50%; + right: var(--spacing-sm); + transform: translateY(-50%); + + svg path { + fill: var(--fg-muted); + } + } + } +} diff --git a/webnext/src/shared/defguard-ui/components/Menu/types.ts b/webnext/src/shared/defguard-ui/components/Menu/types.ts new file mode 100644 index 00000000..1f5166bf --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Menu/types.ts @@ -0,0 +1,31 @@ +import type { Placement } from '@floating-ui/react'; +import type { RefObject } from 'react'; +import type { IconKindValue } from '../Icon/icon-types'; + +export interface MenuProps { + itemGroups: MenuItemsGroup[]; + referenceRef: RefObject; + placement?: Placement; + isOpen: boolean; + setOpen: (val: boolean) => void; + floatingOffset?: number; +} + +export interface MenuItemsGroup { + header?: MenuHeaderProps; + items: MenuItemProps[]; +} + +export interface MenuItemProps { + text: string; + variant?: 'default' | 'danger'; + icon?: IconKindValue; + items?: MenuItemProps[]; + disabled?: boolean; + onClick?: () => void; +} + +export interface MenuHeaderProps { + text: string; + onHelp?: () => void; +} diff --git a/webnext/src/shared/defguard-ui/components/Modal/style.scss b/webnext/src/shared/defguard-ui/components/Modal/style.scss index df84c0cb..f801cf37 100644 --- a/webnext/src/shared/defguard-ui/components/Modal/style.scss +++ b/webnext/src/shared/defguard-ui/components/Modal/style.scss @@ -63,6 +63,10 @@ & > .modal-content { box-sizing: border-box; padding: var(--spacing-xl); + + & > p { + font: var(--t-body-sm-400); + } } } } diff --git a/webnext/src/shared/defguard-ui/components/ModalControls/ModalControls.tsx b/webnext/src/shared/defguard-ui/components/ModalControls/ModalControls.tsx index c7cdc725..1623867e 100644 --- a/webnext/src/shared/defguard-ui/components/ModalControls/ModalControls.tsx +++ b/webnext/src/shared/defguard-ui/components/ModalControls/ModalControls.tsx @@ -28,8 +28,8 @@ export const ModalControls = ({ submitProps, cancelProps, children }: Props) => /> + + + + {copied && ( + + +

{copyTooltip}

+
+
+ )} + + ); +}; diff --git a/webnext/src/shared/defguard-ui/components/CopyField/style.scss b/webnext/src/shared/defguard-ui/components/CopyField/style.scss new file mode 100644 index 00000000..7494c7ee --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/CopyField/style.scss @@ -0,0 +1,51 @@ +.copy-field { + & > .inner { + user-select: none; + width: 100%; + + .label-track { + padding-bottom: var(--spacing-xs); + + p { + font: var(--t-input-title); + color: var(--fg-neutral); + } + } + + .track { + width: 100%; + border: 1px solid var(--border-default); + box-sizing: border-box; + padding: var(--input-spacing-sm) var(--input-spacing-lg); + display: grid; + grid-template-columns: auto 20px; + grid-template-rows: 1fr; + column-gap: var(--input-spacing-sm); + align-items: center; + overflow: hidden; + border-radius: var(--input-border-radius); + + p { + font: var(--t-input-text-primary); + color: var(--fg-muted); + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; + } + + button { + padding: 0; + margin: 0; + border: none; + background-color: transparent; + width: 20px; + height: 20px; + cursor: pointer; + + .icon[data-kind='check-filled'] path { + fill: var(--fg-success); + } + } + } + } +} diff --git a/webnext/src/shared/defguard-ui/components/Fold/Fold.tsx b/webnext/src/shared/defguard-ui/components/Fold/Fold.tsx new file mode 100644 index 00000000..36278637 --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Fold/Fold.tsx @@ -0,0 +1,29 @@ +import './style.scss'; +import clsx from 'clsx'; +import type { HTMLAttributes, PropsWithChildren, Ref } from 'react'; + +export const Fold = ({ + ref, + className, + children, + open, + contentClassName, + ...rest +}: { + open: boolean; + ref?: Ref; + contentClassName?: string; +} & PropsWithChildren & + HTMLAttributes) => { + return ( +
+
{children}
+
+ ); +}; diff --git a/webnext/src/shared/defguard-ui/components/Fold/style.scss b/webnext/src/shared/defguard-ui/components/Fold/style.scss new file mode 100644 index 00000000..88920598 --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Fold/style.scss @@ -0,0 +1,15 @@ +.fold { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 1fr; + + @include animate(grid-template-rows); + + &.folded { + grid-template-rows: 0fr; + } + + .fold-content { + overflow: hidden; + } +} diff --git a/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx b/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx index d652cf48..fe7c73a9 100644 --- a/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx +++ b/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx @@ -10,6 +10,8 @@ import { IconArrowSmall } from './icons/IconArrowSmall'; import { IconCheckCircle } from './icons/IconCheckCircle'; import { IconCheckFilled } from './icons/IconCheckFilled'; import { IconClose } from './icons/IconClose'; +import { IconConfig } from './icons/IconConfig'; +import { IconCopy } from './icons/IconCopy'; import { IconDesktop } from './icons/IconDesktop'; import { IconEmptyPoint } from './icons/IconEmptyPoint'; import { IconFile } from './icons/IconFile'; @@ -18,6 +20,7 @@ import { IconLinux } from './icons/IconLinux'; import { IconLoader } from './icons/IconLoader'; import { IconLockOpen } from './icons/IconLock'; import { IconMobile } from './icons/IconMobile'; +import { IconOpenInNewWindow } from './icons/IconOpenInNewWindow'; import { IconPlus } from './icons/IconPlus'; import { IconStatusSimple } from './icons/IconStatusSimple'; import { IconWindows } from './icons/IconWindows'; @@ -60,6 +63,12 @@ export const Icon = ({ }: Props) => { const IconToRender = useMemo(() => { switch (iconKind) { + case 'copy': + return IconCopy; + case 'config': + return IconConfig; + case 'open-in-new-window': + return IconOpenInNewWindow; case 'arrow-big': return IconArrowBig; case 'arrow-small': diff --git a/webnext/src/shared/defguard-ui/components/Icon/icons/IconArrowSmall.tsx b/webnext/src/shared/defguard-ui/components/Icon/icons/IconArrowSmall.tsx index cb5ac0c4..4092fa37 100644 --- a/webnext/src/shared/defguard-ui/components/Icon/icons/IconArrowSmall.tsx +++ b/webnext/src/shared/defguard-ui/components/Icon/icons/IconArrowSmall.tsx @@ -11,8 +11,8 @@ export const IconArrowSmall = (props: SVGProps) => { {...props} > diff --git a/webnext/src/shared/defguard-ui/components/Icon/icons/IconConfig.tsx b/webnext/src/shared/defguard-ui/components/Icon/icons/IconConfig.tsx new file mode 100644 index 00000000..0fd3641a --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Icon/icons/IconConfig.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconConfig = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/webnext/src/shared/defguard-ui/components/Icon/icons/IconCopy.tsx b/webnext/src/shared/defguard-ui/components/Icon/icons/IconCopy.tsx new file mode 100644 index 00000000..eb5706fa --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Icon/icons/IconCopy.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconCopy = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/webnext/src/shared/defguard-ui/components/Icon/icons/IconLinux.tsx b/webnext/src/shared/defguard-ui/components/Icon/icons/IconLinux.tsx index 6cf5276a..f0a86fb3 100644 --- a/webnext/src/shared/defguard-ui/components/Icon/icons/IconLinux.tsx +++ b/webnext/src/shared/defguard-ui/components/Icon/icons/IconLinux.tsx @@ -11,8 +11,8 @@ export const IconLinux = (props: SVGProps) => { {...props} > diff --git a/webnext/src/shared/defguard-ui/components/Icon/icons/IconOpenInNewWindow.tsx b/webnext/src/shared/defguard-ui/components/Icon/icons/IconOpenInNewWindow.tsx new file mode 100644 index 00000000..69514713 --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Icon/icons/IconOpenInNewWindow.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconOpenInNewWindow = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/webnext/src/shared/defguard-ui/components/Tooltip/Tooltip.tsx b/webnext/src/shared/defguard-ui/components/Tooltip/Tooltip.tsx new file mode 100644 index 00000000..2f4aef1f --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Tooltip/Tooltip.tsx @@ -0,0 +1,18 @@ +import './style.scss'; +import clsx from 'clsx'; +import type { HTMLAttributes, PropsWithChildren, Ref } from 'react'; + +export const Tooltip = ({ + ref, + children, + className, + ...rest +}: PropsWithChildren & { + ref?: Ref; +} & HTMLAttributes) => { + return ( +
+ {children} +
+ ); +}; diff --git a/webnext/src/shared/defguard-ui/components/Tooltip/style.scss b/webnext/src/shared/defguard-ui/components/Tooltip/style.scss new file mode 100644 index 00000000..e79989fc --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Tooltip/style.scss @@ -0,0 +1,14 @@ +.tooltip { + box-sizing: border-box; + padding: var(--tooltip-spacing); + z-index: 3; + border-radius: var(--radius-md); + background-color: var(--bg-dark); + + span, + p, + a { + font: var(--t-tooltip); + color: var(--fg-white); + } +} diff --git a/webnext/src/shared/defguard-ui/scss/_base.scss b/webnext/src/shared/defguard-ui/scss/_base.scss index 407b8a20..647481fb 100644 --- a/webnext/src/shared/defguard-ui/scss/_base.scss +++ b/webnext/src/shared/defguard-ui/scss/_base.scss @@ -45,7 +45,7 @@ h5 { max-height: 100dvh; width: 100%; overflow: hidden auto; - scrollbar-gutter: stable; + scrollbar-gutter: stable both-edges; } #root, diff --git a/webnext/src/shared/defguard-ui/scss/_form.scss b/webnext/src/shared/defguard-ui/scss/_form.scss new file mode 100644 index 00000000..7ed86153 --- /dev/null +++ b/webnext/src/shared/defguard-ui/scss/_form.scss @@ -0,0 +1,6 @@ +.form-col-2 { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr; + column-gap: var(--spacing-md); +} diff --git a/webnext/src/shared/defguard-ui/scss/_shared_tokens.scss b/webnext/src/shared/defguard-ui/scss/_shared_tokens.scss index a90779ed..3c3d0a48 100644 --- a/webnext/src/shared/defguard-ui/scss/_shared_tokens.scss +++ b/webnext/src/shared/defguard-ui/scss/_shared_tokens.scss @@ -199,7 +199,7 @@ $source-code-pro: --t-badge-spacing: 0.3px; // Inputs - --t-input-title: normal 400 12px / 16px #{$geist}; + --t-input-title: normal 500 12px / 16px #{$geist}; --t-input-text-primary: normal 400 12px / 20px #{$geist}; --t-input-text-big: normal 400 16px / 20px #{$geist}; --t-input-error-message: normal 400 12px / 16px #{$geist}; @@ -210,6 +210,11 @@ $source-code-pro: --input-spacing-sm: var(--spacing-sm); --input-spacing-lg: var(--spacing-md); + // Tooltip + --t-tooltip: normal 400 12px / 16px #{$geist}; + --tooltip-letter-spacing: 0.3; + --tooltip-spacing: var(--spacing-md); + // custom // how much space does error message in all takes diff --git a/webnext/src/shared/defguard-ui/scss/index.scss b/webnext/src/shared/defguard-ui/scss/index.scss index 55a38126..4512d331 100644 --- a/webnext/src/shared/defguard-ui/scss/index.scss +++ b/webnext/src/shared/defguard-ui/scss/index.scss @@ -2,3 +2,4 @@ @use './shared_tokens'; @use './themes'; @use './base'; +@use './form'; diff --git a/webnext/src/shared/hooks/useClipboard.tsx b/webnext/src/shared/hooks/useClipboard.tsx new file mode 100644 index 00000000..c0c631d4 --- /dev/null +++ b/webnext/src/shared/hooks/useClipboard.tsx @@ -0,0 +1,18 @@ +import { useCallback } from 'react'; + +export const useClipboard = () => { + const writeToClipboard = useCallback(async (value: string) => { + if (window.isSecureContext) { + try { + await navigator.clipboard.writeText(value); + } catch (e) { + console.error(e); + } + } else { + console.warn('Cannot access clipboard in insecure contexts'); + } + }, []); + return { + writeToClipboard, + }; +}; diff --git a/webnext/src/shared/hooks/useEnrollmentStore.tsx b/webnext/src/shared/hooks/useEnrollmentStore.tsx new file mode 100644 index 00000000..d40256ec --- /dev/null +++ b/webnext/src/shared/hooks/useEnrollmentStore.tsx @@ -0,0 +1,35 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import type { EnrollmentStartResponse } from '../api/types'; + +type Store = Values & Methods; + +type Values = { + token?: string; + enrollmentData?: EnrollmentStartResponse; +}; + +type Methods = { + reset: () => void; + setState: (values: Partial) => void; +}; + +const defaults: Values = { + enrollmentData: undefined, + token: undefined, +}; + +export const useEnrollmentStore = create()( + persist( + (set) => ({ + ...defaults, + reset: () => set(defaults), + setState: (values) => set((s) => ({ ...s, ...values })), + }), + { + name: 'enrollment-store', + version: 1, + storage: createJSONStorage(() => sessionStorage), + }, + ), +); diff --git a/webnext/vite.config.ts b/webnext/vite.config.ts index ad0692c0..1f052c63 100644 --- a/webnext/vite.config.ts +++ b/webnext/vite.config.ts @@ -10,6 +10,13 @@ export default defineConfig({ server: { port: 3002, strictPort: true, + proxy: { + '/api': { + target: 'http://127.0.0.1:8080/', + changeOrigin: true, + secure: false, + }, + }, }, plugins: [ paraglideVitePlugin({