mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2026-05-16 04:48:12 +02:00
(feature): frontend update, docker compose services review, documentation
This commit is contained in:
parent
830f3e7d3e
commit
f70742cf18
76
README.md
76
README.md
@ -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 doc’s “Related” section for the sibling link. **Certs WebAPI** uses a **settings-backed** user store (not Vault’s 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 (Let’s 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:**
|
||||
|
||||
|
||||
217
assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md
Normal file
217
assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md
Normal 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 repo’s 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` |
|
||||
253
assets/docs/PATCH_DELTA_REFERENCE.md
Normal file
253
assets/docs/PATCH_DELTA_REFERENCE.md
Normal 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 API’s 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 0–3) |
|
||||
| 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*
|
||||
65
assets/docs/REVERSE_PROXY_ROUTING.md
Normal file
65
assets/docs/REVERSE_PROXY_ROUTING.md
Normal 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. Vault’s 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 (Let’s 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 Compose’s 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)
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MaksIT.Core" Version="1.5.9" />
|
||||
<PackageReference Include="MaksIT.Core" Version="1.6.5" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
1408
src/MaksIT.WebUI/package-lock.json
generated
1408
src/MaksIT.WebUI/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,17 +21,25 @@ const axiosInstance = axios.create({
|
||||
let isRefreshing = false
|
||||
let refreshPromise: Promise<unknown> | null = null
|
||||
|
||||
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
|
||||
store.dispatch(showLoader())
|
||||
// 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 = [
|
||||
GetApiRoute(ApiRoutes.identityLogin).route,
|
||||
GetApiRoute(ApiRoutes.identityRefresh).route
|
||||
]
|
||||
const excludeUrls = getExcludeUrls()
|
||||
|
||||
// Check if the URL is in the exclude list
|
||||
if (config.url && excludeUrls.includes(config.url)) {
|
||||
@ -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
|
||||
store.dispatch(hideLoader())
|
||||
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
|
||||
store.dispatch(hideLoader())
|
||||
// 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
|
||||
store.dispatch(hideLoader())
|
||||
|
||||
if (error.response) {
|
||||
const contentType = error.response.headers['content-type']
|
||||
async error => {
|
||||
const originalRequest = error.config
|
||||
|
||||
if (contentType && contentType.includes('application/problem+json')) {
|
||||
const problem = error.response.data as ProblemDetails
|
||||
addToast(`${problem.title}: ${problem.detail}`, 'error')
|
||||
}
|
||||
else if (error.response.status === 401) {
|
||||
const problem = error.response.data as ProblemDetails
|
||||
addToast(`${problem.title}: ${problem.detail}`, 'error')
|
||||
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 = 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 = 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
|
||||
}
|
||||
|
||||
@ -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,20 +23,27 @@ const Authorization: FC<AuthorizationProps> = (props) => {
|
||||
}, [identity])
|
||||
|
||||
useEffect(() => {
|
||||
// Load identity from local storage on mount
|
||||
dispatch(setIdentityFromLocalStorage())
|
||||
}, [dispatch])
|
||||
// Load identity from local storage on first mount
|
||||
if (!hydrated) {
|
||||
dispatch(setIdentityFromLocalStorage())
|
||||
}
|
||||
}, [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
|
||||
: <></>
|
||||
}
|
||||
|
||||
export { Authorization }
|
||||
export { Authorization }
|
||||
|
||||
@ -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,10 +344,20 @@ 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}>
|
||||
{col.filter({ columnId: col.id }, handleFilterChange)}
|
||||
<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 })
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'}
|
||||
|
||||
@ -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'}>
|
||||
<
|
||||
</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'}>
|
||||
>
|
||||
</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>
|
||||
@ -188,4 +190,4 @@ const DateTimePickerComponent: FC<DateTimePickerComponentProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export { DateTimePickerComponent }
|
||||
export { DateTimePickerComponent }
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -141,4 +138,4 @@ const SecretComponent: FC<PasswordGeneratorProps> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
export { SecretComponent }
|
||||
export { SecretComponent }
|
||||
|
||||
@ -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.
|
||||
const selectedOption = options.find((option) => option.value === value)
|
||||
const inputValue = showDropdown ? filterValue : (selectedOption?.label ?? '')
|
||||
|
||||
// Fetch the selected option when the parent provides only the id.
|
||||
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>)
|
||||
}
|
||||
if (value === '' || selectedOption) {
|
||||
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.
|
||||
}
|
||||
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) }}
|
||||
@ -218,4 +201,4 @@ const SelectBoxComponent: FC<SelectBoxComponentProps> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
export { SelectBoxComponent }
|
||||
export { SelectBoxComponent }
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
25
src/MaksIT.WebUI/src/components/editors/editorStyles.ts
Normal file
25
src/MaksIT.WebUI/src/components/editors/editorStyles.ts
Normal 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(' ')
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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)) {
|
||||
;(parentDelta as Delta<T>)[key] = formValue as Delta<T>[typeof key]
|
||||
setOp(parentDelta, key, formValue === null ? PatchOperation.RemoveField : PatchOperation.SetField)
|
||||
const isNullish = formValue === null || formValue === undefined
|
||||
if (!isNullish) {
|
||||
;(parentDelta as Delta<T>)[key] = formValue as Delta<T>[typeof key]
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
? false
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
: T extends Array<any>
|
||||
?
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
T extends Function
|
||||
? false
|
||||
: true
|
||||
: false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
: T extends Array<any>
|
||||
? false
|
||||
: true
|
||||
: 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
|
||||
@ -123,4 +113,4 @@ const useFormState = <T extends Record<string, any>>(props: UseFormStateProps<T>
|
||||
|
||||
export {
|
||||
useFormState
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,4 +19,7 @@ export enum PatchOperation {
|
||||
/// When you need to remove an item from a collection
|
||||
/// </summary>
|
||||
RemoveFromCollection,
|
||||
}
|
||||
}
|
||||
|
||||
/** Key for per-item collection operations in PATCH payloads. Must match backend Constants.CollectionItemOperation. */
|
||||
export const COLLECTION_ITEM_OPERATION = 'collectionItemOperation' as const
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -12,4 +14,4 @@ export const ProblemDetailsProto = (): ProblemDetails => ({
|
||||
detail: undefined,
|
||||
instance: undefined,
|
||||
extensions: {}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,16 +27,16 @@ 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(),
|
||||
twoFactorRecoveryCode: string().optional()
|
||||
}).superRefine(LoginRequestSchemaRefine)
|
||||
}).superRefine(LoginRequestSchemaRefine)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export interface RefreshTokenRequest {
|
||||
refreshToken: string
|
||||
force?: boolean
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
@ -32,15 +29,36 @@ interface Identity extends LoginResponse {
|
||||
}
|
||||
|
||||
interface IdentityState {
|
||||
identity: Identity | null
|
||||
showUserOffcanvas: boolean
|
||||
status: 'idle' | 'loading' | 'failed'
|
||||
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 [] = []
|
||||
@ -117,15 +139,15 @@ const enrichStateWithJwtContent = (token: string, identity: Identity) => {
|
||||
}
|
||||
})
|
||||
|
||||
identity.roles = identityRoles
|
||||
identity.roles = identityRoles
|
||||
}
|
||||
|
||||
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) {
|
||||
jwtAcls.splice(jwtAcls.indexOf('global:admin'), 1)
|
||||
@ -145,40 +167,52 @@ 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) => {
|
||||
builder
|
||||
|
||||
|
||||
// Login
|
||||
.addCase(login.pending, (state) => {
|
||||
state.status = 'loading'
|
||||
})
|
||||
.addCase(login.fulfilled, (state, action: PayloadAction<LoginResponse | undefined>) => {
|
||||
state.status = 'idle'
|
||||
if (action.payload) {
|
||||
state.identity = {
|
||||
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) => {
|
||||
@ -206,32 +240,34 @@ 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
|
||||
export default identitySlice.reducer
|
||||
|
||||
@ -10,7 +10,7 @@ export default defineConfig({
|
||||
],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: ['certs-ui-client'],
|
||||
allowedHosts: ['client'],
|
||||
watch: {
|
||||
usePolling: true,
|
||||
},
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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/" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user