diff --git a/crates/defguard_common/src/db/models/gateway.rs b/crates/defguard_common/src/db/models/gateway.rs index 0a7238e7c..d670e4487 100644 --- a/crates/defguard_common/src/db/models/gateway.rs +++ b/crates/defguard_common/src/db/models/gateway.rs @@ -1,6 +1,6 @@ use std::fmt; -use chrono::{NaiveDateTime, Utc}; +use chrono::{NaiveDateTime, Timelike, Utc}; use model_derive::Model; use serde::{Deserialize, Serialize}; use sqlx::{PgExecutor, query, query_as}; @@ -44,6 +44,14 @@ impl Gateway { port: i32, modified_by: Id, ) -> Self { + // FIXME: this is a workaround for reducing timestamp precision. + // `chrono` has nanosecond precision by default, while Postgres only does microseconds. + // It avoids issues when comparing to objects fetched from DB. + let modified_at = Utc::now().naive_utc(); + let modified_at = modified_at + .with_nanosecond((modified_at.nanosecond() / 1_000) * 1_000) + .expect("failed to truncate timestamp precision"); + Self { id: NoId, location_id: network_id, @@ -56,7 +64,7 @@ impl Gateway { certificate_expiry: None, version: None, modified_by, - modified_at: Utc::now().naive_utc(), + modified_at, } } } diff --git a/crates/defguard_core/src/handlers/gateway.rs b/crates/defguard_core/src/handlers/gateway.rs index 859c37a0d..e5c8425c1 100644 --- a/crates/defguard_core/src/handlers/gateway.rs +++ b/crates/defguard_core/src/handlers/gateway.rs @@ -1,5 +1,6 @@ use axum::{ Json, + extract::rejection::JsonRejection, extract::{Path, State}, }; use chrono::NaiveDateTime; @@ -13,6 +14,7 @@ use utoipa::ToSchema; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, + error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, handlers::{ApiResponse, ApiResult}, }; @@ -83,6 +85,7 @@ impl GatewayInfo { } #[derive(Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] pub struct GatewayUpdateData { pub name: String, } @@ -173,8 +176,16 @@ pub(crate) async fn update_gateway( State(appstate): State, session: SessionInfo, context: ApiRequestContext, - Json(data): Json, + payload: Result, JsonRejection>, ) -> ApiResult { + let Json(data) = match payload { + Ok(payload) => payload, + Err(err) => { + let msg = format!("Failed to parse request data: {err}"); + warn!(msg); + return Err(WebError::BadRequest(msg)); + } + }; debug!( "User {} updating gateway {gateway_id}", session.user.username @@ -182,8 +193,9 @@ pub(crate) async fn update_gateway( let gateway = Gateway::find_by_id(&appstate.pool, gateway_id).await?; let Some(mut gateway) = gateway else { - warn!("Gateway {gateway_id} not found"); - return Ok(ApiResponse::json(Value::Null, StatusCode::NOT_FOUND)); + let msg = format!("Gateway {gateway_id} not found"); + warn!(msg); + return Err(WebError::ObjectNotFound(msg)); }; let before = gateway.clone(); @@ -235,8 +247,9 @@ pub(crate) async fn delete_gateway( let gateway = Gateway::find_by_id(&appstate.pool, gateway_id).await?; let Some(gateway) = gateway else { - warn!("Gateway {gateway_id} not found"); - return Ok(ApiResponse::json(Value::Null, StatusCode::NOT_FOUND)); + let msg = format!("Gateway {gateway_id} not found"); + warn!(msg); + return Err(WebError::ObjectNotFound(msg)); }; gateway.clone().delete(&appstate.pool).await?; diff --git a/crates/defguard_core/tests/integration/api/gateway.rs b/crates/defguard_core/tests/integration/api/gateway.rs new file mode 100644 index 000000000..f6c253102 --- /dev/null +++ b/crates/defguard_core/tests/integration/api/gateway.rs @@ -0,0 +1,154 @@ +use defguard_common::db::{ + Id, + models::{WireguardNetwork, gateway::Gateway}, +}; +use defguard_core::handlers::Auth; +use reqwest::StatusCode; +use serde_json::json; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + +use super::common::{make_network, make_test_client, setup_pool}; + +#[sqlx::test] +async fn test_gateway_crud(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, client_state) = make_test_client(pool).await; + + client.login_user("admin", "pass123").await; + + let response = make_network(&client, "network").await; + let network: WireguardNetwork = response.json().await; + client.drain_all_events(); + client.drain_all_events(); + + let gateway_1 = Gateway::new(network.id, "gateway1", "127.0.0.1", 50051, 1) + .save(&client_state.pool) + .await + .unwrap(); + let gateway_2 = Gateway::new(network.id, "gateway2", "1.2.3.1", 55555, 1) + .save(&client_state.pool) + .await + .unwrap(); + + let response = client.get("/api/v1/gateway").send().await; + assert_eq!(response.status(), StatusCode::OK); + let gateways: Vec> = response.json().await; + assert_eq!(gateways.len(), 2); + let gateway_from_list = &gateways[0]; + assert_eq!(gateway_from_list, &gateway_1); + let gateway_from_list = &gateways[1]; + assert_eq!(gateway_from_list, &gateway_2); + + let response = client + .get(format!("/api/v1/gateway/{}", gateway_1.id)) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let gateway_details: Gateway = response.json().await; + assert_eq!(gateway_details, gateway_1); + + let response = client + .put(format!("/api/v1/gateway/{}", gateway_1.id)) + .json(&json!({ + "name": "gateway-updated", + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let updated_gateway: Gateway = response.json().await; + assert_eq!(updated_gateway.name, "gateway-updated"); + assert_eq!(updated_gateway.address, gateway_1.address); + assert_eq!(updated_gateway.port, gateway_1.port); + + let response = client + .delete(format!("/api/v1/gateway/{}", gateway_1.id)) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let response = client + .get(format!("/api/v1/gateway/{}", gateway_1.id)) + .send() + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = client.get("/api/v1/gateway").send().await; + assert_eq!(response.status(), StatusCode::OK); + let gateways: Vec> = response.json().await; + assert_eq!(gateways.len(), 1); +} + +#[sqlx::test] +async fn test_gateway_endpoints_require_admin(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, client_state) = make_test_client(pool).await; + + client.login_user("admin", "pass123").await; + + let response = make_network(&client, "network").await; + let network: WireguardNetwork = response.json().await; + + let gateway = Gateway::new(network.id, "gateway", "127.0.0.1", 50051, 1) + .save(&client_state.pool) + .await + .unwrap(); + + let auth = Auth::new("hpotter", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + let response = client.get("/api/v1/gateway").send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = client + .get(format!("/api/v1/gateway/{}", gateway.id)) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = client + .put(format!("/api/v1/gateway/{}", gateway.id)) + .json(&json!({ + "name": "gateway-updated", + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = client + .delete(format!("/api/v1/gateway/{}", gateway.id)) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[sqlx::test] +async fn test_gateway_update_rejects_unknown_fields(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, client_state) = make_test_client(pool).await; + + client.login_user("admin", "pass123").await; + + let response = make_network(&client, "network").await; + let network: WireguardNetwork = response.json().await; + + let gateway = Gateway::new(network.id, "gateway", "127.0.0.1", 50051, 1) + .save(&client_state.pool) + .await + .unwrap(); + + let response = client + .put(format!("/api/v1/gateway/{}", gateway.id)) + .json(&json!({ + "name": "gateway-updated", + "address": "127.0.0.2", + "port": 50052, + "location_id": 999, + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} diff --git a/crates/defguard_core/tests/integration/api/mod.rs b/crates/defguard_core/tests/integration/api/mod.rs index 846ed95a4..8a879d4f8 100644 --- a/crates/defguard_core/tests/integration/api/mod.rs +++ b/crates/defguard_core/tests/integration/api/mod.rs @@ -5,6 +5,7 @@ mod common; mod enrollment; mod enterprise_settings; mod forward_auth; +mod gateway; mod group; mod location_stats; mod oauth; diff --git a/web/messages/en/gateway.json b/web/messages/en/gateway.json index cef8d9c76..cf410f84e 100644 --- a/web/messages/en/gateway.json +++ b/web/messages/en/gateway.json @@ -1,5 +1,14 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", + "gateway_title": "Gateways", + "gateway_edit_title": "Edit gateway", + "gateway_edit_general_info": "General information", + "gateway_edit_name": "Name", + "gateway_edit_address": "IP or Domain", + "gateway_edit_port": "gRPC port", + "gateway_edit_delete": "Delete", + "gateway_edit_success": "Gateway updated", + "gateway_edit_failed": "Failed to update gateway", "gateway_delete_success": "Gateway deleted", "gateway_delete_failed": "Failed to delete gateway" } diff --git a/web/src/pages/EditEdgePage/EditEdgePage.tsx b/web/src/pages/EditEdgePage/EditEdgePage.tsx index 1ab71420a..f2266be0d 100644 --- a/web/src/pages/EditEdgePage/EditEdgePage.tsx +++ b/web/src/pages/EditEdgePage/EditEdgePage.tsx @@ -99,6 +99,7 @@ const EditEdgeForm = ({ edge }: { edge: Edge }) => { ...value, id: edge.id, }); + form.reset(value); }, }); @@ -141,11 +142,6 @@ const EditEdgeForm = ({ edge }: { edge: Edge }) => { loading: deletePending, disabled: isSubmitting, }} - cancelProps={{ - onClick: () => { - window.history.back(); - }, - }} submitProps={{ loading: isSubmitting, disabled: isDefault, diff --git a/web/src/pages/EditGatewayPage/EditGatewayPage.tsx b/web/src/pages/EditGatewayPage/EditGatewayPage.tsx new file mode 100644 index 000000000..1ec5f8860 --- /dev/null +++ b/web/src/pages/EditGatewayPage/EditGatewayPage.tsx @@ -0,0 +1,159 @@ +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 { Gateway } 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 { getGatewayQueryOptions } from '../../shared/query'; + +export const EditGatewayPage = () => { + const { gatewayId } = useParams({ + from: '/_authorized/_default/gateway/$gatewayId/edit', + }); + const { data: gateway } = useSuspenseQuery(getGatewayQueryOptions(Number(gatewayId))); + const breadcrumbsLinks = [ + + {m.gateway_title()} + , + + {gateway.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(), + connected_at: z.string().nullable(), + disconnected_at: z.string().nullable(), + modified_at: z.string(), + modified_by: z.number(), + version: z.string().nullable(), + location_id: z.number(), +}); + +type FormFields = z.infer; + +const EditGatewayForm = ({ gateway }: { gateway: Gateway }) => { + const navigate = useNavigate(); + + const { mutateAsync: editGateway } = useMutation({ + mutationFn: api.gateway.editGateway, + meta: { + invalidate: ['gateway'], + }, + onSuccess: () => { + Snackbar.success(m.gateway_edit_success()); + }, + onError: () => { + Snackbar.error(m.gateway_edit_failed()); + }, + }); + + const { mutate: deleteGateway, isPending: deletePending } = useMutation({ + mutationFn: () => api.gateway.deleteGateway(gateway.id), + meta: { + invalidate: ['gateway'], + }, + onSuccess: () => { + navigate({ + to: '/locations', + replace: true, + }); + Snackbar.success(m.gateway_delete_success()); + }, + onError: () => { + Snackbar.error(m.gateway_delete_failed()); + }, + }); + + const defaultValues = useMemo((): FormFields => ({ ...gateway }), [gateway]); + + const form = useAppForm({ + defaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: async ({ value }) => { + await editGateway({ + ...value, + id: gateway.id, + }); + form.reset(value); + }, + }); + + return ( +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + + + + {(field) => } + + + + {(field) => } + + + + {(field) => } + + + + ({ + isSubmitting: form.isSubmitting, + isDefault: form.isPristine || form.isDefaultValue, + })} + > + {({ isDefault, isSubmitting }) => ( + { + deleteGateway(); + }, + loading: deletePending, + disabled: isSubmitting, + }} + submitProps={{ + loading: isSubmitting, + disabled: isDefault, + onClick: () => { + form.handleSubmit(); + }, + }} + /> + )} + + +
+ ); +}; diff --git a/web/src/pages/LocationsPage/components/GatewaysTable.tsx b/web/src/pages/LocationsPage/components/GatewaysTable.tsx index 31318add3..3f33bec86 100644 --- a/web/src/pages/LocationsPage/components/GatewaysTable.tsx +++ b/web/src/pages/LocationsPage/components/GatewaysTable.tsx @@ -1,4 +1,5 @@ import { useSuspenseQuery } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; import { createColumnHelper, getCoreRowModel, @@ -47,6 +48,7 @@ const getStatusBadge = (gateway: GatewayInfo) => { export const GatewaysTable = () => { const { data: gateways } = useSuspenseQuery(getGatewaysQueryOptions); + const navigate = useNavigate(); const [search, setSearch] = useState(''); @@ -165,6 +167,18 @@ export const GatewaysTable = () => { const menuItems: MenuItemsGroup[] = [ { items: [ + { + text: m.controls_edit(), + icon: 'edit', + onClick: () => { + navigate({ + to: '/gateway/$gatewayId/edit', + params: { + gatewayId: rowData.id.toString(), + }, + }); + }, + }, { text: m.controls_delete(), icon: 'delete', @@ -189,7 +203,7 @@ export const GatewaysTable = () => { }, }), ], - [], + [navigate], ); const table = useReactTable({ diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 14e777133..dd7b76882 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -60,6 +60,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 AuthorizedDefaultGatewayGatewayIdEditRouteImport } from './routes/_authorized/_default/gateway/$gatewayId/edit' import { Route as AuthorizedDefaultEdgeEdgeIdEditRouteImport } from './routes/_authorized/_default/edge/$edgeId/edit' const SnackbarRoute = SnackbarRouteImport.update({ @@ -344,6 +345,12 @@ const AuthorizedDefaultLocationsLocationIdEditRoute = path: '/locations/$locationId/edit', getParentRoute: () => AuthorizedDefaultRoute, } as any) +const AuthorizedDefaultGatewayGatewayIdEditRoute = + AuthorizedDefaultGatewayGatewayIdEditRouteImport.update({ + id: '/gateway/$gatewayId/edit', + path: '/gateway/$gatewayId/edit', + getParentRoute: () => AuthorizedDefaultRoute, + } as any) const AuthorizedDefaultEdgeEdgeIdEditRoute = AuthorizedDefaultEdgeEdgeIdEditRouteImport.update({ id: '/edge/$edgeId/edit', @@ -402,6 +409,7 @@ export interface FileRoutesByFullPath { '/settings/': typeof AuthorizedDefaultSettingsIndexRoute '/vpn-overview/': typeof AuthorizedDefaultVpnOverviewIndexRoute '/edge/$edgeId/edit': typeof AuthorizedDefaultEdgeEdgeIdEditRoute + '/gateway/$gatewayId/edit': typeof AuthorizedDefaultGatewayGatewayIdEditRoute '/locations/$locationId/edit': typeof AuthorizedDefaultLocationsLocationIdEditRoute } export interface FileRoutesByTo { @@ -454,6 +462,7 @@ export interface FileRoutesByTo { '/settings': typeof AuthorizedDefaultSettingsIndexRoute '/vpn-overview': typeof AuthorizedDefaultVpnOverviewIndexRoute '/edge/$edgeId/edit': typeof AuthorizedDefaultEdgeEdgeIdEditRoute + '/gateway/$gatewayId/edit': typeof AuthorizedDefaultGatewayGatewayIdEditRoute '/locations/$locationId/edit': typeof AuthorizedDefaultLocationsLocationIdEditRoute } export interface FileRoutesById { @@ -509,6 +518,7 @@ export interface FileRoutesById { '/_authorized/_default/settings/': typeof AuthorizedDefaultSettingsIndexRoute '/_authorized/_default/vpn-overview/': typeof AuthorizedDefaultVpnOverviewIndexRoute '/_authorized/_default/edge/$edgeId/edit': typeof AuthorizedDefaultEdgeEdgeIdEditRoute + '/_authorized/_default/gateway/$gatewayId/edit': typeof AuthorizedDefaultGatewayGatewayIdEditRoute '/_authorized/_default/locations/$locationId/edit': typeof AuthorizedDefaultLocationsLocationIdEditRoute } export interface FileRouteTypes { @@ -564,6 +574,7 @@ export interface FileRouteTypes { | '/settings/' | '/vpn-overview/' | '/edge/$edgeId/edit' + | '/gateway/$gatewayId/edit' | '/locations/$locationId/edit' fileRoutesByTo: FileRoutesByTo to: @@ -616,6 +627,7 @@ export interface FileRouteTypes { | '/settings' | '/vpn-overview' | '/edge/$edgeId/edit' + | '/gateway/$gatewayId/edit' | '/locations/$locationId/edit' id: | '__root__' @@ -670,6 +682,7 @@ export interface FileRouteTypes { | '/_authorized/_default/settings/' | '/_authorized/_default/vpn-overview/' | '/_authorized/_default/edge/$edgeId/edit' + | '/_authorized/_default/gateway/$gatewayId/edit' | '/_authorized/_default/locations/$locationId/edit' fileRoutesById: FileRoutesById } @@ -1042,6 +1055,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthorizedDefaultLocationsLocationIdEditRouteImport parentRoute: typeof AuthorizedDefaultRoute } + '/_authorized/_default/gateway/$gatewayId/edit': { + id: '/_authorized/_default/gateway/$gatewayId/edit' + path: '/gateway/$gatewayId/edit' + fullPath: '/gateway/$gatewayId/edit' + preLoaderRoute: typeof AuthorizedDefaultGatewayGatewayIdEditRouteImport + parentRoute: typeof AuthorizedDefaultRoute + } '/_authorized/_default/edge/$edgeId/edit': { id: '/_authorized/_default/edge/$edgeId/edit' path: '/edge/$edgeId/edit' @@ -1082,6 +1102,7 @@ interface AuthorizedDefaultRouteChildren { AuthorizedDefaultSettingsIndexRoute: typeof AuthorizedDefaultSettingsIndexRoute AuthorizedDefaultVpnOverviewIndexRoute: typeof AuthorizedDefaultVpnOverviewIndexRoute AuthorizedDefaultEdgeEdgeIdEditRoute: typeof AuthorizedDefaultEdgeEdgeIdEditRoute + AuthorizedDefaultGatewayGatewayIdEditRoute: typeof AuthorizedDefaultGatewayGatewayIdEditRoute AuthorizedDefaultLocationsLocationIdEditRoute: typeof AuthorizedDefaultLocationsLocationIdEditRoute } @@ -1122,6 +1143,8 @@ const AuthorizedDefaultRouteChildren: AuthorizedDefaultRouteChildren = { AuthorizedDefaultVpnOverviewIndexRoute: AuthorizedDefaultVpnOverviewIndexRoute, AuthorizedDefaultEdgeEdgeIdEditRoute: AuthorizedDefaultEdgeEdgeIdEditRoute, + AuthorizedDefaultGatewayGatewayIdEditRoute: + AuthorizedDefaultGatewayGatewayIdEditRoute, AuthorizedDefaultLocationsLocationIdEditRoute: AuthorizedDefaultLocationsLocationIdEditRoute, } diff --git a/web/src/routes/_authorized/_default/gateway/$gatewayId/edit.tsx b/web/src/routes/_authorized/_default/gateway/$gatewayId/edit.tsx new file mode 100644 index 000000000..8938f3dea --- /dev/null +++ b/web/src/routes/_authorized/_default/gateway/$gatewayId/edit.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { EditGatewayPage } from '../../../../../pages/EditGatewayPage/EditGatewayPage'; +import { getGatewayQueryOptions } from '../../../../../shared/query'; + +export const Route = createFileRoute('/_authorized/_default/gateway/$gatewayId/edit')({ + loader: async ({ context, params }) => { + const parsedId = parseInt(params.gatewayId, 10); + return context.queryClient.ensureQueryData(getGatewayQueryOptions(parsedId)); + }, + component: EditGatewayPage, +}); diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index d992dea2e..9a5c761f9 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -444,7 +444,8 @@ const api = { getGateways: () => client.get('/gateway'), getGateway: (gatewayId: number | string) => client.get(`/gateway/${gatewayId}`), - editGateway: (data: Gateway) => client.put(`/gateway/${data.id}`, data), + editGateway: (data: Gateway) => + client.put(`/gateway/${data.id}`, { name: data.name }), deleteGateway: (gatewayId: number | string) => client.delete(`/gateway/${gatewayId}`), }, acl: { diff --git a/web/src/shared/components/EditPageControls/EditPageControls.tsx b/web/src/shared/components/EditPageControls/EditPageControls.tsx index 9551c61fc..1a25359c7 100644 --- a/web/src/shared/components/EditPageControls/EditPageControls.tsx +++ b/web/src/shared/components/EditPageControls/EditPageControls.tsx @@ -21,21 +21,25 @@ export const EditPageControls = ({ cancelProps, deleteProps, submitProps }: Prop }} /> )} -