= [Path] extends [never] ? { path?: never } : { path: Path };
+
+type ParamsProp = [P] extends [never]
+ ? { params?: never }
+ : [P] extends [void]
+ ? { params?: undefined }
+ : { params: P };
type GetDeleteProps = PathProp &
- (Params extends void ? { params?: undefined } : { params: Params }) & {
+ ParamsProp & {
data?: never;
abortSignal?: AbortSignal;
};
type PostPatchProps = PathProp & {
data: Body;
-} & (Params extends never ? { params?: undefined } : { params: Params }) & {
+} & ParamsProp & {
abortSignal?: AbortSignal;
};
@@ -51,24 +59,31 @@ type RequestHandle = {
invalidateKeys?: QueryKey[];
};
-type Cfg = {
+type Cfg> = {
method: M;
- url: UrlLike;
+ url: U;
invalidateKeys?: QueryKey[];
};
-function createRequest(
- cfg: Cfg | Cfg,
-): RequestHandle<(props: GetDeleteProps) => Promise>>;
+function createRequest, Params = void, Res = unknown>(
+ cfg: Cfg | Cfg,
+): RequestHandle<
+ (props: GetDeleteProps>) => Promise>
+>;
-function createRequest(
- cfg: Cfg | Cfg,
+function createRequest<
+ U extends UrlLike,
+ Body = unknown,
+ Res = unknown,
+ Params = never,
+>(
+ cfg: Cfg | Cfg,
): RequestHandle<
- (props: PostPatchProps) => Promise>
+ (props: PostPatchProps>) => Promise>
>;
function createRequest(
- cfg: Cfg,
+ cfg: Cfg>,
): RequestHandle<(props: any) => Promise>> {
const callbackFn = async (props: any = {}): Promise> => {
const { abortSignal, path, ...rest } = props ?? {};
@@ -81,7 +96,10 @@ function createRequest(
throw new Error(`[${method.toUpperCase()}] requires 'data'.`);
}
- const finalUrl = typeof cfg.url === 'function' ? cfg.url(path) : cfg.url;
+ const finalUrl =
+ typeof cfg.url === 'function'
+ ? (cfg.url as (p: any) => string)(path)
+ : (cfg.url as string);
const axiosRes = await client.request({
url: finalUrl,
@@ -103,35 +121,71 @@ function createRequest(
type HelperOpts = { invalidateKeys?: QueryKey[] };
-export const get = (url: UrlLike) =>
- createRequest({ method: RequestMethod.Get, url });
+export function get(
+ url: string,
+ opts?: HelperOpts,
+): RequestHandle<(props: GetDeleteProps) => Promise>>;
+export function get(
+ url: (path: P) => string,
+ opts?: HelperOpts,
+): RequestHandle<(props: GetDeleteProps) => Promise>>;
+// implementation signature must be compatible with both overloads:
+export function get(url: UrlLike, opts?: HelperOpts) {
+ return createRequest({
+ method: RequestMethod.Get,
+ url: url as UrlLike,
+ invalidateKeys: opts?.invalidateKeys,
+ }) as any;
+}
-export const del = (
- url: UrlLike,
+export function del(
+ url: string,
opts?: HelperOpts,
-) =>
- createRequest({
+): RequestHandle<(props: GetDeleteProps) => Promise>>;
+export function del(
+ url: (path: P) => string,
+ opts?: HelperOpts,
+): RequestHandle<(props: GetDeleteProps) => Promise>>;
+export function del(url: UrlLike, opts?: HelperOpts) {
+ return createRequest({
method: RequestMethod.Delete,
- url,
+ url: url as UrlLike,
invalidateKeys: opts?.invalidateKeys,
- });
+ }) as any;
+}
-export const post = (
- url: UrlLike,
+export function post(
+ url: string,
opts?: HelperOpts,
-) =>
- createRequest({
+): RequestHandle<
+ (props: PostPatchProps) => Promise>
+>;
+export function post(
+ url: (path: P) => string,
+ opts?: HelperOpts,
+): RequestHandle<(props: PostPatchProps) => Promise>>;
+export function post(url: UrlLike, opts?: HelperOpts) {
+ return createRequest({
method: RequestMethod.Post,
- url,
+ url: url as UrlLike,
invalidateKeys: opts?.invalidateKeys,
- });
+ }) as any;
+}
-export const patch = (
- url: UrlLike,
+export function patch(
+ url: string,
opts?: HelperOpts,
-) =>
- createRequest({
+): RequestHandle<
+ (props: PostPatchProps) => Promise>
+>;
+export function patch(
+ url: (path: P) => string,
+ opts?: HelperOpts,
+): RequestHandle<(props: PostPatchProps) => Promise>>;
+export function patch(url: UrlLike, opts?: HelperOpts) {
+ return createRequest({
method: RequestMethod.Patch,
- url,
+ url: url as UrlLike,
invalidateKeys: opts?.invalidateKeys,
- });
+ }) as any;
+}
diff --git a/webnext/src/shared/api/api.ts b/webnext/src/shared/api/api.ts
index ee93766b..58d3d332 100644
--- a/webnext/src/shared/api/api.ts
+++ b/webnext/src/shared/api/api.ts
@@ -10,6 +10,7 @@ import type {
OpenIdMfaCallbackRequest,
PasswordResetFinishRequest,
PasswordResetStartRequest,
+ PasswordResetStartResponse,
TokenRequest,
} from './types';
@@ -22,7 +23,7 @@ const api = {
sendEmail: post(
'/password-reset/request',
),
- start: post('/password-reset/start'),
+ start: post('/password-reset/start'),
finish: post('/password-reset/reset'),
},
openId: {
diff --git a/webnext/src/shared/api/types.ts b/webnext/src/shared/api/types.ts
index ae5f9301..a5feaaeb 100644
--- a/webnext/src/shared/api/types.ts
+++ b/webnext/src/shared/api/types.ts
@@ -32,6 +32,7 @@ export type EnrollmentSettings = {
export type EnrollmentStartResponse = {
admin: AdminInfo;
user: UserInfo;
+ instance: InstanceInfo;
deadline_timestamp: number;
final_page_content: string;
settings: EnrollmentSettings;
@@ -42,13 +43,12 @@ export type PasswordResetStartRequest = {
};
export type PasswordResetStartResponse = {
- admin: AdminInfo;
- user: UserInfo;
deadline_timestamp: number;
};
export type PasswordResetFinishRequest = {
password: string;
+ token: string;
};
export type OpenIdType = 'enrollment' | 'mfa';
@@ -59,8 +59,8 @@ export type OpenIdAuthInfoRequest = {
};
export type OpenIdAuthInfoResponse = {
- url: string;
- button_display_name: string;
+ url?: string;
+ button_display_name?: string;
};
export type OpenIdCallbackRequest = {
@@ -79,3 +79,14 @@ export type OpenIdMfaCallbackRequest = {
state: string;
type: 'mfa';
};
+
+export type InstanceInfo = {
+ id: string;
+ name: string;
+ url: string;
+ proxy_url: string;
+ username: string;
+ enterprise_enabled: boolean;
+ disable_all_traffic: boolean;
+ openid_display_name: string;
+};
diff --git a/webnext/src/shared/components/ContactFooter/ContactFooter.tsx b/webnext/src/shared/components/ContactFooter/ContactFooter.tsx
new file mode 100644
index 00000000..a5803af8
--- /dev/null
+++ b/webnext/src/shared/components/ContactFooter/ContactFooter.tsx
@@ -0,0 +1,14 @@
+import './style.scss';
+import { m } from '../../../paraglide/messages';
+
+type Props = {
+ email: string;
+};
+
+export const ContactFooter = ({ email }: Props) => {
+ return (
+
+ {m.footer_contact()} {email}
+
+ );
+};
diff --git a/webnext/src/shared/components/ContactFooter/style.scss b/webnext/src/shared/components/ContactFooter/style.scss
new file mode 100644
index 00000000..40369f7d
--- /dev/null
+++ b/webnext/src/shared/components/ContactFooter/style.scss
@@ -0,0 +1,16 @@
+.admin-contact {
+ font: var(--t-body-sm-400);
+ color: var(--fg-neutral);
+
+ & > a {
+ font: var(--t-body-sm-400);
+ color: var(--fg-action);
+ text-decoration: none;
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ text-decoration-color: var(--fg-action);
+ }
+ }
+}
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 (
+
+
+
+ );
+};
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/components/Page/style.scss b/webnext/src/shared/components/Page/style.scss
index 652b7463..d82dcfda 100644
--- a/webnext/src/shared/components/Page/style.scss
+++ b/webnext/src/shared/components/Page/style.scss
@@ -5,7 +5,7 @@
min-height: inherit;
&.variant-home {
- --content-cols: 3 / 10;
+ --content-cols: 3 / 11;
}
&.variant-small {
@@ -25,8 +25,8 @@
align-items: center;
justify-content: center;
box-sizing: border-box;
- padding-left: var(--spacing-xl);
- padding-right: var(--spacing-xl);
+ padding-left: 4px;
+ padding-right: 4px;
& > .content-limiter {
min-height: inherit;
@@ -47,6 +47,11 @@
flex-flow: column;
align-items: center;
+ & > footer {
+ padding-top: var(--spacing-3xl);
+ padding-bottom: var(--spacing-3xl);
+ }
+
.enrollment-step {
margin-bottom: var(--spacing-xl);
}
diff --git a/webnext/src/shared/components/PageNavigation/PageNavigation.tsx b/webnext/src/shared/components/PageNavigation/PageNavigation.tsx
index abb9652c..dadbceea 100644
--- a/webnext/src/shared/components/PageNavigation/PageNavigation.tsx
+++ b/webnext/src/shared/components/PageNavigation/PageNavigation.tsx
@@ -2,6 +2,7 @@ import { Button } from '../../defguard-ui/components/Button/Button';
import './style.scss';
type Props = {
+ loading?: boolean;
nextText: string;
backText: string;
onNext?: () => void;
@@ -17,6 +18,7 @@ export const PageNavigation = ({
nextDisabled,
onBack,
onNext,
+ loading = false,
}: Props) => {
return (
@@ -24,11 +26,16 @@ export const PageNavigation = ({
-
+
diff --git a/webnext/src/shared/consts.ts b/webnext/src/shared/consts.ts
index 0be8e07a..db856c80 100644
--- a/webnext/src/shared/consts.ts
+++ b/webnext/src/shared/consts.ts
@@ -3,3 +3,12 @@ export const motionTransitionStandard = {
ease: 'easeOut',
duration: 0.16,
} as const;
+
+export const externalLink = {
+ client: {
+ mobile: {
+ apple: 'https://apps.apple.com/us/app/defguard-vpn-client/id6748068630',
+ google: 'https://play.google.com/store/apps/details?id=net.defguard.mobile',
+ },
+ },
+};
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/CopyField/CopyField.tsx b/webnext/src/shared/defguard-ui/components/CopyField/CopyField.tsx
new file mode 100644
index 00000000..505236a4
--- /dev/null
+++ b/webnext/src/shared/defguard-ui/components/CopyField/CopyField.tsx
@@ -0,0 +1,78 @@
+import './style.scss';
+import { autoUpdate, FloatingPortal, offset, useFloating } from '@floating-ui/react';
+import clsx from 'clsx';
+import { type HTMLAttributes, type Ref, useEffect, useState } from 'react';
+import { useClipboard } from '../../../hooks/useClipboard';
+import { Icon } from '../Icon';
+import { Tooltip } from '../Tooltip/Tooltip';
+
+type Props = {
+ text: string;
+ label: string;
+ copyTooltip: string;
+ ref?: Ref;
+} & HTMLAttributes;
+
+export const CopyField = ({
+ text,
+ label,
+ ref,
+ className,
+ copyTooltip,
+ ...props
+}: Props) => {
+ const { writeToClipboard } = useClipboard();
+
+ const [copied, setCopied] = useState(false);
+
+ const { refs, floatingStyles } = useFloating({
+ placement: 'top',
+ whileElementsMounted: autoUpdate,
+ middleware: [offset(15)],
+ });
+
+ useEffect(() => {
+ if (copied) {
+ const clearCopied = () => {
+ setCopied(false);
+ };
+ const timeout = setTimeout(clearCopied, 1500);
+ return () => {
+ clearTimeout(timeout);
+ };
+ }
+ }, [copied]);
+
+ return (
+ <>
+
+
+
+
+
{text}
+
+
+
+
+ {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 (
+
+ );
+};
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}
+ >
+
+ {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 (
+ <>
+