diff --git a/src/http.rs b/src/http.rs index bd3b6fb3..4cbb85e3 100644 --- a/src/http.rs +++ b/src/http.rs @@ -70,13 +70,39 @@ async fn handle_404() -> (StatusCode, &'static str) { } #[derive(Serialize)] -struct AppInfo<'a> { - version: &'a str, +#[serde(rename_all = "snake_case")] +enum ServerState { + Setup, + Disconnected, + Connected, } -async fn app_info<'a>() -> Result>, ApiError> { +impl From<&AppState> for ServerState { + fn from(state: &AppState) -> Self { + if !state.grpc_server.setup_completed() { + Self::Setup + } else if state.grpc_server.connected.load(Ordering::Relaxed) { + Self::Connected + } else { + Self::Disconnected + } + } +} + +#[derive(Serialize)] +struct AppInfo { + version: &'static str, + server_state: ServerState, +} + +async fn app_info(State(state): State) -> Result, ApiError> { let version = crate_version!(); - Ok(Json(AppInfo { version })) + let server_state = ServerState::from(&state); + + Ok(Json(AppInfo { + version, + server_state, + })) } async fn healthcheck() -> &'static str { @@ -249,9 +275,12 @@ async fn ensure_configured( request: Request, next: Next, ) -> Response { - // Allow healthchecks even before core connects and gives us the cookie key. + // Allow healthchecks and info even before core connects and gives us the cookie key. let path = request.uri().path(); - if matches!(path, "/api/v1/health" | "/api/v1/health-grpc") { + if matches!( + path, + "/api/v1/health" | "/api/v1/health-grpc" | "/api/v1/info" + ) { return next.run(request).await; } @@ -282,6 +311,12 @@ pub async fn run_server( reset_tx, ); + // Preload existing TLS configuration so /api/v1/info can report "disconnected" + // immediately on startup + if let Some(existing_configuration) = config.clone() { + grpc_server.configure(existing_configuration); + } + let server_clone = grpc_server.clone(); let env_config_clone = env_config.clone(); diff --git a/web/messages/en.json b/web/messages/en.json index e1f8a896..11ca20eb 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -108,5 +108,10 @@ "openid_mfa_complete_subtitle": "You have been successfully authenticated. Please close this window and get back to the Defguard VPN Client", "open_desktop_title": "Open the desktop app to continue", "open_desktop_description": "We tried to open the desktop app automatically, but it didn't respond. This can happen if the browser blocks the request or the app didn't start in time.", - "open_desktop_button": "Open desktop app" + "open_desktop_button": "Open desktop app", + "server_warning_setup_title": "Server is in setup mode", + "server_warning_setup_subtitle": "Edge setup is not complete yet. Please contact your administrator.", + "server_warning_disconnected_title": "Core is disconnected", + "server_warning_disconnected_subtitle": "Edge is configured, but it is not connected to Defguard Core. Please contact your administrator.", + "server_warning_retry": "Try again" } diff --git a/web/src/pages/OpenDesktop/OpenDesktopPage.tsx b/web/src/pages/OpenDesktop/OpenDesktopPage.tsx index d4e523f1..5ecee27a 100644 --- a/web/src/pages/OpenDesktop/OpenDesktopPage.tsx +++ b/web/src/pages/OpenDesktop/OpenDesktopPage.tsx @@ -1,7 +1,7 @@ import './style.scss'; import { useSearch } from '@tanstack/react-router'; import { m } from '../../paraglide/messages'; -import { PageProcessEnd } from '../../shared/components/PageProcessEnd/PageProcessEnd'; +import { PageInfo } from '../../shared/components/PageInfo/PageInfo'; import laptopImage from './assets/laptop.png'; export const OpenDesktopPage = () => { @@ -16,7 +16,7 @@ export const OpenDesktopPage = () => { } return ( - { + const serverState = useLoaderData({ from: '/server-warning' }); + + const title = + serverState === 'setup' + ? m.server_warning_setup_title() + : m.server_warning_disconnected_title(); + const subtitle = + serverState === 'setup' + ? m.server_warning_setup_subtitle() + : m.server_warning_disconnected_subtitle(); + + return ( + + ); +}; diff --git a/web/src/pages/SessionEnd/SessionEndPage.tsx b/web/src/pages/SessionEnd/SessionEndPage.tsx index 38d2d67f..bd17ca05 100644 --- a/web/src/pages/SessionEnd/SessionEndPage.tsx +++ b/web/src/pages/SessionEnd/SessionEndPage.tsx @@ -1,9 +1,9 @@ import { m } from '../../paraglide/messages'; -import { PageProcessEnd } from '../../shared/components/PageProcessEnd/PageProcessEnd'; +import { PageInfo } from '../../shared/components/PageInfo/PageInfo'; export const SessionEndPage = () => { return ( - rootRouteImport, } as any) +const ServerWarningRoute = ServerWarningRouteImport.update({ + id: '/server-warning', + path: '/server-warning', + getParentRoute: () => rootRouteImport, +} as any) const PasswordResetRoute = PasswordResetRouteImport.update({ id: '/password-reset', path: '/password-reset', @@ -109,6 +115,7 @@ export interface FileRoutesByFullPath { '/link-invalid': typeof LinkInvalidRoute '/open-desktop': typeof OpenDesktopRoute '/password-reset': typeof PasswordResetRoute + '/server-warning': typeof ServerWarningRoute '/session-end': typeof SessionEndRoute '/openid/callback': typeof OpenidCallbackRoute '/openid/error': typeof OpenidErrorRoute @@ -126,6 +133,7 @@ export interface FileRoutesByTo { '/link-invalid': typeof LinkInvalidRoute '/open-desktop': typeof OpenDesktopRoute '/password-reset': typeof PasswordResetRoute + '/server-warning': typeof ServerWarningRoute '/session-end': typeof SessionEndRoute '/openid/callback': typeof OpenidCallbackRoute '/openid/error': typeof OpenidErrorRoute @@ -144,6 +152,7 @@ export interface FileRoutesById { '/link-invalid': typeof LinkInvalidRoute '/open-desktop': typeof OpenDesktopRoute '/password-reset': typeof PasswordResetRoute + '/server-warning': typeof ServerWarningRoute '/session-end': typeof SessionEndRoute '/openid/callback': typeof OpenidCallbackRoute '/openid/error': typeof OpenidErrorRoute @@ -163,6 +172,7 @@ export interface FileRouteTypes { | '/link-invalid' | '/open-desktop' | '/password-reset' + | '/server-warning' | '/session-end' | '/openid/callback' | '/openid/error' @@ -180,6 +190,7 @@ export interface FileRouteTypes { | '/link-invalid' | '/open-desktop' | '/password-reset' + | '/server-warning' | '/session-end' | '/openid/callback' | '/openid/error' @@ -197,6 +208,7 @@ export interface FileRouteTypes { | '/link-invalid' | '/open-desktop' | '/password-reset' + | '/server-warning' | '/session-end' | '/openid/callback' | '/openid/error' @@ -215,6 +227,7 @@ export interface RootRouteChildren { LinkInvalidRoute: typeof LinkInvalidRoute OpenDesktopRoute: typeof OpenDesktopRoute PasswordResetRoute: typeof PasswordResetRoute + ServerWarningRoute: typeof ServerWarningRoute SessionEndRoute: typeof SessionEndRoute OpenidCallbackRoute: typeof OpenidCallbackRoute OpenidErrorRoute: typeof OpenidErrorRoute @@ -234,6 +247,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SessionEndRouteImport parentRoute: typeof rootRouteImport } + '/server-warning': { + id: '/server-warning' + path: '/server-warning' + fullPath: '/server-warning' + preLoaderRoute: typeof ServerWarningRouteImport + parentRoute: typeof rootRouteImport + } '/password-reset': { id: '/password-reset' path: '/password-reset' @@ -343,6 +363,7 @@ const rootRouteChildren: RootRouteChildren = { LinkInvalidRoute: LinkInvalidRoute, OpenDesktopRoute: OpenDesktopRoute, PasswordResetRoute: PasswordResetRoute, + ServerWarningRoute: ServerWarningRoute, SessionEndRoute: SessionEndRoute, OpenidCallbackRoute: OpenidCallbackRoute, OpenidErrorRoute: OpenidErrorRoute, diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 11a82203..c0640f7a 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -1,7 +1,21 @@ -import { createRootRoute, Outlet } from '@tanstack/react-router'; +import { createRootRoute, Outlet, redirect } from '@tanstack/react-router'; import { SessionGuard } from '../app/SessionGuard'; +import { api } from '../shared/api/api'; export const Route = createRootRoute({ + beforeLoad: async ({ location }) => { + if (location.pathname === '/server-warning') { + return; + } + + const response = await api.appInfo.callbackFn({ params: undefined }); + if (response.data.server_state !== 'connected') { + throw redirect({ + to: '/server-warning', + replace: true, + }); + } + }, component: RootComponent, }); diff --git a/web/src/routes/link-invalid.tsx b/web/src/routes/link-invalid.tsx index 84b43fcb..b8687ca0 100644 --- a/web/src/routes/link-invalid.tsx +++ b/web/src/routes/link-invalid.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/react-router'; import { m } from '../paraglide/messages'; -import { PageProcessEnd } from '../shared/components/PageProcessEnd/PageProcessEnd'; +import { PageInfo } from '../shared/components/PageInfo/PageInfo'; export const Route = createFileRoute('/link-invalid')({ component: RouteComponent, @@ -8,7 +8,7 @@ export const Route = createFileRoute('/link-invalid')({ function RouteComponent() { return ( - { return ( - { + const response = await api.appInfo.callbackFn({}); + return response.data.server_state; + }, + component: ServerWarningPage, +}); diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index 58d3d332..6181cdba 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -15,7 +15,7 @@ import type { } from './types'; const api = { - appInfo: get('/info'), + appInfo: get('/info'), enrollment: { start: post('/enrollment/start'), }, diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index d183fb4f..a4e09931 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -30,6 +30,7 @@ export type UserInfo = { export type AppInfo = { version: string; + server_state: 'setup' | 'disconnected' | 'connected'; }; export type EnrollmentSettings = { diff --git a/web/src/shared/components/PageProcessEnd/PageProcessEnd.tsx b/web/src/shared/components/PageInfo/PageInfo.tsx similarity index 95% rename from web/src/shared/components/PageProcessEnd/PageProcessEnd.tsx rename to web/src/shared/components/PageInfo/PageInfo.tsx index d4550d2e..01b95960 100644 --- a/web/src/shared/components/PageProcessEnd/PageProcessEnd.tsx +++ b/web/src/shared/components/PageInfo/PageInfo.tsx @@ -19,7 +19,7 @@ type Props = { imageSrc?: string; }; -export const PageProcessEnd = ({ +export const PageInfo = ({ link, linkText, subtitle, @@ -28,7 +28,7 @@ export const PageProcessEnd = ({ imageSrc, }: Props) => { return ( - +
{imageSrc ? : } diff --git a/web/src/shared/components/PageProcessEnd/style.scss b/web/src/shared/components/PageInfo/style.scss similarity index 86% rename from web/src/shared/components/PageProcessEnd/style.scss rename to web/src/shared/components/PageInfo/style.scss index b7cb5d09..fbe0d6ae 100644 --- a/web/src/shared/components/PageProcessEnd/style.scss +++ b/web/src/shared/components/PageInfo/style.scss @@ -1,4 +1,4 @@ -.page-process-end { +.page-info { .icon[data-kind='check-circle'] { path { fill: var(--fg-success); @@ -11,6 +11,12 @@ } } + .icon[data-kind='warning'] { + path { + fill: var(--fg-attention); + } + } + .page-content { & > svg { padding-bottom: 0 !important;