diff --git a/crates/defguard_common/src/db/models/proxy.rs b/crates/defguard_common/src/db/models/proxy.rs index 0df6d59331..5b20797702 100644 --- a/crates/defguard_common/src/db/models/proxy.rs +++ b/crates/defguard_common/src/db/models/proxy.rs @@ -1,10 +1,14 @@ +use std::fmt; + use chrono::NaiveDateTime; use model_derive::Model; +use serde::{Deserialize, Serialize}; use sqlx::PgPool; +use utoipa::ToSchema; use crate::db::{Id, NoId}; -#[derive(Model)] +#[derive(Clone, Debug, Deserialize, Model, Serialize, ToSchema, PartialEq)] pub struct Proxy { pub id: I, pub name: String, @@ -18,6 +22,18 @@ pub struct Proxy { pub certificate_expiry: Option, } +impl fmt::Display for Proxy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name) + } +} + +impl fmt::Display for Proxy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[ID {}] {}", self.id, self.name) + } +} + impl Proxy { pub fn new>(name: S, address: S, port: i32, public_address: S) -> Self { Self { diff --git a/crates/defguard_core/src/db/models/activity_log/metadata.rs b/crates/defguard_core/src/db/models/activity_log/metadata.rs index adeb3888e3..406c821b95 100644 --- a/crates/defguard_core/src/db/models/activity_log/metadata.rs +++ b/crates/defguard_core/src/db/models/activity_log/metadata.rs @@ -6,6 +6,7 @@ use defguard_common::db::{ WireguardNetwork, group::Group, oauth2client::OAuth2Client, + proxy::Proxy, settings::{LdapSyncStatus, OpenIdUsernameHandling, SmtpEncryption}, user::User, }, @@ -564,3 +565,9 @@ pub struct UserSnatBindingModifiedMetadata { pub before: UserSnatBinding, pub after: UserSnatBinding, } + +#[derive(Serialize)] +pub struct ProxyModifiedMetadata { + pub before: Proxy, + pub after: Proxy, +} diff --git a/crates/defguard_core/src/db/models/activity_log/mod.rs b/crates/defguard_core/src/db/models/activity_log/mod.rs index cf9314cf42..a2a10d0eba 100644 --- a/crates/defguard_core/src/db/models/activity_log/mod.rs +++ b/crates/defguard_core/src/db/models/activity_log/mod.rs @@ -115,6 +115,8 @@ pub enum EventType { UserSnatBindingAdded, UserSnatBindingRemoved, UserSnatBindingModified, + // Proxy management + ProxyModified, } #[derive(Model, FromRow, Serialize)] diff --git a/crates/defguard_core/src/events.rs b/crates/defguard_core/src/events.rs index 70bae976cf..bcf102322f 100644 --- a/crates/defguard_core/src/events.rs +++ b/crates/defguard_core/src/events.rs @@ -5,7 +5,7 @@ use defguard_common::db::{ Id, models::{ AuthenticationKey, Device, MFAMethod, Settings, User, WebAuthn, WireguardNetwork, - group::Group, oauth2client::OAuth2Client, + group::Group, oauth2client::OAuth2Client, proxy::Proxy, }, }; use defguard_proto::proxy::MfaMethod; @@ -297,6 +297,10 @@ pub enum ApiEventType { before: UserSnatBinding, after: UserSnatBinding, }, + ProxyModified { + before: Proxy, + after: Proxy, + }, } /// Events from Web API diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index 79e337a54a..092eb60568 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -39,6 +39,7 @@ pub mod network_devices; pub mod openid_clients; pub mod openid_flow; pub(crate) mod pagination; +pub mod proxy; pub(crate) mod settings; pub(crate) mod ssh_authorized_keys; pub(crate) mod support; diff --git a/crates/defguard_core/src/handlers/proxy.rs b/crates/defguard_core/src/handlers/proxy.rs new file mode 100644 index 0000000000..6ae43d593d --- /dev/null +++ b/crates/defguard_core/src/handlers/proxy.rs @@ -0,0 +1,116 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use defguard_common::db::models::proxy::Proxy; +use reqwest::StatusCode; +use serde_json::{Value, json}; +use utoipa::ToSchema; + +use crate::{ + appstate::AppState, + auth::{AdminRole, SessionInfo}, + events::{ApiEvent, ApiEventType, ApiRequestContext}, + handlers::{ApiResponse, ApiResult}, +}; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ProxyUpdateData { + pub name: String, +} + +#[utoipa::path( + get, + path = "/api/v1/proxy/{proxy_id}", + responses( + (status = 200, description = "Edge details", body = Proxy), + (status = 401, description = "Unauthorized to get edge details.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "You don't have permission to get edge details.", body = ApiResponse, example = json!({"msg": "access denied"})), + (status = 404, description = "Edge not found", body = ApiResponse, example = json!({"msg": "network not found"})), + (status = 500, description = "Unable to get edge details.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] +pub(crate) async fn proxy_details( + Path(proxy_id): Path, + _role: AdminRole, + session: SessionInfo, + State(appstate): State, +) -> ApiResult { + debug!( + "User {} displaying details for proxy {proxy_id}", + session.user.username + ); + let proxy = Proxy::find_by_id(&appstate.pool, proxy_id).await?; + let response = match proxy { + Some(proxy) => ApiResponse { + json: json!(proxy), + status: StatusCode::OK, + }, + None => ApiResponse { + json: Value::Null, + status: StatusCode::NOT_FOUND, + }, + }; + info!( + "User {} displayed details for proxy {proxy_id}", + session.user.username + ); + + Ok(response) +} + +#[utoipa::path( + put, + path = "/api/v1/proxy/{proxy_id}", + request_body = Proxy, + responses( + (status = 200, description = "Successfully modified edge.", body = ProxyUpdateData), + (status = 401, description = "Unauthorized to modify edge.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "You don't have permission to modify an edge.", body = ApiResponse, example = json!({"msg": "access denied"})), + (status = 404, description = "Edge not found", body = ApiResponse, example = json!({"msg": "proxy not found"})), + (status = 500, description = "Unable to modify edge.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] +pub(crate) async fn update_proxy( + _role: AdminRole, + Path(proxy_id): Path, + State(appstate): State, + session: SessionInfo, + context: ApiRequestContext, + Json(data): Json, +) -> ApiResult { + debug!("User {} updating proxy {proxy_id}", session.user.username); + let proxy = Proxy::find_by_id(&appstate.pool, proxy_id).await?; + + let Some(mut proxy) = proxy else { + warn!("Proxy {proxy_id} not found"); + return Ok(ApiResponse { + json: Value::Null, + status: StatusCode::NOT_FOUND, + }); + }; + let before = proxy.clone(); + + proxy.name = data.name; + proxy.save(&appstate.pool).await?; + + info!("User {} updated proxy {proxy_id}", session.user.username); + + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::ProxyModified { + before, + after: proxy.clone(), + }), + })?; + + Ok(ApiResponse::json(proxy, StatusCode::OK)) +} diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 04611ca25c..b542ead1b0 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -132,6 +132,7 @@ use crate::{ authorization, discovery_keys, openid_configuration, secure_authorization, token, userinfo, }, + proxy::{proxy_details, update_proxy}, settings::{ get_settings, get_settings_essentials, patch_settings, set_default_branding, test_ldap_settings, update_settings, @@ -359,6 +360,8 @@ pub fn build_webapp( .route("/activity_log", get(get_activity_log_events)) // Certificate authority .route("/ca", post(create_ca)) + // Proxy routes + .route("/proxy/{proxy_id}", get(proxy_details).put(update_proxy)) // Proxy setup with SSE .route("/proxy/setup/stream", get(setup_proxy_tls_stream)), ); diff --git a/crates/defguard_core/tests/integration/api/mod.rs b/crates/defguard_core/tests/integration/api/mod.rs index 8783bf4a87..4ca59b3356 100644 --- a/crates/defguard_core/tests/integration/api/mod.rs +++ b/crates/defguard_core/tests/integration/api/mod.rs @@ -9,6 +9,7 @@ mod group; mod oauth; mod openid; mod openid_login; +mod proxy; mod settings; mod snat; mod user; diff --git a/crates/defguard_core/tests/integration/api/proxy.rs b/crates/defguard_core/tests/integration/api/proxy.rs new file mode 100644 index 0000000000..ba4ba23c85 --- /dev/null +++ b/crates/defguard_core/tests/integration/api/proxy.rs @@ -0,0 +1,55 @@ +use defguard_common::db::{Id, models::proxy::Proxy}; +use defguard_core::handlers::{Auth, proxy::ProxyUpdateData}; +use reqwest::StatusCode; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + +use super::common::{make_test_client, setup_pool}; + +#[sqlx::test] +async fn test_update_proxy(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (client, _) = make_test_client(pool.clone()).await; + + // Authorize as an administrator. + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // Create new proxy. + let mut proxy = Proxy::new("test", "localhost", 50051, "public.net") + .save(&pool) + .await + .unwrap(); + + // Modify name + let data = ProxyUpdateData { + name: "modified".to_string(), + }; + let response = client + .put(format!("/api/v1/proxy/{}", proxy.id)) + .json(&data) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // Verify proxy is modified correctly + let proxy_updated: Proxy = response.json().await; + assert_eq!(proxy_updated.name, "modified"); + proxy.name = "modified".to_string(); + assert_eq!(proxy, proxy_updated); + + // Try to modify other fields + let proxy_before_mods = proxy.clone(); + proxy.address = "otherhost".to_string(); + proxy.port = 50052; + proxy.public_address = "otherpublichost.net".to_string(); + let response = client + .put(format!("/api/v1/proxy/{}", proxy.id)) + .json(&proxy) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let proxy_updated: Proxy = response.json().await; + assert_eq!(proxy_before_mods, proxy_updated); +} diff --git a/crates/defguard_event_logger/src/description.rs b/crates/defguard_event_logger/src/description.rs index 1ca6377198..2cebdcafbd 100644 --- a/crates/defguard_event_logger/src/description.rs +++ b/crates/defguard_event_logger/src/description.rs @@ -257,6 +257,9 @@ pub fn get_defguard_event_description(event: &DefguardEvent) -> Option { "Public IP bound to devices owned by user {user} changed from {} to {}", before.public_ip, after.public_ip )), + DefguardEvent::ProxyModified { before: _, after } => { + Some(format!("Modified proxy {after}")) + } } } diff --git a/crates/defguard_event_logger/src/lib.rs b/crates/defguard_event_logger/src/lib.rs index 29c6123957..500c5c41d7 100644 --- a/crates/defguard_event_logger/src/lib.rs +++ b/crates/defguard_event_logger/src/lib.rs @@ -12,11 +12,11 @@ use defguard_core::db::models::activity_log::{ MfaSecurityKeyMetadata, NetworkDeviceMetadata, NetworkDeviceModifiedMetadata, OpenIdAppMetadata, OpenIdAppModifiedMetadata, OpenIdAppStateChangedMetadata, OpenIdProviderMetadata, PasswordChangedByAdminMetadata, PasswordResetMetadata, - SettingsUpdateMetadata, UserGroupsModifiedMetadata, UserMetadata, UserMfaDisabledMetadata, - UserModifiedMetadata, UserSnatBindingMetadata, UserSnatBindingModifiedMetadata, - VpnClientMetadata, VpnClientMfaFailedMetadata, VpnClientMfaMetadata, VpnLocationMetadata, - VpnLocationModifiedMetadata, WebHookMetadata, WebHookModifiedMetadata, - WebHookStateChangedMetadata, + ProxyModifiedMetadata, SettingsUpdateMetadata, UserGroupsModifiedMetadata, UserMetadata, + UserMfaDisabledMetadata, UserModifiedMetadata, UserSnatBindingMetadata, + UserSnatBindingModifiedMetadata, VpnClientMetadata, VpnClientMfaFailedMetadata, + VpnClientMfaMetadata, VpnLocationMetadata, VpnLocationModifiedMetadata, WebHookMetadata, + WebHookModifiedMetadata, WebHookStateChangedMetadata, }, }; use description::{ @@ -468,6 +468,10 @@ pub async fn run_event_logger( }) .ok(), ), + DefguardEvent::ProxyModified { before, after } => ( + EventType::ProxyModified, + serde_json::to_value(ProxyModifiedMetadata { before, after }).ok(), + ), }; (module, event_type, description, metadata) } diff --git a/crates/defguard_event_logger/src/message.rs b/crates/defguard_event_logger/src/message.rs index 3d736281eb..2421e40cc8 100644 --- a/crates/defguard_event_logger/src/message.rs +++ b/crates/defguard_event_logger/src/message.rs @@ -5,7 +5,7 @@ use defguard_common::db::{ Id, models::{ AuthenticationKey, Device, MFAMethod, Settings, User, WebAuthn, WireguardNetwork, - group::Group, oauth2client::OAuth2Client, + group::Group, oauth2client::OAuth2Client, proxy::Proxy, }, }; use defguard_core::{ @@ -337,6 +337,10 @@ pub enum DefguardEvent { before: UserSnatBinding, after: UserSnatBinding, }, + ProxyModified { + before: Proxy, + after: Proxy, + }, } /// Represents activity log events related to client applications diff --git a/crates/defguard_event_router/src/handlers/api.rs b/crates/defguard_event_router/src/handlers/api.rs index 45e809a48f..d4208b29bd 100644 --- a/crates/defguard_event_router/src/handlers/api.rs +++ b/crates/defguard_event_router/src/handlers/api.rs @@ -389,6 +389,10 @@ impl EventRouter { })), Some(location), ), + ApiEventType::ProxyModified { before, after } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::ProxyModified { before, after })), + None, + ), }; self.log_event( EventContext::from_api_context(event.context, location), diff --git a/web/messages/en/edge.json b/web/messages/en/edge.json new file mode 100644 index 0000000000..b58877883f --- /dev/null +++ b/web/messages/en/edge.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "edge_title": "Edge components", + "edge_edit_title": "Edit edge component", + "edge_edit_general_info": "General information", + "edge_edit_name": "Name", + "edge_edit_address": "IP or Domain", + "edge_edit_port": "gRPC port", + "edge_edit_public_address": "Public domain", + "edge_edit_delete": "Delete", + "edge_edit_success": "Edge component updated", + "edge_edit_failed": "Failed to update edge component" +} diff --git a/web/project.inlang/settings.json b/web/project.inlang/settings.json index a080a2894b..17f836ebbb 100644 --- a/web/project.inlang/settings.json +++ b/web/project.inlang/settings.json @@ -18,6 +18,7 @@ "./messages/{locale}/groups.json", "./messages/{locale}/openid.json", "./messages/{locale}/activity.json", + "./messages/{locale}/edge.json", "./messages/{locale}/edge_wizard.json", "./messages/{locale}/settings.json", "./messages/{locale}/gateway_wizard.json" diff --git a/web/src/pages/EdgeListPage/EdgeListPage.tsx b/web/src/pages/EdgeListPage/EdgeListPage.tsx new file mode 100644 index 0000000000..b1302a6e51 --- /dev/null +++ b/web/src/pages/EdgeListPage/EdgeListPage.tsx @@ -0,0 +1,11 @@ +import { m } from '../../paraglide/messages'; +import { Page } from '../../shared/components/Page/Page'; +import { TablePageLayout } from '../../shared/layout/TablePageLayout/TablePageLayout'; + +export const EdgeListPage = () => { + return ( + + TODO + + ); +}; diff --git a/web/src/pages/EditEdgePage/EditEdgePage.tsx b/web/src/pages/EditEdgePage/EditEdgePage.tsx new file mode 100644 index 0000000000..7c39c4659f --- /dev/null +++ b/web/src/pages/EditEdgePage/EditEdgePage.tsx @@ -0,0 +1,157 @@ +import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; +import { Link, useNavigate, useParams } from '@tanstack/react-router'; +import { useMemo } from 'react'; +import z from 'zod'; +import { m } from '../../paraglide/messages'; +import api from '../../shared/api/api'; +import type { Edge } from '../../shared/api/types'; +import { EditPage } from '../../shared/components/EditPage/EditPage'; +import { EditPageControls } from '../../shared/components/EditPageControls/EditPageControls'; +import { EditPageFormSection } from '../../shared/components/EditPageFormSection/EditPageFormSection'; +import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { Snackbar } from '../../shared/defguard-ui/providers/snackbar/snackbar'; +import { ThemeSpacing } from '../../shared/defguard-ui/types'; +import { useAppForm } from '../../shared/form'; +import { formChangeLogic } from '../../shared/formLogic'; +import { getEdgeQueryOptions } from '../../shared/query'; + +export const EditEdgePage = () => { + const { edgeId } = useParams({ + from: '/_authorized/_default/edge/$edgeId/edit', + }); + const { data: edge } = useSuspenseQuery(getEdgeQueryOptions(Number(edgeId))); + const breadcrumbsLinks = [ + + Edge components + , + + {edge.name} + , + ]; + return ( + + + + ); +}; + +const formSchema = z.object({ + name: z.string(m.form_error_required()).min(1, m.form_error_required()), + address: z.string().nullable(), + port: z.number().nullable(), + public_address: z.string().nullable(), +}); + +type FormFields = z.infer; + +const EditEdgeForm = ({ edge }: { edge: Edge }) => { + const navigate = useNavigate(); + + const { mutateAsync: editEdge } = useMutation({ + mutationFn: api.edge.editEdge, + meta: { + invalidate: ['edge'], + }, + onSuccess: () => { + Snackbar.success(m.edge_edit_success()); + }, + onError: () => { + Snackbar.error(m.edge_edit_failed()); + }, + }); + + const { mutate: deleteEdge, isPending: deletePending } = useMutation({ + mutationFn: () => api.edge.deleteEdge(edge.id), + meta: { + invalidate: ['edge'], + }, + onSuccess: () => { + navigate({ + to: '/edge', + replace: true, + }); + }, + }); + + const defaultValues = useMemo((): FormFields => ({ ...edge }), [edge]); + + const form = useAppForm({ + defaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: async ({ value }) => { + await editEdge({ + ...value, + id: edge.id, + }); + }, + }); + + return ( +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + + + + {(field) => } + + + + {(field) => } + + + + {(field) => } + + + + {(field) => } + + + ({ + isSubmitting: form.isSubmitting, + isDefault: form.isPristine || form.isDefaultValue, + })} + > + {({ isDefault, isSubmitting }) => ( + { + deleteEdge(); + }, + loading: deletePending, + disabled: isSubmitting, + }} + cancelProps={{ + onClick: () => { + window.history.back(); + }, + }} + submitProps={{ + loading: isSubmitting, + disabled: isDefault, + onClick: () => { + form.handleSubmit(); + }, + }} + /> + )} + + +
+ ); +}; diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index f7c3e9933b..c0cb54e137 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -39,6 +39,7 @@ import { Route as AuthorizedDefaultActivityRouteImport } from './routes/_authori import { Route as AuthorizedDefaultVpnOverviewIndexRouteImport } from './routes/_authorized/_default/vpn-overview/index' import { Route as AuthorizedDefaultSettingsIndexRouteImport } from './routes/_authorized/_default/settings/index' import { Route as AuthorizedDefaultLocationsIndexRouteImport } from './routes/_authorized/_default/locations/index' +import { Route as AuthorizedDefaultEdgeIndexRouteImport } from './routes/_authorized/_default/edge/index' import { Route as AuthorizedDefaultVpnOverviewLocationIdRouteImport } from './routes/_authorized/_default/vpn-overview/$locationId' import { Route as AuthorizedDefaultUserUsernameRouteImport } from './routes/_authorized/_default/user/$username' import { Route as AuthorizedDefaultSettingsSmtpRouteImport } from './routes/_authorized/_default/settings/smtp' @@ -55,6 +56,7 @@ import { Route as AuthorizedDefaultAclAddRuleRouteImport } from './routes/_autho import { Route as AuthorizedDefaultAclAddDestinationRouteImport } from './routes/_authorized/_default/acl/add-destination' import { Route as AuthorizedDefaultAclAddAliasRouteImport } from './routes/_authorized/_default/acl/add-alias' import { Route as AuthorizedDefaultLocationsLocationIdEditRouteImport } from './routes/_authorized/_default/locations/$locationId/edit' +import { Route as AuthorizedDefaultEdgeEdgeIdEditRouteImport } from './routes/_authorized/_default/edge/$edgeId/edit' const SnackbarRoute = SnackbarRouteImport.update({ id: '/snackbar', @@ -215,6 +217,12 @@ const AuthorizedDefaultLocationsIndexRoute = path: '/locations/', getParentRoute: () => AuthorizedDefaultRoute, } as any) +const AuthorizedDefaultEdgeIndexRoute = + AuthorizedDefaultEdgeIndexRouteImport.update({ + id: '/edge/', + path: '/edge/', + getParentRoute: () => AuthorizedDefaultRoute, + } as any) const AuthorizedDefaultVpnOverviewLocationIdRoute = AuthorizedDefaultVpnOverviewLocationIdRouteImport.update({ id: '/vpn-overview/$locationId', @@ -311,10 +319,15 @@ const AuthorizedDefaultLocationsLocationIdEditRoute = path: '/locations/$locationId/edit', getParentRoute: () => AuthorizedDefaultRoute, } as any) +const AuthorizedDefaultEdgeEdgeIdEditRoute = + AuthorizedDefaultEdgeEdgeIdEditRouteImport.update({ + id: '/edge/$edgeId/edit', + path: '/edge/$edgeId/edit', + getParentRoute: () => AuthorizedDefaultRoute, + } as any) export interface FileRoutesByFullPath { '/404': typeof R404Route - '/': typeof AuthorizedDefaultRouteWithChildren '/auth': typeof AuthRouteWithChildren '/consent': typeof ConsentRoute '/playground': typeof PlaygroundRoute @@ -354,14 +367,15 @@ export interface FileRoutesByFullPath { '/settings/smtp': typeof AuthorizedDefaultSettingsSmtpRoute '/user/$username': typeof AuthorizedDefaultUserUsernameRoute '/vpn-overview/$locationId': typeof AuthorizedDefaultVpnOverviewLocationIdRoute - '/locations/': typeof AuthorizedDefaultLocationsIndexRoute - '/settings/': typeof AuthorizedDefaultSettingsIndexRoute - '/vpn-overview/': typeof AuthorizedDefaultVpnOverviewIndexRoute + '/edge': typeof AuthorizedDefaultEdgeIndexRoute + '/locations': typeof AuthorizedDefaultLocationsIndexRoute + '/settings': typeof AuthorizedDefaultSettingsIndexRoute + '/vpn-overview': typeof AuthorizedDefaultVpnOverviewIndexRoute + '/edge/$edgeId/edit': typeof AuthorizedDefaultEdgeEdgeIdEditRoute '/locations/$locationId/edit': typeof AuthorizedDefaultLocationsLocationIdEditRoute } export interface FileRoutesByTo { '/404': typeof R404Route - '/': typeof AuthorizedDefaultRouteWithChildren '/consent': typeof ConsentRoute '/playground': typeof PlaygroundRoute '/snackbar': typeof SnackbarRoute @@ -400,9 +414,11 @@ export interface FileRoutesByTo { '/settings/smtp': typeof AuthorizedDefaultSettingsSmtpRoute '/user/$username': typeof AuthorizedDefaultUserUsernameRoute '/vpn-overview/$locationId': typeof AuthorizedDefaultVpnOverviewLocationIdRoute + '/edge': typeof AuthorizedDefaultEdgeIndexRoute '/locations': typeof AuthorizedDefaultLocationsIndexRoute '/settings': typeof AuthorizedDefaultSettingsIndexRoute '/vpn-overview': typeof AuthorizedDefaultVpnOverviewIndexRoute + '/edge/$edgeId/edit': typeof AuthorizedDefaultEdgeEdgeIdEditRoute '/locations/$locationId/edit': typeof AuthorizedDefaultLocationsLocationIdEditRoute } export interface FileRoutesById { @@ -449,16 +465,17 @@ export interface FileRoutesById { '/_authorized/_default/settings/smtp': typeof AuthorizedDefaultSettingsSmtpRoute '/_authorized/_default/user/$username': typeof AuthorizedDefaultUserUsernameRoute '/_authorized/_default/vpn-overview/$locationId': typeof AuthorizedDefaultVpnOverviewLocationIdRoute + '/_authorized/_default/edge/': typeof AuthorizedDefaultEdgeIndexRoute '/_authorized/_default/locations/': typeof AuthorizedDefaultLocationsIndexRoute '/_authorized/_default/settings/': typeof AuthorizedDefaultSettingsIndexRoute '/_authorized/_default/vpn-overview/': typeof AuthorizedDefaultVpnOverviewIndexRoute + '/_authorized/_default/edge/$edgeId/edit': typeof AuthorizedDefaultEdgeEdgeIdEditRoute '/_authorized/_default/locations/$locationId/edit': typeof AuthorizedDefaultLocationsLocationIdEditRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/404' - | '/' | '/auth' | '/consent' | '/playground' @@ -498,14 +515,15 @@ export interface FileRouteTypes { | '/settings/smtp' | '/user/$username' | '/vpn-overview/$locationId' - | '/locations/' - | '/settings/' - | '/vpn-overview/' + | '/edge' + | '/locations' + | '/settings' + | '/vpn-overview' + | '/edge/$edgeId/edit' | '/locations/$locationId/edit' fileRoutesByTo: FileRoutesByTo to: | '/404' - | '/' | '/consent' | '/playground' | '/snackbar' @@ -544,9 +562,11 @@ export interface FileRouteTypes { | '/settings/smtp' | '/user/$username' | '/vpn-overview/$locationId' + | '/edge' | '/locations' | '/settings' | '/vpn-overview' + | '/edge/$edgeId/edit' | '/locations/$locationId/edit' id: | '__root__' @@ -592,9 +612,11 @@ export interface FileRouteTypes { | '/_authorized/_default/settings/smtp' | '/_authorized/_default/user/$username' | '/_authorized/_default/vpn-overview/$locationId' + | '/_authorized/_default/edge/' | '/_authorized/_default/locations/' | '/_authorized/_default/settings/' | '/_authorized/_default/vpn-overview/' + | '/_authorized/_default/edge/$edgeId/edit' | '/_authorized/_default/locations/$locationId/edit' fileRoutesById: FileRoutesById } @@ -640,7 +662,7 @@ declare module '@tanstack/react-router' { '/_authorized': { id: '/_authorized' path: '' - fullPath: '/' + fullPath: '' preLoaderRoute: typeof AuthorizedRouteImport parentRoute: typeof rootRouteImport } @@ -689,7 +711,7 @@ declare module '@tanstack/react-router' { '/_authorized/_default': { id: '/_authorized/_default' path: '' - fullPath: '/' + fullPath: '' preLoaderRoute: typeof AuthorizedDefaultRouteImport parentRoute: typeof AuthorizedRoute } @@ -801,24 +823,31 @@ declare module '@tanstack/react-router' { '/_authorized/_default/vpn-overview/': { id: '/_authorized/_default/vpn-overview/' path: '/vpn-overview' - fullPath: '/vpn-overview/' + fullPath: '/vpn-overview' preLoaderRoute: typeof AuthorizedDefaultVpnOverviewIndexRouteImport parentRoute: typeof AuthorizedDefaultRoute } '/_authorized/_default/settings/': { id: '/_authorized/_default/settings/' path: '/settings' - fullPath: '/settings/' + fullPath: '/settings' preLoaderRoute: typeof AuthorizedDefaultSettingsIndexRouteImport parentRoute: typeof AuthorizedDefaultRoute } '/_authorized/_default/locations/': { id: '/_authorized/_default/locations/' path: '/locations' - fullPath: '/locations/' + fullPath: '/locations' preLoaderRoute: typeof AuthorizedDefaultLocationsIndexRouteImport parentRoute: typeof AuthorizedDefaultRoute } + '/_authorized/_default/edge/': { + id: '/_authorized/_default/edge/' + path: '/edge' + fullPath: '/edge' + preLoaderRoute: typeof AuthorizedDefaultEdgeIndexRouteImport + parentRoute: typeof AuthorizedDefaultRoute + } '/_authorized/_default/vpn-overview/$locationId': { id: '/_authorized/_default/vpn-overview/$locationId' path: '/vpn-overview/$locationId' @@ -931,6 +960,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthorizedDefaultLocationsLocationIdEditRouteImport parentRoute: typeof AuthorizedDefaultRoute } + '/_authorized/_default/edge/$edgeId/edit': { + id: '/_authorized/_default/edge/$edgeId/edit' + path: '/edge/$edgeId/edit' + fullPath: '/edge/$edgeId/edit' + preLoaderRoute: typeof AuthorizedDefaultEdgeEdgeIdEditRouteImport + parentRoute: typeof AuthorizedDefaultRoute + } } } @@ -956,9 +992,11 @@ interface AuthorizedDefaultRouteChildren { AuthorizedDefaultSettingsSmtpRoute: typeof AuthorizedDefaultSettingsSmtpRoute AuthorizedDefaultUserUsernameRoute: typeof AuthorizedDefaultUserUsernameRoute AuthorizedDefaultVpnOverviewLocationIdRoute: typeof AuthorizedDefaultVpnOverviewLocationIdRoute + AuthorizedDefaultEdgeIndexRoute: typeof AuthorizedDefaultEdgeIndexRoute AuthorizedDefaultLocationsIndexRoute: typeof AuthorizedDefaultLocationsIndexRoute AuthorizedDefaultSettingsIndexRoute: typeof AuthorizedDefaultSettingsIndexRoute AuthorizedDefaultVpnOverviewIndexRoute: typeof AuthorizedDefaultVpnOverviewIndexRoute + AuthorizedDefaultEdgeEdgeIdEditRoute: typeof AuthorizedDefaultEdgeEdgeIdEditRoute AuthorizedDefaultLocationsLocationIdEditRoute: typeof AuthorizedDefaultLocationsLocationIdEditRoute } @@ -990,10 +1028,12 @@ const AuthorizedDefaultRouteChildren: AuthorizedDefaultRouteChildren = { AuthorizedDefaultUserUsernameRoute: AuthorizedDefaultUserUsernameRoute, AuthorizedDefaultVpnOverviewLocationIdRoute: AuthorizedDefaultVpnOverviewLocationIdRoute, + AuthorizedDefaultEdgeIndexRoute: AuthorizedDefaultEdgeIndexRoute, AuthorizedDefaultLocationsIndexRoute: AuthorizedDefaultLocationsIndexRoute, AuthorizedDefaultSettingsIndexRoute: AuthorizedDefaultSettingsIndexRoute, AuthorizedDefaultVpnOverviewIndexRoute: AuthorizedDefaultVpnOverviewIndexRoute, + AuthorizedDefaultEdgeEdgeIdEditRoute: AuthorizedDefaultEdgeEdgeIdEditRoute, AuthorizedDefaultLocationsLocationIdEditRoute: AuthorizedDefaultLocationsLocationIdEditRoute, } diff --git a/web/src/routes/_authorized/_default/edge/$edgeId/edit.tsx b/web/src/routes/_authorized/_default/edge/$edgeId/edit.tsx new file mode 100644 index 0000000000..c80468125f --- /dev/null +++ b/web/src/routes/_authorized/_default/edge/$edgeId/edit.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { EditEdgePage } from '../../../../../pages/EditEdgePage/EditEdgePage'; +import { getEdgeQueryOptions } from '../../../../../shared/query'; + +export const Route = createFileRoute('/_authorized/_default/edge/$edgeId/edit')({ + loader: async ({ context, params }) => { + const parsedId = parseInt(params.edgeId, 10); + return context.queryClient.ensureQueryData(getEdgeQueryOptions(parsedId)); + }, + component: EditEdgePage, +}); diff --git a/web/src/routes/_authorized/_default/edge/index.tsx b/web/src/routes/_authorized/_default/edge/index.tsx new file mode 100644 index 0000000000..12516233e9 --- /dev/null +++ b/web/src/routes/_authorized/_default/edge/index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { EdgeListPage } from '../../../../pages/EdgeListPage/EdgeListPage'; + +export const Route = createFileRoute('/_authorized/_default/edge/')({ + component: EdgeListPage, +}); diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index 80d5df5dc4..66ba817d74 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -38,6 +38,7 @@ import type { DeleteAuthKeyRequest, DeleteGatewayRequest, Device, + Edge, EditAclAliasRequest, EditAclDestination, EditAclRuleRequest, @@ -356,6 +357,11 @@ const api = { mail: { sendTestEmail: (data: { email: string }) => client.post('/mail/test', data), }, + edge: { + getEdge: (edgeId: number | string) => client.get(`/proxy/${edgeId}`), + editEdge: (data: Edge) => client.put(`/proxy/${data.id}`, data), + deleteEdge: (edgeId: number | string) => client.delete(`/proxy/${edgeId}`), + }, acl: { destination: { getDestinations: () => client.get('/acl/destination'), diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 129ea56960..41c1547cdb 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -909,6 +909,14 @@ export type ActivityLogSortKey = | 'module' | 'device'; +export interface Edge { + id: number; + name: string; + address: string | null; + port: number | null; + public_address: string | null; +} + export interface PaginationParams { page?: number; } diff --git a/web/src/shared/query.ts b/web/src/shared/query.ts index 96ba1f6cbd..356d21ab27 100644 --- a/web/src/shared/query.ts +++ b/web/src/shared/query.ts @@ -28,6 +28,13 @@ export const getLocationsQueryOptions = queryOptions({ select: (resp) => resp.data, }); +export const getEdgeQueryOptions = (id: number) => + queryOptions({ + queryFn: () => api.edge.getEdge(id), + queryKey: ['edge', id], + select: (resp) => resp.data, + }); + export const getNetworkDevicesQueryOptions = queryOptions({ queryFn: api.network_device.getDevices, queryKey: ['device', 'network'],