diff --git a/webnext/messages/en.json b/webnext/messages/en.json index fce4b1cd..54f0163a 100644 --- a/webnext/messages/en.json +++ b/webnext/messages/en.json @@ -1,13 +1,24 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", + "misc_or": "or", + "misc_and": "and", "controls_back": "Back", "controls_continue": "Continue", + "controls_cancel": "Cancel", + "controls_submit": "Submit", + "controls_hide": "Hide", + "controls_show": "Show", "form_error_min_len": "Minimum length of {length}", "form_error_email": "Enter valid email", "form_error_required": "Field is required", + "form_label_token": "Token", + "form_label_email": "email", + "form_label_url": "URL", "cmp_enrol_step": "Step {current}/{max}", "cmp_enrol_final": "Final step", - "start_footer_contact": "If you need assistance, please contact your defguard administrator at", + "cmp_openid_button": "Sign in with {provider}", + "cmp_copy_field_tooltip": "Copied", + "footer_contact": "If you need assistance, please contact your defguard administrator at", "start_footer_copyright": "Copyright ©2023-{currentYear} Defguard Sp. z o.o.", "start_multi_title": "Get Started with Defguard", "start_multi_subtitle": "Please select the option that suits your needs.", @@ -48,5 +59,31 @@ "client_download_for": "Defguard for {platform}", "client_download_supports_newer": "Supports {platform} and newer.", "client_download_supports_and": "Supports {platform} and {other}.", - "client_download_works_with": "Works with {platform}." + "client_download_works_with": "Works with {platform}.", + "client_download_modal_title": "Download confirmation", + "client_download_modal_content": "Please make sure to download at least one client, as you'll need it in the next step to configure your VPN device.", + "client_download_modal_cancel": "Back to download", + "enrollment_start_title": "Select activation type", + "enrollment_start_subtitle": "Select the configuration type based on your organization's approach.", + "enrollment_start_external_title": "Sign in with External SSO", + "enrollment_start_external_subtitle": "Select this option if you want to start the enrollment process using an external SSO (Google, Microsoft, etc.).", + "enrollment_start_internal_title": "Enter personal enrolment token", + "enrollment_start_internal_subtitle": "Select this option if your administrator has sent you an email or message with your personal token. If you haven't received your token, please contact your administrator.", + "client_setup_title": "Configure your defguard client/app", + "client_setup_subtitle": "Select the activation method according to your device type.", + "client_setup_desktop_title": "Desktop client", + "client_setup_desktop_auto_title": "Automatic configuration", + "client_setup_desktop_auto_explain_1": "Click the button below for automatic configuration.", + "client_setup_desktop_auto_explain_2": "Before using this option make sure the Defguard desktop client is already installed.", + "client_setup_desktop_auto_button_one_click": "One-Click Configuration", + "client_setup_desktop_auto_button_download": "Download for Desktop", + "client_setup_desktop_manual_title": "Manual configuration", + "client_setup_desktop_manual_subtitle": "Activate your desktop client manually by entering the URL and token you see bellow.", + "client_setup_desktop_manual_fold": "{intent} advanced configuration", + "client_setup_mobile_title": "Mobile application", + "client_setup_mobile_subtitle": "Scan QR code bellow to activate Defguard mobile application.", + "client_setup_mobile_forgot": "If you forgot to install the mobile app, click one of the buttons bellow.", + "client_setup_mobile_google": "Google Play", + "client_setup_mobile_apple": "Apple Store", + "client_setup_footer_extra": "Once your Defguard client is configured, you can close this window." } diff --git a/webnext/package.json b/webnext/package.json index f78eb4d3..61c07d99 100644 --- a/webnext/package.json +++ b/webnext/package.json @@ -26,10 +26,13 @@ "@uidotdev/usehooks": "^2.4.1", "axios": "^1.12.2", "clsx": "^2.1.1", + "lodash-es": "^4.17.21", "motion": "^12.23.21", + "qrcode.react": "^4.2.0", "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 +40,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..8bc90490 100644 --- a/webnext/pnpm-lock.yaml +++ b/webnext/pnpm-lock.yaml @@ -44,9 +44,15 @@ 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) + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.1.1) qs: specifier: ^6.14.0 version: 6.14.0 @@ -56,6 +62,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 +78,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 +1206,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 +1934,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==} @@ -2101,6 +2122,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -2160,6 +2186,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 +3544,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 +4277,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.17.21: {} + lodash.merge@4.6.2: {} lodash.truncate@4.4.2: {} @@ -4385,6 +4422,10 @@ snapshots: punycode@2.3.1: {} + qrcode.react@4.2.0(react@19.1.1): + dependencies: + react: 19.1.1 + qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -4456,6 +4497,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/pages/ClientDownload/ClientDownloadPage.tsx b/webnext/src/pages/ClientDownload/ClientDownloadPage.tsx index 37a88202..d0647645 100644 --- a/webnext/src/pages/ClientDownload/ClientDownloadPage.tsx +++ b/webnext/src/pages/ClientDownload/ClientDownloadPage.tsx @@ -1,5 +1,6 @@ import './style.scss'; import { useNavigate } from '@tanstack/react-router'; +import { useState } from 'react'; import { m } from '../../paraglide/messages'; import { Page } from '../../shared/components/Page/Page'; import { PageNavigation } from '../../shared/components/PageNavigation/PageNavigation'; @@ -7,6 +8,8 @@ import { EnrollmentStep } from '../../shared/components/Step/Step'; import { Button } from '../../shared/defguard-ui/components/Button/Button'; import { Icon } from '../../shared/defguard-ui/components/Icon'; import type { IconKindValue } from '../../shared/defguard-ui/components/Icon/icon-types'; +import { Modal } from '../../shared/defguard-ui/components/Modal/Modal'; +import { ModalControls } from '../../shared/defguard-ui/components/ModalControls/ModalControls'; import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; import { ThemeSpacing } from '../../shared/defguard-ui/types'; import androidIcon from './assets/android.png'; @@ -15,6 +18,7 @@ import laptopIcon from './assets/laptop.png'; import desktopIcon from './assets/pc-tower.png'; export const ClientDownloadPage = () => { + const [modalOpen, setModalOpen] = useState(false); const navigate = useNavigate(); return ( @@ -87,6 +91,31 @@ export const ClientDownloadPage = () => { icon={iosIcon} /> + { + setModalOpen(false); + }} + > +

{m.client_download_modal_content()}

+ setModalOpen(false), + }} + submitProps={{ + text: m.controls_continue(), + onClick: () => { + navigate({ + to: '/enrollment-start', + replace: true, + }); + }, + }} + /> +
{ @@ -97,7 +126,7 @@ export const ClientDownloadPage = () => { }} nextText={m.controls_continue()} onNext={() => { - console.log('todo'); + setModalOpen(true); }} /> diff --git a/webnext/src/pages/ClientDownload/style.scss b/webnext/src/pages/ClientDownload/style.scss index 00425584..22d74f67 100644 --- a/webnext/src/pages/ClientDownload/style.scss +++ b/webnext/src/pages/ClientDownload/style.scss @@ -11,6 +11,7 @@ flex-flow: row nowrap; align-items: center; justify-content: flex-start; + column-gap: var(--spacing-lg); p { font: var(--t-body-sm-400); diff --git a/webnext/src/pages/Home/HomePage.tsx b/webnext/src/pages/Home/HomePage.tsx index e03e12ea..4195666f 100644 --- a/webnext/src/pages/Home/HomePage.tsx +++ b/webnext/src/pages/Home/HomePage.tsx @@ -17,12 +17,9 @@ export const HomePage = () => {
-

{m.start_footer_contact()}

-

{m.start_footer_copyright({ currentYear: currentYear.toString() })}

- ); }; diff --git a/webnext/src/pages/Home/components/HomeChoice.tsx b/webnext/src/pages/Home/components/HomeChoice.tsx index 6ebfacbb..eeb44325 100644 --- a/webnext/src/pages/Home/components/HomeChoice.tsx +++ b/webnext/src/pages/Home/components/HomeChoice.tsx @@ -20,7 +20,7 @@ export const HomeChoice = () => { title={m.start_multi_enrollment_title()} subtitle={m.start_multi_enrollment_subtitle()} buttonText={m.start_multi_enrollment_button()} - buttonIcon="lock-open" + buttonIcon="arrow-big" link="/download" onClick={() => {}} /> diff --git a/webnext/src/pages/Home/style.scss b/webnext/src/pages/Home/style.scss index 168c3a09..5e07d11f 100644 --- a/webnext/src/pages/Home/style.scss +++ b/webnext/src/pages/Home/style.scss @@ -19,20 +19,11 @@ & > footer { margin-top: auto; - padding-top: 50px; p { text-align: center; - - &:nth-child(1) { - font: var(--t-body-sm-400); - color: var(--fg-neutral); - } - - &:nth-child(3) { - font: var(--t-body-xs-400); - color: var(--fg-muted); - } + font: var(--t-body-xs-400); + color: var(--fg-muted); } } } diff --git a/webnext/src/pages/PasswordForm/PasswordFormPage.tsx b/webnext/src/pages/PasswordForm/PasswordFormPage.tsx index 3faaa8af..44c9b884 100644 --- a/webnext/src/pages/PasswordForm/PasswordFormPage.tsx +++ b/webnext/src/pages/PasswordForm/PasswordFormPage.tsx @@ -4,9 +4,11 @@ import { Container } from '../../shared/components/Container/Container'; import { Page } from '../../shared/components/Page/Page'; import './style.scss'; import { revalidateLogic, useStore } from '@tanstack/react-form'; -import { useNavigate } from '@tanstack/react-router'; +import { useMutation } from '@tanstack/react-query'; +import { useNavigate, useSearch } from '@tanstack/react-router'; import clsx from 'clsx'; import { useCallback } from 'react'; +import { api } from '../../shared/api/api'; import { Button } from '../../shared/defguard-ui/components/Button/Button'; import { Icon } from '../../shared/defguard-ui/components/Icon'; import type { IconKindValue } from '../../shared/defguard-ui/components/Icon/icon-types'; @@ -91,7 +93,7 @@ const formSchema = z path: ['password'], }); } - if (repeat.length) { + if (repeat.length && repeat !== password) { ctx.addIssue({ message: m.password_form_check_repeat_match(), code: 'custom', @@ -109,8 +111,19 @@ const defaultFormValues: FormFields = { }; export const PasswordFormPage = () => { + const { token } = useSearch({ + from: '/password-reset', + }); + const navigate = useNavigate(); + const { mutateAsync } = useMutation({ + mutationFn: api.password.finish.callbackFn, + onError: (e) => { + console.error(e); + }, + }); + const form = useAppForm({ defaultValues: defaultFormValues, validationLogic: revalidateLogic({ @@ -120,10 +133,15 @@ export const PasswordFormPage = () => { validators: { onDynamic: formSchema, }, - onSubmit: (values) => { - console.table(values); + onSubmit: async ({ value }) => { + await mutateAsync({ + data: { + password: value.password, + token, + }, + }); navigate({ - to: '/password/form/finish', + to: '/password/finish', replace: true, }); }, @@ -164,7 +182,15 @@ export const PasswordFormPage = () => { )} - + + + +
+
{m.client_setup_mobile_title()}
+

{m.client_setup_mobile_subtitle()}

+
+
+
+ +
+
+

{m.client_setup_mobile_forgot()}

+ +
+
+
+
+

{m.client_setup_footer_extra()}

+ +
+ + ); +}; diff --git a/webnext/src/pages/enrollment/ConfigureClient/style.scss b/webnext/src/pages/enrollment/ConfigureClient/style.scss new file mode 100644 index 00000000..3773a306 --- /dev/null +++ b/webnext/src/pages/enrollment/ConfigureClient/style.scss @@ -0,0 +1,121 @@ +/* stylelint-disable no-descending-specificity */ +#configure-client-page .page-content { + .container-with-icon { + width: 100%; + + header { + padding-bottom: var(--spacing-2xl); + } + } + + footer { + p { + font: var(--t-body-xs-500); + color: var(--fg-muted); + + &.finish { + color: var(--fg-success); + } + } + } +} + +#configure-client-page #setup-desktop { + .buttons { + display: flex; + flex-flow: row; + align-items: center; + justify-content: space-between; + } + + .divider { + padding: var(--spacing-xl) 0; + } + + .fold-button { + display: grid; + grid-template-columns: 20px auto; + grid-template-rows: 1fr; + column-gap: var(--spacing-sm); + align-items: center; + background-color: transparent; + border: none; + cursor: pointer; + padding: 0; + margin: 0; + + svg path { + fill: var(--fg-action); + } + + & > span { + font: var(--t-body-sm-400); + color: var(--fg-action); + } + } + + header { + p { + &:nth-child(2) { + font: var(--t-body-sm-600); + color: var(--fg-faded); + } + + &:nth-child(3) { + font: var(--t-body-sm-400); + color: var(--fg-neutral); + + span { + color: var(--fg-attention); + } + } + } + } + + .manual { + & > p { + &:nth-child(1) { + font: var(--t-body-sm-600); + color: var(--fg-faded); + } + + &:nth-child(2) { + font: var(--t-body-sm-400); + color: var(--fg-neutral); + } + } + } +} + +#configure-client-page #setup-mobile { + header { + p { + font: var(--t-body-sm-400); + color: var(--fg-neutral); + } + } + + .bottom { + display: grid; + grid-template-columns: 100px 1fr; + grid-template-rows: 1fr; + align-items: center; + column-gap: var(--spacing-4xl); + } + + .download { + & > p { + font: var(--t-body-xs-500); + color: var(--fg-muted); + padding-bottom: var(--spacing-lg); + } + + .links { + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + column-gap: var(--spacing-md); + } + } +} diff --git a/webnext/src/pages/enrollment/EnrollmentStart/EnrollmentStartPage.tsx b/webnext/src/pages/enrollment/EnrollmentStart/EnrollmentStartPage.tsx new file mode 100644 index 00000000..b17ae2a9 --- /dev/null +++ b/webnext/src/pages/enrollment/EnrollmentStart/EnrollmentStartPage.tsx @@ -0,0 +1,134 @@ +import { useLoaderData, 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 { useMutation } from '@tanstack/react-query'; +import z from 'zod'; +import { api } from '../../../shared/api/api'; +import { Button } from '../../../shared/defguard-ui/components/Button/Button'; +import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { useAppForm } from '../../../shared/defguard-ui/form'; +import { ThemeSpacing } from '../../../shared/defguard-ui/types'; +import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; +import { useEnrollmentStore } from '../../../shared/hooks/useEnrollmentStore'; + +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 loaderData = useLoaderData({ + from: '/enrollment-start', + }); + const setEnrollment = useEnrollmentStore((s) => s.setState); + + const { mutateAsync } = useMutation({ + mutationFn: api.enrollment.start.callbackFn, + onError: (e) => { + console.error(e); + }, + }); + + const form = useAppForm({ + defaultValues, + validationLogic: revalidateLogic({ + mode: 'change', + modeAfterSubmission: 'change', + }), + validators: { + onDynamic: formSchema, + onSubmit: formSchema, + }, + onSubmit: async ({ value }) => { + const response = await mutateAsync({ + data: { + token: value.token, + }, + }); + + setEnrollment({ + enrollmentData: response.data, + token: value.token, + }); + + navigate({ + to: '/client-setup', + replace: true, + }); + }, + }); + + return ( + + +
+

{m.enrollment_start_title()}

+

{m.enrollment_start_subtitle()}

+
+ + {isPresent(loaderData?.url) && isPresent(loaderData.button_display_name) && ( + <> + +
+
{m.enrollment_start_external_title()}
+

{m.enrollment_start_external_subtitle()}

+
+ + + + + + {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 53ea8180..fe7c73a9 100644 --- a/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx +++ b/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx @@ -9,12 +9,18 @@ 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 { IconConfig } from './icons/IconConfig'; +import { IconCopy } from './icons/IconCopy'; 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'; 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'; @@ -57,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': @@ -89,8 +101,14 @@ export const Icon = ({ return IconApple; case 'android': return IconAndroid; + case 'close': + return IconClose; + case 'file': + return IconFile; + case 'globe': + return IconGlobe; default: - throw Error('Unimplemented icon kind'); + throw Error(`Unimplemented icon kind: ${iconKind}`); } }, [iconKind]); 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/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/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/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/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/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/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/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..f801cf37 --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Modal/style.scss @@ -0,0 +1,73 @@ +#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); + + & > p { + font: var(--t-body-sm-400); + } + } + } + } +} 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..1623867e --- /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/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 29412a26..647481fb 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 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 c8bf9e70..3c3d0a48 100644 --- a/webnext/src/shared/defguard-ui/scss/_shared_tokens.scss +++ b/webnext/src/shared/defguard-ui/scss/_shared_tokens.scss @@ -157,7 +157,7 @@ $source-code-pro: // Menu --t-menu-title: 600 12px/24px #{$geist}; - --t-menu-body: 400 14px/24px #{$geist}; + --t-menu-text: 400 14px/24px #{$geist}; --menu-spacing-icon: var(--spacing-md); --menu-padding-sides: var(--spacing-sm); --menu-border-radius-group: var(--radius-lg); @@ -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/src/test_components/page/TestPage.tsx b/webnext/src/test_components/page/TestPage.tsx index 52284719..ff226153 100644 --- a/webnext/src/test_components/page/TestPage.tsx +++ b/webnext/src/test_components/page/TestPage.tsx @@ -1,4 +1,4 @@ -import { type PropsWithChildren, useState } from 'react'; +import { type PropsWithChildren, useMemo, useRef, useState } from 'react'; import { Page } from '../../shared/components/Page/Page'; import './style.scss'; import { Avatar } from '../../shared/defguard-ui/components/Avatar/Avatar'; @@ -9,7 +9,12 @@ 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 { Menu } from '../../shared/defguard-ui/components/Menu/Menu'; +import type { MenuItemsGroup } from '../../shared/defguard-ui/components/Menu/types'; +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'; +import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; export const TestPage = () => { return ( @@ -117,6 +122,10 @@ export const TestPage = () => { + + + +
); }; @@ -141,3 +150,93 @@ const TestButtonTransition = () => { /> ); }; + +const TestModalButton = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> +