(feature): frontend update, docker compose services review, documentation

This commit is contained in:
Maksym Sadovnychyy 2026-04-12 14:54:32 +02:00
parent 830f3e7d3e
commit f70742cf18
40 changed files with 1933 additions and 1136 deletions

View File

@ -22,6 +22,9 @@ If you find this project useful, please consider supporting its development:
- [Table of Contents](#table-of-contents)
- [Changelog](#changelog)
- [Contributing](#contributing)
- [Patch and delta reference](#patch-and-delta-reference)
- [Login and refresh token architecture](#login-and-refresh-token-architecture)
- [Reverse proxy routing (YARP)](#reverse-proxy-routing-yarp)
- [Architecture](#architecture)
- [Current Limitations](#current-limitations)
- [Architecture Scheme](#architecture-scheme)
@ -54,6 +57,43 @@ Version history and release notes live in [CHANGELOG.md](CHANGELOG.md).
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, pull request expectations, and security reporting.
## Patch and delta reference
How PATCH payloads (deltas) are built and applied is documented in **[assets/docs/PATCH_DELTA_REFERENCE.md](assets/docs/PATCH_DELTA_REFERENCE.md)**. It matches the **MaksIT.Core** contract and is aligned with the same topic in **MaksIT-Vault** (this repo focuses on **account** PATCH and **`hostnames`** in the WebUI).
- [TL;DR (start here)](assets/docs/PATCH_DELTA_REFERENCE.md#tldr-start-here)
- [1. Core contract (MaksIT.Core)](assets/docs/PATCH_DELTA_REFERENCE.md#1-core-contract-maksitcore)
- [2. Backend (BE) rules](assets/docs/PATCH_DELTA_REFERENCE.md#2-backend-be-rules)
- [3. Frontend (FE) rules](assets/docs/PATCH_DELTA_REFERENCE.md#3-frontend-fe-rules)
- [4. Payload examples](assets/docs/PATCH_DELTA_REFERENCE.md#4-payload-examples)
- [5. Quick reference](assets/docs/PATCH_DELTA_REFERENCE.md#5-quick-reference)
- [6. Related docs](assets/docs/PATCH_DELTA_REFERENCE.md#6-related-docs)
- [7. Current implementation vs reference](assets/docs/PATCH_DELTA_REFERENCE.md#7-current-implementation-vs-reference-maksit-certsui)
## Login and refresh token architecture
How login, JWT access tokens, refresh tokens, axios interceptors, and logout interact is documented in **[assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md)**. It is **aligned** with the same topic in **MaksIT-Vault** when both repos sit side by side; see the docs “Related” section for the sibling link. **Certs WebAPI** uses a **settings-backed** user store (not Vaults database/ACL stack); **2FA** is **not** implemented on the Certs backend (the WebUI login fields are disabled).
- [1. Overview](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md#1-overview)
- [2. Token model](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md#2-token-model)
- [3. Backend flow](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md#3-backend-flow)
- [4. Frontend flow](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md#4-frontend-flow)
- [5. API summary](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md#5-api-summary)
- [6. Sequence overview](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md#6-sequence-overview)
- [7. Security notes](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md#7-security-notes)
- [8. Key files reference](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md#8-key-files-reference)
## Reverse proxy routing (YARP)
How the **YARP** edge splits **ACME challenge**, **Swagger**, **WebAPI**, and **WebUI** traffic is documented in **[assets/docs/REVERSE_PROXY_ROUTING.md](assets/docs/REVERSE_PROXY_ROUTING.md)**. It is aligned with **MaksIT-Vault** for the same deployment pattern; Certs adds **`/.well-known/acme-challenge/`** routing for HTTP-01.
- [Route table](assets/docs/REVERSE_PROXY_ROUTING.md#route-table)
- [HTTP-01 (Lets Encrypt)](assets/docs/REVERSE_PROXY_ROUTING.md#http-01-lets-encrypt)
- [Kubernetes (Helm)](assets/docs/REVERSE_PROXY_ROUTING.md#kubernetes-helm)
- [Automation and clients](assets/docs/REVERSE_PROXY_ROUTING.md#automation-and-clients)
- [Direct vs proxied ports (local dev)](assets/docs/REVERSE_PROXY_ROUTING.md#direct-vs-proxied-ports-local-dev)
- [Related files](assets/docs/REVERSE_PROXY_ROUTING.md#related-files)
---
## Architecture
@ -307,18 +347,18 @@ In the project root (`/opt/Compose/MaksIT.CertsUI`), create a new file named `do
```bash
sudo tee /opt/Compose/MaksIT.CertsUI/docker-compose.yml <<EOF
services:
reverse-proxy:
reverseproxy:
image: cr.maks-it.com/certs-ui/reverseproxy:latest
container_name: reverse-proxy
container_name: reverseproxy
ports:
- "8080:8080"
depends_on:
- certs-ui-client
- certs-ui-server
- client
- server
networks:
- certs-ui-network
certs-ui-client:
client:
image: cr.maks-it.com/certs-ui/client:latest
container_name: certs-ui-client
volumes:
@ -326,7 +366,7 @@ services:
networks:
- certs-ui-network
certs-ui-server:
server:
image: cr.maks-it.com/certs-ui/server:latest
container_name: certs-ui-server
environment:
@ -416,9 +456,9 @@ podman compose -f docker-compose.yml up --build
```
This command builds and starts the following services:
- **reverse-proxy**: Exposes both `certs-ui-client` and `certs-ui-server` on the same hostname.
- **certs-ui-client**: The WebUI for managing certificates.
- **certs-ui-server**: The backend server handling ACME logic and certificate management.
- **reverseproxy**: YARP edge on port 8080; routes `/api`, `/.well-known/`, and the SPA to **`server`** / **`client`** (same layout as `src/docker-compose.yml` in this repo).
- **client**: WebUI (Vite) — Compose service name used by YARP (`http://client:5173/` inside the stack).
- **server**: WebAPI — Compose service name used by YARP (`http://server:5000/` inside the stack).
**Stop the services:**
@ -542,18 +582,18 @@ In the project root (`C:\Compose\MaksIT.CertsUI`), create a new file named `dock
```powershell
Set-Content -Path 'C:\Compose\MaksIT.CertsUI\docker-compose.yml' -Value @'
services:
reverse-proxy:
reverseproxy:
image: cr.maks-it.com/certs-ui/reverseproxy:latest
container_name: reverse-proxy
container_name: reverseproxy
ports:
- "8080:8080"
depends_on:
- certs-ui-client
- certs-ui-server
- client
- server
networks:
- certs-ui-network
certs-ui-client:
client:
image: cr.maks-it.com/certs-ui/client:latest
container_name: certs-ui-client
volumes:
@ -561,7 +601,7 @@ services:
networks:
- certs-ui-network
certs-ui-server:
server:
image: cr.maks-it.com/certs-ui/server:latest
container_name: certs-ui-server
environment:
@ -591,9 +631,9 @@ docker compose -f docker-compose.yml up --build
```
This command builds and starts the following services:
- **reverse-proxy**: Exposes both `certs-ui-client` and `certs-ui-server` on the same hostname.
- **certs-ui-client**: The WebUI for managing certificates.
- **certs-ui-server**: The backend server handling ACME logic and certificate management.
- **reverseproxy**: YARP edge on port 8080; routes `/api`, `/.well-known/`, and the SPA to **`server`** / **`client`** (same layout as `src/docker-compose.yml` in this repo).
- **client**: WebUI (Vite) — Compose service name used by YARP (`http://client:5173/` inside the stack).
- **server**: WebAPI — Compose service name used by YARP (`http://server:5000/` inside the stack).
**Stop the services:**

View File

@ -0,0 +1,217 @@
# Login and Refresh Token Architecture
This document describes how authentication (login), token refresh, and logout work across the **MaksIT.CertsUI** WebAPI and WebUI.
**Audience:** Backend (C# / ASP.NET) and Frontend (TypeScript / React) developers.
**Related:** This repos WebUI identity layer is **aligned** with **MaksIT-Vault** [`LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md`](../../maksit-vault/assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md) when both projects sit side by side under the same parent folder; otherwise open that path in your Vault clone. Vault documents **2FA**, **API-key RBAC**, and a **database-backed** user store. **MaksIT.CertsUI** uses a **settings-backed** user list, **no 2FA** on the backend (optional `LoginRequest` fields exist for shared models but are ignored; the WebUI 2FA inputs are commented out), and **no Vault-style ACL product surface**—JWT may still carry role/ACL claims for shared **MaksIT.Core** JWT helpers, but Certs is not an ACL administration app.
---
## 1. Overview
- **Access token:** Short-lived JWT used in the `Authorization: Bearer <token>` header for API calls.
- **Refresh token:** Opaque string stored with the user in settings; used to obtain a new access token (and optionally a new refresh token) when the access token expires.
- **Login** returns both tokens; the client stores them and uses the access token until it expires, then calls **refresh** with the refresh token.
- **Logout** revokes the current session (or all sessions) on the server and clears tokens on the client. On Certs, the logout HTTP endpoint does **not** require a `Authorization` header; it identifies the session via the **access token** in the JSON body (see §3.4).
---
## 2. Token model
### 2.1 Backend: `JwtToken` (domain)
**Location:** `src/MaksIT.Webapi/Domain/JwtToken.cs`
| Property | Type | Description |
|----------|------|-------------|
| `Id` | Guid | Token identifier. |
| `Token` | string | The JWT access token string. |
| `TokenType` | string | Typically `"Bearer"`. |
| `IssuedAt` | DateTime | When the token was issued (UTC). |
| `ExpiresAt` | DateTime | When the access token expires. |
| `RefreshToken` | string | Opaque refresh token. |
| `RefreshTokenExpiresAt` | DateTime | When the refresh token expires. |
| `IsRevoked` | bool | If true, token is treated as unusable (revoked entries are removed when resolving refresh). |
- A **User** holds a list of `JwtToken` instances (multiple devices/sessions).
- New tokens are **upserted** via `User.UpsertJwtToken`.
### 2.2 API response: `LoginResponse`
**Location:** `src/MaksIT.Models/LetsEncryptServer/Identity/Login/LoginResponse.cs`
Returned by **login** and **refresh**:
- `TokenType` (e.g. `"Bearer"`)
- `Token` (access JWT)
- `ExpiresAt` (access token expiry)
- `RefreshToken`
- `RefreshTokenExpiresAt`
There is **no** `Username` field on this model; the WebUI derives display name from **JWT claims** when hydrating identity (`identitySlice`).
---
## 3. Backend flow
### 3.1 Layers
| Layer | Component | Responsibility |
|-------|------------|----------------|
| API | `IdentityController` | Login, refresh, logout, and authenticated `PATCH` user. |
| Service | `IdentityService` | Loads/saves **settings**, validates credentials, issues JWTs via `JwtGenerator`, maps domain `JwtToken` to `LoginResponse`. |
| Domain | `User` | Password validation, JWT token list (upsert/remove/revoke). |
| Persistence | `ISettingsService` | Users and tokens persist in application settings (not a separate identity database). |
### 3.2 Login
**Endpoint:** `POST /api/identity/login`
**Controller:** `IdentityController.Login``IdentityService.LoginAsync`
1. **Load** settings via `ISettingsService.LoadAsync`.
2. **Resolve user** by username (`GetUserByName`).
3. **Validate password** (`User.ValidatePassword`) with configured **pepper**.
4. **Optional 2FA fields** on `LoginRequest` are **not** validated by Certs—ignored if sent.
5. **Generate** access JWT via `JwtGenerator.TryGenerateToken` (secret, issuer, audience, expiration from config).
6. **Generate** opaque refresh token and build a domain `JwtToken` with access + refresh expiry (`RefreshExpiration` from config).
7. **Upsert** token on user, **SetLastLogin**, persist settings.
8. Return `LoginResponse` (no username field; client uses JWT claims).
**Request body (`LoginRequest`):** `username`, `password`, optional unused `twoFactorCode` / `twoFactorRecoveryCode` (shared model shape only).
### 3.3 Refresh
**Endpoint:** `POST /api/identity/refresh`
**Controller:** `IdentityController.RefreshToken``IdentityService.RefreshTokenAsync`
**Request body (`RefreshTokenRequest`):** `refreshToken` only (`src/MaksIT.Models/LetsEncryptServer/Identity/Login/RefreshTokenRequest.cs`). The WebUI may send a `force` flag for parity with shared thunk code; the Certs API model does **not** include it (extra properties are typically ignored by the serializer).
1. **Load** settings.
2. **Resolve user** by refresh token (`GetByRefreshToken`).
3. **Remove** revoked JWT rows (`RemoveRevokedJwtTokens`).
4. **Find** the token where `RefreshToken` matches.
5. **Unauthorized** if not found → e.g. “Invalid refresh token.”
6. **If the access token is still valid** (`UtcNow <= token.ExpiresAt`): update last login, save settings, return the **same** `LoginResponse` (no new JWT). There is **no** server-side `force` refresh path like Vault.
7. **If access expired** but refresh is still valid (`UtcNow <= RefreshTokenExpiresAt`): issue a **new** access JWT + new refresh token, upsert token, save, return new `LoginResponse`.
8. **If refresh is expired**: remove that token record, return **401** “Refresh token has expired.”
### 3.4 Logout
**Endpoint:** `POST /api/identity/logout` (**no** `JwtAuthorizationFilter` on this action)
**Controller:** `IdentityController.Logout``IdentityService.Logout`
1. **Load** settings.
2. **Resolve user** by **access JWT string** in the body (`LogoutRequest.Token`) via `GetByJwtToken`.
3. If found: **`LogoutFromAllDevices`** → `RevokeAllJwtTokens()`; else → `RemoveJwtToken(token)` for the current session.
4. Persist settings if the user was updated.
5. Return success (implementation may still return OK if the token was unknown—clients should clear local state regardless).
**Request body (`LogoutRequest`):** `token` (access JWT), `logoutFromAllDevices`.
The WebUI sends the current access token from stored identity; it does not rely on a Bearer header for this route.
---
## 4. Frontend flow
### 4.1 Identity state and storage
**Redux:** `identitySlice` (`src/MaksIT.WebUI/src/redux/slices/identitySlice.ts`)
- **State:** `identity: Identity | null`, `hydrated: boolean`, `status`, `showUserOffcanvas`.
- **Persistence:** Login/refresh responses are written to **localStorage** via `writeIdentity`; on load, `setIdentityFromLocalStorage` reads and hydrates state and **enriches** from JWT claims (`userId`, `username`, `roles`, `isGlobalAdmin`, `acls` when those claims exist).
**Identity type** extends `LoginResponse` with client-side fields: `userId`, `username`, `roles`, `isGlobalAdmin`, `acls`.
### 4.2 Login UI
**Component:** `LoginScreen` (`src/MaksIT.WebUI/src/components/LoginScreen.tsx`)
- Form: username and password; **2FA** inputs are **commented out** until the backend supports them.
- On submit: `dispatch(login(loginRequest))`.
- On successful login, `identitySlice` stores the response in state and localStorage; `LoginScreen` redirects when identity is present and refresh token is not expired.
### 4.3 Route protection
**Component:** `Authorization` (`src/MaksIT.WebUI/src/components/Authorization.tsx`)
- Wraps protected routes.
- On mount, if not hydrated, dispatches `setIdentityFromLocalStorage()`.
- **When hydrated:** if `identity` is missing or `refreshTokenExpiresAt` is in the past, redirects to `/login` (with `state.from` for return URL).
- Renders children only when hydrated and refresh token is not expired.
**Refresh token** expiry is what forces re-login; the **access** token may expire while refresh is still valid (handled by axios).
### 4.4 Axios: token attachment and refresh
**File:** `src/MaksIT.WebUI/src/axiosConfig.ts`
- **Excluded URLs** (no Bearer token, no refresh loop): login and refresh routes (`GetApiRoute(ApiRoutes.identityLogin).route`, `GetApiRoute(ApiRoutes.identityRefresh).route`).
- **Request interceptor:** If access token is expired but refresh is still valid by client clock, await a single shared `refreshJwt()`; on success attach new `Authorization`; on failure dispatch `clearIdentity()` and reject (do not send protected calls with an expired access token).
- **Response interceptor:** On **401**, optionally retry once after refresh when refresh is still valid; on refresh failure, `clearIdentity()`.
- **Serialization:** `isRefreshing` / `refreshPromise` so concurrent requests share one refresh.
### 4.5 Async thunks and clearIdentity
- **`login`:** POST login; on success writes identity and enriches from JWT.
- **`refreshJwt(force?)`:** POST refresh with `refreshToken` (and optional `force` in body for shared code paths; server ignores `force` on Certs). On failure, identity cleared.
- **`logout`:** POST logout with access token in body, then clear state/localStorage.
- **`clearIdentity()`:** Clears Redux and localStorage without calling logout API (used when refresh fails).
---
## 5. API summary
| Method | Endpoint | Bearer required | Purpose |
|--------|----------|-----------------|--------|
| POST | `/api/identity/login` | No | Login; returns access + refresh token. |
| POST | `/api/identity/refresh` | No | Exchange refresh token for same or new tokens. |
| POST | `/api/identity/logout` | No | Revoke session(s) using access token **in body**. |
Other identity routes (e.g. `PATCH /api/identity/user/{id}`) use `JwtAuthorizationFilter` and require a valid JWT.
Base route: `api/identity` (`IdentityController`, `AppMap`).
---
## 6. Sequence overview
**Login:** User submits credentials → POST `/api/identity/login` → settings updated with new `JwtToken` → WebUI stores identity → redirect into app.
**Authenticated request (access token valid):** Interceptor adds `Authorization: Bearer` → API validates JWT.
**Access expired, refresh valid:** Interceptor awaits `refreshJwt()` → POST `/api/identity/refresh` → updated identity → original request retried with new token.
**401 on protected request:** Response interceptor attempts refresh; if refresh returns 401, `clearIdentity()` and redirect to `/login`.
**Logout:** POST `/api/identity/logout` with body `{ token, logoutFromAllDevices }` → server removes token(s) from settings → client clears storage.
Replace illustrative “organizations” examples in Vault with Certs resources (e.g. **accounts**, **certificate flows**)—the **mechanism** is the same: no protected API should run after refresh has failed without clearing identity.
---
## 7. Security notes
- **Passwords** use salt + server-side **pepper**; not stored in plain text.
- **Refresh tokens** are stored per user in settings; expiry and invalidation are enforced in `IdentityService.RefreshTokenAsync`.
- **2FA** is **not** implemented on the Certs WebAPI; do not enable the WebUI 2FA fields until backend support exists.
- **Login/refresh** do not require Bearer; other protected controllers use `JwtAuthorizationFilter`.
- Frontend keeps **one** identity in localStorage; refresh is serialized to avoid duplicate refresh storms.
---
## 8. Key files reference
| Area | File |
|------|------|
| Domain User | `src/MaksIT.Webapi/Domain/User.cs` |
| Domain JwtToken | `src/MaksIT.Webapi/Domain/JwtToken.cs` |
| API service | `src/MaksIT.Webapi/Services/IdentityService.cs` |
| API controller | `src/MaksIT.Webapi/Controllers/IdentityController.cs` |
| API models | `src/MaksIT.Models/LetsEncryptServer/Identity/Login/`, `.../Logout/` |
| Frontend state | `src/MaksIT.WebUI/src/redux/slices/identitySlice.ts` |
| Frontend HTTP | `src/MaksIT.WebUI/src/axiosConfig.ts` |
| Frontend routes | `src/MaksIT.WebUI/src/components/Authorization.tsx` |
| Frontend login UI | `src/MaksIT.WebUI/src/components/LoginScreen.tsx` |
| Frontend API map | `src/MaksIT.WebUI/src/AppMap.tsx` |

View File

@ -0,0 +1,253 @@
# PATCH Delta Handling Backend & Frontend Reference
This document is the **single reference** for how PATCH payloads (deltas) are structured and interpreted so backend (BE) and frontend (FE) stay consistent. It is **aligned** with the same contract as **MaksIT-Vault** [`assets/docs/PATCH_DELTA_REFERENCE.md`](../../maksit-vault/assets/docs/PATCH_DELTA_REFERENCE.md) (Core rules, collection-item key, and `deepDelta` behavior) when both repos sit side by side under the same parent folder; otherwise open that path in your Vault clone. Vault adds RBAC-specific collections (`entityScopes`, `versions`); **MaksIT-CertsUI** uses the same **MaksIT.Core** patch model with a **hostnames** collection on account PATCH.
**Audience:** Backend (C# / ASP.NET) and Frontend (TypeScript / React) developers.
---
## TL;DR (start here)
- **PATCH** sends only **what changed**, not the full resource. Each change is tagged with an **operation** (set, remove, add item, remove item).
- **Root fields** (e.g. `description`, `contact`): send new value + `operations["fieldName"] = SetField` or `RemoveField`.
- **Collections** (e.g. `hostnames`): **do not** replace the whole array when the API is “patchable collection” semantics. Send **per-item** changes: each added item has `operations.collectionItemOperation = AddToCollection`, each removed item has `RemoveFromCollection`, and changed items send identity and changed fields.
- **Frontend (Certs WebUI):** For **Edit Account**, use
`deepDelta(formState, backupState, { arrays: { hostnames: { identityKey: 'hostname', idFieldKey: 'hostname' } } })`
so hostname rows are itemized (including “add first hostname”) and stay in sync with the backend.
- **Backend:** Use `TryGetOperation(Constants.CollectionItemOperation, out var op)` on each collection item; never treat root `Operations["hostnames"] = SetField` as “replace all” if the API follows per-item patch semantics.
---
## 1. Core contract (MaksIT.Core)
The following come from **MaksIT.Core** and must be respected by all consumers.
### 1.1 PatchOperation enum
| Value | Integer | Meaning |
|-------|---------|--------|
| `SetField` | 0 | Set or replace a scalar or root-level value |
| `RemoveField` | 1 | Set a field to null |
| `AddToCollection` | 2 | Add an item to a collection (used on **collection items**, not root) |
| `RemoveFromCollection` | 3 | Remove an item from a collection (used on **collection items**, not root) |
- **Source:** `MaksIT.Core.Webapi.Models.PatchOperation`
- **FE mirror:** `PatchOperation` enum in WebUI (`src/MaksIT.WebUI/src/models/PatchOperation.ts`) must keep the same numeric values for JSON serialization.
### 1.2 PatchRequestModelBase
- **Operations:** `Dictionary<string, PatchOperation>?` (C#) / `{ [key: string]: PatchOperation }` (TS).
- **Lookup:** Case-insensitive by **property name** (e.g. `"hostnames"`, `"description"`).
- **Usage:**
- **Root level:** `Operations["propertyName"]` describes the operation for that property (e.g. `SetField` for a changed field, `RemoveField` for null).
- **Collection items:** Each element of a collection property is itself a patch model; it uses a **reserved key** (see below) to indicate add/remove/update for that item.
### 1.3 Collection item operation key
For **elements inside a collection property** (e.g. each item in `hostnames`), the operation is stored under a fixed key so the backend can distinguish “add/remove this item” from “update fields of this item”.
- **Key name:** `collectionItemOperation`
- **BE:** `Constants.CollectionItemOperation` (same string across MaksIT services when aligned with Core).
- **FE:** `COLLECTION_ITEM_OPERATION` in `src/MaksIT.WebUI/src/models/PatchOperation.ts`; same string in payloads and in `deepDelta`. Keep in sync with backend.
**Allowed values for collection items:** `AddToCollection` (2), `RemoveFromCollection` (3). For in-place updates (same item, changed fields), the item typically has an `id` and no `collectionItemOperation`, or field-level changes follow your APIs semantics.
---
## 2. Backend (BE) rules
### 2.1 Root-level properties
- Read `request.TryGetOperation(propertyName, out var op)`.
- If `op == SetField`: apply the new value from the request for that property.
- If `op == RemoveField`: set the property to null (or clear it) where applicable.
- If the property is not in `Operations` but is present with a value, treat as optional direct assignment (or ignore), depending on your API convention; for strict PATCH, prefer requiring operations for changed fields.
### 2.2 Collection properties (e.g. hostnames)
- **Rule:** Collection properties are patched **only via per-item operations** when the API follows patchable-collection semantics. The backend does **not** treat root `Operations["hostnames"] = SetField` as “replace the entire collection” unless explicitly documented for that endpoint.
- For each item in the collection payload:
1. Call `item.TryGetOperation(Constants.CollectionItemOperation, out var collectionOp)` (or the agreed constant).
2. If `collectionOp == AddToCollection`: add the item to the collection (merge by id if present, or append).
3. If `collectionOp == RemoveFromCollection`: remove the item (by `item.Id` or by matching key fields such as hostname string).
4. If no `collectionItemOperation` but the item is identifiable: treat as **in-place update**.
5. If no `collectionItemOperation` and the item cannot be identified: **do not add** ambiguous items; the FE must send `AddToCollection` for new rows when required by your rules.
### 2.3 Consistency checklist (BE)
- [ ] Use the same `CollectionItemOperation` key as the FE (see Constants / Core).
- [ ] Do not rely on root-level `SetField` for patchable collections to mean “replace all”; use per-item add/remove/update only (unless documented otherwise).
- [ ] For add: require `TryGetOperation(CollectionItemOperation) == AddToCollection` (or equivalent) for new items where applicable.
- [ ] For remove: use `RemoveFromCollection` and/or identity fields agreed with the FE.
---
## 3. Frontend (FE) rules
### 3.1 Building the delta (deepDelta)
- **Scalar / root fields:** Emit the new value and set `operations[propertyName] = SetField` or `RemoveField` as appropriate.
- **Primitive arrays:** Emit the full array and `operations[propertyName] = SetField` (full replace).
- **Object arrays that are “patchable collections”:** Must always produce **itemized deltas** when configured with an **array policy** (identity key / id field):
- Each added item must have `operations.collectionItemOperation = AddToCollection`.
- Each removed item must have `operations.collectionItemOperation = RemoveFromCollection`.
- Updated items carry changed fields and identity; see `deepDelta` implementation in `src/MaksIT.WebUI/src/functions/deep/deepDelta.ts`.
### 3.2 Patchable collections identity requirement
For the backend to interpret add/remove/update correctly, each collection **item** must be identifiable:
- **Existing items:** Use `id` from the server when present.
- **New items:** May have no server `id`; the FE must pass an **array policy** with `identityKey` / `idFieldKey` so the delta stays itemized (e.g. hostname string as stable key for hostnames).
### 3.3 Shared array policies (this repository)
Unlike MaksIT-Vault, **MaksIT-CertsUI** does **not** ship `patchCollectionPolicies.ts`. The **Edit Account** form passes an **inline** policy for the `hostnames` collection:
| Collection | Policy (inline) | Used in |
|------------|-----------------|---------|
| `hostnames` | `{ identityKey: 'hostname', idFieldKey: 'hostname' }` | `EditAccount.tsx` |
Example:
```ts
deepDelta(fromFormState, fromBackupState, {
arrays: {
hostnames: {
identityKey: 'hostname',
idFieldKey: 'hostname',
},
},
})
```
For **Vault**-style shared policies (`ENTITY_SCOPES_ARRAY_POLICY`, `VERSIONS_ARRAY_POLICY`), see the Vault repo and forms that edit `entityScopes` / `versions`.
### 3.4 Consistency checklist (FE)
- [ ] Use the same `PatchOperation` numeric values as Core (SetField 0, RemoveField 1, AddToCollection 2, RemoveFromCollection 3).
- [ ] Use `COLLECTION_ITEM_OPERATION` from `PatchOperation.ts` (same string as backend `Constants.CollectionItemOperation`).
- [ ] For account `hostnames`, pass the `hostnames` array policy in `deepDelta` so the delta is **itemized**, not a blind full-replace of the array.
- [ ] New items must have `operations.collectionItemOperation = AddToCollection` when the backend expects it.
---
## 4. Payload examples
### 4.1 Root-level SetField (scalar)
```json
{
"description": "Updated",
"operations": {
"description": 0
}
}
```
`0` = SetField.
### 4.2 Root-level RemoveField (clear optional field)
```json
{
"operations": { "someOptionalField": 1 }
}
```
`1` = RemoveField.
### 4.3 Root-level SetField (primitive array full replace)
```json
{
"tags": ["a", "b", "c"],
"operations": {
"tags": 0
}
}
```
### 4.4 Collection property itemized (add items)
Example shape for new hostname rows (numeric ops match `PatchOperation` enum):
```json
{
"hostnames": [
{
"hostname": "api.example.com",
"isDisabled": false,
"operations": { "collectionItemOperation": 2 }
}
]
}
```
`2` = AddToCollection.
### 4.5 Collection property remove item
```json
{
"hostnames": [
{
"hostname": "old.example.com",
"operations": { "collectionItemOperation": 3 }
}
]
}
```
`3` = RemoveFromCollection (identity may be `hostname` or server `id` depending on API).
### 4.6 Collection property in-place update
Item exists; fields change; no `collectionItemOperation` on the item (or only nested field operations per your model).
---
## 5. Quick reference
| Aspect | Backend | Frontend |
|--------|---------|----------|
| Operation enum | `PatchOperation` (Core) | `PatchOperation` (same values 03) |
| Root operations | `TryGetOperation(propertyName, out op)` | `operations[propertyName] = op` |
| Collection item key | `Constants.CollectionItemOperation` (`"collectionItemOperation"`) | `COLLECTION_ITEM_OPERATION` in payload and deepDelta |
| New collection item | Require `AddToCollection` on item when applicable | Send `operations.collectionItemOperation: 2` for new items |
| Certs account hostnames | Per-item patch semantics | `deepDelta` + `hostnames` array policy in `EditAccount.tsx` |
---
## 6. Related docs
- **MaksIT.Core:** `PatchOperation`, `PatchRequestModelBase` (README / XML).
- **Aligned reference (RBAC / Vault collections):** [MaksIT-Vault `PATCH_DELTA_REFERENCE.md`](../../maksit-vault/assets/docs/PATCH_DELTA_REFERENCE.md) — same Core rules; extra sections for `entityScopes` and `versions`.
---
## 7. Current implementation vs reference (MaksIT-CertsUI)
### 7.1 Frontend (FE)
**Checked:** `deepDelta` usage in `src/MaksIT.WebUI/src/forms/EditAccount.tsx` for account PATCH; inline `hostnames` array policy; `COLLECTION_ITEM_OPERATION` in `PatchOperation.ts` and usage in `deepDelta.ts`.
- **Safe:** Hostname rows use `identityKey` / `idFieldKey` so itemized deltas include `AddToCollection` / `RemoveFromCollection` where appropriate.
- **Consistent:** Same `PatchOperation` values and collection-item key string as Core.
**FE summary:** Follows the shared reference; scope is **account + hostnames** (no Vault RBAC collections in this product).
### 7.2 Backend (BE)
**Maintainers:** Confirm the account PATCH handler in MaksIT.CertsUI WebAPI applies the same per-item rules (`TryGetOperation(CollectionItemOperation, ...)`) for `hostnames` as described in sections 2.2 and 3.
### 7.3 Gaps and maintenance
| Topic | Status | Note |
|-------|--------|------|
| Shared policies file | N/A in Certs | Inline policy in `EditAccount.tsx`; Vault uses `patchCollectionPolicies.ts` for RBAC collections. |
| New forms with patchable collections | **Ongoing** | When adding a form that patches a collection, pass the correct `arrays: { key: policy }` to `deepDelta`. |
---
*Last updated: 2026-04-12*

View File

@ -0,0 +1,65 @@
# Reverse proxy routing (YARP)
The **`ReverseProxy`** project ([`src/ReverseProxy`](../../src/ReverseProxy)) is an **ASP.NET + YARP** edge that listens on **port 8080** in Docker Compose (`docker-compose.override.yml` maps `8080:8080`).
**Config:** [`src/ReverseProxy/appsettings.json`](../../src/ReverseProxy/appsettings.json)
**Related:** The same topic in **MaksIT-Vault** is documented at [`REVERSE_PROXY_ROUTING.md`](../../maksit-vault/assets/docs/REVERSE_PROXY_ROUTING.md) when both repos sit side by side; otherwise open that path in your Vault clone. Vaults table does **not** include an **ACME HTTP-01** path—Certs adds **`/.well-known/acme-challenge/`** so challenges hit the WebAPI on the **same public host** as the UI.
---
## Route table
Routes use explicit **`Order`** (lower = matched first), matching **MaksIT-Vault**, so the SPA catch-all never wins over `/api`, `/swagger`, or `/.well-known/` when JSON key order varies.
Compose service names are **`server`** (WebAPI) and **`client`** (Vite/WebUI). Cluster IDs **`webapiCluster`** / **`webuiCluster`** match Vault for a parallel mental model.
| Order | Path match | Cluster | Upstream (Compose) |
|------|------------|---------|---------------------|
| 5 | `/.well-known/acme-challenge/{**catch-all}` | `webapiCluster` | `http://server:5000/` |
| 10 | `/swagger/{**catch-all}` | `webapiCluster` | `http://server:5000/` |
| 20 | `/api/{**catch-all}` | `webapiCluster` | `http://server:5000/` |
| 1000 | `/{**catch-all}` | `webuiCluster` | `http://client:5173/` |
YARP forwards the **same path** to the destination. Example:
- Client: `POST http://localhost:8080/api/identity/login`
- Proxied to: `POST http://server:5000/api/identity/login`
Controllers use the usual **`/api/...`** prefix (e.g. `api/identity`, account and certificate flows)—there is **no** `api/vault`-style segment. Locally, the Web UI uses `public/config.js` / `.env` with `http://localhost:8080/api` so XHR calls go **through** YARP.
### HTTP-01 (Lets Encrypt)
Traffic for **`/.well-known/acme-challenge/*`** must reach **MaksIT.Webapi** so the HTTP-01 validator can fetch the token file. The dedicated route sends that path to the **`server`** service (same `webapiCluster` as `/api`).
### Kubernetes (Helm)
The chart can mount **`config.js`** from a ConfigMap (`certsClientRuntime.apiUrl`). Defaults in `values.yaml` may use a full origin (example host); you can also use a **relative** API base such as **`/api`** so the browser uses the same host and port as the page (ingress / port-forward to **8080** on the reverse-proxy Service) without hard-coding `localhost`. Use a full URL only if the UI and API are served from different origins.
---
## Automation and clients
- **Base URL** for scripts, the Agent, or any HTTP client talking to the **composed** stack should be the **proxy origin**: `http://localhost:8080` when you use Composes published port.
- **Path shape:** Call **`/api/...`** on that origin (either concatenate `BaseAddress` + `api/...` or set `VITE_API_URL` / runtime `API_URL` to `http://localhost:8080/api` so the client already includes `/api`).
- **YARP** forwards request headers to the WebAPI by default (including **`Authorization: Bearer`** for JWT). No special transform is required unless you customize YARP transforms.
---
## Direct vs proxied ports (local dev)
| Scenario | Typical base URL for Certs HTTP API |
|----------|-------------------------------------|
| Docker Compose (this repo) | `http://localhost:8080` (through YARP) |
| Run **MaksIT.Webapi** only (F5 / `dotnet run`) | See `launchSettings.json` (e.g. `http://localhost:5016`) — **no** YARP |
| Run **ReverseProxy** only (outside Compose) | `launchSettings`: e.g. `http://localhost:5276` — cluster addresses in `appsettings.json` must resolve (Compose service names only work **inside** the Compose network) |
If authentication succeeds but API calls fail, confirm traffic reaches the **same** WebAPI instance and data volume you expect (not a different port or stale container).
---
## Related files
- [`src/docker-compose.yml`](../../src/docker-compose.yml), [`src/docker-compose.override.yml`](../../src/docker-compose.override.yml)
- [`src/ReverseProxy/Program.cs`](../../src/ReverseProxy/Program.cs)
- WebUI runtime API base: [`src/MaksIT.WebUI/public/config.js`](../../src/MaksIT.WebUI/public/config.js), Helm `certsClientRuntime.apiUrl` in [`src/helm/values.yaml`](../../src/helm/values.yaml)

View File

@ -9,13 +9,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PackageReference Include="coverlet.collector" Version="8.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3">
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@ -8,13 +8,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.5.9" />
<PackageReference Include="MaksIT.Results" Version="1.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="MaksIT.Core" Version="1.6.5" />
<PackageReference Include="MaksIT.Results" Version="2.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.5" />
</ItemGroup>
</Project>

View File

@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.5.9" />
<PackageReference Include="MaksIT.Core" Version="1.6.5" />
</ItemGroup>
</Project>

View File

@ -1,11 +1,12 @@
import js from '@eslint/js'
import { defineConfig } from 'eslint/config'
import globals from 'globals'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
export default defineConfig(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
@ -29,6 +30,7 @@ export default tseslint.config(
],
...reactHooks.configs.recommended.rules,
'react-hooks/set-state-in-effect': 'off',
// react-refresh plugin rules
'react-refresh/only-export-components': [
@ -45,6 +47,7 @@ export default tseslint.config(
// @typescript-eslint plugin rules
'@typescript-eslint/no-unused-vars': ['warn', { 'argsIgnorePattern': '^_', 'ignoreRestSiblings': true }],
'@typescript-eslint/no-empty-object-type': 'off',
'react-hooks/refs': 'off',
},
},
)

File diff suppressed because it is too large Load Diff

View File

@ -10,45 +10,45 @@
"preview": "vite preview"
},
"dependencies": {
"@reduxjs/toolkit": "^2.10.1",
"@reduxjs/toolkit": "^2.11.2",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.13.2",
"axios": "^1.13.6",
"client-zip": "^2.5.0",
"date-fns": "^4.1.0",
"jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"lucide-react": "^0.553.0",
"lodash": "^4.17.23",
"lucide-react": "^0.576.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-redux": "^9.2.0",
"react-router-dom": "^7.9.6",
"react-router-dom": "^7.13.1",
"react-virtualized": "^9.22.6",
"uuid": "^13.0.0",
"zod": "^4.1.12"
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@eslint/js": "^9.39.3",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/lodash": "^4.17.20",
"@types/react": "^19.2.5",
"@tailwindcss/vite": "^4.2.1",
"@types/lodash": "^4.17.24",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-resizable": "^3.0.8",
"@types/react-virtualized": "^9.22.3",
"@vitejs/plugin-react-swc": "^4.2.2",
"eslint": "^9.39.1",
"@vitejs/plugin-react-swc": "^4.2.3",
"eslint": "^9.39.3",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.1.17",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.2"
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
}
}

View File

@ -1,13 +1,18 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- axios config bags use dynamic fields (skipLoader) */
import axios from 'axios'
import { readIdentity } from './localStorage/identity'
import { ApiRoutes, GetApiRoute } from './AppMap'
import { store } from './redux/store'
import { refreshJwt } from './redux/slices/identitySlice'
import { refreshJwt, clearIdentity } from './redux/slices/identitySlice'
import { hideLoader, showLoader } from './redux/slices/loaderSlice'
import { addToast } from './components/Toast/addToast'
import { ProblemDetails } from './models/ProblemDetails'
interface RequestOptions {
skipLoader?: boolean
}
// Create an Axios instance
const axiosInstance = axios.create({
timeout: 10000, // Set a timeout if needed
@ -16,18 +21,26 @@ const axiosInstance = axios.create({
let isRefreshing = false
let refreshPromise: Promise<unknown> | null = null
// Add a request interceptor
axiosInstance.interceptors.request.use(
async config => {
// Dispatch request
store.dispatch(showLoader())
// List of URLs to exclude from adding Bearer token
const excludeUrls = [
const getExcludeUrls = () => [
GetApiRoute(ApiRoutes.identityLogin).route,
GetApiRoute(ApiRoutes.identityRefresh).route
]
const isAuthExcludedUrl = (url: string | undefined) =>
url !== undefined && getExcludeUrls().includes(url)
// Add a request interceptor
axiosInstance.interceptors.request.use(
async config => {
// Dispatch request (unless explicitly skipped)
const skipLoader = (config as any).skipLoader as boolean | undefined
if (!skipLoader) {
store.dispatch(showLoader())
}
// List of URLs to exclude from adding Bearer token
const excludeUrls = getExcludeUrls()
// Check if the URL is in the exclude list
if (config.url && excludeUrls.includes(config.url)) {
return config
@ -50,8 +63,15 @@ axiosInstance.interceptors.request.use(
if (newIdentity) {
config.headers.Authorization = `${newIdentity.tokenType} ${newIdentity.token}`
}
else {
// Refresh failed (e.g. 401); identity was cleared by identitySlice. Do not send request with expired token.
store.dispatch(clearIdentity())
if (!skipLoader) store.dispatch(hideLoader())
return Promise.reject(new Error('Session expired. Please sign in again.'))
}
} else {
}
}
else {
config.headers.Authorization = `${identity.tokenType} ${identity.token}`
}
}
@ -60,7 +80,10 @@ axiosInstance.interceptors.request.use(
},
error => {
// Handle request error
const skipLoader = (error.config as any)?.skipLoader as boolean | undefined
if (!skipLoader) {
store.dispatch(hideLoader())
}
return Promise.reject(error)
}
)
@ -68,26 +91,67 @@ axiosInstance.interceptors.request.use(
// Add a response interceptor
axiosInstance.interceptors.response.use(
response => {
// Dispatch request end
// Dispatch request end (unless explicitly skipped)
const skipLoader = (response.config as any)?.skipLoader as boolean | undefined
if (!skipLoader) {
store.dispatch(hideLoader())
}
return response
},
error => {
// Handle response error
async error => {
const originalRequest = error.config
const skipLoader = (originalRequest as any)?.skipLoader as boolean | undefined
if (!skipLoader) {
store.dispatch(hideLoader())
}
if (error.response?.status === 401 && originalRequest && !originalRequest._retryAfterRefresh && !isAuthExcludedUrl(originalRequest.url)) {
const identity = readIdentity()
if (identity && new Date(identity.refreshTokenExpiresAt) > new Date()) {
originalRequest._retryAfterRefresh = true
try {
if (!isRefreshing) {
isRefreshing = true
refreshPromise = store.dispatch(refreshJwt())
.finally(() => { isRefreshing = false })
}
await refreshPromise
const newIdentity = readIdentity()
if (newIdentity) {
originalRequest.headers.Authorization = `${newIdentity.tokenType} ${newIdentity.token}`
return axiosInstance(originalRequest)
}
}
catch {
// Refresh failed (e.g. 401); clear identity so UI redirects to login
store.dispatch(clearIdentity())
}
}
}
if (error.response) {
const contentType = error.response.headers['content-type']
const data = error.response.data
if (contentType && contentType.includes('application/problem+json')) {
const problem = error.response.data as ProblemDetails
addToast(`${problem.title}: ${problem.detail}`, 'error')
const problem = data as ProblemDetails
const detail = problem.detail ?? ''
const errors = problem.errors
? Object.entries(problem.errors)
.flatMap(([key, msgs]) => (msgs ?? []).map(m => `${key}: ${m}`))
.join('; ')
: ''
const message = [detail, errors].filter(Boolean).join(' ') || problem.title || 'Request failed'
addToast(message, 'error')
}
else if (error.response.status === 401) {
const problem = error.response.data as ProblemDetails
addToast(`${problem.title}: ${problem.detail}`, 'error')
const problem = data as ProblemDetails
const message = problem.detail ?? problem.title ?? 'Unauthorized'
addToast(message, 'error')
}
}
return Promise.reject(error)
}
)
@ -98,14 +162,24 @@ axiosInstance.interceptors.response.use(
* @param timeout Optional timeout in milliseconds to override the default.
* @returns The response data, or undefined if an error occurs.
*/
const getData = async <TResponse>(url: string, timeout?: number): Promise<TResponse | undefined> => {
const getData = async <TResponse>(
url: string,
timeout?: number,
options?: RequestOptions
): Promise<TResponse | undefined> => {
try {
const response = await axiosInstance.get<TResponse>(url, {
const config: any = {
headers: {
'Content-Type': 'application/json'
},
...(timeout ? { timeout } : {})
})
}
if (options?.skipLoader) {
config.skipLoader = true
}
const response = await axiosInstance.get<TResponse>(url, config)
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
@ -120,14 +194,25 @@ const getData = async <TResponse>(url: string, timeout?: number): Promise<TRespo
* @param timeout Optional timeout in milliseconds to override the default.
* @returns The response data, or undefined if an error occurs.
*/
const postData = async <TRequest, TResponse>(url: string, data?: TRequest, timeout?: number): Promise<TResponse | undefined> => {
const postData = async <TRequest, TResponse>(
url: string,
data?: TRequest,
timeout?: number,
options?: RequestOptions
): Promise<TResponse | undefined> => {
try {
const response = await axiosInstance.post<TResponse>(url, data, {
const config: any = {
headers: {
'Content-Type': 'application/json'
},
...(timeout ? { timeout } : {})
})
}
if (options?.skipLoader) {
config.skipLoader = true
}
const response = await axiosInstance.post<TResponse>(url, data, config)
return response.data
} catch {
@ -143,14 +228,25 @@ const postData = async <TRequest, TResponse>(url: string, data?: TRequest, timeo
* @param timeout Optional timeout in milliseconds to override the default.
* @returns The response data, or undefined if an error occurs.
*/
const patchData = async <TRequest, TResponse>(url: string, data: TRequest, timeout?: number): Promise<TResponse | undefined> => {
const patchData = async <TRequest, TResponse>(
url: string,
data: TRequest,
timeout?: number,
options?: RequestOptions
): Promise<TResponse | undefined> => {
try {
const response = await axiosInstance.patch<TResponse>(url, data, {
const config: any = {
headers: {
'Content-Type': 'application/json'
},
...(timeout ? { timeout } : {})
})
}
if (options?.skipLoader) {
config.skipLoader = true
}
const response = await axiosInstance.patch<TResponse>(url, data, config)
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
@ -165,14 +261,25 @@ const patchData = async <TRequest, TResponse>(url: string, data: TRequest, timeo
* @param timeout Optional timeout in milliseconds to override the default.
* @returns The response data, or undefined if an error occurs.
*/
const putData = async <TRequest, TResponse>(url: string, data: TRequest, timeout?: number): Promise<TResponse | undefined> => {
const putData = async <TRequest, TResponse>(
url: string,
data: TRequest,
timeout?: number,
options?: RequestOptions
): Promise<TResponse | undefined> => {
try {
const response = await axiosInstance.put<TResponse>(url, data, {
const config: any = {
headers: {
'Content-Type': 'application/json'
},
...(timeout ? { timeout } : {})
})
}
if (options?.skipLoader) {
config.skipLoader = true
}
const response = await axiosInstance.put<TResponse>(url, data, config)
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
@ -186,14 +293,24 @@ const putData = async <TRequest, TResponse>(url: string, data: TRequest, timeout
* @param timeout Optional timeout in milliseconds to override the default.
* @returns The response data, or undefined if an error occurs.
*/
const deleteData = async <TResponse>(url: string, timeout?: number): Promise<TResponse | undefined> => {
const deleteData = async <TResponse>(
url: string,
timeout?: number,
options?: RequestOptions
): Promise<TResponse | undefined> => {
try {
const response = await axiosInstance.delete<TResponse>(url, {
const config: any = {
headers: {
'Content-Type': 'application/json'
},
...(timeout ? { timeout } : {})
})
}
if (options?.skipLoader) {
config.skipLoader = true
}
const response = await axiosInstance.delete<TResponse>(url, config)
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
@ -211,15 +328,22 @@ const deleteData = async <TResponse>(url: string, timeout?: number): Promise<TRe
const postBinary = async <TResponse>(
url: string,
data: Blob | ArrayBuffer | Uint8Array,
timeout?: number
timeout?: number,
options?: RequestOptions
): Promise<TResponse | undefined> => {
try {
const response = await axiosInstance.post<TResponse>(url, data, {
const config: any = {
headers: {
'Content-Type': 'application/octet-stream'
},
...(timeout ? { timeout } : {})
})
}
if (options?.skipLoader) {
config.skipLoader = true
}
const response = await axiosInstance.post<TResponse>(url, data, config)
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
@ -237,13 +361,20 @@ const postBinary = async <TResponse>(
const getBinary = async (
url: string,
timeout?: number,
as: 'arraybuffer' | 'blob' = 'arraybuffer'
as: 'arraybuffer' | 'blob' = 'arraybuffer',
options?: RequestOptions
): Promise<{ data: ArrayBuffer | Blob, headers: Record<string, string> } | undefined> => {
try {
const response = await axiosInstance.get(url, {
const config: any = {
responseType: as,
...(timeout ? { timeout } : {})
})
}
if (options?.skipLoader) {
config.skipLoader = true
}
const response = await axiosInstance.get(url, config)
return {
data: response.data,
@ -268,7 +399,8 @@ const getBinary = async (
const postFormData = async <TResponse>(
url: string,
form: FormData | Record<string, string | Blob | File | (string | Blob | File)[]>,
timeout?: number
timeout?: number,
options?: RequestOptions
): Promise<TResponse | undefined> => {
try {
const formData =
@ -286,10 +418,16 @@ const postFormData = async <TResponse>(
return fd
})()
const response = await axiosInstance.post<TResponse>(url, formData, {
const config: any = {
// Do NOT set Content-Type; the browser will set the correct multipart boundary
...(timeout ? { timeout } : {})
})
}
if (options?.skipLoader) {
config.skipLoader = true
}
const response = await axiosInstance.post<TResponse>(url, formData, config)
return response.data
} catch {
@ -314,7 +452,8 @@ const postFile = async <TResponse>(
fieldName: string = 'file',
filename?: string,
extraFields?: Record<string, string>,
timeout?: number
timeout?: number,
options?: RequestOptions
): Promise<TResponse | undefined> => {
const fd = new FormData()
const inferredName = filename ?? (file instanceof File ? file.name : 'file')
@ -324,9 +463,31 @@ const postFile = async <TResponse>(
Object.entries(extraFields).forEach(([k, v]) => fd.append(k, v))
}
return postFormData<TResponse>(url, fd, timeout)
return postFormData<TResponse>(url, fd, timeout, options)
}
/** Options that disable the global loader for a request (for background/UI-only fetches). */
const noLoaderOptions: RequestOptions = { skipLoader: true }
/**
* GET without showing the global loader. Use for background fetches (e.g. table filters, remote labels).
*/
const getDataWithoutLoader = async <TResponse>(
url: string,
timeout?: number
): Promise<TResponse | undefined> =>
getData<TResponse>(url, timeout, noLoaderOptions)
/**
* POST without showing the global loader. Use for background fetches (e.g. remote selects, table filters).
*/
const postDataWithoutLoader = async <TRequest, TResponse>(
url: string,
data?: TRequest,
timeout?: number
): Promise<TResponse | undefined> =>
postData<TRequest, TResponse>(url, data, timeout, noLoaderOptions)
export {
axiosInstance,
getData,
@ -337,5 +498,7 @@ export {
postBinary,
getBinary,
postFormData,
postFile
postFile,
getDataWithoutLoader,
postDataWithoutLoader
}

View File

@ -13,7 +13,7 @@ const Authorization: FC<AuthorizationProps> = (props) => {
const navigate = useNavigate()
const location = useLocation()
const dispatch = useAppDispatch()
const { identity } = useAppSelector((state) => state.identity)
const { identity, hydrated } = useAppSelector((state) => state.identity)
const isTokenExpired = useMemo(() => {
if (!identity || !identity.refreshTokenExpiresAt)
@ -23,16 +23,23 @@ const Authorization: FC<AuthorizationProps> = (props) => {
}, [identity])
useEffect(() => {
// Load identity from local storage on mount
// Load identity from local storage on first mount
if (!hydrated) {
dispatch(setIdentityFromLocalStorage())
}, [dispatch])
}
}, [dispatch, hydrated])
useEffect(() => {
if (!hydrated) return
if (isTokenExpired) {
// Optionally, pass the current location for redirect after login
navigate('/login', { replace: true, state: { from: location } })
}
}, [isTokenExpired, navigate, location])
}, [hydrated, isTokenExpired, navigate, location])
if (!hydrated) {
return <></>
}
return !isTokenExpired
? children

View File

@ -54,6 +54,30 @@ const DEFAULT_COL_WIDTH = 150
const HEADER_ROWS = 2
const ROW_HEIGHT = 40
/** Normalizes rawd to a valid PagedResponse; treats undefined or invalid data (e.g. error payloads) as empty. */
function normalizePagedResponse<T>(rawd: PagedResponse<T> | undefined): PagedResponse<T> {
if (rawd != null && Array.isArray(rawd.items)) {
return {
items: rawd.items,
pageNumber: rawd.pageNumber ?? 0,
pageSize: rawd.pageSize ?? 0,
totalCount: rawd.totalCount ?? 0,
totalPages: rawd.totalPages ?? 0,
hasPreviousPage: rawd.hasPreviousPage ?? false,
hasNextPage: rawd.hasNextPage ?? false
}
}
return {
items: [],
totalCount: 0,
pageNumber: 0,
pageSize: 0,
totalPages: 0,
hasPreviousPage: false,
hasNextPage: false
}
}
const DataTable = <T extends Record<string, unknown>,>(props: DataTableProps<T>) => {
const {
rawd,
@ -75,18 +99,20 @@ const DataTable = <T extends Record<string, unknown>,>(props: DataTableProps<T>)
} = props
const {
items = [],
pageNumber = 0,
pageSize = 0,
totalCount = 0,
totalPages = 0,
hasPreviousPage = false,
hasNextPage = false,
} = rawd || {}
items,
pageNumber,
pageSize,
totalCount,
totalPages,
hasPreviousPage,
hasNextPage,
} = normalizePagedResponse(rawd)
const gridRef = useRef<MultiGrid>(null)
const filterMeasureRef = useRef<HTMLDivElement>(null)
const [selectedRowIndex, setSelectedRowIndex] = useState<number | null>(null)
const [measuredFilterRowHeight, setMeasuredFilterRowHeight] = useState(0)
const filterValues = useRef<Record<string, Record<string, string>>>({})
const [colWidths, setColWidths] = useState<number[]>(() => {
@ -128,6 +154,32 @@ const DataTable = <T extends Record<string, unknown>,>(props: DataTableProps<T>)
}
}, [colWidths, storageKey])
// Measure filter row content in a hidden node, then set height so row can animate from 0
useEffect(() => {
const el = filterMeasureRef.current
if (!el || columns.length === 0) return
const padding = 12
const updateHeight = () => {
const contentHeight = el.offsetHeight
if (contentHeight <= 0) return
const total = contentHeight + padding
setMeasuredFilterRowHeight((prev) => (prev !== total ? total : prev))
}
const ro = new ResizeObserver(() => {
updateHeight()
gridRef.current?.recomputeGridSize()
})
ro.observe(el)
updateHeight()
return () => ro.disconnect()
}, [columns, colWidths])
useEffect(() => {
if (measuredFilterRowHeight && gridRef.current) {
gridRef.current.recomputeGridSize()
}
}, [measuredFilterRowHeight])
const debouncedOnFilterChange = useMemo(
() => (onFilterChange ? debounce(onFilterChange, 500) : undefined),
[onFilterChange]
@ -292,11 +344,21 @@ const DataTable = <T extends Record<string, unknown>,>(props: DataTableProps<T>)
// Filter row
if (rowIndex === 1) {
return isActionCol ? (
<div key={key} style={style} className={commonClasses} />
<div
key={key}
style={{ ...style, transition: 'height 0.25s ease-out' }}
className={commonClasses}
/>
) : (
<div key={key} style={style} className={commonClasses}>
<div
key={key}
style={{ ...style, transition: 'height 0.25s ease-out' }}
className={'box-border flex min-w-0 items-stretch overflow-hidden border-b border-r border-gray-200 px-2 py-1'}
>
<div className={'w-full min-w-0 self-start'}>
{col.filter({ columnId: col.id }, handleFilterChange)}
</div>
</div>
)
}
@ -378,7 +440,24 @@ const DataTable = <T extends Record<string, unknown>,>(props: DataTableProps<T>)
}
return (
<div className={`col-span-${colspan} flex flex-col h-full w-full`}>
<div className={`col-span-${colspan} flex flex-col h-full w-full relative`}>
{/* Off-screen node to measure filter row content height so row can start at 0 and animate */}
{columns[0] && (
<div
ref={filterMeasureRef}
aria-hidden={true}
style={{
position: 'absolute',
left: -9999,
top: 0,
width: colWidths[1],
visibility: 'hidden',
pointerEvents: 'none',
}}
>
{columns[0].filter({ columnId: columns[0].id }, handleFilterChange)}
</div>
)}
<div className={'flex-1'}>
<AutoSizer>
{({ height, width }) => (
@ -391,7 +470,7 @@ const DataTable = <T extends Record<string, unknown>,>(props: DataTableProps<T>)
fixedRowCount={HEADER_ROWS}
height={height}
rowCount={items.length + HEADER_ROWS}
rowHeight={({ index }) => index === 1 ? ROW_HEIGHT * 2 : ROW_HEIGHT}
rowHeight={({ index }) => index === 1 ? measuredFilterRowHeight : ROW_HEIGHT}
width={width}
onScroll={({ scrollTop, clientHeight, scrollHeight }) =>
handleGridScroll({ scrollTop, clientHeight, scrollHeight })

View File

@ -4,11 +4,11 @@ interface LazyLoadTableColumnProps {
key: string
title: string
dataIndex: string
renderColumn?: (value: any) => React.ReactNode
renderColumn?: (value: unknown) => React.ReactNode
}
interface LazyLoadTableProps {
data: any[]
data: Record<string, unknown>[]
columns: LazyLoadTableColumnProps[]
loadMore: () => void
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
@ -69,7 +69,9 @@ const LazyLoadTable: FC<LazyLoadTableProps> = (props) => {
>
{columns.map((column, colIndex) => (
<td className={'py-2 px-4 border-b'} key={colIndex}>
{column.renderColumn ? column.renderColumn(row[column.dataIndex]) : row[column.dataIndex]}
{column.renderColumn
? column.renderColumn(row[column.dataIndex])
: String(row[column.dataIndex] ?? '')}
</td>
))}
</tr>

View File

@ -1,4 +1,4 @@
import { FC, useEffect, KeyboardEvent } from 'react'
import React, { useEffect, KeyboardEvent } from 'react'
import { LoginRequest, LoginRequestSchema } from '../models/identity/login/LoginRequest'
import { useAppDispatch, useAppSelector } from '../redux/hooks'
import { login } from '../redux/slices/identitySlice'
@ -6,7 +6,11 @@ import { useFormState } from '../hooks/useFormState'
import { useNavigate } from 'react-router-dom'
import { ButtonComponent, TextBoxComponent } from './editors'
const LoginScreen: FC = () => {
const LoginScreen: React.FC = () => {
/* Backend has no 2FA support yet re-enable when API is ready.
const [use2FA, setUse2FA] = useState(false)
const [use2FARecovery, setUse2FARecovery] = useState(false)
*/
const navigate = useNavigate()
const dispatch = useAppDispatch()
@ -30,6 +34,15 @@ const LoginScreen: FC = () => {
}
}, [identity, navigate])
/*
const handleUse2FA = (e: React.ChangeEvent<HTMLInputElement>) => {
setUse2FA(e.target.checked)
if (!e.target.checked) {
setUse2FARecovery(false)
}
}
*/
const handleLogin = () => {
if (!formIsValid) return
@ -57,12 +70,14 @@ const LoginScreen: FC = () => {
<img src={'/logo.png'} alt={'Logo'} className={'h-10 w-auto'} />
</a>
<div className={'w-full max-w-md bg-white rounded-lg shadow-md p-8 space-y-6'} onKeyDown={handleSubmit} tabIndex={0}>
{/* App logo and name above form */}
<div className={'flex justify-center items-center space-x-3 mb-2'}>
<img src={'/certs-ui-logo-only.png'} alt={'CertsUI'} className={'h-12 w-auto'} />
<span className={'text-2xl font-bold text-gray-800 select-none'}>{import.meta.env.VITE_APP_TITLE}</span>
</div>
{/* Form */}
<div className={'space-y-4'}>
<div className={'space-y-4'}>
@ -73,6 +88,7 @@ const LoginScreen: FC = () => {
onChange={(e) => handleInputChange('username', e.target.value)}
errorText={errors.username}
/>
<TextBoxComponent
label={'Password'}
placeholder={'Password...'}
@ -82,6 +98,43 @@ const LoginScreen: FC = () => {
errorText={errors.password}
/>
</div>
{/*
Backend has no 2FA support yet restore useState, handleUse2FA, CheckBox import, and this block when API is ready.
<div className={'flex items-center gap-4'}>
<CheckBoxComponent label={'Use 2FA'} value={use2FA} onChange={handleUse2FA} />
{use2FA && (
<CheckBoxComponent
label={'Use 2FA Recovery'}
value={use2FARecovery}
onChange={(e) => setUse2FARecovery(e.target.checked)}
/>
)}
</div>
{use2FA && (
<div className={'space-y-4'}>
{use2FARecovery ? (
<TextBoxComponent
label={'2FA Recovery Code'}
placeholder={'Recovery code...'}
value={formState.twoFactorRecoveryCode}
onChange={(e) => handleInputChange('twoFactorRecoveryCode', e.target.value)}
errorText={errors.twoFactorRecoveryCode}
/>
) : (
<TextBoxComponent
label={'2FA Code'}
placeholder={'Authentication code...'}
value={formState.twoFactorCode}
onChange={(e) => handleInputChange('twoFactorCode', e.target.value)}
errorText={errors.twoFactorCode}
/>
)}
</div>
)}
*/}
{/* Submit */}
<ButtonComponent
label={'Sign in'}

View File

@ -4,6 +4,7 @@ import { ButtonComponent } from './ButtonComponent'
import { TextBoxComponent } from './TextBoxComponent'
import { CircleX } from 'lucide-react'
import { FieldContainer } from './FieldContainer'
import { getInputClasses } from './editorStyles'
const DISPLAY_FORMAT = 'yyyy-MM-dd HH:mm'
@ -37,15 +38,6 @@ const DateTimePickerComponent: FC<DateTimePickerComponentProps> = ({
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (value !== prevValueRef.current) {
const newDate = parsedValue || new Date()
setCurrentViewDate(newDate)
setTempDate(newDate)
prevValueRef.current = value
}
}, [value, parsedValue])
const formatForDisplay = (date: Date) => format(date, DISPLAY_FORMAT)
const daysCount = getDaysInMonth(currentViewDate)
@ -84,6 +76,18 @@ const DateTimePickerComponent: FC<DateTimePickerComponentProps> = ({
setShowDropdown(false)
}
const handleOpen = () => {
if (readOnly || disabled) {
return
}
const newDate = parsedValue || new Date()
setCurrentViewDate(newDate)
setTempDate(newDate)
prevValueRef.current = value
setShowDropdown(true)
}
const actionButtons = () => {
const className = 'p-1 text-gray-600 hover:text-gray-800 bg-white'
return [
@ -125,12 +129,10 @@ const DateTimePickerComponent: FC<DateTimePickerComponentProps> = ({
<input
type={'text'}
value={value ? formatForDisplay(parsedValue!) : ''}
onFocus={() => !readOnly && !disabled && setShowDropdown(true)}
onFocus={handleOpen}
readOnly
placeholder={placeholder}
className={`shadow appearance-none border rounded w-full px-3 py-2 text-gray-700 leading-tight focus:outline-none focus:shadow-outline ${
errorText ? 'border-red-500' : ''
} ${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}${readOnly && !disabled ? ' text-gray-500 cursor-default' : ''}`}
className={getInputClasses({ errorText, disabled, readOnly })}
disabled={disabled}
/>
@ -140,22 +142,22 @@ const DateTimePickerComponent: FC<DateTimePickerComponentProps> = ({
</div>
{showDropdown && !readOnly && !disabled && (
<div className={'absolute left-0 right-0 bg-white border border-gray-300 rounded mt-1 w-full shadow-lg z-10'}>
<div className={'flex justify-between items-center px-3 py-2'}>
<button onClick={handlePrevMonth} type={'button'}>
<div className={'absolute left-0 top-full mt-1 w-72 min-w-0 bg-white border border-gray-300 rounded shadow-lg z-10'}>
<div className={'flex justify-between items-center px-2 py-1.5'}>
<button onClick={handlePrevMonth} type={'button'} className={'rounded py-1 px-2 text-gray-700 hover:bg-gray-100'}>
&lt;
</button>
<span>{format(currentViewDate, 'MMMM yyyy')}</span>
<button onClick={handleNextMonth} type={'button'}>
<span className={'text-sm'}>{format(currentViewDate, 'MMMM yyyy')}</span>
<button onClick={handleNextMonth} type={'button'} className={'rounded py-1 px-2 text-gray-700 hover:bg-gray-100'}>
&gt;
</button>
</div>
<div className={'grid grid-cols-7 gap-1 px-3 py-2'}>
<div className={'grid grid-cols-7 gap-0.5 px-2 pb-1.5'}>
{daysArray.map((day) => (
<div
key={day}
onClick={() => handleDayClick(day)}
className={`p-2 cursor-pointer text-center ${
className={`p-1.5 cursor-pointer text-center text-sm ${
tempDate.getDate() === day &&
tempDate.getMonth() === currentViewDate.getMonth() &&
tempDate.getFullYear() === currentViewDate.getFullYear()
@ -167,7 +169,7 @@ const DateTimePickerComponent: FC<DateTimePickerComponentProps> = ({
</div>
))}
</div>
<div className={'px-3 py-2'}>
<div className={'px-2 py-1.5'}>
<TextBoxComponent
label={'Time'}
type={'time'}
@ -177,7 +179,7 @@ const DateTimePickerComponent: FC<DateTimePickerComponentProps> = ({
readOnly={readOnly}
/>
</div>
<div className={'px-3 py-2 gap-2 flex justify-between'}>
<div className={'px-2 py-1.5 gap-2 flex justify-between'}>
<ButtonComponent label={'Clear'} buttonHierarchy={'secondary'} onClick={handleClear} />
<ButtonComponent label={'Confirm'} buttonHierarchy={'primary'} onClick={handleConfirm} />
</div>

View File

@ -4,6 +4,7 @@ import { TrngResponse } from '../../models/TrngResponse'
import { getData } from '../../axiosConfig'
import { ApiRoutes, GetApiRoute } from '../../AppMap'
import { FieldContainer } from './FieldContainer'
import { getInputClasses } from './editorStyles'
interface PasswordGeneratorProps {
@ -122,11 +123,7 @@ const SecretComponent: FC<PasswordGeneratorProps> = (props) => {
value={value}
onChange={handleOnChange}
placeholder={placeholder}
className={`
shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline
${errorText ? 'border-red-500' : ''}
${readOnly ? 'bg-gray-100 text-gray-500' : ''}
`}
className={getInputClasses({ errorText, readOnly })}
readOnly={readOnly}
/>

View File

@ -2,6 +2,7 @@ import { debounce } from 'lodash'
import { CircleX } from 'lucide-react'
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { FieldContainer } from './FieldContainer'
import { getInputClasses } from './editorStyles'
export interface SelectBoxComponentOption {
value: string | number
@ -58,13 +59,11 @@ const SelectBoxComponent: FC<SelectBoxComponentProps> = (props) => {
// Refs to store previous values to detect changes
const initRef = useRef(false)
const prevValue = useRef(value)
const prevFilterValue = useRef(filterValue)
// Update the selected value and notify parent via onValueChange callback.
const handleValueChange = useCallback(
(newValue: string | number) => {
prevValue.current = newValue
// Simulate a ChangeEvent with the new value
onChange?.({ target: { value: newValue } } as ChangeEvent<HTMLInputElement>)
},
@ -104,33 +103,20 @@ const SelectBoxComponent: FC<SelectBoxComponentProps> = (props) => {
[filterFields, debounceOnFilterChange, showDropdown, handleValueChange, disabled]
)
// Effect to sync external value with filter text and trigger filtering.
useEffect(() => {
// When value is cleared, also clear the filter.
if (value === '') {
if (prevValue.current !== value) {
// Simulate clearing the filter input.
handleFilterChange({ target: { value: '' } } as ChangeEvent<HTMLInputElement>)
}
return
}
// Find the option that matches the current value.
const selectedOption = options.find((option) => option.value === value)
if (selectedOption) {
if (filterValue !== selectedOption.label) {
setFilterValue(selectedOption.label) // Only update if the filterValue is different.
}
const inputValue = showDropdown ? filterValue : (selectedOption?.label ?? '')
// Fetch the selected option when the parent provides only the id.
useEffect(() => {
if (value === '' || selectedOption) {
return
}
// If the value does not correspond to an existing option,
// trigger filtering using the idField.
if (debounceOnFilterChange && !initRef.current) {
debounceOnFilterChange(`${idField} == "${value}"`)
initRef.current = true
}
}, [value, filterValue, options, idField, debounceOnFilterChange, handleFilterChange])
}, [value, selectedOption, idField, debounceOnFilterChange])
// Handle click on an option from the dropdown.
const handleOptionClick = (optionValue: string | number) => {
@ -174,13 +160,10 @@ const SelectBoxComponent: FC<SelectBoxComponentProps> = (props) => {
<div className={'relative'}>
<input
type={'text'}
value={filterValue}
value={inputValue}
onChange={handleFilterChange}
placeholder={placeholder}
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline
${errorText ? 'border-red-500' : ''}
${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}
${readOnly && !disabled ? 'text-gray-500 cursor-default' : ''}`}
className={getInputClasses({ errorText, disabled, readOnly })}
disabled={readOnly || disabled}
// Open dropdown when input is focused.
onFocus={() => { if (!disabled) setShowDropdown(true) }}

View File

@ -1,6 +1,7 @@
import { Eye, EyeOff } from 'lucide-react'
import { ChangeEvent, FC, useEffect, useRef, useState } from 'react'
import { FieldContainer } from './FieldContainer'
import { getInputClasses } from './editorStyles'
interface TextBoxComponentProps {
label: string
@ -49,20 +50,16 @@ const TextBoxComponent: FC<TextBoxComponentProps> = (props) => {
// Se il type è "textarea", comportamento invariato
if (type === 'textarea') {
return (
<div className={`${colspan ? `col-span-${colspan}` : 'w-full'}`}>
<label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>
<FieldContainer colspan={colspan} label={label} errorText={errorText}>
<textarea
value={value}
onChange={handleOnChange}
placeholder={placeholder}
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline ${
errorText ? 'border-red-500' : ''
} ${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}${readOnly && !disabled ? ' text-gray-500 cursor-default' : ''}`}
className={getInputClasses({ errorText, disabled, readOnly })}
readOnly={readOnly}
disabled={disabled}
/>
{errorText && <p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>}
</div>
</FieldContainer>
)
}
@ -79,9 +76,7 @@ const TextBoxComponent: FC<TextBoxComponentProps> = (props) => {
value={value}
onChange={handleOnChange}
placeholder={placeholder}
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline ${
errorText ? 'border-red-500' : ''
} ${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}${readOnly && !disabled ? ' text-gray-500 cursor-default' : ''}`}
className={getInputClasses({ errorText, disabled, readOnly, extra: 'pr-10' })}
readOnly={readOnly}
disabled={disabled}
/>
@ -103,9 +98,7 @@ const TextBoxComponent: FC<TextBoxComponentProps> = (props) => {
value={value}
onChange={handleOnChange}
placeholder={placeholder}
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline ${
errorText ? 'border-red-500' : ''
} ${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}${readOnly && !disabled ? ' text-gray-500 cursor-default' : ''}`}
className={getInputClasses({ errorText, disabled, readOnly })}
readOnly={readOnly}
disabled={disabled}
/>

View File

@ -0,0 +1,25 @@
/**
* Shared Tailwind classes for editor components (TextBox, SelectBox, DateTimePicker, Secret).
* Keeps input styling uniform and avoids drift.
*/
export const inputBaseClasses =
'border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500/30'
export interface InputClassOptions {
errorText?: string
disabled?: boolean
readOnly?: boolean
/** Extra classes (e.g. pr-10 for input with trailing button) */
extra?: string
}
export function getInputClasses(options: InputClassOptions): string {
const { errorText, disabled = false, readOnly = false, extra = '' } = options
const border = errorText ? 'border-red-500' : 'border-gray-300'
const state =
disabled
? 'bg-gray-100 text-gray-500 cursor-default'
: 'bg-white' + (readOnly ? ' text-gray-500 cursor-default' : '')
return [inputBaseClasses, border, state, extra].filter(Boolean).join(' ')
}

View File

@ -3,7 +3,7 @@ import { FormContainer, FormContent, FormFooter, FormHeader } from '../component
import { ButtonComponent, CheckBoxComponent, TextBoxComponent } from '../components/editors'
import { GetAccountResponse } from '../models/letsEncryptServer/account/responses/GetAccountResponse'
import { useFormState } from '../hooks/useFormState'
import { array, boolean, object, Schema, string } from 'zod'
import { array, boolean, object, string, ZodType } from 'zod'
import { PlusIcon, TrashIcon } from 'lucide-react'
import { getData, patchData } from '../axiosConfig'
import { ApiRoutes, GetApiRoute } from '../AppMap'
@ -23,7 +23,7 @@ const EditAccountHostnameFormProto = (): EditAccountHostnameFormProps => ({
hostname: ''
})
const EditAccountHostnameFormSchema: Schema<EditAccountHostnameFormProps> = object({
const EditAccountHostnameFormSchema: ZodType<EditAccountHostnameFormProps> = object({
isDisabled: boolean(),
hostname: string()
})
@ -50,7 +50,7 @@ const RegisterFormProto = (): EditAccountFormProps => ({
hostnames: [],
})
const RegisterFormSchema: Schema<EditAccountFormProps> = object({
const RegisterFormSchema: ZodType<EditAccountFormProps> = object({
isDisabled: boolean(),
description: string(),

View File

@ -69,7 +69,7 @@ const LetsEncryptTermsOfService: FC = () => {
{error && <div className={'shrink-0 text-red-600'}>{error}</div>}
{objectUrl && !error && (
<iframe
title={"Let's Encrypt Terms of Service PDF"}
title={'Let\'s Encrypt Terms of Service PDF'}
src={objectUrl}
className={
'min-h-0 w-full flex-1 rounded border border-gray-200 bg-gray-50'

View File

@ -5,7 +5,7 @@ import { GetAccountResponse } from '../models/letsEncryptServer/account/response
import { ApiRoutes, GetApiRoute } from '../AppMap'
import { ButtonComponent, CheckBoxComponent, RadioGroupComponent, SelectBoxComponent, TextBoxComponent } from '../components/editors'
import { ChallengeType } from '../entities/ChallengeType'
import z, { array, boolean, object, Schema, string } from 'zod'
import z, { array, boolean, object, string, ZodType } from 'zod'
import { useFormState } from '../hooks/useFormState'
import { enumToArr } from '../functions'
import { PostAccountRequest, PostAccountRequestSchema } from '../models/letsEncryptServer/account/requests/PostAccountRequest'
@ -45,7 +45,7 @@ const RegisterFormProto = (): RegisterFormProps => ({
agreeToS: false,
})
const RegisterFormSchema: Schema<RegisterFormProps> = object({
const RegisterFormSchema: ZodType<RegisterFormProps> = object({
description: string(),
contact: string(),

View File

@ -1,4 +1,4 @@
import { PatchOperation } from '../../models/PatchOperation.js'
import { COLLECTION_ITEM_OPERATION, PatchOperation } from '../../models/PatchOperation.js'
import { deepCopy } from './deepCopy.js'
import { deepEqual } from './deepEqual.js'
@ -9,7 +9,7 @@ export type Identifiable<I extends string | number = string | number> = {
}
type OperationBag<K extends string = string> = {
operations?: Partial<Record<K | 'collectionItemOperation', PatchOperation>>
operations?: Partial<Record<K | typeof COLLECTION_ITEM_OPERATION, PatchOperation>>
}
type EnsureId<T extends Identifiable> = { id?: T['id'] }
@ -230,8 +230,11 @@ export const deepDelta = <T extends Record<string, unknown>>(
// --- PRIMITIVE / TYPE CHANGED ---
if (!deepEqual(formValue, backupValue)) {
const isNullish = formValue === null || formValue === undefined
if (!isNullish) {
;(parentDelta as Delta<T>)[key] = formValue as Delta<T>[typeof key]
setOp(parentDelta, key, formValue === null ? PatchOperation.RemoveField : PatchOperation.SetField)
}
setOp(parentDelta, key, isNullish ? PatchOperation.RemoveField : PatchOperation.SetField)
}
}
}
@ -284,6 +287,23 @@ export const deepDelta = <T extends Record<string, unknown>>(
(policy?.idFieldKey ??
(typeof identityKey === 'string' ? identityKey : 'id')) as keyof U & string
/**
* Decides which field to use for the identity in the delta payload.
*
* Rules:
* - If the item has a real `id` (server-assigned), always emit it as `id`
* so the backend can match and update/remove correctly.
* - Otherwise fall back to the policy's idFieldKey (e.g. "_deltaId") so
* synthetic identities never go into `id` where a Guid is expected.
*/
const getIdFieldForItem = (item: U): keyof U & string => {
const directId = (item as Identifiable).id
if (directId !== null && directId !== undefined && String(directId).length > 0) {
return 'id' as keyof U & string
}
return idFieldKey
}
const sameRoot = (f: U, b: U): boolean => {
if (!rootKey) return true
return (f as PlainObject)[rootKey] === (b as PlainObject)[rootKey]
@ -309,7 +329,7 @@ export const deepDelta = <T extends Record<string, unknown>>(
if (fid === null || fid === undefined) {
const addItem = {} as DeltaArrayItem<U>
Object.assign(addItem, formItem as Partial<U>)
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
addItem.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }
// normalize children as AddToCollection
for (const ck of childrenKeys) {
@ -318,7 +338,7 @@ export const deepDelta = <T extends Record<string, unknown>>(
const normalized = (v as Identifiable[]).map(child => {
const c = {} as DeltaArrayItem<Identifiable>
Object.assign(c, child as Partial<Identifiable>)
c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
c.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }
return c
})
;(addItem as PlainObject)[ck] = normalized
@ -334,8 +354,8 @@ export const deepDelta = <T extends Record<string, unknown>>(
if (!backupItem) {
const addItem = {} as DeltaArrayItem<U>
Object.assign(addItem, formItem as Partial<U>)
;(addItem as PlainObject)[idFieldKey] = fid as IdLike // store identity for server convenience
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
;(addItem as PlainObject)[getIdFieldForItem(formItem)] = fid as IdLike // store identity
addItem.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }
for (const ck of childrenKeys) {
const v = (addItem as PlainObject)[ck]
@ -343,7 +363,7 @@ export const deepDelta = <T extends Record<string, unknown>>(
const normalized = (v as Identifiable[]).map(child => {
const c = {} as DeltaArrayItem<Identifiable>
Object.assign(c, child as Partial<Identifiable>)
c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
c.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }
return c
})
;(addItem as PlainObject)[ck] = normalized
@ -357,13 +377,14 @@ export const deepDelta = <T extends Record<string, unknown>>(
// 1.c) Re-parenting: root changed
if (!sameRoot(formItem, backupItem)) {
const removeItem = {} as DeltaArrayItem<U>
;(removeItem as PlainObject)[idFieldKey] = fid as IdLike
removeItem.operations = { collectionItemOperation: PatchOperation.RemoveFromCollection }
;(removeItem as PlainObject)[getIdFieldForItem(backupItem)] = fid as IdLike
removeItem.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection }
arrayDelta.push(removeItem)
const addItem = {} as DeltaArrayItem<U>
Object.assign(addItem, formItem as Partial<U>)
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
;(addItem as PlainObject)[getIdFieldForItem(formItem)] = fid as IdLike
addItem.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }
if (dropChildren) {
for (const ck of childrenKeys) {
@ -378,7 +399,7 @@ export const deepDelta = <T extends Record<string, unknown>>(
const normalized = (v as Identifiable[]).map(child => {
const c = {} as DeltaArrayItem<Identifiable>
Object.assign(c, child as Partial<Identifiable>)
c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
c.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }
return c
})
;(addItem as PlainObject)[ck] = normalized
@ -399,7 +420,7 @@ export const deepDelta = <T extends Record<string, unknown>>(
if (roleBecameNull) {
const removeItem = {} as DeltaArrayItem<U>
;(removeItem as PlainObject)[idFieldKey] = fid as IdLike
removeItem.operations = { collectionItemOperation: PatchOperation.RemoveFromCollection }
removeItem.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection }
arrayDelta.push(removeItem)
continue
}
@ -407,7 +428,7 @@ export const deepDelta = <T extends Record<string, unknown>>(
// 1.e) Field-level diff
const itemDeltaBase = {} as (PlainObject & OperationBag & { id?: U['id'] })
;(itemDeltaBase as PlainObject)[idFieldKey] = fid as IdLike
;(itemDeltaBase as PlainObject)[getIdFieldForItem(formItem)] = fid as IdLike
calculateDelta(
formItem as PlainObject,
@ -427,8 +448,8 @@ export const deepDelta = <T extends Record<string, unknown>>(
if (bid === null || bid === undefined) continue
if (!formMap.has(bid as string | number)) {
const removeItem = {} as DeltaArrayItem<U>
;(removeItem as PlainObject)[idFieldKey] = bid as IdLike
removeItem.operations = { collectionItemOperation: PatchOperation.RemoveFromCollection }
;(removeItem as PlainObject)[getIdFieldForItem(backupItem)] = bid as IdLike
removeItem.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection }
arrayDelta.push(removeItem)
}
}

View File

@ -1,25 +1,25 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Schema } from 'zod'
import { useCallback, useMemo, useState } from 'react'
import { ZodType } from 'zod'
import { $ZodIssue } from 'zod/v4/core'
import { deepCopy } from '../functions/deep'
interface UseFormStateProps<FormState> {
initialState: FormState;
validationSchema: Schema<unknown>;
initialState: FormState
validationSchema: ZodType<FormState>
}
type IsPlainObject<T> = T extends object
?
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
? T extends Function
T extends Function
? false
// eslint-disable-next-line @typescript-eslint/no-explicit-any
: T extends Array<any>
? false
: true
: false;
: false
type Decrement<N extends number> = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9][N];
type Decrement<N extends number> = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9][N]
type Path<T, Depth extends number = 5> = Depth extends 0
? never
@ -32,25 +32,22 @@ type Path<T, Depth extends number = 5> = Depth extends 0
: never;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const useFormState = <T extends Record<string, any>>(props: UseFormStateProps<T>) => {
const useFormState = <T extends Record<string, any>,>(props: UseFormStateProps<T>) => {
const {
initialState,
validationSchema
} = props
const [formState, setFormState] = useState(initialState)
const [errors, setErrors] = useState<Partial<Record<Path<T>, string>>>({} as Partial<Record<Path<T>, string>>)
const [formIsValid, setFormIsValid] = useState<boolean>(true)
// Memoize the validation schema
const memoizedValidationSchema = useMemo(() => validationSchema, [validationSchema])
const validateForm = useCallback(() => {
const validationResult = memoizedValidationSchema.safeParse(formState)
setFormIsValid(validationResult.success)
const validationResult = useMemo(() => {
return memoizedValidationSchema.safeParse(formState)
}, [formState, memoizedValidationSchema])
const errors = useMemo(() => {
if (!validationResult.success) {
const validationErrors = validationResult.error.issues
@ -63,23 +60,16 @@ const useFormState = <T extends Record<string, any>>(props: UseFormStateProps<T>
return acc
}
const newErrors = flattenErrors(validationErrors)
setErrors(newErrors)
return
return flattenErrors(validationErrors)
}
// Reset errors on successful validation
setErrors(Object.keys(formState).reduce((acc, key) => ({
return Object.keys(formState).reduce((acc, key) => ({
...acc,
[key]: ''
}), {} as Partial<Record<Path<T>, string>>))
}), {} as Partial<Record<Path<T>, string>>)
}, [formState, validationResult])
}, [formState, memoizedValidationSchema])
useEffect(() => {
validateForm()
}, [formState, validateForm])
const formIsValid = validationResult.success
/**
* Handles input change for nested objects

View File

@ -20,3 +20,6 @@ export enum PatchOperation {
/// </summary>
RemoveFromCollection,
}
/** Key for per-item collection operations in PATCH payloads. Must match backend Constants.CollectionItemOperation. */
export const COLLECTION_ITEM_OPERATION = 'collectionItemOperation' as const

View File

@ -3,6 +3,8 @@ export interface ProblemDetails {
title?: string;
detail?: string;
instance?: string;
/** Validation errors: property name -> list of messages (ASP.NET ValidationProblemDetails) */
errors?: Record<string, string[]>;
extensions: { [key: string]: never };
}

View File

@ -1,4 +1,4 @@
import { object, RefinementCtx, Schema, string } from 'zod'
import { object, RefinementCtx, ZodType, string, ZodIssueCode } from 'zod'
export interface LoginRequest {
username: string
@ -8,9 +8,10 @@ export interface LoginRequest {
}
const LoginRequestSchemaRefine = (data: LoginRequest, ctx: RefinementCtx) => {
if (data.username === '') {
ctx.addIssue({
code: 'custom',
code: ZodIssueCode.custom,
message: 'Username cannot be empty',
path: ['username']
})
@ -18,7 +19,7 @@ const LoginRequestSchemaRefine = (data: LoginRequest, ctx: RefinementCtx) => {
if (data.password === '') {
ctx.addIssue({
code: 'custom',
code: ZodIssueCode.custom,
message: 'Password cannot be empty',
path: ['password']
})
@ -26,14 +27,14 @@ const LoginRequestSchemaRefine = (data: LoginRequest, ctx: RefinementCtx) => {
if (data.twoFactorCode && data.twoFactorRecoveryCode) {
ctx.addIssue({
code: 'custom',
code: ZodIssueCode.custom,
message: 'Cannot have both twoFactorCode and twoFactorRecoveryCode',
path: ['twoFactorCode', 'twoFactorRecoveryCode']
})
}
}
export const LoginRequestSchema: Schema<LoginRequest> = object({
export const LoginRequestSchema: ZodType<LoginRequest> = object({
username: string(),
password: string(),
twoFactorCode: string().optional(),

View File

@ -1,3 +1,4 @@
export interface RefreshTokenRequest {
refreshToken: string
force?: boolean
}

View File

@ -1,4 +1,4 @@
import { object, RefinementCtx, Schema, z } from 'zod'
import { object, RefinementCtx, ZodType, z } from 'zod'
import { PatchRequestModelBase, PatchRequestModelBaseSchema } from '../../PatchRequestModelBase'
import { PatchUserEntityScopeRequest, PatchUserEntityScopeRequestSchema } from './PatchUserEntityScopeRequest'
@ -60,7 +60,7 @@ const PatchUserChangePasswordRequestSchemaRefine = (data: PatchUserChangePasswor
}
}
export const PatchUserChangePasswordRequestSchema: Schema<PatchUserChangePasswordRequest> = PatchRequestModelBaseSchema.and(
export const PatchUserChangePasswordRequestSchema: ZodType<PatchUserChangePasswordRequest> = PatchRequestModelBaseSchema.and(
object({
password: z.string(),
confirmPassword: z.string().optional()

View File

@ -15,14 +15,11 @@ import { enumToArr, parseAclEntries } from '../../functions'
import { Role } from '../../models/identity/Role'
import { AclEntry } from '../../functions/acl/parseAclEntry'
interface IdentityRole {
value: string | number,
label: string
}
interface Identity extends LoginResponse {
userId?: string,
username?: string
@ -35,12 +32,33 @@ interface IdentityState {
identity: Identity | null
showUserOffcanvas: boolean
status: 'idle' | 'loading' | 'failed'
/** Indicates whether identity has been hydrated from localStorage at least once. */
hydrated: boolean
}
/** API JSON may be camelCase or PascalCase; normalize so Authorization and axios see stable keys. */
const normalizeLoginResponse = (raw: LoginResponse | undefined): LoginResponse | undefined => {
if (!raw) return undefined
const r = raw as Record<string, unknown>
const str = (camel: keyof LoginResponse, pascal: string) => {
const v = r[camel] ?? r[pascal]
if (v == null) return ''
return typeof v === 'string' ? v : String(v)
}
return {
tokenType: str('tokenType', 'TokenType'),
token: str('token', 'Token'),
expiresAt: str('expiresAt', 'ExpiresAt'),
refreshToken: str('refreshToken', 'RefreshToken'),
refreshTokenExpiresAt: str('refreshTokenExpiresAt', 'RefreshTokenExpiresAt'),
}
}
const initialState: IdentityState = {
identity: null,
showUserOffcanvas: false,
status: 'idle',
hydrated: false,
}
const login = createAsyncThunk(
@ -70,14 +88,15 @@ const logout = createAsyncThunk(
const refreshJwt = createAsyncThunk(
'auth/refreshJwt',
async () => {
async (force?: boolean) => {
const identity = readIdentity()
if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date())
return
const apiRoute = GetApiRoute(ApiRoutes.identityRefresh)
const response = await postData<RefreshTokenRequest, LoginResponse>(apiRoute.route, {
refreshToken: identity.refreshToken
refreshToken: identity.refreshToken,
force
})
return response
@ -85,14 +104,17 @@ const refreshJwt = createAsyncThunk(
)
const enrichStateWithJwtContent = (token: string, identity: Identity) => {
const jwtContent = jwtDecode(token) as never
const jwtContent = jwtDecode(token) as Record<string, unknown>
if (jwtContent) {
if (jwtContent[Claims.NameIdentifier])
identity.userId = jwtContent[Claims.NameIdentifier]
identity.userId = jwtContent[Claims.NameIdentifier] as string
if (jwtContent[Claims.Name])
identity.username = jwtContent[Claims.Name]
if (identity.username == null || identity.username?.trim() === '') {
const nameClaim = jwtContent[Claims.Name] as string | undefined
const usernameClaim = (jwtContent['username'] ?? jwtContent['preferred_username']) as string | undefined
identity.username = (usernameClaim?.trim()) ? usernameClaim : nameClaim
}
if (jwtContent[Claims.Role]) {
@ -104,9 +126,9 @@ const enrichStateWithJwtContent = (token: string, identity: Identity) => {
})
const jwtRoles: string[] = Array.isArray(jwtContent[Claims.Role])
? jwtContent[Claims.Role]
? jwtContent[Claims.Role] as string[]
: jwtContent[Claims.Role]
? [jwtContent[Claims.Role]]
? [jwtContent[Claims.Role] as string]
: []
const identityRoles: IdentityRole [] = []
@ -122,9 +144,9 @@ const enrichStateWithJwtContent = (token: string, identity: Identity) => {
if (jwtContent[Claims.AclEntry]) {
const jwtAcls: string[] = Array.isArray(jwtContent[Claims.AclEntry])
? jwtContent[Claims.AclEntry]
? jwtContent[Claims.AclEntry] as string[]
: jwtContent[Claims.AclEntry]
? [jwtContent[Claims.AclEntry]]
? [jwtContent[Claims.AclEntry] as string]
: []
if (jwtAcls?.includes('global:admin') ?? false) {
@ -145,21 +167,32 @@ const identitySlice = createSlice({
initialState,
reducers: {
setIdentityFromLocalStorage: (state) => {
const identity = readIdentity()
const raw = readIdentity()
const identity = normalizeLoginResponse(raw)
if (identity) {
if (identity?.token && identity.refreshTokenExpiresAt) {
writeIdentity(identity)
state.identity = {
isGlobalAdmin: false,
...identity
}
enrichStateWithJwtContent(identity.token, state.identity)
}
state.hydrated = true
},
setShowUserOffcanvas: (state) => {
state.showUserOffcanvas = true
},
setHideUserOffcanvas: (state) => {
state.showUserOffcanvas = false
},
/** Clears identity from state and localStorage (e.g. after refresh failed with 401). Does not call logout API. */
clearIdentity: (state) => {
state.identity = null
state.showUserOffcanvas = false
state.status = 'idle'
removeIdentity()
}
},
extraReducers: (builder) => {
@ -171,14 +204,15 @@ const identitySlice = createSlice({
})
.addCase(login.fulfilled, (state, action: PayloadAction<LoginResponse | undefined>) => {
state.status = 'idle'
if (action.payload) {
const payload = normalizeLoginResponse(action.payload)
if (payload?.token && payload.refreshTokenExpiresAt) {
state.identity = {
isGlobalAdmin: false,
...action.payload
...payload
}
writeIdentity(action.payload)
writeIdentity(payload)
enrichStateWithJwtContent(action.payload.token, state.identity)
enrichStateWithJwtContent(payload.token, state.identity)
}
})
.addCase(login.rejected, (state) => {
@ -207,31 +241,33 @@ const identitySlice = createSlice({
.addCase(refreshJwt.fulfilled, (state, action: PayloadAction<LoginResponse | undefined>) => {
state.status = 'idle'
if (action.payload) {
const payload = normalizeLoginResponse(action.payload)
if (payload?.token && payload.refreshTokenExpiresAt) {
state.identity = {
isGlobalAdmin: false,
...action.payload
...payload
}
writeIdentity(action.payload)
writeIdentity(payload)
enrichStateWithJwtContent(action.payload.token, state.identity)
enrichStateWithJwtContent(payload.token, state.identity)
}
else {
state.identity = null
state.showUserOffcanvas = false
removeIdentity()
}
})
.addCase(refreshJwt.rejected, (state) => {
state.status = 'failed'
state.status = 'idle'
state.identity = null
state.showUserOffcanvas = false
removeIdentity()
})
},
})
export { login, logout, refreshJwt }
export const { setIdentityFromLocalStorage, setShowUserOffcanvas, setHideUserOffcanvas } = identitySlice.actions
export const { setIdentityFromLocalStorage, setShowUserOffcanvas, setHideUserOffcanvas, clearIdentity } = identitySlice.actions
export const selectIdentity = (state: RootState) => state
export default identitySlice.reducer

View File

@ -10,7 +10,7 @@ export default defineConfig({
],
server: {
host: '0.0.0.0',
allowedHosts: ['certs-ui-client'],
allowedHosts: ['client'],
watch: {
usePolling: true,
},

View File

@ -9,14 +9,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PackageReference Include="coverlet.collector" Version="8.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3">
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@ -11,10 +11,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Results" Version="1.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
<PackageReference Include="MaksIT.Results" Version="2.0.1" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup>

View File

@ -5,7 +5,9 @@ using MaksIT.Webapi;
using MaksIT.Webapi.Authorization.Filters;
using MaksIT.Webapi.BackgroundServices;
using MaksIT.Webapi.Services;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
@ -33,11 +35,17 @@ builder.Services.Configure<Configuration>(configurationSection);
// Add logging
builder.Logging.AddConsoleLogger();
// Add services to the container.
// JSON: camelCase property names (matches TypeScript models and MaksIT-Vault). MaksIT.Results ObjectResult must use the same options.
static void ConfigureJsonSerializerOptions(JsonSerializerOptions options) {
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
}
builder.Services.AddControllers()
.AddJsonOptions(options => {
options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
.AddJsonOptions(options => ConfigureJsonSerializerOptions(options.JsonSerializerOptions));
builder.Services.AddOptions<JsonOptions>().Configure(o =>
ConfigureJsonSerializerOptions(o.JsonSerializerOptions));
// Add custom authorization filter
builder.Services.AddScoped<JwtAuthorizationFilter>();

View File

@ -8,8 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.DataProtection.Extensions" Version="10.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>

View File

@ -2,31 +2,35 @@
"ReverseProxy": {
"Routes": {
"well-known-acme-challenge-route": {
"Order": 5,
"Match": { "Path": "/.well-known/acme-challenge/{**catch-all}" },
"ClusterId": "certs-ui-server"
"ClusterId": "webapiCluster"
},
"swagger-route": {
"Order": 10,
"Match": { "Path": "/swagger/{**catch-all}" },
"ClusterId": "certs-ui-server"
"ClusterId": "webapiCluster"
},
"api-route": {
"Order": 20,
"Match": { "Path": "/api/{**catch-all}" },
"ClusterId": "certs-ui-server"
"ClusterId": "webapiCluster"
},
"default-route": {
"Order": 1000,
"Match": { "Path": "{**catch-all}" },
"ClusterId": "certs-ui-client"
"ClusterId": "webuiCluster"
}
},
"Clusters": {
"certs-ui-server": {
"webapiCluster": {
"Destinations": {
"d1": { "Address": "http://certs-ui-server:5000/" }
"d1": { "Address": "http://server:5000/" }
}
},
"certs-ui-client": {
"webuiCluster": {
"Destinations": {
"d1": { "Address": "http://certs-ui-client:5173/" }
"d1": { "Address": "http://client:5173/" }
}
}
}

View File

@ -6,8 +6,9 @@
<DockerPublishLocally>False</DockerPublishLocally>
<ProjectGuid>0233e43f-435d-4309-b20c-ecd4bfbd2e63</ProjectGuid>
<DockerLaunchAction>LaunchBrowser</DockerLaunchAction>
<!-- reverseproxy is the only service with host port mappings; server/client are internal-only. Swagger is served via YARP at /swagger. -->
<DockerServiceUrl>{Scheme}://localhost:{ServicePort}/swagger</DockerServiceUrl>
<DockerServiceName>maksit-certs-ui</DockerServiceName>
<DockerServiceName>reverseproxy</DockerServiceName>
</PropertyGroup>
<ItemGroup>
<None Include="docker-compose.override.yml">

View File

@ -1,38 +1,39 @@
# See docker-compose.yml header for maksit-certs-ui-* container/image naming (no clash with maksit-vault-*).
networks:
maksit-certs-ui-network:
driver: bridge
services:
reverse-proxy:
container_name: reverse-proxy
reverseproxy:
environment:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_HTTP_PORTS: "8080"
ports:
- "8080:8080"
depends_on:
- certs-ui-client
- certs-ui-server
networks:
- maks-it
- maksit-certs-ui-network
depends_on:
- client
- server
certs-ui-client:
container_name: certs-ui-client
client:
environment:
- ASPNETCORE_ENVIRONMENT=Development
ASPNETCORE_ENVIRONMENT: Development
volumes:
- ./MaksIT.WebUI:/app
networks:
- maks-it
- maksit-certs-ui-network
certs-ui-server:
container_name: certs-ui-server
server:
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ASPNETCORE_HTTP_PORTS=5000
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_HTTP_PORTS: "5000"
volumes:
- D:\Compose\MaksIT.CertsUI\acme:/acme
- D:\Compose\MaksIT.CertsUI\cache:/cache
- D:\Compose\MaksIT.CertsUI\data:/data
- D:\Compose\MaksIT.CertsUI\tmp:/tmp
- D:\Compose\MaksIT.CertsUI\configMap\appsettings.json:/configMap/appsettings.json:ro
- D:\Compose\MaksIT.CertsUI\secrets\appsecrets.json:/secrets/appsecrets.json:ro
- D:/Compose/MaksIT.CertsUI/acme:/acme
- D:/Compose/MaksIT.CertsUI/cache:/cache
- D:/Compose/MaksIT.CertsUI/data:/data
- D:/Compose/MaksIT.CertsUI/tmp:/tmp
- D:/Compose/MaksIT.CertsUI/configMap/appsettings.json:/configMap/appsettings.json:ro
- D:/Compose/MaksIT.CertsUI/secrets/appsecrets.json:/secrets/appsecrets.json:ro
networks:
- maks-it
networks:
maks-it:
driver: bridge
- maksit-certs-ui-network

View File

@ -1,18 +1,25 @@
# Naming: maksit-certs-ui-<role> for containers and local images (parallel to maksit-vault-* in the Vault repo).
# DOCKER_REGISTRY is optional (e.g. cr.example.com/); YARP still targets Compose service names client / server in appsettings.json.
name: maksit-certs-ui
services:
reverse-proxy:
image: ${DOCKER_REGISTRY-}reverse-proxy
reverseproxy:
container_name: maksit-certs-ui-reverseproxy
image: ${DOCKER_REGISTRY-}maksit-certs-ui-reverseproxy
build:
context: .
dockerfile: ReverseProxy/Dockerfile
certs-ui-client:
image: ${DOCKER_REGISTRY-}certs-ui-client
client:
container_name: maksit-certs-ui-client
image: ${DOCKER_REGISTRY-}maksit-certs-ui-client
build:
context: .
dockerfile: MaksIT.WebUI/Dockerfile
certs-ui-server:
image: ${DOCKER_REGISTRY-}certs-ui-server
server:
container_name: maksit-certs-ui-server
image: ${DOCKER_REGISTRY-}maksit-certs-ui-server
build:
context: .
dockerfile: MaksIT.Webapi/Dockerfile