From 4009046fdb31d6ba697ddb9c6b5232c13592b5b8 Mon Sep 17 00:00:00 2001 From: shit Date: Fri, 29 May 2026 00:49:58 +0300 Subject: [PATCH] Add Cloudflare Access authentication settings --- src/pages/auth/login/login.page.tsx | 61 +++++++- .../remnawave-settings.page.component.tsx | 7 + src/shared/api/hooks/auth/auth.hooks.ts | 21 +++ src/shared/api/hooks/auth/auth.query.hooks.ts | 17 ++- .../remnawave-settings.mutation.hooks.ts | 27 +++- .../remnawave-settings.query.hooks.ts | 10 +- .../authentification-settings-card.widget.tsx | 141 ++++++++++++++++-- 7 files changed, 270 insertions(+), 14 deletions(-) diff --git a/src/pages/auth/login/login.page.tsx b/src/pages/auth/login/login.page.tsx index 33833b5e..c195a581 100644 --- a/src/pages/auth/login/login.page.tsx +++ b/src/pages/auth/login/login.page.tsx @@ -1,22 +1,40 @@ import { Badge, Box, Center, Divider, Group, Image, Stack, Text, Title } from '@mantine/core' import { GetStatusCommand } from '@remnawave/backend-contract' -import { useMemo } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { OAuth2LoginButtonsFeature } from '@features/auth/oauth2-login-button/oauth2-login-button.feature' import { PasskeyLoginButtonFeature } from '@features/auth/passkey-login-button' +import { useCloudflareAccessLogin } from '@shared/api/hooks/auth/auth.hooks' import { useGetAuthStatus } from '@shared/api/hooks/auth/auth.query.hooks' import { RegisterFormFeature } from '@features/auth/register-form' import { LoginFormFeature } from '@features/auth/login-form' import { parseColoredTextUtil } from '@shared/utils/misc' +import { useAuth } from '@shared/hooks/use-auth' import { Logo, Page } from '@shared/ui' +type TAuthStatusAuthentication = + NonNullable & { + cloudflareAccess?: { + enabled: boolean + } + } + +type TAuthStatusWithCloudflareAccess = GetStatusCommand.Response['response'] & { + authentication: null | TAuthStatusAuthentication +} + const getAuthMethods = (authStatus: GetStatusCommand.Response['response'] | undefined) => { + const authStatusWithCloudflareAccess = authStatus as TAuthStatusWithCloudflareAccess | undefined + const isPasswordEnabled = authStatus?.authentication?.password?.enabled ?? false const isPasskeyEnabled = authStatus?.authentication?.passkey?.enabled ?? false + const isCloudflareAccessEnabled = + authStatusWithCloudflareAccess?.authentication?.cloudflareAccess?.enabled ?? false const isOAuth2Enabled = Object.values(authStatus?.authentication?.oauth2?.providers ?? {}).some(Boolean) ?? false return { + isCloudflareAccessEnabled, isOAuth2Enabled, isPasskeyEnabled, isPasswordEnabled, @@ -90,6 +108,9 @@ const AlternativeAuthMethods = ({ export const LoginPage = () => { const { data: authStatus } = useGetAuthStatus() + const { setIsAuthenticated } = useAuth() + const isCloudflareAccessAttemptedRef = useRef(false) + const [isCloudflareAccessFailed, setIsCloudflareAccessFailed] = useState(false) const titleParts = useMemo(() => { if (authStatus?.branding.title) { @@ -105,6 +126,32 @@ export const LoginPage = () => { const isRegister = !authStatus?.isLoginAllowed && authStatus?.isRegisterAllowed const authMethods = getAuthMethods(authStatus) + const { mutate: cloudflareAccessLogin, isPending: isCloudflareAccessPending } = + useCloudflareAccessLogin({ + mutationFns: { + onSuccess() { + setIsAuthenticated(true) + }, + onError() { + setIsCloudflareAccessFailed(true) + } + } + }) + + useEffect(() => { + if ( + !authStatus?.isLoginAllowed || + !authMethods.isCloudflareAccessEnabled || + isCloudflareAccessAttemptedRef.current + ) { + return + } + + isCloudflareAccessAttemptedRef.current = true + setIsCloudflareAccessFailed(false) + cloudflareAccessLogin({ variables: {} }) + }, [authStatus?.isLoginAllowed, authMethods.isCloudflareAccessEnabled, cloudflareAccessLogin]) + return ( @@ -119,6 +166,18 @@ export const LoginPage = () => { )} + {isCloudflareAccessPending && ( + + Authenticating with Cloudflare Access... + + )} + + {isCloudflareAccessFailed && ( + + Cloudflare Access authentication failed. + + )} + {!isRegister && authStatus && authStatus.authentication && ( diff --git a/src/pages/dashboard/remnawave-settings/components/remnawave-settings.page.component.tsx b/src/pages/dashboard/remnawave-settings/components/remnawave-settings.page.component.tsx index 4d2e04b8..38ac8a62 100644 --- a/src/pages/dashboard/remnawave-settings/components/remnawave-settings.page.component.tsx +++ b/src/pages/dashboard/remnawave-settings/components/remnawave-settings.page.component.tsx @@ -5,6 +5,7 @@ import { Container } from '@mantine/core' import { AuthentificationSettingsCardWidget } from '@widgets/remnawave-settings/authentification-settings-card/authentification-settings-card.widget' import { BrandingSettingsCardWidget } from '@widgets/remnawave-settings/branding-settings-card/branding-settings-card.widget' +import { TCloudflareAccessSettings } from '@shared/api/hooks/remnawave-settings/remnawave-settings.mutation.hooks' import { ApiTokensCardWidget } from '@widgets/remnawave-settings/api-tokens-card/api-tokens-card.widget' import { LoadingScreen, Logo, Page, PageHeaderShared } from '@shared/ui' @@ -15,6 +16,9 @@ interface IProps { export const RemnawaveSettingsPageComponent = (props: IProps) => { const { remnawaveSettings, apiTokensData } = props + const remnawaveSettingsWithCloudflareAccess = remnawaveSettings as typeof remnawaveSettings & { + cloudflareAccessSettings?: null | TCloudflareAccessSettings + } const { t } = useTranslation() @@ -37,6 +41,9 @@ export const RemnawaveSettingsPageComponent = (props: IProps) => { { + setToken({ token: data.accessToken }) + } + } +}) + export const usePasskeyAuthenticationVerify = createMutationHook({ endpoint: VerifyPasskeyAuthenticationCommand.TSQ_url, bodySchema: VerifyPasskeyAuthenticationCommand.RequestSchema, diff --git a/src/shared/api/hooks/auth/auth.query.hooks.ts b/src/shared/api/hooks/auth/auth.query.hooks.ts index f64331d0..96d6e84c 100644 --- a/src/shared/api/hooks/auth/auth.query.hooks.ts +++ b/src/shared/api/hooks/auth/auth.query.hooks.ts @@ -4,6 +4,7 @@ import { } from '@remnawave/backend-contract' import { createQueryKeys } from '@lukemorales/query-key-factory' import { keepPreviousData } from '@tanstack/react-query' +import { z } from 'zod' import { sToMs } from '@shared/utils/time-utils' @@ -18,9 +19,23 @@ export const authQueryKeys = createQueryKeys('auth', { } }) +const GetStatusResponseSchema = GetStatusCommand.ResponseSchema.extend({ + response: GetStatusCommand.ResponseSchema.shape.response.extend({ + authentication: z.nullable( + GetStatusCommand.ResponseSchema.shape.response.shape.authentication.unwrap().extend({ + cloudflareAccess: z + .object({ + enabled: z.boolean() + }) + .default({ enabled: false }) + }) + ) + }) +}) + export const useGetAuthStatus = createGetQueryHook({ endpoint: GetStatusCommand.TSQ_url, - responseSchema: GetStatusCommand.ResponseSchema, + responseSchema: GetStatusResponseSchema, getQueryKey: () => authQueryKeys.getAuthStatus.queryKey, rQueryParams: { refetchOnMount: false, diff --git a/src/shared/api/hooks/remnawave-settings/remnawave-settings.mutation.hooks.ts b/src/shared/api/hooks/remnawave-settings/remnawave-settings.mutation.hooks.ts index 227bf5e4..9d002da0 100644 --- a/src/shared/api/hooks/remnawave-settings/remnawave-settings.mutation.hooks.ts +++ b/src/shared/api/hooks/remnawave-settings/remnawave-settings.mutation.hooks.ts @@ -1,12 +1,35 @@ import { UpdateRemnawaveSettingsCommand } from '@remnawave/backend-contract' import { notifications } from '@mantine/notifications' +import { z } from 'zod' import { createMutationHook } from '../../tsq-helpers' +export const CloudflareAccessSettingsSchema = z.object({ + enabled: z.boolean(), + teamDomain: z.nullable(z.string()), + audience: z.nullable(z.string()), + emailAllowlistEnabled: z.boolean(), + allowedEmails: z.array(z.string()), + allowedDomains: z.array(z.string()) +}) + +export type TCloudflareAccessSettings = z.infer + +export const UpdateRemnawaveSettingsRequestSchema = + UpdateRemnawaveSettingsCommand.RequestSchema.extend({ + cloudflareAccessSettings: CloudflareAccessSettingsSchema.optional() + }) + +const UpdateRemnawaveSettingsResponseSchema = UpdateRemnawaveSettingsCommand.ResponseSchema.extend({ + response: UpdateRemnawaveSettingsCommand.ResponseSchema.shape.response.extend({ + cloudflareAccessSettings: z.nullable(CloudflareAccessSettingsSchema).default(null) + }) +}) + export const useUpdateRemnawaveSettings = createMutationHook({ endpoint: UpdateRemnawaveSettingsCommand.TSQ_url, - bodySchema: UpdateRemnawaveSettingsCommand.RequestSchema, - responseSchema: UpdateRemnawaveSettingsCommand.ResponseSchema, + bodySchema: UpdateRemnawaveSettingsRequestSchema, + responseSchema: UpdateRemnawaveSettingsResponseSchema, requestMethod: UpdateRemnawaveSettingsCommand.endpointDetails.REQUEST_METHOD, rMutationParams: { onSuccess: () => { diff --git a/src/shared/api/hooks/remnawave-settings/remnawave-settings.query.hooks.ts b/src/shared/api/hooks/remnawave-settings/remnawave-settings.query.hooks.ts index b60db416..23e4e6b7 100644 --- a/src/shared/api/hooks/remnawave-settings/remnawave-settings.query.hooks.ts +++ b/src/shared/api/hooks/remnawave-settings/remnawave-settings.query.hooks.ts @@ -1,8 +1,10 @@ import { GetRemnawaveSettingsCommand } from '@remnawave/backend-contract' import { createQueryKeys } from '@lukemorales/query-key-factory' +import { z } from 'zod' import { sToMs } from '@shared/utils/time-utils' +import { CloudflareAccessSettingsSchema } from './remnawave-settings.mutation.hooks' import { createGetQueryHook, errorHandler } from '../../tsq-helpers' export const remnawaveSettingsQueryKeys = createQueryKeys('remnawaveSettings', { @@ -11,9 +13,15 @@ export const remnawaveSettingsQueryKeys = createQueryKeys('remnawaveSettings', { } }) +const GetRemnawaveSettingsResponseSchema = GetRemnawaveSettingsCommand.ResponseSchema.extend({ + response: GetRemnawaveSettingsCommand.ResponseSchema.shape.response.extend({ + cloudflareAccessSettings: z.nullable(CloudflareAccessSettingsSchema).default(null) + }) +}) + export const useGetRemnawaveSettings = createGetQueryHook({ endpoint: GetRemnawaveSettingsCommand.TSQ_url, - responseSchema: GetRemnawaveSettingsCommand.ResponseSchema, + responseSchema: GetRemnawaveSettingsResponseSchema, getQueryKey: () => remnawaveSettingsQueryKeys.getRemnawaveSettings.queryKey, rQueryParams: { refetchOnMount: false, diff --git a/src/widgets/remnawave-settings/authentification-settings-card/authentification-settings-card.widget.tsx b/src/widgets/remnawave-settings/authentification-settings-card/authentification-settings-card.widget.tsx index b2fc5b3d..f290127b 100644 --- a/src/widgets/remnawave-settings/authentification-settings-card/authentification-settings-card.widget.tsx +++ b/src/widgets/remnawave-settings/authentification-settings-card/authentification-settings-card.widget.tsx @@ -17,17 +17,21 @@ import { } from '@remnawave/backend-contract' import { TbAlertCircle, TbFingerprint, TbKey, TbPassword, TbServer } from 'react-icons/tb' import { BiLogoGithub, BiLogoTelegram } from 'react-icons/bi' +import { SiCloudflare, SiKeycloak } from 'react-icons/si' import { zodResolver } from 'mantine-form-zod-resolver' import { PiGlobe, PiKey } from 'react-icons/pi' import { useDisclosure } from '@mantine/hooks' import { useTranslation } from 'react-i18next' -import { SiKeycloak } from 'react-icons/si' import { modals } from '@mantine/modals' import { useForm } from '@mantine/form' import { TFunction } from 'i18next' +import { + TCloudflareAccessSettings, + UpdateRemnawaveSettingsRequestSchema, + useUpdateRemnawaveSettings +} from '@shared/api/hooks/remnawave-settings/remnawave-settings.mutation.hooks' import { PasskeysDrawerComponent } from '@widgets/remnawave-settings/passkeys-settings-drawer/passkeys-drawer.component' -import { useUpdateRemnawaveSettings } from '@shared/api/hooks/remnawave-settings/remnawave-settings.mutation.hooks' import { HelpActionIconShared, THelpDrawerAvailableScreen } from '@shared/ui/help-drawer' import { CheckboxCardShared } from '@shared/ui/checkbox-card/checkbox-card.shared' import { BaseOverlayHeader } from '@shared/ui/overlays/base-overlay-header' @@ -38,6 +42,7 @@ import { handleFormErrors } from '@shared/utils/misc' import { queryClient } from '@shared/api' interface IProps { + cloudflareAccessSettings?: null | TCloudflareAccessSettings oauth2Settings: NonNullable passkeySettings: NonNullable< GetRemnawaveSettingsCommand.Response['response']['passkeySettings'] @@ -47,6 +52,15 @@ interface IProps { > } +const DEFAULT_CLOUDFLARE_ACCESS_SETTINGS: TCloudflareAccessSettings = { + enabled: false, + teamDomain: null, + audience: null, + emailAllowlistEnabled: true, + allowedEmails: [], + allowedDomains: [] +} + const getOAuth2ProvidersConfig = () => [ { @@ -179,18 +193,28 @@ const getFieldConfig = (t: TFunction) => }) as const export const AuthentificationSettingsCardWidget = (props: IProps) => { - const { passkeySettings, passwordSettings, oauth2Settings } = props + const { + passkeySettings, + passwordSettings, + oauth2Settings, + cloudflareAccessSettings = DEFAULT_CLOUDFLARE_ACCESS_SETTINGS + } = props const { t } = useTranslation() const [drawerOpened, { open: openDrawer, close: closeDrawer }] = useDisclosure(false) - const form = useForm>({ + const form = useForm< + NonNullable & { + cloudflareAccessSettings: TCloudflareAccessSettings + } + >({ name: 'auth-settings', mode: 'uncontrolled', validate: zodResolver( - UpdateRemnawaveSettingsCommand.RequestSchema.pick({ + UpdateRemnawaveSettingsRequestSchema.pick({ passkeySettings: true, passwordSettings: true, - oauth2Settings: true + oauth2Settings: true, + cloudflareAccessSettings: true }) ), initialValues: { @@ -209,7 +233,8 @@ export const AuthentificationSettingsCardWidget = (props: IProps) => { keycloak: oauth2Settings.keycloak, generic: oauth2Settings.generic, telegram: oauth2Settings.telegram - } + }, + cloudflareAccessSettings: cloudflareAccessSettings ?? DEFAULT_CLOUDFLARE_ACCESS_SETTINGS } }) @@ -223,7 +248,9 @@ export const AuthentificationSettingsCardWidget = (props: IProps) => { form.setValues({ passkeySettings: data.passkeySettings!, passwordSettings: data.passwordSettings!, - oauth2Settings: data.oauth2Settings! + oauth2Settings: data.oauth2Settings!, + cloudflareAccessSettings: + data.cloudflareAccessSettings ?? DEFAULT_CLOUDFLARE_ACCESS_SETTINGS }) form.resetDirty() @@ -274,7 +301,8 @@ export const AuthentificationSettingsCardWidget = (props: IProps) => { variables: { passkeySettings: values.passkeySettings, passwordSettings: values.passwordSettings, - oauth2Settings: values.oauth2Settings + oauth2Settings: values.oauth2Settings, + cloudflareAccessSettings: values.cloudflareAccessSettings } }) }) @@ -499,6 +527,101 @@ export const AuthentificationSettingsCardWidget = (props: IProps) => { {/* OAuth2 */} {OAUTH2_PROVIDERS.map(renderOAuth2Provider)} + + {/* Cloudflare Access */} + +
+ + + + } + > + + Cloudflare Access + + + + e.stopPropagation()} + size="md" + {...form.getInputProps( + 'cloudflareAccessSettings.enabled', + { + type: 'checkbox' + } + )} + /> + +
+ + + + + + + + + + + + + + +