Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion src/pages/auth/login/login.page.tsx
Original file line number Diff line number Diff line change
@@ -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<GetStatusCommand.Response['response']['authentication']> & {
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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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])
Comment on lines +141 to +153

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No retry path after Cloudflare Access failure

Once isCloudflareAccessAttemptedRef.current is set to true, the effect is permanently gated and will never fire again in the same component instance. If the initial automatic login request fails (e.g., network hiccup, CF misconfiguration), the red badge appears but there is no way to reattempt login short of a full page refresh. When Cloudflare Access is the only configured auth method, the user is left with no actionable path forward — no retry button, no other form visible.


return (
<Page title="Login">
<Stack align="center" gap="xs">
Expand All @@ -119,6 +166,18 @@ export const LoginPage = () => {
</Badge>
)}

{isCloudflareAccessPending && (
<Badge color="orange" mt={10} size="lg" variant="light">
Authenticating with Cloudflare Access...
</Badge>
)}

{isCloudflareAccessFailed && (
<Badge color="red" mt={10} size="lg" variant="light">
Cloudflare Access authentication failed.
</Badge>
)}

{!isRegister && authStatus && authStatus.authentication && (
<Box maw={800} p={30} w={{ base: 440, sm: 500, md: 500 }}>
<Stack gap="lg">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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()

Expand All @@ -37,6 +41,9 @@ export const RemnawaveSettingsPageComponent = (props: IProps) => {
<Container fluid p={0} size="xl">
<Masonry columns={{ 300: 1, 1400: 2, 2000: 3, 3000: 4 }} gap={16}>
<AuthentificationSettingsCardWidget
cloudflareAccessSettings={
remnawaveSettingsWithCloudflareAccess.cloudflareAccessSettings
}
oauth2Settings={remnawaveSettings.oauth2Settings}
passkeySettings={remnawaveSettings.passkeySettings}
passwordSettings={remnawaveSettings.passwordSettings}
Expand Down
21 changes: 21 additions & 0 deletions src/shared/api/hooks/auth/auth.hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@ import {
VerifyPasskeyAuthenticationCommand
} from '@remnawave/backend-contract'
import { notifications } from '@mantine/notifications'
import { z } from 'zod'

import { setToken } from '@entities/auth/session-store'

import { createMutationHook } from '../../tsq-helpers'

export const AUTH_QUERY_KEY = 'auth'

const CloudflareAccessResponseSchema = z.object({
response: z.object({
accessToken: z.string()
})
})

const CloudflareAccessRequestSchema = z.object({})

export const useLogin = createMutationHook({
endpoint: LoginCommand.TSQ_url,
bodySchema: LoginCommand.RequestSchema,
Expand Down Expand Up @@ -84,6 +93,18 @@ export const useOAuth2Authorize = createMutationHook({
}
})

export const useCloudflareAccessLogin = createMutationHook({
endpoint: '/api/auth/cloudflare-access',
bodySchema: CloudflareAccessRequestSchema,
responseSchema: CloudflareAccessResponseSchema,
requestMethod: 'post',
rMutationParams: {
onSuccess: (data) => {
setToken({ token: data.accessToken })
}
}
})

export const usePasskeyAuthenticationVerify = createMutationHook({
endpoint: VerifyPasskeyAuthenticationCommand.TSQ_url,
bodySchema: VerifyPasskeyAuthenticationCommand.RequestSchema,
Expand Down
17 changes: 16 additions & 1 deletion src/shared/api/hooks/auth/auth.query.hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof CloudflareAccessSettingsSchema>

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: () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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', {
Expand All @@ -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,
Expand Down
Loading