diff --git a/webnext/src/pages/PasswordForm/PasswordFormPage.tsx b/webnext/src/pages/PasswordForm/PasswordFormPage.tsx index 44c9b884..b9be1e44 100644 --- a/webnext/src/pages/PasswordForm/PasswordFormPage.tsx +++ b/webnext/src/pages/PasswordForm/PasswordFormPage.tsx @@ -167,6 +167,7 @@ export const PasswordFormPage = () => { )} @@ -178,7 +179,11 @@ export const PasswordFormPage = () => { }} > {(field) => ( - + )} diff --git a/webnext/src/routeTree.gen.ts b/webnext/src/routeTree.gen.ts index d5a618ff..2c91b64d 100644 --- a/webnext/src/routeTree.gen.ts +++ b/webnext/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as PasswordIndexRouteImport } from './routes/password/index' import { Route as PasswordSentRouteImport } from './routes/password/sent' import { Route as PasswordFinishRouteImport } from './routes/password/finish' +import { Route as OpenidCallbackRouteImport } from './routes/openid/callback' const TestRoute = TestRouteImport.update({ id: '/test', @@ -64,6 +65,11 @@ const PasswordFinishRoute = PasswordFinishRouteImport.update({ path: '/password/finish', getParentRoute: () => rootRouteImport, } as any) +const OpenidCallbackRoute = OpenidCallbackRouteImport.update({ + id: '/openid/callback', + path: '/openid/callback', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -72,6 +78,7 @@ export interface FileRoutesByFullPath { '/enrollment-start': typeof EnrollmentStartRoute '/password-reset': typeof PasswordResetRoute '/test': typeof TestRoute + '/openid/callback': typeof OpenidCallbackRoute '/password/finish': typeof PasswordFinishRoute '/password/sent': typeof PasswordSentRoute '/password': typeof PasswordIndexRoute @@ -83,6 +90,7 @@ export interface FileRoutesByTo { '/enrollment-start': typeof EnrollmentStartRoute '/password-reset': typeof PasswordResetRoute '/test': typeof TestRoute + '/openid/callback': typeof OpenidCallbackRoute '/password/finish': typeof PasswordFinishRoute '/password/sent': typeof PasswordSentRoute '/password': typeof PasswordIndexRoute @@ -95,6 +103,7 @@ export interface FileRoutesById { '/enrollment-start': typeof EnrollmentStartRoute '/password-reset': typeof PasswordResetRoute '/test': typeof TestRoute + '/openid/callback': typeof OpenidCallbackRoute '/password/finish': typeof PasswordFinishRoute '/password/sent': typeof PasswordSentRoute '/password/': typeof PasswordIndexRoute @@ -108,6 +117,7 @@ export interface FileRouteTypes { | '/enrollment-start' | '/password-reset' | '/test' + | '/openid/callback' | '/password/finish' | '/password/sent' | '/password' @@ -119,6 +129,7 @@ export interface FileRouteTypes { | '/enrollment-start' | '/password-reset' | '/test' + | '/openid/callback' | '/password/finish' | '/password/sent' | '/password' @@ -130,6 +141,7 @@ export interface FileRouteTypes { | '/enrollment-start' | '/password-reset' | '/test' + | '/openid/callback' | '/password/finish' | '/password/sent' | '/password/' @@ -142,6 +154,7 @@ export interface RootRouteChildren { EnrollmentStartRoute: typeof EnrollmentStartRoute PasswordResetRoute: typeof PasswordResetRoute TestRoute: typeof TestRoute + OpenidCallbackRoute: typeof OpenidCallbackRoute PasswordFinishRoute: typeof PasswordFinishRoute PasswordSentRoute: typeof PasswordSentRoute PasswordIndexRoute: typeof PasswordIndexRoute @@ -212,6 +225,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PasswordFinishRouteImport parentRoute: typeof rootRouteImport } + '/openid/callback': { + id: '/openid/callback' + path: '/openid/callback' + fullPath: '/openid/callback' + preLoaderRoute: typeof OpenidCallbackRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -222,6 +242,7 @@ const rootRouteChildren: RootRouteChildren = { EnrollmentStartRoute: EnrollmentStartRoute, PasswordResetRoute: PasswordResetRoute, TestRoute: TestRoute, + OpenidCallbackRoute: OpenidCallbackRoute, PasswordFinishRoute: PasswordFinishRoute, PasswordSentRoute: PasswordSentRoute, PasswordIndexRoute: PasswordIndexRoute, diff --git a/webnext/src/routes/client-setup.tsx b/webnext/src/routes/client-setup.tsx index 635d58cb..787878fb 100644 --- a/webnext/src/routes/client-setup.tsx +++ b/webnext/src/routes/client-setup.tsx @@ -1,17 +1,31 @@ import { createFileRoute, redirect } from '@tanstack/react-router'; +import z from 'zod'; import { ConfigureClientPage } from '../pages/enrollment/ConfigureClient/ConfigureClientPage'; +import { api } from '../shared/api/api'; import type { EnrollmentStartResponse } from '../shared/api/types'; +import { isPresent } from '../shared/defguard-ui/utils/isPresent'; import { useEnrollmentStore } from '../shared/hooks/useEnrollmentStore'; -const _fetchDesktop = async () => { - const _resp = await fetch('https://api.git.com/repos/defguard/client/releases/latest', { - method: 'GET', - }); -}; +const schema = z.object({ + code: z.string().trim().optional(), + state: z.string().trim().optional(), +}); export const Route = createFileRoute('/client-setup')({ component: ConfigureClientPage, - beforeLoad: () => { + validateSearch: schema, + loaderDeps: ({ search }) => ({ search }), + beforeLoad: ({ search }) => { + // if openId flow just pass the search to the context + if (search && isPresent(search.code) && isPresent(search.state)) { + return { + openid: { + code: search.code, + state: search.state, + }, + }; + } + // if not openId then expect state to be in session const state = useEnrollmentStore.getState(); if (state.token === undefined || state.enrollmentData === undefined) { throw redirect({ @@ -19,8 +33,39 @@ export const Route = createFileRoute('/client-setup')({ replace: true, }); } + return { + openid: undefined, + }; }, - loader: () => { + loader: async ({ context: { openid } }) => { + if (openid) { + try { + const openIdResponse = await api.openId.enrollmentCallback.callbackFn({ + data: { + code: openid.code, + state: openid.state, + type: 'enrollment', + }, + }); + + const enrollResponse = await api.enrollment.start.callbackFn({ + data: { + token: openIdResponse.data.token, + }, + }); + + return { + token: openIdResponse.data.token, + enrollmentData: enrollResponse.data, + }; + } catch (e) { + console.error(e); + throw redirect({ + to: '/', + replace: true, + }); + } + } const state = useEnrollmentStore.getState(); return { token: state.token as string, diff --git a/webnext/src/routes/enrollment-start.tsx b/webnext/src/routes/enrollment-start.tsx index 68785a8b..b8c546f8 100644 --- a/webnext/src/routes/enrollment-start.tsx +++ b/webnext/src/routes/enrollment-start.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, redirect } from '@tanstack/react-router'; +import { createFileRoute } from '@tanstack/react-router'; import type { AxiosError } from 'axios'; import { EnrollmentStartPage } from '../pages/enrollment/EnrollmentStart/EnrollmentStartPage'; import { api } from '../shared/api/api'; @@ -6,6 +6,7 @@ import { api } from '../shared/api/api'; export const Route = createFileRoute('/enrollment-start')({ component: EnrollmentStartPage, loader: async () => { + // workaround cuz this endpoint throws 404 when openid is not configured const resp = await api.openId.authInfo .callbackFn({ data: { @@ -13,15 +14,8 @@ export const Route = createFileRoute('/enrollment-start')({ }, }) .catch((e: AxiosError) => { - //FIXME: should redirect - if (e.status !== 500) { - throw redirect({ - to: '/', - replace: true, - }); - } + console.error(e); }); - //FIXME return resp?.data; }, }); diff --git a/webnext/src/routes/openid/callback.tsx b/webnext/src/routes/openid/callback.tsx new file mode 100644 index 00000000..47173022 --- /dev/null +++ b/webnext/src/routes/openid/callback.tsx @@ -0,0 +1,24 @@ +import { createFileRoute, redirect } from '@tanstack/react-router'; +import z from 'zod'; + +const schema = z.object({ + state: z.string().trim().min(1), + code: z.string().trim().min(1), +}); + +// this route exists only for redirect, this is done to maintain compatibility with old UI and retain existing links +export const Route = createFileRoute('/openid/callback')({ + component: RouteComponent, + validateSearch: schema, + loaderDeps: ({ search }) => ({ search }), + beforeLoad: ({ search }) => { + throw redirect({ + to: '/client-setup', + search: search, + }); + }, +}); + +function RouteComponent() { + return null; +} diff --git a/webnext/src/shared/defguard-ui/components/FieldBox/FieldBox.tsx b/webnext/src/shared/defguard-ui/components/FieldBox/FieldBox.tsx new file mode 100644 index 00000000..7912214b --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/FieldBox/FieldBox.tsx @@ -0,0 +1,49 @@ +import './style.scss'; +import clsx from 'clsx'; +import { isPresent } from '../../utils/isPresent'; +import { Icon } from '../Icon'; +import { InteractionBox } from '../InteractionBox/InteractionBox'; +import type { FieldBoxProps } from './types'; + +// generalized field box for components like Input, shouldn't be in layout on it's own +export const FieldBox = ({ + children, + disabled, + error, + className, + boxRef, + interactionRef, + iconLeft, + iconRight, + size, + onInteractionClick, + ...rest +}: FieldBoxProps) => { + const hasIconLeft = isPresent(iconLeft); + const hasIconRight = isPresent(iconRight); + return ( +
+ {hasIconLeft && } + {children} + {hasIconRight && ( + + )} +
+ ); +}; diff --git a/webnext/src/shared/defguard-ui/components/FieldBox/style.scss b/webnext/src/shared/defguard-ui/components/FieldBox/style.scss new file mode 100644 index 00000000..9732c9fd --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/FieldBox/style.scss @@ -0,0 +1,97 @@ +.field-box { + --outline-width: 0; + --border-color: var(--border-default); + --background-color: var(--bg-default); + + position: relative; + box-sizing: border-box; + display: grid; + grid-template-rows: 1fr; + align-items: center; + column-gap: var(--input-spacing-sm); + overflow: hidden; + cursor: pointer; + border: 1px solid var(--border-color); + border-radius: var(--input-border-radius); + padding: var(--input-spacing-sm) var(--input-spacing-lg); + outline: var(--outline-width) solid var(--border-action); + outline-offset: -2px; + + @include animate(border-color, background-color); + + p, + span { + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + &.size-default { + min-height: var(--input-size-primary); + + p, + span { + font: var(--t-input-text-primary); + color: var(--fg-default); + } + } + + &.size-lg { + min-height: var(--input-size-lg); + + p, + span { + font: var(--t-input-text-big); + } + } + + &.grid-default { + grid-template-columns: 1fr; + } + + &.grid-left { + grid-template-columns: 20px 1fr; + } + + &.grid-right { + grid-template-columns: 1fr 20px; + } + + &.grid-both { + grid-template-columns: 20px 1fr 20px; + } + + .placeholder { + color: var(--fg-muted); + } + + .interaction-box { + & > button { + height: 28px; + width: 28px; + } + } + + &:not(.disabled, .error) { + &:hover { + --border-color: var(--border-emphasis); + } + + &:focus-within { + --border-colo: var(--border-default); + --outline-width: 2px; + } + } + + &.error { + --border-color: var(--border-critical); + } + + &.disabled { + --border-color: var(--border-disabled); + --background-color: var(--bg-disabled); + + cursor: not-allowed; + } +} diff --git a/webnext/src/shared/defguard-ui/components/FieldBox/types.ts b/webnext/src/shared/defguard-ui/components/FieldBox/types.ts new file mode 100644 index 00000000..94f4ff05 --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/FieldBox/types.ts @@ -0,0 +1,15 @@ +import type { HTMLAttributes, MouseEventHandler, PropsWithChildren, Ref } from 'react'; +import type { IconKindValue } from '../Icon/icon-types'; + +export type FieldSize = 'lg' | 'default'; + +export interface FieldBoxProps extends HTMLAttributes, PropsWithChildren { + boxRef?: Ref; + interactionRef?: Ref; + error?: boolean; + disabled?: boolean; + iconLeft?: IconKindValue; + iconRight?: IconKindValue; + size?: FieldSize; + onInteractionClick?: MouseEventHandler; +} diff --git a/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx b/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx index fe7c73a9..85cb37e1 100644 --- a/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx +++ b/webnext/src/shared/defguard-ui/components/Icon/Icon.tsx @@ -16,12 +16,14 @@ import { IconDesktop } from './icons/IconDesktop'; import { IconEmptyPoint } from './icons/IconEmptyPoint'; import { IconFile } from './icons/IconFile'; import { IconGlobe } from './icons/IconGlobe'; +import { IconHide } from './icons/IconHide'; 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 { IconShow } from './icons/IconShow'; import { IconStatusSimple } from './icons/IconStatusSimple'; import { IconWindows } from './icons/IconWindows'; @@ -63,6 +65,10 @@ export const Icon = ({ }: Props) => { const IconToRender = useMemo(() => { switch (iconKind) { + case 'show': + return IconShow; + case 'hide': + return IconHide; case 'copy': return IconCopy; case 'config': diff --git a/webnext/src/shared/defguard-ui/components/Icon/icon-types.ts b/webnext/src/shared/defguard-ui/components/Icon/icon-types.ts index eacdc835..2ac20707 100644 --- a/webnext/src/shared/defguard-ui/components/Icon/icon-types.ts +++ b/webnext/src/shared/defguard-ui/components/Icon/icon-types.ts @@ -1,4 +1,5 @@ export const IconKind = { + Hide: 'hide', ArrowBig: 'arrow-big', ArrowSmall: 'arrow-small', PlusCircle: 'plus-circle', @@ -43,7 +44,7 @@ export const IconKind = { Activity: 'activity', AccessSettings: 'access-settings', Profile: 'profile', - Attension: 'attension', + Attention: 'attention', Warning: 'warning', Download: 'download', Code: 'code', diff --git a/webnext/src/shared/defguard-ui/components/Icon/icons/IconHide.tsx b/webnext/src/shared/defguard-ui/components/Icon/icons/IconHide.tsx new file mode 100644 index 00000000..3dbe44a0 --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Icon/icons/IconHide.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconHide = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/webnext/src/shared/defguard-ui/components/Icon/icons/IconShow.tsx b/webnext/src/shared/defguard-ui/components/Icon/icons/IconShow.tsx new file mode 100644 index 00000000..54ee682f --- /dev/null +++ b/webnext/src/shared/defguard-ui/components/Icon/icons/IconShow.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +export const IconShow = (props: SVGProps) => { + return ( + + + + ); +}; diff --git a/webnext/src/shared/defguard-ui/components/Input/Input.tsx b/webnext/src/shared/defguard-ui/components/Input/Input.tsx index c24d6a57..0196787c 100644 --- a/webnext/src/shared/defguard-ui/components/Input/Input.tsx +++ b/webnext/src/shared/defguard-ui/components/Input/Input.tsx @@ -1,10 +1,12 @@ -import { useId, useRef } from 'react'; +import { type HTMLInputTypeAttribute, useId, useMemo, useRef, useState } from 'react'; import './style.scss'; import clsx from 'clsx'; import { isPresent } from '../../utils/isPresent'; import { mergeRefs } from '../../utils/mergeRefs'; +import { FieldBox } from '../FieldBox/FieldBox'; import { FieldError } from '../FieldError/FieldError'; import { FieldLabel } from '../FieldLabel/FieldLabel'; +import type { IconKindValue } from '../Icon/icon-types'; import type { InputProps } from './types'; export const Input = ({ @@ -13,17 +15,33 @@ export const Input = ({ label, ref, name, + placeholder, + onChange, + onBlur, + onFocus, + boxProps, size = 'default', type = 'text', required = false, disabled = false, - onChange, - onBlur, - onFocus, - placeholder, + autocomplete = 'off', }: InputProps) => { + const isPassword = useMemo(() => type === 'password', [type]); + + const [inputTypeInner, setInputType] = useState(type); const innerRef = useRef(null); const id = useId(); + + const interactionIconRight = useMemo((): IconKindValue | undefined => { + if (isPassword) { + if (inputTypeInner === 'password') { + return 'show'; + } else { + return 'hide'; + } + } + }, [isPassword, inputTypeInner]); + return (
{isPresent(label) && } -
{ innerRef.current?.focus(); }} + iconRight={interactionIconRight} + onInteractionClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + if (isPassword) { + setInputType((s) => { + if (s === 'password') { + return 'text'; + } + return 'password'; + }); + } + }} + {...boxProps} > -
+
diff --git a/webnext/src/shared/defguard-ui/components/Input/style.scss b/webnext/src/shared/defguard-ui/components/Input/style.scss index 34047cba..b2db68c8 100644 --- a/webnext/src/shared/defguard-ui/components/Input/style.scss +++ b/webnext/src/shared/defguard-ui/components/Input/style.scss @@ -11,34 +11,16 @@ user-select: none; } - .track { - --outline-width: 0; - --border-color: var(--border-default); - --background-color: var(--bg-default); - --input-size: var(--input-size-primary); - - box-sizing: border-box; - cursor: pointer; - border: 1px solid var(--border-color); - border-radius: var(--input-border-radius); - position: relative; - padding: var(--input-spacing-sm) var(--input-spacing-lg); - display: flex; - flex-flow: row; - align-items: center; - outline: var(--outline-width) solid var(--border-action); - outline-offset: -2px; - - @include animate(border-color, background-color); - + .input-track { input { font: var(--t-input-text-primary); color: var(--fg-default); border: none; background: none; border-radius: 0; - height: var(--input-size); width: 100%; + max-width: 100%; + overflow: hidden; margin: 0; padding: 0; @@ -46,30 +28,15 @@ font: var(--t-input-text-primary); color: var(--fg-muted); } - } - - &:not(.disabled) { - &:hover { - --border-color: var(--border-emphasis); - } - &:focus-within { - --border-colo: var(--border-default); - --outline-width: 2px; + &:disabled { + cursor: not-allowed; } } - - &.disabled { - --border-color: var(--border-disabled); - --background-color: var(--bg-disabled); - - cursor: not-allowed; - pointer-events: none; - } } &.disabled { - pointer-events: none; + user-select: none; & > .field-label { cursor: not-allowed; diff --git a/webnext/src/shared/defguard-ui/components/Input/types.ts b/webnext/src/shared/defguard-ui/components/Input/types.ts index 4af97b19..b406c109 100644 --- a/webnext/src/shared/defguard-ui/components/Input/types.ts +++ b/webnext/src/shared/defguard-ui/components/Input/types.ts @@ -1,9 +1,10 @@ -import type { HTMLAttributes, HTMLInputTypeAttribute, Ref } from 'react'; +import type { HTMLAttributes, HTMLInputAutoCompleteAttribute, Ref } from 'react'; +import type { FieldBoxProps, FieldSize } from '../FieldBox/types'; export type InputProps = { value: string | null; - size?: 'default' | 'big'; - type?: HTMLInputTypeAttribute; + size?: FieldSize; + type?: 'password' | 'text'; ref?: Ref; error?: string; name?: string; @@ -12,11 +13,13 @@ export type InputProps = { disabled?: boolean; placeholder?: string; onChange?: (value: string) => void; + boxProps?: Partial; + autocomplete?: HTMLInputAutoCompleteAttribute; } & Pick, 'onBlur' | 'onFocus'>; export type FormInputProps = Pick< InputProps, - 'name' | 'placeholder' | 'disabled' | 'required' | 'label' + 'name' | 'placeholder' | 'disabled' | 'required' | 'label' | 'autocomplete' | 'type' > & { mapError?: (error: string) => string | undefined; }; diff --git a/webnext/src/shared/defguard-ui/components/InteractionBox/InteractionBox.tsx b/webnext/src/shared/defguard-ui/components/InteractionBox/InteractionBox.tsx index 81531e4f..c648e536 100644 --- a/webnext/src/shared/defguard-ui/components/InteractionBox/InteractionBox.tsx +++ b/webnext/src/shared/defguard-ui/components/InteractionBox/InteractionBox.tsx @@ -1,4 +1,4 @@ -import type { Ref } from 'react'; +import type { MouseEventHandler, Ref } from 'react'; import { Icon } from '../Icon'; import type { IconKindValue } from '../Icon/icon-types'; import './style.scss'; @@ -7,11 +7,12 @@ import clsx from 'clsx'; type Props = { iconSize: number; icon: IconKindValue; - onClick?: () => void; + onClick?: MouseEventHandler; id?: string; className?: string; ref?: Ref; disabled?: boolean; + tabIndex?: number; }; export const InteractionBox = ({ @@ -21,12 +22,21 @@ export const InteractionBox = ({ className, id, ref, + tabIndex, disabled = false, }: Props) => { return (
- +
); }; diff --git a/webnext/src/test_components/page/TestPage.tsx b/webnext/src/test_components/page/TestPage.tsx index ff226153..9f47f723 100644 --- a/webnext/src/test_components/page/TestPage.tsx +++ b/webnext/src/test_components/page/TestPage.tsx @@ -9,6 +9,7 @@ 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 { Input } from '../../shared/defguard-ui/components/Input/Input'; 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'; @@ -126,6 +127,29 @@ export const TestPage = () => { + + + + + + + + + + + + + + + + + ); };