Initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:8080
|
||||||
|
VITE_OAUTH_CLIENT_ID=stalwart-webui
|
||||||
|
#VITE_ACCESS_TOKEN=OPEN_SESAME
|
||||||
|
VITE_OAUTH_SCOPES=
|
||||||
|
|
||||||
|
# Dev-only debug logging (any non-empty value enables; only active when DEV=true)
|
||||||
|
VITE_DEBUG_JMAP=true
|
||||||
|
VITE_DEBUG_FORMS=true
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Build and Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- run: npm run typecheck
|
||||||
|
|
||||||
|
- run: npx eslint src/
|
||||||
|
|
||||||
|
- run: npx vitest run
|
||||||
|
|
||||||
|
- run: npm run build
|
||||||
|
|
||||||
|
- name: Create webui.zip
|
||||||
|
working-directory: dist
|
||||||
|
run: zip -r ../webui.zip .
|
||||||
|
|
||||||
|
- name: Upload release asset
|
||||||
|
uses: softprops/action-gh-release@v3
|
||||||
|
with:
|
||||||
|
files: webui.zip
|
||||||
+27
-19
@@ -1,21 +1,29 @@
|
|||||||
# Generated by Cargo
|
# Logs
|
||||||
# will have compiled files and executables
|
logs
|
||||||
debug
|
*.log
|
||||||
target
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
# These are backup files generated by rustfmt
|
node_modules
|
||||||
**/*.rs.bk
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
# Editor directories and files
|
||||||
*.pdb
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
# Generated by cargo mutants
|
.idea
|
||||||
# Contains mutation testing data
|
.DS_Store
|
||||||
**/mutants.out*/
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
# RustRover
|
*.njsproj
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
*.sln
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
*.sw?
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
.ignore
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
scripts/
|
||||||
#.idea/
|
*.md
|
||||||
|
!README.md
|
||||||
|
/SPEC-*
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 120
|
||||||
|
}
|
||||||
@@ -1,2 +1,149 @@
|
|||||||
# webui
|
<p align="center">
|
||||||
Stalwart WebUI
|
<a href="https://stalw.art">
|
||||||
|
<img src="./img/logo-red.svg" height="150">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 align="center">
|
||||||
|
Web-based User Interface for Stalwart 🛡️
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/stalwartlabs/webui/actions/workflows/build.yml"><img src="https://img.shields.io/github/actions/workflow/status/stalwartlabs/webui/build.yml?style=flat-square" alt="continuous integration"></a>
|
||||||
|
|
||||||
|
<a href="https://www.gnu.org/licenses/agpl-3.0"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?label=license&style=flat-square" alt="License: AGPL v3"></a>
|
||||||
|
|
||||||
|
<a href="https://stalw.art/docs/get-started/"><img src="https://img.shields.io/badge/read_the-docs-red?style=flat-square" alt="Documentation"></a>
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://mastodon.social/@stalwartlabs"><img src="https://img.shields.io/mastodon/follow/109929667531941122?style=flat-square&logo=mastodon&color=%236364ff&label=Follow%20on%20Mastodon" alt="Mastodon"></a>
|
||||||
|
|
||||||
|
<a href="https://twitter.com/stalwartlabs"><img src="https://img.shields.io/twitter/follow/stalwartlabs?style=flat-square&logo=x&label=Follow%20on%20Twitter" alt="Twitter"></a>
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://discord.gg/jtgtCNj66U"><img src="https://img.shields.io/discord/923615863037390889?label=Join%20Discord&logo=discord&style=flat-square" alt="Discord"></a>
|
||||||
|
|
||||||
|
<a href="https://matrix.to/#/#stalwart:matrix.org"><img src="https://img.shields.io/matrix/stalwartmail%3Amatrix.org?label=Join%20Matrix&logo=matrix&style=flat-square" alt="Matrix"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
**Stalwart WebUI** is schema-driven single-page application for administering [Stalwart](https://stalw.art). After authentication the panel fetches a JSON schema from the server and dynamically generates all forms, lists, navigation, and layouts from that schema. Nothing is hardcoded.
|
||||||
|
|
||||||
|
Key features:
|
||||||
|
|
||||||
|
- **Schema-driven UI**: All forms, lists, and navigation are generated from a JSON schema fetched from `/api/schema` after login. No object types, field names, or layouts are hardcoded.
|
||||||
|
- **JMAP protocol**: All data operations (queries, creates, updates, deletes, blob uploads) use JMAP (RFC 8620) with method chaining and result references.
|
||||||
|
- **Permission-aware**: Every button, link, field, and section respects the user's permissions. Elements the user cannot access are hidden.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<img src="./img/screencast-setup.gif">
|
||||||
|
|
||||||
|
## Get Started
|
||||||
|
|
||||||
|
Stalwart WebUI is included with Stalwart Mail Server, to install Stalwart Mail Server on your server by following the instructions for your platform:
|
||||||
|
|
||||||
|
- [Linux / MacOS](https://stalw.art/docs/install/linux)
|
||||||
|
- [Windows](https://stalw.art/docs/install/windows)
|
||||||
|
- [Docker](https://stalw.art/docs/install/docker)
|
||||||
|
|
||||||
|
All documentation is available at [stalw.art/docs/get-started](https://stalw.art/docs/get-started).
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
|
||||||
|
- Node.js 18 or later
|
||||||
|
- A running Stalwart instance (for JMAP API calls)
|
||||||
|
|
||||||
|
Install dependencies:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
Configuration is done through Vite environment variables. Copy or edit `.env.development` in the project root:
|
||||||
|
|
||||||
|
```
|
||||||
|
VITE_API_BASE_URL=http://localhost:443
|
||||||
|
VITE_OAUTH_CLIENT_ID=stalwart-webui
|
||||||
|
VITE_ACCESS_TOKEN=
|
||||||
|
VITE_OAUTH_SCOPES=
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `VITE_API_BASE_URL` | URL of the Stalwart server. Used for all API requests during development. In production builds (when empty or unset) requests are relative to the current origin. |
|
||||||
|
| `VITE_OAUTH_CLIENT_ID` | OAuth 2.0 client ID. Defaults to `stalwart-webui`. |
|
||||||
|
| `VITE_ACCESS_TOKEN` | When set, skips the OAuth flow entirely and uses this token for all requests. Useful for local development and testing. |
|
||||||
|
| `VITE_OAUTH_SCOPES` | Optional OAuth scopes. Omitted from the authorization request when empty. |
|
||||||
|
|
||||||
|
### Bypassing OAuth for development
|
||||||
|
|
||||||
|
Set `VITE_ACCESS_TOKEN` to a valid bearer token to skip the login page and go straight to the admin panel. You can obtain a token from the Stalwart server's token endpoint or use an API key:
|
||||||
|
|
||||||
|
```
|
||||||
|
VITE_ACCESS_TOKEN=your-bearer-token-here
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the dev server
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts Vite's development server with hot module replacement, typically at `http://localhost:5173`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run the unit tests (Vitest):
|
||||||
|
|
||||||
|
```
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tests in watch mode:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run test:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building for production
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs the TypeScript compiler followed by Vite's production build. Output
|
||||||
|
goes to the `dist/` directory.
|
||||||
|
|
||||||
|
To preview the production build locally:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
If you are having problems running Stalwart Mail Server, you found a bug or just have a question,
|
||||||
|
do not hesitate to reach us on [Github Discussions](https://github.com/stalwartlabs/mail-server/discussions),
|
||||||
|
[Reddit](https://www.reddit.com/r/stalwartlabs), [Discord](https://discord.gg/aVQr3jF8jd) or [Matrix](https://matrix.to/#/#stalwart:matrix.org).
|
||||||
|
Additionally you may purchase a subscription to obtain priority support from Stalwart Labs LLC
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is dual-licensed under the **GNU Affero General Public License v3.0** (AGPL-3.0; as published by the Free Software Foundation) and the **Stalwart Enterprise License v1 (SELv1)**:
|
||||||
|
|
||||||
|
- The [GNU Affero General Public License v3.0](./LICENSES/AGPL-3.0-only.txt) is a free software license that ensures your freedom to use, modify, and distribute the software, with the condition that any modified versions of the software must also be distributed under the same license.
|
||||||
|
- The [Stalwart Enterprise License v1 (SELv1)](./LICENSES/LicenseRef-SEL.txt) is a proprietary license designed for commercial use. It offers additional features and greater flexibility for businesses that do not wish to comply with the AGPL-3.0 license requirements.
|
||||||
|
|
||||||
|
Each file in this project contains a license notice at the top, indicating the applicable license(s). The license notice follows the [REUSE guidelines](https://reuse.software/) to ensure clarity and consistency. The full text of each license is available in the [LICENSES](./LICENSES/) directory.
|
||||||
|
|
||||||
|
## Copyright
|
||||||
|
|
||||||
|
Copyright (C) 2024, Stalwart Labs LLC
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
mkdir -p .ignore
|
||||||
|
rm -f .ignore/webui.zip
|
||||||
|
cd dist && zip -r ../.ignore/webui.zip .
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import prettierConfig from 'eslint-config-prettier'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
prettierConfig,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/components/ui/**/*.{ts,tsx}'],
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" style="enable-background:new 0 0 680.5 252.1;" xml:space="preserve" viewBox="99.5 84.68 481.62 82.75">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#100E42;}
|
||||||
|
.st1{fill:#DB2D54;}
|
||||||
|
.st2{fill:#FFFFFF;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path class="st1" d="M227.8,143.6c0.3,4.2,2.1,7.6,5.1,10.1c3.1,2.5,7.1,3.8,12.1,3.8c4.3,0,7.9-0.9,10.5-2.8c2.7-1.9,4-4.5,4-7.8 c0-2.4-0.7-4.3-2.2-5.7c-1.5-1.4-3.4-2.5-6-3.2c-2.5-0.7-6-1.5-10.6-2.3c-4.6-0.8-8.6-1.9-11.9-3.2c-3.3-1.3-6-3.3-8.1-6.1 c-2.1-2.7-3.1-6.3-3.1-10.7c0-4.1,1.1-7.7,3.2-10.9c2.1-3.2,5.1-5.7,9-7.4c3.8-1.8,8.2-2.6,13.2-2.6c5.1,0,9.6,1,13.7,2.9 c4,1.9,7.2,4.5,9.5,7.8c2.3,3.3,3.6,7.1,3.8,11.4h-11.5c-0.4-3.7-2-6.6-4.8-8.9c-2.8-2.2-6.3-3.4-10.6-3.4c-4.1,0-7.5,0.9-9.9,2.7 c-2.5,1.8-3.7,4.3-3.7,7.6c0,2.3,0.7,4.1,2.2,5.5c1.5,1.4,3.4,2.4,5.9,3.1c2.4,0.7,5.9,1.4,10.5,2.2c4.6,0.8,8.6,1.9,11.9,3.3 c3.3,1.4,6,3.4,8.2,6c2.1,2.6,3.2,6.1,3.2,10.5c0,4.2-1.1,8-3.4,11.3c-2.2,3.3-5.4,5.9-9.4,7.8c-4,1.9-8.6,2.8-13.7,2.8 c-5.6,0-10.6-1-14.9-3.1c-4.3-2-7.6-4.9-10-8.5c-2.4-3.6-3.7-7.8-3.7-12.5L227.8,143.6z"/>
|
||||||
|
<path class="st1" d="M278.5,102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0,2,0.4,3.5,1.2,4.3c0.8,0.9,2.2,1.3,4.2,1.3h8.4v9.7h-10.6 c-5,0-8.6-1.2-10.8-3.5c-2.2-2.3-3.4-5.9-3.4-10.7V102.1z"/>
|
||||||
|
<path class="st1" d="M356.8,114.6v52.2h-9.7l-1.2-7.9c-1.8,2.6-4.2,4.7-7,6.2c-2.9,1.6-6.2,2.3-10,2.3c-4.8,0-9-1.1-12.7-3.2 c-3.7-2.1-6.7-5.2-8.8-9.3c-2.1-4-3.2-8.8-3.2-14.2c0-5.3,1.1-10,3.2-14c2.1-4,5.1-7.2,8.8-9.4c3.7-2.2,7.9-3.3,12.6-3.3 c3.9,0,7.2,0.7,10.1,2.2c2.9,1.5,5.2,3.5,6.9,6.1l1.3-7.6H356.8z M341.7,153.3c2.8-3.2,4.2-7.3,4.2-12.4c0-5.2-1.4-9.4-4.2-12.6 c-2.8-3.3-6.5-4.9-11-4.9c-4.6,0-8.2,1.6-11,4.8c-2.8,3.2-4.2,7.4-4.2,12.5c0,5.2,1.4,9.4,4.2,12.6c2.8,3.2,6.5,4.8,11,4.8 C335.2,158.1,338.9,156.5,341.7,153.3z"/>
|
||||||
|
<path class="st1" d="M365.5,97.5l11-2.1v71.3h-11V97.5z"/>
|
||||||
|
<path class="st1" d="M380.3,114.6h11.6l11.9,39.9l11.9-39.9h10.1l11.4,39.9l12.3-39.9h11.2l-17.3,52.2h-11.8l-11-35.5l-11.4,35.5 l-11.9,0.1L380.3,114.6z"/>
|
||||||
|
<path class="st1" d="M513.7,114.6v52.2H504l-1.2-7.9c-1.8,2.6-4.2,4.7-7,6.2c-2.9,1.6-6.2,2.3-10,2.3c-4.8,0-9-1.1-12.7-3.2 c-3.7-2.1-6.7-5.2-8.8-9.3c-2.1-4-3.2-8.8-3.2-14.2c0-5.3,1.1-10,3.2-14c2.1-4,5.1-7.2,8.8-9.4c3.7-2.2,7.9-3.3,12.6-3.3 c3.9,0,7.2,0.7,10.1,2.2c2.9,1.5,5.2,3.5,6.9,6.1l1.3-7.6H513.7z M498.6,153.3c2.8-3.2,4.2-7.3,4.2-12.4c0-5.2-1.4-9.4-4.2-12.6 c-2.8-3.3-6.5-4.9-11-4.9c-4.6,0-8.2,1.6-11,4.8c-2.8,3.2-4.2,7.4-4.2,12.5c0,5.2,1.4,9.4,4.2,12.6c2.8,3.2,6.5,4.8,11,4.8 C492.2,158.1,495.8,156.5,498.6,153.3z"/>
|
||||||
|
<path class="st1" d="M551.3,114.6v10.3h-4.9c-4.6,0-7.8,1.5-9.9,4.4c-2,3-3.1,6.7-3.1,11.3v26.2h-11v-52.2h9.8l1.2,7.8 c1.5-2.4,3.4-4.4,5.8-5.8c2.4-1.4,5.6-2.1,9.6-2.1H551.3z"/>
|
||||||
|
<path class="st1" d="M556.3,102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0,2,0.4,3.5,1.2,4.3c0.8,0.9,2.2,1.3,4.2,1.3h8.4v9.7h-10.6 c-5,0-8.6-1.2-10.8-3.5s-3.4-5.9-3.4-10.7V102.1z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st1" d="M149.1,84.7h-4.8l-44.8,25.9v8.3l44.8,25.9h4.8l44.8-25.9v-8.3L149.1,84.7z M182,114.7h-35.3V94.4L182,114.7z M146.7,135.1l-35.3-20.4l27-15.6v15.6v4.1v0.5l6.3,3.6h22.9L146.7,135.1z"/>
|
||||||
|
<polygon class="st1" points="99.5,129.9 99.5,140.9 144.3,166.8 149.1,166.8 193.9,140.9 193.9,129.9 146.7,157.2 "/>
|
||||||
|
<polygon class="st1" points="187.3,166.8 193.9,163 193.9,152 168.2,166.8 "/>
|
||||||
|
<polygon class="st1" points="99.5,163 106.1,166.8 125.2,166.8 99.5,152 "/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 260 KiB |
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<base href="/" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Portal</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Generated
+7160
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"name": "stalwart-webui",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Stalwart WebUI",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"typecheck": "tsc -p tsconfig.app.json --noEmit",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"format": "prettier --write src/",
|
||||||
|
"format:check": "prettier --check src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"i18next": "^26.0.4",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
|
"otpauth": "^9.5.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-i18next": "^17.0.2",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router-dom": "^7.14.0",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"prettier": "^3.8.2",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.0",
|
||||||
|
"vite": "^8.0.4",
|
||||||
|
"vitest": "^4.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
+18
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||||
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Outlet />
|
||||||
|
<Toaster />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { ArrowLeft, ArrowRight, Check, Copy, Loader2, Rocket } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
import { FieldWidget } from '@/components/forms/FieldWidget';
|
||||||
|
import { FormEditionContext } from '@/components/forms/FormEditionContext';
|
||||||
|
import { DefaultLogo } from '@/components/common/Logo';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { resolveObject, resolveSchema, resolveForm, buildCreateDefaults, deepMerge } from '@/lib/schemaResolver';
|
||||||
|
import { calculateJmapPatch } from '@/lib/jmapPatch';
|
||||||
|
import { jmapGet, jmapSet, getAccountId } from '@/services/jmap/client';
|
||||||
|
import { friendlySetError } from '@/lib/jmapErrors';
|
||||||
|
|
||||||
|
import type { Field, Fields, Form, FormField } from '@/types/schema';
|
||||||
|
import type { JmapSetError, JmapSetResponse } from '@/types/jmap';
|
||||||
|
|
||||||
|
const BOOTSTRAP_VIEW = 'x:Bootstrap';
|
||||||
|
|
||||||
|
interface RenderableField {
|
||||||
|
formField: FormField;
|
||||||
|
field: Field;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BootstrapWizard() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
|
|
||||||
|
const resolved = useMemo(() => {
|
||||||
|
if (!schema) return null;
|
||||||
|
const obj = resolveObject(schema, BOOTSTRAP_VIEW);
|
||||||
|
if (!obj) return null;
|
||||||
|
const sch = resolveSchema(schema, obj.objectName);
|
||||||
|
if (!sch || sch.type !== 'single') return null;
|
||||||
|
const form = resolveForm(schema, BOOTSTRAP_VIEW, obj.objectName, sch.schemaName);
|
||||||
|
return { obj, sch, fields: sch.fields, form };
|
||||||
|
}, [schema]);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||||
|
const [originalData, setOriginalData] = useState<Record<string, unknown>>({});
|
||||||
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
const [generalError, setGeneralError] = useState<string | null>(null);
|
||||||
|
const [successExtras, setSuccessExtras] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const sections = useMemo((): { title?: string; subtitle?: string; fields: RenderableField[] }[] => {
|
||||||
|
if (!resolved) return [];
|
||||||
|
const { fields, form } = resolved;
|
||||||
|
if (!form) return [];
|
||||||
|
return form.sections
|
||||||
|
.map((section) => {
|
||||||
|
const renderableFields: RenderableField[] = [];
|
||||||
|
for (const ff of section.fields) {
|
||||||
|
const field = fields.properties[ff.name];
|
||||||
|
if (!field) continue;
|
||||||
|
if (field.update === 'serverSet') continue;
|
||||||
|
if (field.enterprise) continue;
|
||||||
|
renderableFields.push({ formField: ff, field });
|
||||||
|
}
|
||||||
|
return { title: section.title, fields: renderableFields };
|
||||||
|
})
|
||||||
|
.filter((s) => s.fields.length > 0);
|
||||||
|
}, [resolved]);
|
||||||
|
|
||||||
|
const fetchProperties = useMemo((): string[] => {
|
||||||
|
if (!resolved) return ['id'];
|
||||||
|
const set = new Set<string>(['id']);
|
||||||
|
for (const section of sections) {
|
||||||
|
for (const rf of section.fields) set.add(rf.formField.name);
|
||||||
|
}
|
||||||
|
return Array.from(set);
|
||||||
|
}, [resolved, sections]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!schema || !resolved) return;
|
||||||
|
const { obj, sch } = resolved;
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const accountId = getAccountId(obj.objectName);
|
||||||
|
const responses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties, ctrl.signal);
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
|
||||||
|
const server = (responses[0]?.[1]?.list as Array<Record<string, unknown>> | undefined)?.[0] ?? {};
|
||||||
|
const defaults = buildCreateDefaults(schema, obj, sch);
|
||||||
|
const seeded = deepMerge(defaults, server);
|
||||||
|
setFormData(seeded);
|
||||||
|
setOriginalData({});
|
||||||
|
} catch (err) {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||||
|
setGeneralError(
|
||||||
|
err instanceof Error ? err.message : t('bootstrap.failedToLoad', 'Failed to load bootstrap state.'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ctrl.abort();
|
||||||
|
};
|
||||||
|
}, [schema, resolved, fetchProperties, t]);
|
||||||
|
|
||||||
|
const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||||
|
setFieldErrors((prev) => {
|
||||||
|
if (!prev[fieldName]) return prev;
|
||||||
|
const copy = { ...prev };
|
||||||
|
delete copy[fieldName];
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const validateSectionRequired = useCallback(
|
||||||
|
(sectionFields: RenderableField[], data: Record<string, unknown>): Record<string, string> => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
for (const { formField, field } of sectionFields) {
|
||||||
|
if (field.update === 'serverSet' || field.update === 'immutable') continue;
|
||||||
|
const fieldType = field.type;
|
||||||
|
const eligible =
|
||||||
|
fieldType.type === 'string' ||
|
||||||
|
fieldType.type === 'number' ||
|
||||||
|
fieldType.type === 'utcDateTime' ||
|
||||||
|
fieldType.type === 'enum' ||
|
||||||
|
fieldType.type === 'blobId' ||
|
||||||
|
fieldType.type === 'objectId';
|
||||||
|
if (!eligible) continue;
|
||||||
|
if ('nullable' in fieldType && fieldType.nullable) continue;
|
||||||
|
const value = data[formField.name];
|
||||||
|
const isEmpty = value === undefined || value === null || (typeof value === 'string' && value === '');
|
||||||
|
if (isEmpty) errors[formField.name] = t('form.required', 'This field is required.');
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applySetError = useCallback(
|
||||||
|
(error: JmapSetError): number | null => {
|
||||||
|
setGeneralError(friendlySetError(error));
|
||||||
|
|
||||||
|
const fieldToPage = new Map<string, number>();
|
||||||
|
sections.forEach((section, idx) => {
|
||||||
|
for (const rf of section.fields) {
|
||||||
|
if (!fieldToPage.has(rf.formField.name)) fieldToPage.set(rf.formField.name, idx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
let earliest: number | null = null;
|
||||||
|
const record = (topLevel: string, msg: string) => {
|
||||||
|
if (!fieldToPage.has(topLevel)) return;
|
||||||
|
newErrors[topLevel] = msg;
|
||||||
|
const idx = fieldToPage.get(topLevel)!;
|
||||||
|
if (earliest === null || idx < earliest) earliest = idx;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error.properties) {
|
||||||
|
for (const prop of error.properties) {
|
||||||
|
const top = prop.split('/')[0];
|
||||||
|
record(top, error.description ?? t('form.invalidValue', 'Invalid value.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error.validationErrors) {
|
||||||
|
for (const ve of error.validationErrors) {
|
||||||
|
const top = ve.property?.split('/')[0] ?? '';
|
||||||
|
if (!top) continue;
|
||||||
|
const msg =
|
||||||
|
ve.type === 'Required'
|
||||||
|
? t('form.required', 'This field is required.')
|
||||||
|
: ve.type === 'MaxLength'
|
||||||
|
? t('form.maxLengthIs', 'Maximum length is {{max}}.', { max: ve.required })
|
||||||
|
: ve.type === 'MinLength'
|
||||||
|
? t('form.minLengthIs', 'Minimum length is {{min}}.', { min: ve.required })
|
||||||
|
: ve.type === 'MaxValue'
|
||||||
|
? t('form.maxValueIs', 'Maximum value is {{max}}.', { max: ve.required })
|
||||||
|
: ve.type === 'MinValue'
|
||||||
|
? t('form.minValueIs', 'Minimum value is {{min}}.', { min: ve.required })
|
||||||
|
: t('form.invalidValue', 'Invalid value.');
|
||||||
|
record(top, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(newErrors).length > 0) setFieldErrors(newErrors);
|
||||||
|
return earliest;
|
||||||
|
},
|
||||||
|
[sections, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canGoBack = currentPage > 0;
|
||||||
|
const isLastPage = currentPage === sections.length - 1;
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
const current = sections[currentPage];
|
||||||
|
if (!current) return;
|
||||||
|
const errs = validateSectionRequired(current.fields, formData);
|
||||||
|
if (Object.keys(errs).length > 0) {
|
||||||
|
setFieldErrors(errs);
|
||||||
|
setGeneralError(t('form.correctErrorsBelow', 'Please correct the errors below.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGeneralError(null);
|
||||||
|
setFieldErrors({});
|
||||||
|
setCurrentPage((p) => Math.min(p + 1, sections.length - 1));
|
||||||
|
}, [currentPage, sections, formData, validateSectionRequired, t]);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
setGeneralError(null);
|
||||||
|
setFieldErrors({});
|
||||||
|
setCurrentPage((p) => Math.max(p - 1, 0));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!resolved) return;
|
||||||
|
const { obj } = resolved;
|
||||||
|
|
||||||
|
let firstInvalid: number | null = null;
|
||||||
|
const allErrors: Record<string, string> = {};
|
||||||
|
sections.forEach((section, idx) => {
|
||||||
|
const errs = validateSectionRequired(section.fields, formData);
|
||||||
|
for (const [k, v] of Object.entries(errs)) {
|
||||||
|
allErrors[k] = v;
|
||||||
|
if (firstInvalid === null) firstInvalid = idx;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (firstInvalid !== null) {
|
||||||
|
setFieldErrors(allErrors);
|
||||||
|
setGeneralError(t('form.correctErrorsBelow', 'Please correct the errors below.'));
|
||||||
|
setCurrentPage(firstInvalid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setGeneralError(null);
|
||||||
|
setFieldErrors({});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const patch = calculateJmapPatch(originalData, formData);
|
||||||
|
|
||||||
|
const accountId = getAccountId(obj.objectName);
|
||||||
|
const responses = await jmapSet(obj.objectName, accountId, {
|
||||||
|
update: { singleton: patch },
|
||||||
|
});
|
||||||
|
const setResult = responses[responses.length - 1]?.[1] as unknown as JmapSetResponse;
|
||||||
|
|
||||||
|
if (setResult.updated && 'singleton' in setResult.updated) {
|
||||||
|
setSuccessExtras(setResult.updated.singleton ?? {});
|
||||||
|
} else if (setResult.notUpdated && setResult.notUpdated.singleton) {
|
||||||
|
const earliest = applySetError(setResult.notUpdated.singleton);
|
||||||
|
if (earliest !== null) setCurrentPage(earliest);
|
||||||
|
} else {
|
||||||
|
setGeneralError(t('bootstrap.noConfirm', 'The server did not confirm the update.'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setGeneralError(
|
||||||
|
err instanceof Error ? err.message : t('bootstrap.failedToComplete', 'Failed to complete setup.'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [resolved, sections, formData, originalData, validateSectionRequired, applySetError, t]);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(
|
||||||
|
async (key: string, value: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(key);
|
||||||
|
setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: t('bootstrap.copyFailed', 'Copy failed'),
|
||||||
|
description: t('bootstrap.clipboardBlocked', 'Your browser blocked clipboard access.'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!schema || !resolved) {
|
||||||
|
return (
|
||||||
|
<WizardShell>
|
||||||
|
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||||
|
{t('form.loadingSchema', 'Loading schema...')}
|
||||||
|
</div>
|
||||||
|
</WizardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<WizardShell>
|
||||||
|
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||||
|
{t('bootstrap.loadingSetup', 'Loading setup...')}
|
||||||
|
</div>
|
||||||
|
</WizardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successExtras !== null) {
|
||||||
|
return (
|
||||||
|
<WizardShell>
|
||||||
|
<SuccessScreen
|
||||||
|
extras={successExtras}
|
||||||
|
fields={resolved.fields}
|
||||||
|
labelsByName={labelsByFieldName(resolved.form)}
|
||||||
|
onCopy={handleCopy}
|
||||||
|
copied={copied}
|
||||||
|
/>
|
||||||
|
</WizardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sections.length === 0) {
|
||||||
|
return (
|
||||||
|
<WizardShell>
|
||||||
|
<div className="rounded-md border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
{t('bootstrap.emptyForm', 'Setup form is empty. The server did not return any bootstrap fields.')}
|
||||||
|
</div>
|
||||||
|
</WizardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = sections[currentPage];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WizardShell>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.welcome', 'Welcome to Stalwart')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{t('bootstrap.welcomeSubtitle', "Let's get your server set up.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProgressIndicator total={sections.length} current={currentPage} />
|
||||||
|
|
||||||
|
{generalError && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
|
||||||
|
<p className="text-sm text-destructive whitespace-pre-line">{generalError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
{current.title && (
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{current.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent className={current.title ? '' : 'pt-6'}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<FormEditionContext.Provider value="oss">
|
||||||
|
{current.fields.map(({ formField, field }) => (
|
||||||
|
<FieldWidget
|
||||||
|
key={formField.name}
|
||||||
|
field={field}
|
||||||
|
formField={formField}
|
||||||
|
value={formData[formField.name]}
|
||||||
|
onChange={(v) => handleFieldChange(formField.name, v)}
|
||||||
|
readOnly={saving}
|
||||||
|
error={fieldErrors[formField.name]}
|
||||||
|
schema={schema}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</FormEditionContext.Provider>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<Button type="button" variant="outline" onClick={handleBack} disabled={!canGoBack || saving}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
{t('common.back', 'Back')}
|
||||||
|
</Button>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t('bootstrap.stepOf', 'Step {{current}} of {{total}}', {
|
||||||
|
current: currentPage + 1,
|
||||||
|
total: sections.length,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{isLastPage ? (
|
||||||
|
<Button type="button" onClick={handleSubmit} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Rocket className="h-4 w-4 mr-2" />}
|
||||||
|
{t('bootstrap.finishSetup', 'Finish setup')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="button" onClick={handleNext} disabled={saving}>
|
||||||
|
{t('common.next', 'Next')}
|
||||||
|
<ArrowRight className="h-4 w-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WizardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WizardShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-content-background">
|
||||||
|
<header className="flex items-center px-6 py-4 border-b bg-background">
|
||||||
|
<DefaultLogo />
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 overflow-auto p-6">
|
||||||
|
<div className="mx-auto max-w-3xl">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressIndicator({ total, current }: { total: number; current: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
aria-label={t('bootstrap.stepOf', 'Step {{current}} of {{total}}', { current: current + 1, total })}
|
||||||
|
>
|
||||||
|
{Array.from({ length: total }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-2 w-2 rounded-full transition-colors ${
|
||||||
|
i < current ? 'bg-primary' : i === current ? 'bg-primary' : 'bg-muted-foreground/20'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuccessScreen({
|
||||||
|
extras,
|
||||||
|
fields,
|
||||||
|
labelsByName,
|
||||||
|
onCopy,
|
||||||
|
copied,
|
||||||
|
}: {
|
||||||
|
extras: Record<string, unknown>;
|
||||||
|
fields: Fields;
|
||||||
|
labelsByName: Map<string, string>;
|
||||||
|
onCopy: (key: string, value: string) => void;
|
||||||
|
copied: string | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const entries = Object.entries(extras).filter(([k, v]) => {
|
||||||
|
if (k === 'id' || k === 'blobId' || k === '@type') return false;
|
||||||
|
return typeof v === 'string' && v.length > 0;
|
||||||
|
}) as [string, string][];
|
||||||
|
|
||||||
|
const hasCredentials = entries.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<Check className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.complete', 'Setup complete')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{hasCredentials
|
||||||
|
? t(
|
||||||
|
'bootstrap.credentialsCreated',
|
||||||
|
'Your administrator account has been created. Write these down now: the password will not be shown again.',
|
||||||
|
)
|
||||||
|
: t('bootstrap.configuredSuccessfully', 'Stalwart has been configured successfully.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasCredentials && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 space-y-4">
|
||||||
|
{entries.map(([key, value]) => {
|
||||||
|
const field = fields.properties[key];
|
||||||
|
const label = labelsByName.get(key) ?? field?.description?.split('\n')[0] ?? key;
|
||||||
|
return (
|
||||||
|
<div key={key} className="space-y-1.5">
|
||||||
|
<div className="text-sm font-medium">{label}</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<code className="flex-1 rounded bg-muted p-2 text-sm break-all select-all">{value}</code>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => onCopy(key, value)}>
|
||||||
|
{copied === key ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-md border border-primary/20 bg-primary/5 p-4">
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="font-medium">{t('bootstrap.nextStepLabel', 'Next step:')}</span>{' '}
|
||||||
|
{t(
|
||||||
|
'bootstrap.nextStepBody',
|
||||||
|
'restart Stalwart for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelsByFieldName(form: Form | null): Map<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
if (!form) return map;
|
||||||
|
for (const section of form.sections) {
|
||||||
|
for (const ff of section.fields) map.set(ff.name, ff.label);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BootstrapWizard;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface EnterpriseUpsellProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnterpriseUpsell({ open, onClose }: EnterpriseUpsellProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('enterprise.trialTitle')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('enterprise.trialDescription')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<a href="https://license.stalw.art/trial" target="_blank" rel="noopener noreferrer">
|
||||||
|
{t('enterprise.trialButton')}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Search, List, Settings, Plus } from 'lucide-react';
|
||||||
|
import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore';
|
||||||
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
|
import { resolveObject } from '@/lib/schemaResolver';
|
||||||
|
import type { Schema } from '@/types/schema';
|
||||||
|
|
||||||
|
const MAX_RESULTS = 15;
|
||||||
|
|
||||||
|
const TYPE_ORDER: Record<SearchIndexEntry['type'], number> = {
|
||||||
|
link: 0,
|
||||||
|
form: 1,
|
||||||
|
field: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getObjectKind(schema: Schema, viewName: string): 'singleton' | 'object' | null {
|
||||||
|
const resolved = resolveObject(schema, viewName);
|
||||||
|
if (!resolved) return null;
|
||||||
|
return resolved.objectType.type === 'singleton' ? 'singleton' : 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionInfo(
|
||||||
|
entryType: SearchIndexEntry['type'],
|
||||||
|
objectKind: 'singleton' | 'object' | null,
|
||||||
|
t: (key: string, fallback: string) => string,
|
||||||
|
): { label: string; Icon: typeof List } {
|
||||||
|
if (entryType === 'link') {
|
||||||
|
return objectKind === 'singleton'
|
||||||
|
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
|
||||||
|
: { label: t('globalSearch.list', 'List'), Icon: List };
|
||||||
|
}
|
||||||
|
return objectKind === 'singleton'
|
||||||
|
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
|
||||||
|
: { label: t('globalSearch.create', 'Create'), Icon: Plus };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNavigationPath(
|
||||||
|
entryType: SearchIndexEntry['type'],
|
||||||
|
objectKind: 'singleton' | 'object' | null,
|
||||||
|
section: string,
|
||||||
|
viewName: string,
|
||||||
|
): string {
|
||||||
|
const encodedView = viewName;
|
||||||
|
if (entryType === 'link') {
|
||||||
|
return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}`;
|
||||||
|
}
|
||||||
|
return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}/new`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyName(viewName: string): string {
|
||||||
|
const stripped = viewName.replace(/^x:/, '');
|
||||||
|
const parts = stripped.split('/');
|
||||||
|
return parts[parts.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobalSearch() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const GROUP_LABELS: Record<SearchIndexEntry['type'], string> = {
|
||||||
|
link: t('globalSearch.pages', 'Pages'),
|
||||||
|
form: t('globalSearch.formSections', 'Form Sections'),
|
||||||
|
field: t('globalSearch.fields', 'Fields'),
|
||||||
|
};
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
|
const searchIndex = useSchemaStore((s) => s.searchIndex);
|
||||||
|
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||||
|
|
||||||
|
const handleQueryChange = useCallback((value: string) => {
|
||||||
|
setQuery(value);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => setDebouncedQuery(value), 300);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
if (!debouncedQuery.trim() || !schema) return [];
|
||||||
|
|
||||||
|
const tokens = debouncedQuery
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
if (tokens.length === 0) return [];
|
||||||
|
|
||||||
|
const filtered = searchIndex.filter((entry) => {
|
||||||
|
const haystack = (entry.text + ' ' + (entry.keywords?.join(' ') ?? '')).toLowerCase();
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (!haystack.includes(token)) return false;
|
||||||
|
}
|
||||||
|
const resolved = resolveObject(schema, entry.viewName);
|
||||||
|
if (!resolved) return false;
|
||||||
|
return hasObjectPermission(resolved.permissionPrefix, 'Get');
|
||||||
|
});
|
||||||
|
|
||||||
|
filtered.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
|
||||||
|
return filtered.slice(0, MAX_RESULTS);
|
||||||
|
}, [debouncedQuery, searchIndex, schema, hasObjectPermission]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const map = new Map<SearchIndexEntry['type'], SearchIndexEntry[]>();
|
||||||
|
for (const entry of results) {
|
||||||
|
const arr = map.get(entry.type);
|
||||||
|
if (arr) arr.push(entry);
|
||||||
|
else map.set(entry.type, [entry]);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(entry: SearchIndexEntry) => {
|
||||||
|
if (!schema) return;
|
||||||
|
const objectKind = getObjectKind(schema, entry.viewName);
|
||||||
|
const path = getNavigationPath(entry.type, objectKind, entry.section, entry.viewName);
|
||||||
|
setDropdownOpen(false);
|
||||||
|
setQuery('');
|
||||||
|
setDebouncedQuery('');
|
||||||
|
navigate(path);
|
||||||
|
},
|
||||||
|
[schema, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (!dropdownOpen || results.length === 0) return;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => (i + 1) % results.length);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => (i - 1 + results.length) % results.length);
|
||||||
|
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelect(results[activeIndex]);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dropdownOpen, results, activeIndex, handleSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showDropdown = dropdownOpen && debouncedQuery.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center px-4" ref={containerRef}>
|
||||||
|
<div className="relative w-full max-w-md">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleQueryChange(e.target.value);
|
||||||
|
setDropdownOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
if (query.trim()) setDropdownOpen(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 pl-9 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showDropdown && (
|
||||||
|
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||||
|
{t('globalSearch.noResults', 'No results found.')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-80 overflow-y-auto py-1">
|
||||||
|
{Array.from(groups.entries()).map(([type, entries]) => (
|
||||||
|
<div key={type}>
|
||||||
|
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{GROUP_LABELS[type]}</div>
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const flatIdx = results.indexOf(entry);
|
||||||
|
const objectKind = schema ? getObjectKind(schema, entry.viewName) : null;
|
||||||
|
const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${type}-${entry.viewName}-${flatIdx}`}
|
||||||
|
type="button"
|
||||||
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent ${
|
||||||
|
flatIdx === activeIndex ? 'bg-accent' : ''
|
||||||
|
}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelect(entry);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setActiveIndex(flatIdx)}
|
||||||
|
>
|
||||||
|
<ActionIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">{actionLabel}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { getApiBaseUrl } from '@/services/api';
|
||||||
|
|
||||||
|
export function DefaultLogo() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="95 84 500 90"
|
||||||
|
aria-label={t('logo.stalwartAlt', 'Stalwart Logo')}
|
||||||
|
className="h-7 w-auto max-w-[320px]"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
className="fill-current"
|
||||||
|
d="M227.8 143.6c.3 4.2 2.1 7.6 5.1 10.1 3.1 2.5 7.1 3.8 12.1 3.8 4.3 0 7.9-.9 10.5-2.8 2.7-1.9 4-4.5 4-7.8 0-2.4-.7-4.3-2.2-5.7-1.5-1.4-3.4-2.5-6-3.2-2.5-.7-6-1.5-10.6-2.3-4.6-.8-8.6-1.9-11.9-3.2-3.3-1.3-6-3.3-8.1-6.1-2.1-2.7-3.1-6.3-3.1-10.7 0-4.1 1.1-7.7 3.2-10.9s5.1-5.7 9-7.4c3.8-1.8 8.2-2.6 13.2-2.6 5.1 0 9.6 1 13.7 2.9 4 1.9 7.2 4.5 9.5 7.8s3.6 7.1 3.8 11.4h-11.5c-.4-3.7-2-6.6-4.8-8.9-2.8-2.2-6.3-3.4-10.6-3.4-4.1 0-7.5.9-9.9 2.7-2.5 1.8-3.7 4.3-3.7 7.6 0 2.3.7 4.1 2.2 5.5 1.5 1.4 3.4 2.4 5.9 3.1 2.4.7 5.9 1.4 10.5 2.2 4.6.8 8.6 1.9 11.9 3.3 3.3 1.4 6 3.4 8.2 6 2.1 2.6 3.2 6.1 3.2 10.5 0 4.2-1.1 8-3.4 11.3-2.2 3.3-5.4 5.9-9.4 7.8-4 1.9-8.6 2.8-13.7 2.8-5.6 0-10.6-1-14.9-3.1-4.3-2-7.6-4.9-10-8.5-2.4-3.6-3.7-7.8-3.7-12.5l11.5.3zM278.5 102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0 2 .4 3.5 1.2 4.3.8.9 2.2 1.3 4.2 1.3h8.4v9.7h-10.6c-5 0-8.6-1.2-10.8-3.5-2.2-2.3-3.4-5.9-3.4-10.7v-50.5zM356.8 114.6v52.2h-9.7l-1.2-7.9c-1.8 2.6-4.2 4.7-7 6.2-2.9 1.6-6.2 2.3-10 2.3-4.8 0-9-1.1-12.7-3.2-3.7-2.1-6.7-5.2-8.8-9.3-2.1-4-3.2-8.8-3.2-14.2 0-5.3 1.1-10 3.2-14s5.1-7.2 8.8-9.4c3.7-2.2 7.9-3.3 12.6-3.3 3.9 0 7.2.7 10.1 2.2 2.9 1.5 5.2 3.5 6.9 6.1l1.3-7.6h9.7zm-15.1 38.7c2.8-3.2 4.2-7.3 4.2-12.4 0-5.2-1.4-9.4-4.2-12.6-2.8-3.3-6.5-4.9-11-4.9-4.6 0-8.2 1.6-11 4.8-2.8 3.2-4.2 7.4-4.2 12.5 0 5.2 1.4 9.4 4.2 12.6 2.8 3.2 6.5 4.8 11 4.8s8.2-1.6 11-4.8zM365.5 97.5l11-2.1v71.3h-11V97.5zM380.3 114.6h11.6l11.9 39.9 11.9-39.9h10.1l11.4 39.9 12.3-39.9h11.2l-17.3 52.2h-11.8l-11-35.5-11.4 35.5-11.9.1-17-52.3zM513.7 114.6v52.2H504l-1.2-7.9c-1.8 2.6-4.2 4.7-7 6.2-2.9 1.6-6.2 2.3-10 2.3-4.8 0-9-1.1-12.7-3.2-3.7-2.1-6.7-5.2-8.8-9.3-2.1-4-3.2-8.8-3.2-14.2 0-5.3 1.1-10 3.2-14s5.1-7.2 8.8-9.4c3.7-2.2 7.9-3.3 12.6-3.3 3.9 0 7.2.7 10.1 2.2 2.9 1.5 5.2 3.5 6.9 6.1l1.3-7.6h9.7zm-15.1 38.7c2.8-3.2 4.2-7.3 4.2-12.4 0-5.2-1.4-9.4-4.2-12.6-2.8-3.3-6.5-4.9-11-4.9-4.6 0-8.2 1.6-11 4.8-2.8 3.2-4.2 7.4-4.2 12.5 0 5.2 1.4 9.4 4.2 12.6 2.8 3.2 6.5 4.8 11 4.8 4.6 0 8.2-1.6 11-4.8zM551.3 114.6v10.3h-4.9c-4.6 0-7.8 1.5-9.9 4.4-2 3-3.1 6.7-3.1 11.3v26.2h-11v-52.2h9.8l1.2 7.8c1.5-2.4 3.4-4.4 5.8-5.8 2.4-1.4 5.6-2.1 9.6-2.1h2.5zM556.3 102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0 2 .4 3.5 1.2 4.3.8.9 2.2 1.3 4.2 1.3h8.4v9.7h-10.6c-5 0-8.6-1.2-10.8-3.5s-3.4-5.9-3.4-10.7v-50.5z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#db2d54"
|
||||||
|
d="M149.1 84.7h-4.8l-44.8 25.9v8.3l44.8 25.9h4.8l44.8-25.9v-8.3l-44.8-25.9zm32.9 30h-35.3V94.4l35.3 20.3zm-35.3 20.4-35.3-20.4 27-15.6v20.2l6.3 3.6h22.9l-20.9 12.2zM99.5 129.9v11l44.8 25.9h4.8l44.8-25.9v-11l-47.2 27.3zM187.3 166.8l6.6-3.8v-11l-25.7 14.8zM99.5 163l6.6 3.8h19.1L99.5 152z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Logo() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function fetchLogo() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getApiBaseUrl()}/logo`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
|
||||||
|
if (response.ok && contentType.startsWith('image/')) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setLogoUrl(url);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!controller.signal.aborted) setFailed(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!controller.signal.aborted) setFailed(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchLogo();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (logoUrl) {
|
||||||
|
URL.revokeObjectURL(logoUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [logoUrl]);
|
||||||
|
|
||||||
|
if (logoUrl && !failed) {
|
||||||
|
return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DefaultLogo />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Loader2, Search, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
|
import { useObjectList, useObjectLabel, useNoPermissionMessage } from '@/lib/objectOptions';
|
||||||
|
import type { Schema } from '@/types/schema';
|
||||||
|
|
||||||
|
interface ObjectPickerProps {
|
||||||
|
schema: Schema;
|
||||||
|
objectName: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
onClear?: () => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObjectPicker({ schema, objectName, value, onChange, onClear, placeholder }: ObjectPickerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const list = useObjectList(objectName, schema);
|
||||||
|
const fromList = list.options.find((o) => o.id === value)?.label;
|
||||||
|
const { label: cheapLabel, loading: labelLoading } = useObjectLabel(
|
||||||
|
objectName,
|
||||||
|
fromList ? null : value || null,
|
||||||
|
schema,
|
||||||
|
);
|
||||||
|
const display = fromList ?? cheapLabel;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||||
|
const noPermissionMessage = useNoPermissionMessage(schema, objectName);
|
||||||
|
|
||||||
|
const handleOpenChange = (next: boolean) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (next) void list.ensureLoaded();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{value && (
|
||||||
|
<Badge variant="secondary" className="gap-1 pr-1.5 text-sm">
|
||||||
|
{labelLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : (display ?? value)}
|
||||||
|
{onClear && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5"
|
||||||
|
aria-label={t('common.clear', 'Clear')}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{!value && placeholder && <span className="text-sm text-muted-foreground">{placeholder}</span>}
|
||||||
|
{noPermissionMessage ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 cursor-not-allowed opacity-60"
|
||||||
|
aria-label={t('common.search', 'Search')}
|
||||||
|
aria-disabled="true"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setTooltipOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{noPermissionMessage}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
aria-label={t('common.search', 'Search')}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0 w-72" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={t('common.searchPlaceholder', 'Search...')} />
|
||||||
|
<CommandList>
|
||||||
|
{list.loading && (
|
||||||
|
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{t('common.loading', 'Loading...')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!list.loading && <CommandEmpty>{t('common.noResultsDot', 'No results.')}</CommandEmpty>}
|
||||||
|
<CommandGroup>
|
||||||
|
{list.options.map((opt) => (
|
||||||
|
<CommandItem
|
||||||
|
key={opt.id}
|
||||||
|
value={opt.id}
|
||||||
|
keywords={[opt.label]}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(opt.id);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
|
import { X, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
function BufferedExprInput({
|
||||||
|
value,
|
||||||
|
onCommit,
|
||||||
|
...rest
|
||||||
|
}: { value: string; onCommit: (v: string) => void } & Omit<
|
||||||
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
'onChange' | 'value'
|
||||||
|
>) {
|
||||||
|
const [local, setLocal] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocal(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
if (local !== value) onCommit(local);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
{...rest}
|
||||||
|
value={local}
|
||||||
|
onChange={(e) => setLocal(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') commit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExpressionEditorProps {
|
||||||
|
value: {
|
||||||
|
match: Record<string, { if: string; then: string }>;
|
||||||
|
else: string;
|
||||||
|
};
|
||||||
|
onChange: (value: { match: Record<string, { if: string; then: string }>; else: string }) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpressionEditor({ value, onChange, readOnly = false }: ExpressionEditorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [nextIndex, setNextIndex] = useState(() => {
|
||||||
|
const keys = Object.keys(value.match).map(Number).filter(Number.isFinite);
|
||||||
|
return keys.length > 0 ? Math.max(...keys) + 1 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchEntries = Object.entries(value.match);
|
||||||
|
|
||||||
|
const handleConditionChange = (key: string, field: 'if' | 'then', fieldValue: string) => {
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
match: {
|
||||||
|
...value.match,
|
||||||
|
[key]: { ...value.match[key], [field]: fieldValue },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleElseChange = (elseValue: string) => {
|
||||||
|
onChange({ ...value, else: elseValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const key = String(nextIndex);
|
||||||
|
setNextIndex(nextIndex + 1);
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
match: { ...value.match, [key]: { if: '', then: '' } },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (key: string) => {
|
||||||
|
const { [key]: _removed, ...rest } = value.match;
|
||||||
|
void _removed;
|
||||||
|
onChange({ ...value, match: rest });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||||
|
{matchEntries.map(([key, entry], index) => (
|
||||||
|
<div key={key}>
|
||||||
|
<div className="rounded-md border bg-muted/30 p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground w-12 shrink-0">
|
||||||
|
{t('expression.if', 'IF')}
|
||||||
|
</span>
|
||||||
|
<BufferedExprInput
|
||||||
|
value={entry.if}
|
||||||
|
onCommit={(v) => handleConditionChange(key, 'if', v)}
|
||||||
|
disabled={readOnly}
|
||||||
|
placeholder={t('expression.condition', 'Condition')}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
{!readOnly && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => handleRemove(key)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground w-12 shrink-0">
|
||||||
|
{t('expression.then', 'THEN')}
|
||||||
|
</span>
|
||||||
|
<BufferedExprInput
|
||||||
|
value={entry.then}
|
||||||
|
onCommit={(v) => handleConditionChange(key, 'then', v)}
|
||||||
|
disabled={readOnly}
|
||||||
|
placeholder={t('expression.result', 'Result')}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
{!readOnly && <div className="w-8 shrink-0" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{index < matchEntries.length - 1 && <Separator className="my-3" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
{t('expression.addCondition', 'Add condition')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{matchEntries.length > 0 && <Separator />}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{matchEntries.length > 0 && (
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground w-12 shrink-0">
|
||||||
|
{t('expression.else', 'ELSE')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<BufferedExprInput
|
||||||
|
value={value.else}
|
||||||
|
onCommit={handleElseChange}
|
||||||
|
disabled={readOnly}
|
||||||
|
placeholder={
|
||||||
|
matchEntries.length > 0 ? t('expression.defaultValue', 'Default value') : t('expression.value', 'Value')
|
||||||
|
}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
{!readOnly && matchEntries.length > 0 && <div className="w-8 shrink-0" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
|
|
||||||
|
export type EditionValue = 'enterprise' | 'community' | 'oss';
|
||||||
|
|
||||||
|
export const FormEditionContext = createContext<EditionValue | null>(null);
|
||||||
|
|
||||||
|
export function useEffectiveEdition(): EditionValue {
|
||||||
|
const override = useContext(FormEditionContext);
|
||||||
|
const storeEdition = useAccountStore((s) => s.edition);
|
||||||
|
return override ?? storeEdition;
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import * as OTPAuth from 'otpauth';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import { Loader2, ShieldCheck, ShieldOff } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
interface OtpAuthValue {
|
||||||
|
otpUrl?: string | null;
|
||||||
|
otpCode?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OtpAuthFieldProps {
|
||||||
|
value: unknown;
|
||||||
|
onChange: (value: unknown) => void;
|
||||||
|
readOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECRET_MASK = '*****';
|
||||||
|
|
||||||
|
const STALWART_IMAGE_URL = 'https://stalw.art/img/favicon-32x32.png';
|
||||||
|
|
||||||
|
function buildOtpAuthUrl(totp: OTPAuth.TOTP): string {
|
||||||
|
const base = totp.toString();
|
||||||
|
const sep = base.includes('?') ? '&' : '?';
|
||||||
|
return `${base}${sep}image=${encodeURIComponent(STALWART_IMAGE_URL)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTotp(): { totp: OTPAuth.TOTP; url: string } {
|
||||||
|
const totp = new OTPAuth.TOTP({
|
||||||
|
issuer: 'Stalwart',
|
||||||
|
label: 'account',
|
||||||
|
algorithm: 'SHA1',
|
||||||
|
digits: 6,
|
||||||
|
period: 30,
|
||||||
|
secret: new OTPAuth.Secret({ size: 20 }),
|
||||||
|
});
|
||||||
|
return { totp, url: buildOtpAuthUrl(totp) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const objValue = (value as OtpAuthValue | null | undefined) ?? {};
|
||||||
|
const isConfigured = objValue.otpUrl != null && objValue.otpUrl !== '';
|
||||||
|
|
||||||
|
const [setupTotp, setSetupTotp] = useState<OTPAuth.TOTP | null>(null);
|
||||||
|
const [setupUrl, setSetupUrl] = useState<string | null>(null);
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||||
|
const [setupCode, setSetupCode] = useState('');
|
||||||
|
const [setupError, setSetupError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!setupUrl) return;
|
||||||
|
let cancelled = false;
|
||||||
|
QRCode.toDataURL(setupUrl, { width: 220, margin: 1 })
|
||||||
|
.then((dataUrl) => {
|
||||||
|
if (!cancelled) setQrDataUrl(dataUrl);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to render QR code', err);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [setupUrl]);
|
||||||
|
|
||||||
|
const startSetup = () => {
|
||||||
|
const { totp, url } = generateTotp();
|
||||||
|
setSetupTotp(totp);
|
||||||
|
setSetupUrl(url);
|
||||||
|
setSetupCode('');
|
||||||
|
setSetupError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmSetup = () => {
|
||||||
|
if (!setupTotp || !setupUrl) return;
|
||||||
|
if (!setupCode.trim()) {
|
||||||
|
setSetupError(t('otp.enterCodePrompt', 'Enter the code shown in your authenticator.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delta = setupTotp.validate({ token: setupCode.trim(), window: 1 });
|
||||||
|
if (delta === null) {
|
||||||
|
setSetupError(t('otp.codeIncorrect', 'That code is incorrect. Make sure your authenticator clock is in sync.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSetupError(null);
|
||||||
|
onChange({ otpUrl: setupUrl, otpCode: setupCode.trim() });
|
||||||
|
setSetupTotp(null);
|
||||||
|
setSetupUrl(null);
|
||||||
|
setSetupCode('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelSetup = () => {
|
||||||
|
setSetupTotp(null);
|
||||||
|
setSetupUrl(null);
|
||||||
|
setSetupCode('');
|
||||||
|
setSetupError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const otpCodeValue = useMemo(
|
||||||
|
() => (typeof objValue.otpCode === 'string' && objValue.otpCode !== SECRET_MASK ? objValue.otpCode : ''),
|
||||||
|
[objValue.otpCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCodeChange = (code: string) => {
|
||||||
|
onChange({ ...objValue, otpCode: code });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisable = () => {
|
||||||
|
onChange({ ...objValue, otpUrl: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (readOnly) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{isConfigured
|
||||||
|
? t('otp.statusEnabled', 'Two-factor authentication is enabled.')
|
||||||
|
: t('otp.statusDisabled', 'Two-factor authentication is not enabled.')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConfigured && setupUrl) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-muted/30 p-4 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t('otp.scanPrompt', 'Scan with your authenticator app')}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
'otp.scanDescription',
|
||||||
|
'Scan the QR code below with Google Authenticator, 1Password, Authy, or any other TOTP app, then enter the 6-digit code it shows to confirm setup.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{qrDataUrl ? (
|
||||||
|
<img
|
||||||
|
src={qrDataUrl}
|
||||||
|
alt={t('otp.qrCodeAlt', 'TOTP QR code')}
|
||||||
|
width={220}
|
||||||
|
height={220}
|
||||||
|
className="rounded-md border bg-white p-2"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[220px] w-[220px] items-center justify-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm font-medium">{t('otp.confirmationCodeLabel', 'Confirmation code')}</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="123456"
|
||||||
|
value={setupCode}
|
||||||
|
onChange={(e) => setSetupCode(e.target.value.replace(/\s+/g, ''))}
|
||||||
|
maxLength={10}
|
||||||
|
className="font-mono tracking-widest text-center max-w-[12rem]"
|
||||||
|
/>
|
||||||
|
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={cancelSetup}>
|
||||||
|
{t('common.cancel', 'Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" onClick={confirmSetup}>
|
||||||
|
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||||
|
{t('common.confirm', 'Confirm')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConfigured) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t('otp.statusDisabled', 'Two-factor authentication is not enabled.')}
|
||||||
|
</span>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={startSetup}>
|
||||||
|
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||||
|
{t('otp.setUp', 'Set up TOTP')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-muted/30 p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="text-sm font-medium">{t('otp.statusEnabled', 'Two-factor authentication is enabled.')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
'otp.currentCodePrompt',
|
||||||
|
'Enter your current 6-digit code to authorise any change to this account (including disabling two-factor authentication).',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm font-medium">{t('otp.currentCodeLabel', 'Current code')}</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="123456"
|
||||||
|
value={otpCodeValue}
|
||||||
|
onChange={(e) => handleCodeChange(e.target.value.replace(/\s+/g, ''))}
|
||||||
|
maxLength={10}
|
||||||
|
className="font-mono tracking-widest text-center max-w-[12rem]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button type="button" variant="destructive" size="sm" onClick={handleDisable} disabled={!otpCodeValue.trim()}>
|
||||||
|
<ShieldOff className="mr-2 h-4 w-4" />
|
||||||
|
{t('otp.disable', 'Disable')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('[ErrorBoundary] Caught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="alert" className="flex flex-col items-center justify-center gap-4 p-8">
|
||||||
|
<div className="text-destructive text-lg font-semibold">
|
||||||
|
{i18n.t('errorBoundary.title', 'Something went wrong')}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm max-w-md text-center">
|
||||||
|
{this.state.error?.message ?? i18n.t('errors.unexpectedError', 'An unexpected error occurred.')}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={this.handleReset}>
|
||||||
|
{i18n.t('errorBoundary.tryAgain', 'Try Again')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
import { useCacheStore } from '@/stores/cacheStore';
|
||||||
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
|
import { resolveObject } from '@/lib/schemaResolver';
|
||||||
|
import { DynamicList } from '@/components/lists/DynamicList';
|
||||||
|
import { DynamicForm } from '@/components/forms/DynamicForm';
|
||||||
|
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
|
||||||
|
import { DashboardView } from '@/features/dashboard/components/DashboardView';
|
||||||
|
import { DeliveryTracePage } from '@/features/troubleshoot/DeliveryTracePage';
|
||||||
|
import { LiveTracingPage } from '@/features/tracing/components/LiveTracingPage';
|
||||||
|
import { TraceDetailView } from '@/features/tracing/components/TraceDetailView';
|
||||||
|
import { ActionPage } from '@/features/actions/ActionPage';
|
||||||
|
|
||||||
|
interface MainContentProps {
|
||||||
|
viewName?: string;
|
||||||
|
id?: string;
|
||||||
|
section?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MainContent({ viewName, id, section }: MainContentProps) {
|
||||||
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
|
const invalidateAllObjectLists = useCacheStore((s) => s.invalidateAllObjectLists);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invalidateAllObjectLists();
|
||||||
|
}, [viewName, invalidateAllObjectLists]);
|
||||||
|
|
||||||
|
if (!viewName) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8 text-muted-foreground">Select a view from the sidebar.</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewName.startsWith('Dashboard/')) {
|
||||||
|
const dashboardId = viewName.slice('Dashboard/'.length);
|
||||||
|
return <DashboardView dashboardId={dashboardId} section={section ?? ''} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewName.startsWith('CustomComponent/')) {
|
||||||
|
const componentName = viewName.slice('CustomComponent/'.length);
|
||||||
|
if (componentName === 'Dashboard') {
|
||||||
|
const firstId = schema?.dashboards?.[0]?.id ?? 'overview';
|
||||||
|
return <DashboardView dashboardId={firstId} section={section ?? ''} />;
|
||||||
|
}
|
||||||
|
if (componentName === 'LiveDelivery') {
|
||||||
|
return <DeliveryTracePage />;
|
||||||
|
}
|
||||||
|
if (componentName === 'LiveTracing') {
|
||||||
|
return <LiveTracingPage />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||||
|
Unknown component: {componentName}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema) {
|
||||||
|
return <div className="flex items-center justify-center p-8 text-muted-foreground">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveObject(schema, viewName);
|
||||||
|
if (!resolved) {
|
||||||
|
return <div className="flex items-center justify-center p-8 text-destructive">Unknown view: {viewName}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved.objectName === 'x:Action') {
|
||||||
|
return <ActionPage viewName={viewName} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved.objectType.type === 'singleton') {
|
||||||
|
return <DynamicForm viewName={viewName} objectId="singleton" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === 'new') {
|
||||||
|
return <DynamicForm viewName={viewName} objectId={null} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
if (resolved.objectName === 'x:Trace' && id !== 'new') {
|
||||||
|
return <TraceDetailView viewName={viewName} objectId={id} />;
|
||||||
|
}
|
||||||
|
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
|
||||||
|
if (!canUpdate) {
|
||||||
|
return <DynamicViewPage viewName={viewName} objectId={id} />;
|
||||||
|
}
|
||||||
|
return <DynamicForm viewName={viewName} objectId={id} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DynamicList viewName={viewName} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
|
||||||
|
const location = useLocation();
|
||||||
|
if (!accessToken && !bypassToken) {
|
||||||
|
const originalPath = location.pathname + location.search;
|
||||||
|
return <Navigate to="/login" replace state={{ from: originalPath }} />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import * as LucideIcons from 'lucide-react';
|
||||||
|
const { ChevronDown, Lock } = LucideIcons;
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
import {
|
||||||
|
visibleLayouts,
|
||||||
|
findFirstVisibleLinkInLayout,
|
||||||
|
findFirstAccessibleLinkInLayout,
|
||||||
|
isLinkEnterprise,
|
||||||
|
isLinkVisible,
|
||||||
|
} from '@/lib/layout';
|
||||||
|
import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema';
|
||||||
|
|
||||||
|
function LucideIcon({ name, className }: { name: string; className?: string }) {
|
||||||
|
const formatted = name
|
||||||
|
.split('-')
|
||||||
|
.map((s) => s[0].toUpperCase() + s.slice(1))
|
||||||
|
.join('');
|
||||||
|
const IconComp = (LucideIcons as Record<string, unknown>)[formatted] as LucideIcons.LucideIcon | undefined;
|
||||||
|
if (!IconComp) return <LucideIcons.Circle className={className} />;
|
||||||
|
return <IconComp className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveViewPath(sectionName: string, viewName: string): string {
|
||||||
|
return `/${sectionName}/${viewName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathMatchesView(currentPath: string, sectionName: string, viewName: string): boolean {
|
||||||
|
const base = `/${sectionName}/${viewName}`;
|
||||||
|
if (currentPath === base || currentPath.startsWith(`${base}/`)) return true;
|
||||||
|
if (viewName === 'CustomComponent/Dashboard') {
|
||||||
|
const dashBase = `/${sectionName}/Dashboard/`;
|
||||||
|
return currentPath.startsWith(dashBase);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtreeContainsActive(items: LayoutSubItem[], currentPath: string, sectionName: string): boolean {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type === 'link') {
|
||||||
|
if (pathMatchesView(currentPath, sectionName, item.viewName)) return true;
|
||||||
|
} else if (item.type === 'container') {
|
||||||
|
if (subtreeContainsActive(item.items, currentPath, sectionName)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type === 'link') {
|
||||||
|
if (!checkLinkVisible(item.viewName)) continue;
|
||||||
|
const enterprise = checkIsEnterprise(item.viewName);
|
||||||
|
if (enterprise && edition === 'oss') continue;
|
||||||
|
return true;
|
||||||
|
} else if (item.type === 'container') {
|
||||||
|
if (subtreeHasVisibleLink(item.items, edition)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkLinkVisible(viewName: string): boolean {
|
||||||
|
const schema = useSchemaStore.getState().schema;
|
||||||
|
if (!schema) return true;
|
||||||
|
|
||||||
|
const accountStore = useAccountStore.getState();
|
||||||
|
return isLinkVisible(
|
||||||
|
schema,
|
||||||
|
viewName,
|
||||||
|
accountStore.edition,
|
||||||
|
(prefix: string) => accountStore.hasObjectPermission(prefix, 'Get'),
|
||||||
|
(perm: string) => accountStore.hasPermission(perm),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkIsEnterprise(viewName: string): boolean {
|
||||||
|
const schema = useSchemaStore.getState().schema;
|
||||||
|
if (!schema) return false;
|
||||||
|
const edition = useAccountStore.getState().edition;
|
||||||
|
return isLinkEnterprise(schema, viewName, edition);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarSubItemProps {
|
||||||
|
item: LayoutSubItem;
|
||||||
|
depth: number;
|
||||||
|
sectionName: string;
|
||||||
|
currentPath: string;
|
||||||
|
navigate: ReturnType<typeof useNavigate>;
|
||||||
|
edition: string;
|
||||||
|
onUpsell: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: SidebarSubItemProps) {
|
||||||
|
if (item.type === 'link') {
|
||||||
|
if (!checkLinkVisible(item.viewName)) return null;
|
||||||
|
|
||||||
|
const path = resolveViewPath(sectionName, item.viewName);
|
||||||
|
const isActive = pathMatchesView(currentPath, sectionName, item.viewName);
|
||||||
|
const enterprise = checkIsEnterprise(item.viewName);
|
||||||
|
const isLocked = enterprise && edition === 'community';
|
||||||
|
const isHidden = enterprise && edition === 'oss';
|
||||||
|
|
||||||
|
if (isHidden) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start gap-2 font-normal',
|
||||||
|
isActive && 'bg-accent text-accent-foreground',
|
||||||
|
depth > 0 && 'text-sm',
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
||||||
|
onClick={() => {
|
||||||
|
if (isLocked) {
|
||||||
|
onUpsell();
|
||||||
|
} else {
|
||||||
|
navigate(path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="truncate">{item.name || 'Overview'}</span>
|
||||||
|
{isLocked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'container') {
|
||||||
|
if (!subtreeHasVisibleLink(item.items, edition)) return null;
|
||||||
|
|
||||||
|
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
|
||||||
|
return (
|
||||||
|
<Collapsible defaultOpen={containsActive}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-start gap-2 font-normal text-sm"
|
||||||
|
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
|
||||||
|
<span className="truncate">{item.name}</span>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
{item.items.map((sub) => (
|
||||||
|
<SidebarSubItem
|
||||||
|
key={sub.type === 'link' ? sub.viewName : sub.name}
|
||||||
|
item={sub}
|
||||||
|
depth={depth + 1}
|
||||||
|
sectionName={sectionName}
|
||||||
|
currentPath={currentPath}
|
||||||
|
navigate={navigate}
|
||||||
|
edition={edition}
|
||||||
|
onUpsell={onUpsell}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarTopItemProps {
|
||||||
|
item: LayoutItem;
|
||||||
|
sectionName: string;
|
||||||
|
currentPath: string;
|
||||||
|
navigate: ReturnType<typeof useNavigate>;
|
||||||
|
edition: string;
|
||||||
|
onUpsell: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: SidebarTopItemProps) {
|
||||||
|
if ('link' in item) {
|
||||||
|
const { name, icon, viewName } = item.link;
|
||||||
|
|
||||||
|
if (!checkLinkVisible(viewName)) return null;
|
||||||
|
|
||||||
|
const path = resolveViewPath(sectionName, viewName);
|
||||||
|
const isActive = pathMatchesView(currentPath, sectionName, viewName);
|
||||||
|
const enterprise = checkIsEnterprise(viewName);
|
||||||
|
const isLocked = enterprise && edition === 'community';
|
||||||
|
const isHidden = enterprise && edition === 'oss';
|
||||||
|
|
||||||
|
if (isHidden) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
|
||||||
|
onClick={() => {
|
||||||
|
if (isLocked) {
|
||||||
|
onUpsell();
|
||||||
|
} else {
|
||||||
|
navigate(path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
{isLocked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('container' in item) {
|
||||||
|
const { name, icon, items } = item.container;
|
||||||
|
if (!subtreeHasVisibleLink(items, edition)) return null;
|
||||||
|
|
||||||
|
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible defaultOpen={containsActive}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
|
||||||
|
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
<ChevronDown className="ml-auto h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
{items.map((sub) => (
|
||||||
|
<SidebarSubItem
|
||||||
|
key={sub.type === 'link' ? sub.viewName : sub.name}
|
||||||
|
item={sub}
|
||||||
|
depth={1}
|
||||||
|
sectionName={sectionName}
|
||||||
|
currentPath={currentPath}
|
||||||
|
navigate={navigate}
|
||||||
|
edition={edition}
|
||||||
|
onUpsell={onUpsell}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const activeSection = useUIStore((s) => s.activeSection);
|
||||||
|
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||||
|
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||||
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
|
const edition = useAccountStore((s) => s.edition);
|
||||||
|
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||||
|
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||||
|
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||||
|
|
||||||
|
const layouts = useMemo(
|
||||||
|
() =>
|
||||||
|
schema ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) : [],
|
||||||
|
[schema, edition, hasObjectPermission, hasPermission],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!schema) return;
|
||||||
|
if (layouts.length === 0) return;
|
||||||
|
if (!layouts.find((l) => l.name === activeSection)) {
|
||||||
|
setActiveSection(layouts[0].name);
|
||||||
|
}
|
||||||
|
}, [schema, layouts, activeSection, setActiveSection]);
|
||||||
|
|
||||||
|
if (!sidebarOpen || !schema) return null;
|
||||||
|
|
||||||
|
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
|
||||||
|
if (!layout) return null;
|
||||||
|
|
||||||
|
const handleSectionClick = (target: Layout) => {
|
||||||
|
setActiveSection(target.name);
|
||||||
|
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
||||||
|
const first =
|
||||||
|
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
|
||||||
|
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
|
||||||
|
if (first) navigate(`/${target.name}/${first}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="fixed top-14 left-0 bottom-0 z-30 hidden w-64 flex-col border-r bg-background md:flex">
|
||||||
|
<ScrollArea className="flex-1 py-2">
|
||||||
|
<nav className="flex flex-col gap-0.5 px-2">
|
||||||
|
{layout.items.map((item) => (
|
||||||
|
<SidebarTopItem
|
||||||
|
key={'link' in item ? item.link.viewName : item.container.name}
|
||||||
|
item={item}
|
||||||
|
sectionName={layout.name}
|
||||||
|
currentPath={location.pathname}
|
||||||
|
navigate={navigate}
|
||||||
|
edition={edition}
|
||||||
|
onUpsell={() => setUpsellOpen(true)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{layouts.length > 1 && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex items-center justify-around border-t bg-background px-2 py-2">
|
||||||
|
{layouts.map((target) => {
|
||||||
|
const Icon = (LucideIcons as Record<string, unknown>)[
|
||||||
|
target.icon
|
||||||
|
.split('-')
|
||||||
|
.map((s) => s[0].toUpperCase() + s.slice(1))
|
||||||
|
.join('')
|
||||||
|
] as LucideIcons.LucideIcon | undefined;
|
||||||
|
const isActive = target.name === activeSection;
|
||||||
|
return (
|
||||||
|
<Tooltip key={target.name}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={target.name}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
onClick={() => handleSectionClick(target)}
|
||||||
|
className={cn('h-9 w-9', isActive && 'bg-accent text-accent-foreground')}
|
||||||
|
>
|
||||||
|
{Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.Circle className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">{target.name}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import * as LucideIcons from 'lucide-react';
|
||||||
|
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles } = LucideIcons;
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { GlobalSearch } from '@/components/common/GlobalSearch';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import Logo from '@/components/common/Logo';
|
||||||
|
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||||
|
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
|
||||||
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
|
||||||
|
function getIcon(name: string): LucideIcons.LucideIcon {
|
||||||
|
const formatted = name
|
||||||
|
.split('-')
|
||||||
|
.map((s) => s[0].toUpperCase() + s.slice(1))
|
||||||
|
.join('');
|
||||||
|
return ((LucideIcons as Record<string, unknown>)[formatted] as LucideIcons.LucideIcon) || LucideIcons.Circle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const theme = useUIStore((s) => s.theme);
|
||||||
|
const toggleTheme = useUIStore((s) => s.toggleTheme);
|
||||||
|
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||||
|
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||||
|
const accounts = useAuthStore((s) => s.accounts);
|
||||||
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
|
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const edition = useAccountStore((s) => s.edition);
|
||||||
|
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||||
|
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||||
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
|
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||||
|
|
||||||
|
const navigableLayouts = schema
|
||||||
|
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-40 flex h-14 items-center gap-4 border-b bg-background px-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="md:hidden">
|
||||||
|
<Menu className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Link to="/" className="flex shrink-0 items-center">
|
||||||
|
<Logo />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<GlobalSearch />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{edition !== 'enterprise' && <EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />}
|
||||||
|
|
||||||
|
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
|
||||||
|
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" aria-label={t('userMenu', 'User menu')}>
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
{schema && navigableLayouts.length > 0 && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel>{t('sections', 'Sections')}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
{navigableLayouts.map((layout) => {
|
||||||
|
const Icon = getIcon(layout.icon);
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={layout.name}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveSection(layout.name);
|
||||||
|
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
||||||
|
const firstLink =
|
||||||
|
findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPermission) ??
|
||||||
|
findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPermission);
|
||||||
|
if (firstLink) {
|
||||||
|
navigate(`/${layout.name}/${firstLink}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon className="mr-2 h-4 w-4" />
|
||||||
|
{layout.name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Object.keys(accounts).length > 0 && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel>{t('accounts', 'Accounts')}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
{Object.entries(accounts).map(([id, info]) => (
|
||||||
|
<DropdownMenuItem key={id} onClick={() => switchAccount(id)}>
|
||||||
|
{id === activeAccountId && <Check className="mr-2 h-4 w-4" />}
|
||||||
|
<span className={id !== activeAccountId ? 'ml-6' : ''}>{info.name || id}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{edition !== 'enterprise' && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem onClick={() => setUpsellOpen(true)}>
|
||||||
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
|
{t('tryEnterprise', 'Try Enterprise')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
{t('logout', 'Logout')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root;
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
));
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||||
|
);
|
||||||
|
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||||
|
);
|
||||||
|
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||||
|
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...props} />
|
||||||
|
));
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />,
|
||||||
|
);
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Tooltip as RechartsTooltip } from 'recharts';
|
||||||
|
import type { TooltipPayload } from 'recharts/types/state/tooltipSlice';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export const CHART_COLORS = [
|
||||||
|
'hsl(var(--chart-1))',
|
||||||
|
'hsl(var(--chart-2))',
|
||||||
|
'hsl(var(--chart-3))',
|
||||||
|
'hsl(var(--chart-4))',
|
||||||
|
'hsl(var(--chart-5))',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function getChartColor(index: number): string {
|
||||||
|
return CHART_COLORS[index % CHART_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartContainer({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||||
|
return <div className={cn('w-full', className)}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
formatter,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: TooltipPayload;
|
||||||
|
label?: string;
|
||||||
|
formatter?: (value: number) => string;
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-background p-2 shadow-md">
|
||||||
|
{label && <p className="mb-1 text-xs text-muted-foreground">{label}</p>}
|
||||||
|
{payload.map((entry, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-sm">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: entry.color ?? 'currentColor' }}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">{entry.name ?? ''}</span>
|
||||||
|
<span className="ml-auto font-medium">
|
||||||
|
{formatter && typeof entry.value === 'number' ? formatter(entry.value) : String(entry.value ?? '')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RechartsTooltip };
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
));
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||||
|
|
||||||
|
const Collapsible = CollapsiblePrimitive.Root;
|
||||||
|
|
||||||
|
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||||
|
|
||||||
|
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { ChevronsUpDown, Check, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
|
||||||
|
export interface ComboboxOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
marker?: ReactNode;
|
||||||
|
keywords?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComboboxProps {
|
||||||
|
options: ComboboxOption[];
|
||||||
|
value: string;
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
emptyText?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
contentClassName?: string;
|
||||||
|
nullable?: boolean;
|
||||||
|
nullLabel?: string;
|
||||||
|
onFirstOpen?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Combobox({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
placeholder = 'Select...',
|
||||||
|
searchPlaceholder = 'Search...',
|
||||||
|
emptyText = 'No matches.',
|
||||||
|
disabled,
|
||||||
|
contentClassName,
|
||||||
|
nullable,
|
||||||
|
nullLabel = 'None',
|
||||||
|
onFirstOpen,
|
||||||
|
className,
|
||||||
|
}: ComboboxProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [triggerWidth, setTriggerWidth] = useState<number | undefined>(undefined);
|
||||||
|
const firstOpenRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && triggerRef.current) {
|
||||||
|
setTriggerWidth(triggerRef.current.offsetWidth);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleOpenChange = (next: boolean) => {
|
||||||
|
if (next && !firstOpenRef.current) {
|
||||||
|
firstOpenRef.current = true;
|
||||||
|
onFirstOpen?.();
|
||||||
|
}
|
||||||
|
setOpen(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selected = options.find((o) => o.value === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn('w-full justify-between font-normal', !selected && 'text-muted-foreground', className)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 truncate">
|
||||||
|
{selected?.marker}
|
||||||
|
{selected?.label ?? placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className={cn('p-0', contentClassName)}
|
||||||
|
align="start"
|
||||||
|
style={triggerWidth ? { width: triggerWidth } : undefined}
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={searchPlaceholder} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{nullable && (
|
||||||
|
<CommandItem
|
||||||
|
value="__null__"
|
||||||
|
onSelect={() => {
|
||||||
|
onValueChange('');
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="mr-2 h-4 w-4 opacity-50" />
|
||||||
|
<span className="text-muted-foreground">{nullLabel}</span>
|
||||||
|
</CommandItem>
|
||||||
|
)}
|
||||||
|
{options.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
keywords={[option.label, ...(option.keywords ?? [])]}
|
||||||
|
onSelect={() => {
|
||||||
|
onValueChange(option.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={cn('mr-2 h-4 w-4', option.value === value ? 'opacity-100' : 'opacity-0')} />
|
||||||
|
<span className="flex flex-1 items-center gap-2 truncate">
|
||||||
|
{option.marker}
|
||||||
|
<span className="flex flex-col truncate">
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
{option.description && (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { type DialogProps } from '@radix-ui/react-dialog';
|
||||||
|
import { Command as CommandPrimitive } from 'cmdk';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Command.displayName = CommandPrimitive.displayName;
|
||||||
|
|
||||||
|
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogContent className="overflow-hidden p-0">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator ref={ref} className={cn('-mx-1 h-px bg-border', className)} {...props} />
|
||||||
|
));
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return <span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />;
|
||||||
|
};
|
||||||
|
CommandShortcut.displayName = 'CommandShortcut';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = 'DialogHeader';
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = 'DialogFooter';
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
|
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />;
|
||||||
|
};
|
||||||
|
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70');
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root;
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||||
|
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
));
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
));
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'flex touch-none select-none transition-colors',
|
||||||
|
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||||
|
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
));
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label ref={ref} className={cn('px-2 py-1.5 text-sm font-semibold', className)} {...props} />
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn('shrink-0 bg-border', orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<HTMLOListElement, React.HTMLAttributes<HTMLOListElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<ol
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ToastViewport.displayName = 'ToastViewport';
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border bg-background text-foreground',
|
||||||
|
destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||||
|
success: 'border-green-500 bg-background text-foreground',
|
||||||
|
warning: 'border-yellow-500 bg-background text-foreground',
|
||||||
|
info: 'border-blue-500 bg-background text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.HTMLAttributes<HTMLLIElement> &
|
||||||
|
VariantProps<typeof toastVariants> & {
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
>(({ className, variant, open, onOpenChange, ...props }, ref) => (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
data-state={open ? 'open' : 'closed'}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Toast.displayName = 'Toast';
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
aria-label="Close"
|
||||||
|
className={cn(
|
||||||
|
'absolute right-1 top-1 rounded-md p-1 text-foreground/60 transition-colors hover:text-foreground focus:outline-none focus:ring-1 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
type="button"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ToastClose.displayName = 'ToastClose';
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('text-sm font-semibold [&+div]:text-xs', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ToastTitle.displayName = 'ToastTitle';
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => <div ref={ref} className={cn('text-sm opacity-90', className)} {...props} />,
|
||||||
|
);
|
||||||
|
ToastDescription.displayName = 'ToastDescription';
|
||||||
|
|
||||||
|
export { ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, toastVariants };
|
||||||
|
export type { VariantProps };
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Toast, ToastClose, ToastDescription, ToastTitle, ToastViewport } from '@/components/ui/toast';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast();
|
||||||
|
|
||||||
|
const visibleToasts = toasts.filter((t) => t.open !== false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastViewport>
|
||||||
|
{visibleToasts.map(function ({ id, title, description, variant, open, onOpenChange, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} variant={variant} open={open} onOpenChange={onOpenChange} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && <ToastDescription>{description}</ToastDescription>}
|
||||||
|
</div>
|
||||||
|
<ToastClose onClick={() => onOpenChange?.(false)} />
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ToastViewport>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
));
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { Check, X, HelpCircle, ChevronRight } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { jmapMapToArray } from '@/lib/jmapUtils';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
import { resolveSchema, resolveVariantForm, resolveForm } from '@/lib/schemaResolver';
|
||||||
|
import { formatSize, formatDuration } from '@/lib/durationFormat';
|
||||||
|
import type { Schema, Field, FieldType, FormField, Form, Fields, EnumVariant } from '@/types/schema';
|
||||||
|
|
||||||
|
export interface DynamicViewProps {
|
||||||
|
schema: Schema;
|
||||||
|
objectName: string;
|
||||||
|
viewName: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
visibleFields?: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DynamicView({ schema, objectName, viewName, data, visibleFields }: DynamicViewProps) {
|
||||||
|
const resolved = useMemo(() => {
|
||||||
|
const sch = resolveSchema(schema, objectName);
|
||||||
|
if (!sch) return null;
|
||||||
|
|
||||||
|
let fields: Fields | null = null;
|
||||||
|
let form: Form | null = null;
|
||||||
|
let variantSchemaName: string | undefined;
|
||||||
|
|
||||||
|
if (sch.type === 'single') {
|
||||||
|
fields = sch.fields;
|
||||||
|
form = resolveForm(schema, viewName, objectName, sch.schemaName);
|
||||||
|
} else {
|
||||||
|
const variantName = typeof data['@type'] === 'string' ? data['@type'] : undefined;
|
||||||
|
if (variantName) {
|
||||||
|
const variant = sch.variants.find((v) => v.name === variantName);
|
||||||
|
if (variant?.schemaName) {
|
||||||
|
variantSchemaName = variant.schemaName;
|
||||||
|
fields = schema.fields[variant.schemaName] ?? null;
|
||||||
|
form = resolveVariantForm(schema, viewName, objectName, variant.schemaName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parentForm = resolveForm(schema, viewName, objectName, objectName);
|
||||||
|
if (parentForm && form) {
|
||||||
|
form = { ...form, sections: [...parentForm.sections, ...form.sections] };
|
||||||
|
} else if (parentForm) {
|
||||||
|
form = parentForm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sch, fields, form, variantSchemaName };
|
||||||
|
}, [schema, objectName, viewName, data]);
|
||||||
|
|
||||||
|
if (!resolved) return null;
|
||||||
|
const { fields, form, sch } = resolved;
|
||||||
|
|
||||||
|
const sections: { title?: string; items: { ff: FormField; field: Field }[] }[] = [];
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
for (const section of form.sections) {
|
||||||
|
const items: { ff: FormField; field: Field }[] = [];
|
||||||
|
for (const ff of section.fields) {
|
||||||
|
if (ff.name === '@type') continue;
|
||||||
|
if (visibleFields && !visibleFields.has(ff.name)) continue;
|
||||||
|
const field = fields?.properties[ff.name];
|
||||||
|
if (!field) continue;
|
||||||
|
if (isEmptyValue(data[ff.name])) continue;
|
||||||
|
items.push({ ff, field });
|
||||||
|
}
|
||||||
|
if (items.length > 0) {
|
||||||
|
sections.push({ title: section.title, items });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (fields) {
|
||||||
|
const items: { ff: FormField; field: Field }[] = [];
|
||||||
|
for (const [name, field] of Object.entries(fields.properties)) {
|
||||||
|
if (visibleFields && !visibleFields.has(name)) continue;
|
||||||
|
if (isEmptyValue(data[name])) continue;
|
||||||
|
items.push({ ff: { name, label: name }, field });
|
||||||
|
}
|
||||||
|
if (items.length > 0) {
|
||||||
|
sections.push({ items });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantLabel =
|
||||||
|
sch.type === 'multiple' && typeof data['@type'] === 'string'
|
||||||
|
? sch.variants.find((v) => v.name === data['@type'])?.label
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{variantLabel && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{variantLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{sections.map((section, si) => (
|
||||||
|
<Card key={si}>
|
||||||
|
{section.title && (
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">{section.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent className={section.title ? 'pt-0' : ''}>
|
||||||
|
<dl className="space-y-2">
|
||||||
|
{section.items.map(({ ff, field }) => (
|
||||||
|
<ViewField key={ff.name} label={ff.label} field={field} value={data[ff.name]} schema={schema} />
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewField({ label, field, value, schema }: { label: string; field: Field; value: unknown; schema: Schema }) {
|
||||||
|
const isBlock = isBlockType(field.type, value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={isBlock ? 'space-y-1' : 'flex items-baseline gap-2'}>
|
||||||
|
<dt className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 min-w-[140px]">
|
||||||
|
{label}
|
||||||
|
{field.description && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground/50 cursor-help shrink-0" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
<div className="text-xs prose prose-sm dark:prose-invert">
|
||||||
|
<ReactMarkdown>{field.description.replace(/\\n/g, '\n')}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm min-w-0">
|
||||||
|
<ViewValue type={field.type} value={value} schema={schema} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockType(type: FieldType, value: unknown): boolean {
|
||||||
|
if (type.type === 'object' || type.type === 'objectList') return true;
|
||||||
|
if (type.type === 'map') return true;
|
||||||
|
if (type.type === 'string' && (type.format === 'text' || type.format === 'html')) return true;
|
||||||
|
if (type.type === 'set' && value && typeof value === 'object') {
|
||||||
|
return Object.keys(value as Record<string, unknown>).length > 5;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewValue({ type, value, schema }: { type: FieldType; value: unknown; schema: Schema }) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return <span className="italic text-muted-foreground">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type.type) {
|
||||||
|
case 'string':
|
||||||
|
return <StringValue value={value} format={type.format} />;
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
return <NumberValue value={value} format={type.format} />;
|
||||||
|
|
||||||
|
case 'utcDateTime':
|
||||||
|
return <DateTimeValue value={value} />;
|
||||||
|
|
||||||
|
case 'boolean':
|
||||||
|
return value === true ? <Check className="h-4 w-4 text-green-600" /> : <X className="h-4 w-4 text-red-500" />;
|
||||||
|
|
||||||
|
case 'enum':
|
||||||
|
return <EnumValue value={value} enumName={type.enumName} schema={schema} />;
|
||||||
|
|
||||||
|
case 'blobId':
|
||||||
|
return <span className="font-mono text-xs">{String(value)}</span>;
|
||||||
|
|
||||||
|
case 'objectId':
|
||||||
|
return <span>{String(value)}</span>;
|
||||||
|
|
||||||
|
case 'object':
|
||||||
|
return <ObjectValue value={value} objectName={type.objectName} schema={schema} />;
|
||||||
|
|
||||||
|
case 'objectList':
|
||||||
|
return <ObjectListValue value={value} objectName={type.objectName} schema={schema} />;
|
||||||
|
|
||||||
|
case 'set':
|
||||||
|
return <SetValue value={value} classType={type.class} schema={schema} />;
|
||||||
|
|
||||||
|
case 'map':
|
||||||
|
return <MapValue value={value} keyClass={type.keyClass} valueClass={type.valueClass} schema={schema} />;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <span>{JSON.stringify(value)}</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StringValue({ value, format }: { value: unknown; format: string }) {
|
||||||
|
const str = String(value);
|
||||||
|
if (!str) return <span className="italic text-muted-foreground">-</span>;
|
||||||
|
|
||||||
|
if (format === 'text' || format === 'html' || str.includes('\n')) {
|
||||||
|
return <pre className="whitespace-pre-wrap break-all rounded bg-muted/50 p-2 text-xs font-mono">{str}</pre>;
|
||||||
|
}
|
||||||
|
if (format === 'secret' || format === 'secretText') {
|
||||||
|
return <span className="text-muted-foreground">*****</span>;
|
||||||
|
}
|
||||||
|
return <span className="break-all">{str}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberValue({ value, format }: { value: unknown; format: string }) {
|
||||||
|
const num = typeof value === 'number' ? value : Number(value);
|
||||||
|
if (isNaN(num)) return <span>{String(value)}</span>;
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'size':
|
||||||
|
return <span>{formatSize(num)}</span>;
|
||||||
|
case 'duration':
|
||||||
|
return <span>{formatDuration(num)}</span>;
|
||||||
|
default:
|
||||||
|
return <span>{num.toLocaleString()}</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateTimeValue({ value }: { value: unknown }) {
|
||||||
|
if (!value) return <span className="italic text-muted-foreground">-</span>;
|
||||||
|
const str = String(value);
|
||||||
|
const d = new Date(str);
|
||||||
|
const formatted = !isNaN(d.getTime())
|
||||||
|
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(d)
|
||||||
|
: str;
|
||||||
|
return <span>{formatted}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EnumValue({ value, enumName, schema }: { value: unknown; enumName: string; schema: Schema }) {
|
||||||
|
const str = String(value);
|
||||||
|
const variants = schema.enums[enumName] ?? [];
|
||||||
|
const variant = variants.find((v: EnumVariant) => v.name === str);
|
||||||
|
if (variant) {
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" style={variant.color ? { backgroundColor: variant.color, color: '#fff' } : undefined}>
|
||||||
|
{variant.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span>{str}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ObjectValue({
|
||||||
|
value,
|
||||||
|
objectName,
|
||||||
|
schema,
|
||||||
|
defaultOpen = false,
|
||||||
|
}: {
|
||||||
|
value: unknown;
|
||||||
|
objectName: string;
|
||||||
|
schema: Schema;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}) {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return <span className="italic text-muted-foreground">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = value as Record<string, unknown>;
|
||||||
|
|
||||||
|
const sch = resolveSchema(schema, objectName);
|
||||||
|
let fields: Record<string, Field> | undefined;
|
||||||
|
let form: Form | null = null;
|
||||||
|
let variantLabel: string | undefined;
|
||||||
|
|
||||||
|
if (sch?.type === 'multiple') {
|
||||||
|
const variantName = typeof data['@type'] === 'string' ? data['@type'] : undefined;
|
||||||
|
if (variantName) {
|
||||||
|
const variant = sch.variants.find((v) => v.name === variantName);
|
||||||
|
variantLabel = variant?.label;
|
||||||
|
if (variant?.schemaName) {
|
||||||
|
fields = schema.fields[variant.schemaName]?.properties;
|
||||||
|
form = schema.forms[variant.schemaName] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (sch?.type === 'single') {
|
||||||
|
fields = schema.fields[sch.schemaName]?.properties;
|
||||||
|
form = schema.forms[sch.schemaName] ?? schema.forms[objectName] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = buildViewEntries(data, fields, form);
|
||||||
|
|
||||||
|
if (entries.length === 0 && !variantLabel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerLabel = variantLabel ?? findDisplayValue(data) ?? objectName.replace(/^x:/, '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible defaultOpen={defaultOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1.5 text-sm hover:text-foreground text-muted-foreground transition-colors [&[data-state=open]>svg]:rotate-90"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform duration-200" />
|
||||||
|
<span>{triggerLabel}</span>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="ml-5 mt-2 border-l-2 border-muted/40 pl-4 space-y-2">
|
||||||
|
{entries.map(({ key, label, field, value: val }) => {
|
||||||
|
if (!field) {
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-baseline gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground shrink-0 min-w-[120px]">{label}</span>
|
||||||
|
<span className="text-sm">{typeof val === 'object' ? JSON.stringify(val) : String(val ?? '-')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const block = isBlockType(field.type, val);
|
||||||
|
return (
|
||||||
|
<div key={key} className={block ? 'space-y-1' : 'flex items-baseline gap-2'}>
|
||||||
|
<span className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 min-w-[120px]">
|
||||||
|
{label}
|
||||||
|
{field.description && <FieldTooltip description={field.description} />}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm min-w-0">
|
||||||
|
<ViewValue type={field.type} value={val} schema={schema} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ObjectListValue({ value, objectName, schema }: { value: unknown; objectName: string; schema: Schema }) {
|
||||||
|
const items = jmapMapToArray<Record<string, unknown>>(value);
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <span className="italic text-muted-foreground">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<ObjectValue key={i} value={item} objectName={objectName} schema={schema} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SetValue({
|
||||||
|
value,
|
||||||
|
classType,
|
||||||
|
schema,
|
||||||
|
}: {
|
||||||
|
value: unknown;
|
||||||
|
classType: { type: string; enumName?: string };
|
||||||
|
schema: Schema;
|
||||||
|
}) {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return <span className="italic text-muted-foreground">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(value as Record<string, unknown>);
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return <span className="italic text-muted-foreground">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = keys.map((k) => {
|
||||||
|
if (classType.type === 'enum' && classType.enumName) {
|
||||||
|
const variants = schema.enums[classType.enumName] ?? [];
|
||||||
|
const v = variants.find((e: EnumVariant) => e.name === k);
|
||||||
|
return v?.label ?? k;
|
||||||
|
}
|
||||||
|
return k;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{labels.map((label, i) => (
|
||||||
|
<Badge key={i} variant="secondary" className="text-xs">
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapValue({
|
||||||
|
value,
|
||||||
|
keyClass,
|
||||||
|
valueClass,
|
||||||
|
schema,
|
||||||
|
}: {
|
||||||
|
value: unknown;
|
||||||
|
keyClass: { type: string; enumName?: string };
|
||||||
|
valueClass: { type: string; objectName?: string };
|
||||||
|
schema: Schema;
|
||||||
|
}) {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return <span className="italic text-muted-foreground">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(value as Record<string, unknown>);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return <span className="italic text-muted-foreground">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entries.map(([k, v]) => {
|
||||||
|
let keyLabel = k;
|
||||||
|
if (keyClass.type === 'enum' && keyClass.enumName) {
|
||||||
|
const variants = schema.enums[keyClass.enumName] ?? [];
|
||||||
|
const variant = variants.find((e: EnumVariant) => e.name === k);
|
||||||
|
if (variant) keyLabel = variant.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueClass.type === 'object' && valueClass.objectName) {
|
||||||
|
return (
|
||||||
|
<div key={k}>
|
||||||
|
<span className="text-sm font-medium">{keyLabel}</span>
|
||||||
|
<div className="ml-2">
|
||||||
|
<ObjectValue value={v} objectName={valueClass.objectName} schema={schema} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={k} className="flex items-baseline gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground shrink-0 min-w-[120px]">{keyLabel}</span>
|
||||||
|
<span className="text-sm">{typeof v === 'object' ? JSON.stringify(v) : String(v ?? '-')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTooltip({ description }: { description: string }) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground/50 cursor-help shrink-0" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
<div className="text-xs prose prose-sm dark:prose-invert">
|
||||||
|
<ReactMarkdown>{description.replace(/\\n/g, '\n')}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ViewEntry {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
field: Field | undefined;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildViewEntries(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
fields?: Record<string, Field>,
|
||||||
|
form?: Form | null,
|
||||||
|
): ViewEntry[] {
|
||||||
|
const entries: ViewEntry[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
for (const section of form.sections) {
|
||||||
|
for (const ff of section.fields) {
|
||||||
|
if (ff.name === '@type') continue;
|
||||||
|
if (isEmptyValue(data[ff.name])) continue;
|
||||||
|
seen.add(ff.name);
|
||||||
|
entries.push({
|
||||||
|
key: ff.name,
|
||||||
|
label: ff.label,
|
||||||
|
field: fields?.[ff.name],
|
||||||
|
value: data[ff.name],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(data)) {
|
||||||
|
if (key === '@type' || key === 'id' || seen.has(key)) continue;
|
||||||
|
if (isEmptyValue(val)) continue;
|
||||||
|
entries.push({
|
||||||
|
key,
|
||||||
|
label: key,
|
||||||
|
field: fields?.[key],
|
||||||
|
value: val,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyValue(value: unknown): boolean {
|
||||||
|
if (value === null || value === undefined) return true;
|
||||||
|
if (value === '') return true;
|
||||||
|
if (Array.isArray(value) && value.length === 0) return true;
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
return Object.keys(value as Record<string, unknown>).length === 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDisplayValue(data: Record<string, unknown>): string | null {
|
||||||
|
for (const key of [
|
||||||
|
'name',
|
||||||
|
'domain',
|
||||||
|
'headerFrom',
|
||||||
|
'envelopeFrom',
|
||||||
|
'sourceIp',
|
||||||
|
'orgName',
|
||||||
|
'organizationName',
|
||||||
|
'email',
|
||||||
|
'from',
|
||||||
|
'to',
|
||||||
|
'subject',
|
||||||
|
'policyDomain',
|
||||||
|
]) {
|
||||||
|
const val = data[key];
|
||||||
|
if (typeof val === 'string' && val.length > 0) return val;
|
||||||
|
}
|
||||||
|
for (const val of Object.values(data)) {
|
||||||
|
if (typeof val === 'string' && val.length > 0 && val.length <= 80) return val;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
import { resolveObject, resolveList } from '@/lib/schemaResolver';
|
||||||
|
import { jmapGet, getAccountId } from '@/services/jmap/client';
|
||||||
|
import { DynamicView } from './DynamicView';
|
||||||
|
|
||||||
|
interface DynamicViewPageProps {
|
||||||
|
viewName: string;
|
||||||
|
objectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DynamicViewPage({ viewName, objectId }: DynamicViewPageProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
|
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!schema) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const resolved = resolveObject(schema!, viewName);
|
||||||
|
if (!resolved) throw new Error(t('view.couldNotResolve', 'Could not resolve object'));
|
||||||
|
|
||||||
|
const accountId = getAccountId(resolved.objectName);
|
||||||
|
const responses = await jmapGet(resolved.objectName, accountId, [objectId]);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const getResult = responses.find(([name]) => name.endsWith('/get'));
|
||||||
|
if (!getResult) throw new Error(t('view.noGetResponse', 'No get response'));
|
||||||
|
|
||||||
|
const result = getResult[1] as { list?: Array<Record<string, unknown>> };
|
||||||
|
const item = result.list?.[0];
|
||||||
|
if (!item) throw new Error(t('view.objectNotFound', 'Object not found'));
|
||||||
|
|
||||||
|
setData(item);
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(e instanceof Error ? e.message : t('view.failedToLoad', 'Failed to load'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [schema, viewName, objectId, t]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data || !schema) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate(-1)}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
{t('common.back', 'Back')}
|
||||||
|
</Button>
|
||||||
|
<div className="text-destructive p-4">{error ?? t('view.failedToLoad', 'Failed to load')}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveObject(schema, viewName);
|
||||||
|
if (!resolved) return null;
|
||||||
|
|
||||||
|
const list = resolveList(schema, viewName, resolved.objectName);
|
||||||
|
const labelProp = list?.labelProperty ?? list?.columns?.[0]?.name;
|
||||||
|
const displayName = labelProp && typeof data[labelProp] === 'string' ? (data[labelProp] as string) : undefined;
|
||||||
|
const singularName = list?.singularName ?? resolved.objectName.replace(/^x:/, '');
|
||||||
|
const title = displayName
|
||||||
|
? `${singularName.charAt(0).toUpperCase() + singularName.slice(1)}: ${displayName}`
|
||||||
|
: singularName.charAt(0).toUpperCase() + singularName.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate(-1)}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
{t('common.back', 'Back')}
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-xl font-semibold">{title}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DynamicView schema={schema} objectName={resolved.objectName} viewName={viewName} data={data} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Zap, Loader2, ChevronRight, CircleCheck, CircleAlert } from 'lucide-react';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
|
import { resolveObject, resolveSchema, resolveVariantForm } from '@/lib/schemaResolver';
|
||||||
|
import { jmapSet, getAccountId } from '@/services/jmap/client';
|
||||||
|
import { FieldWidget } from '@/components/forms/FieldWidget';
|
||||||
|
import { DynamicView } from '@/components/views/DynamicView';
|
||||||
|
import type { Schema, ObjectVariant } from '@/types/schema';
|
||||||
|
import type { JmapSetResponse } from '@/types/jmap';
|
||||||
|
|
||||||
|
type ActionState =
|
||||||
|
| { kind: 'pick' }
|
||||||
|
| { kind: 'form'; variant: ObjectVariant; formData: Record<string, unknown> }
|
||||||
|
| { kind: 'submitting'; variant: ObjectVariant }
|
||||||
|
| {
|
||||||
|
kind: 'result';
|
||||||
|
variant: ObjectVariant;
|
||||||
|
props: Record<string, unknown> | null;
|
||||||
|
inputData: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
| { kind: 'error'; variant: ObjectVariant; error: string };
|
||||||
|
|
||||||
|
interface ActionPageProps {
|
||||||
|
viewName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionPage({ viewName }: ActionPageProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
|
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||||
|
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||||
|
const [state, setState] = useState<ActionState>({ kind: 'pick' });
|
||||||
|
|
||||||
|
const resolved = useMemo(() => {
|
||||||
|
if (!schema) return null;
|
||||||
|
return resolveObject(schema, viewName);
|
||||||
|
}, [schema, viewName]);
|
||||||
|
|
||||||
|
const sch = useMemo(() => {
|
||||||
|
if (!schema || !resolved) return null;
|
||||||
|
return resolveSchema(schema, resolved.objectName);
|
||||||
|
}, [schema, resolved]);
|
||||||
|
|
||||||
|
const canCreate = resolved ? hasObjectPermission(resolved.permissionPrefix, 'Create') : false;
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const variants = sch?.type === 'multiple' ? sch.variants : [];
|
||||||
|
const map = new Map<string, ObjectVariant[]>();
|
||||||
|
const otherLabel = t('actions.otherGroup', 'Other');
|
||||||
|
for (const v of variants) {
|
||||||
|
if (!hasPermission(`action${v.name}`)) continue;
|
||||||
|
const colonIdx = v.label.indexOf(':');
|
||||||
|
const group = colonIdx > 0 ? v.label.slice(0, colonIdx).trim() : otherLabel;
|
||||||
|
let list = map.get(group);
|
||||||
|
if (!list) {
|
||||||
|
list = [];
|
||||||
|
map.set(group, list);
|
||||||
|
}
|
||||||
|
list.push(v);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [sch, hasPermission, t]);
|
||||||
|
|
||||||
|
const list = schema?.lists[viewName] ?? schema?.lists[resolved?.objectName ?? ''];
|
||||||
|
const title = list?.title ?? t('actions.title', 'Actions');
|
||||||
|
const subtitle = list?.subtitle ?? resolved?.objectType?.description;
|
||||||
|
|
||||||
|
const handlePickVariant = useCallback(
|
||||||
|
(variant: ObjectVariant) => {
|
||||||
|
if (!schema || !resolved) return;
|
||||||
|
if (!variant.schemaName) {
|
||||||
|
executeAction(schema, resolved.objectName, variant.name, {}, setState);
|
||||||
|
} else {
|
||||||
|
const fieldsDef = schema.fields[variant.schemaName!];
|
||||||
|
const defaults = fieldsDef?.defaults ? { ...fieldsDef.defaults } : {};
|
||||||
|
setState({ kind: 'form', variant, formData: defaults });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[schema, resolved],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmitForm = useCallback(
|
||||||
|
async (variant: ObjectVariant, formData: Record<string, unknown>) => {
|
||||||
|
if (!schema || !resolved) return;
|
||||||
|
setState({ kind: 'submitting', variant });
|
||||||
|
|
||||||
|
const fields = variant.schemaName ? schema.fields[variant.schemaName] : null;
|
||||||
|
const payload: Record<string, unknown> = { '@type': variant.name };
|
||||||
|
|
||||||
|
if (fields) {
|
||||||
|
for (const [name, def] of Object.entries(fields.properties)) {
|
||||||
|
if (def.update === 'serverSet') continue;
|
||||||
|
if (name in formData) {
|
||||||
|
const isSecret =
|
||||||
|
def.type.type === 'string' && (def.type.format === 'secret' || def.type.format === 'secretText');
|
||||||
|
if (isSecret && formData[name] === '*****') continue;
|
||||||
|
payload[name] = formData[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accountId = getAccountId(resolved.objectName);
|
||||||
|
const responses = await jmapSet(resolved.objectName, accountId, {
|
||||||
|
create: { 'action-0': payload },
|
||||||
|
});
|
||||||
|
const setResponse = responses[responses.length - 1];
|
||||||
|
const setResult = setResponse[1] as unknown as JmapSetResponse;
|
||||||
|
|
||||||
|
if (setResult.created && setResult.created['action-0']) {
|
||||||
|
const created = setResult.created['action-0'];
|
||||||
|
const noisyKeys = new Set(['id', 'blobId']);
|
||||||
|
const extraKeys = Object.keys(created).filter((k) => !noisyKeys.has(k));
|
||||||
|
if (extraKeys.length > 0) {
|
||||||
|
const extra: Record<string, unknown> = {};
|
||||||
|
for (const k of extraKeys) extra[k] = created[k];
|
||||||
|
setState({ kind: 'result', variant, props: extra, inputData: formData });
|
||||||
|
} else {
|
||||||
|
setState({ kind: 'result', variant, props: null, inputData: formData });
|
||||||
|
}
|
||||||
|
} else if (setResult.notCreated && setResult.notCreated['action-0']) {
|
||||||
|
const err = setResult.notCreated['action-0'];
|
||||||
|
setState({
|
||||||
|
kind: 'error',
|
||||||
|
variant,
|
||||||
|
error: (err as { description?: string }).description ?? t('actions.actionFailed', 'Action failed.'),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState({ kind: 'result', variant, props: null, inputData: formData });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState({
|
||||||
|
kind: 'error',
|
||||||
|
variant,
|
||||||
|
error: e instanceof Error ? e.message : t('actions.actionFailed', 'Action failed.'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[schema, resolved, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!schema || !resolved || !sch) return null;
|
||||||
|
|
||||||
|
if (state.kind === 'pick') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">{title}</h1>
|
||||||
|
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Array.from(groups.entries()).map(([group, groupVariants]) => (
|
||||||
|
<div key={group}>
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground mb-2">{group}</h3>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{groupVariants.map((v) => {
|
||||||
|
const colonIdx = v.label.indexOf(':');
|
||||||
|
const actionName = colonIdx > 0 ? v.label.slice(colonIdx + 1).trim() : v.label;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={v.name}
|
||||||
|
variant="outline"
|
||||||
|
className="justify-between h-auto py-3 px-4"
|
||||||
|
disabled={!canCreate}
|
||||||
|
onClick={() => handlePickVariant(v)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<span>{actionName}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.kind === 'form') {
|
||||||
|
return (
|
||||||
|
<ActionFormView
|
||||||
|
schema={schema}
|
||||||
|
variant={state.variant}
|
||||||
|
formData={state.formData}
|
||||||
|
onSubmit={(data) => handleSubmitForm(state.variant, data)}
|
||||||
|
onBack={() => setState({ kind: 'pick' })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.kind === 'submitting') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t('actions.executing', 'Executing {{label}}...', { label: state.variant.label })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.kind === 'result') {
|
||||||
|
return (
|
||||||
|
<ActionResultView
|
||||||
|
schema={schema}
|
||||||
|
variant={state.variant}
|
||||||
|
props={state.props}
|
||||||
|
inputData={state.inputData}
|
||||||
|
onNewAction={() => setState({ kind: 'pick' })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.kind === 'error') {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl space-y-4 pt-8">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CircleAlert className="h-5 w-5 text-destructive shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t('actions.failedTitle', 'Action failed')}</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{state.error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setState({ kind: 'pick' })}>
|
||||||
|
{t('actions.backToActions', 'Back to actions')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeAction(
|
||||||
|
schema: Schema,
|
||||||
|
objectName: string,
|
||||||
|
variantName: string,
|
||||||
|
formData: Record<string, unknown>,
|
||||||
|
setState: React.Dispatch<React.SetStateAction<ActionState>>,
|
||||||
|
) {
|
||||||
|
const variant = { name: variantName, label: variantName } as ObjectVariant;
|
||||||
|
setState({ kind: 'submitting', variant });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accountId = getAccountId(objectName);
|
||||||
|
const payload: Record<string, unknown> = { '@type': variantName, ...formData };
|
||||||
|
const responses = await jmapSet(objectName, accountId, {
|
||||||
|
create: { 'action-0': payload },
|
||||||
|
});
|
||||||
|
const setResponse = responses[responses.length - 1];
|
||||||
|
const setResult = setResponse[1] as unknown as JmapSetResponse;
|
||||||
|
|
||||||
|
const sch = resolveSchema(schema, objectName);
|
||||||
|
const realVariant =
|
||||||
|
sch?.type === 'multiple' ? (sch.variants.find((v) => v.name === variantName) ?? variant) : variant;
|
||||||
|
|
||||||
|
if (setResult.created && setResult.created['action-0']) {
|
||||||
|
const created = setResult.created['action-0'];
|
||||||
|
const noisyKeys = new Set(['id', 'blobId']);
|
||||||
|
const extraKeys = Object.keys(created).filter((k) => !noisyKeys.has(k));
|
||||||
|
if (extraKeys.length > 0) {
|
||||||
|
const extra: Record<string, unknown> = {};
|
||||||
|
for (const k of extraKeys) extra[k] = created[k];
|
||||||
|
setState({ kind: 'result', variant: realVariant, props: extra, inputData: formData });
|
||||||
|
} else {
|
||||||
|
setState({ kind: 'result', variant: realVariant, props: null, inputData: formData });
|
||||||
|
}
|
||||||
|
} else if (setResult.notCreated && setResult.notCreated['action-0']) {
|
||||||
|
const err = setResult.notCreated['action-0'];
|
||||||
|
setState({
|
||||||
|
kind: 'error',
|
||||||
|
variant: realVariant,
|
||||||
|
error: (err as { description?: string }).description ?? i18n.t('actions.actionFailed', 'Action failed.'),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState({ kind: 'result', variant: realVariant, props: null, inputData: formData });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState({
|
||||||
|
kind: 'error',
|
||||||
|
variant,
|
||||||
|
error: e instanceof Error ? e.message : i18n.t('actions.actionFailed', 'Action failed.'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionFormView({
|
||||||
|
schema,
|
||||||
|
variant,
|
||||||
|
formData: initialData,
|
||||||
|
onSubmit,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
schema: Schema;
|
||||||
|
variant: ObjectVariant;
|
||||||
|
formData: Record<string, unknown>;
|
||||||
|
onSubmit: (data: Record<string, unknown>) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [formData, setFormData] = useState<Record<string, unknown>>(initialData);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
if (!variant.schemaName) return null;
|
||||||
|
const fields = schema.fields[variant.schemaName];
|
||||||
|
const form = resolveVariantForm(schema, '', '', variant.schemaName);
|
||||||
|
if (!fields || !form) return null;
|
||||||
|
|
||||||
|
const mutableSections = form.sections
|
||||||
|
.map((section) => ({
|
||||||
|
...section,
|
||||||
|
fields: section.fields.filter((ff) => {
|
||||||
|
const def = fields.properties[ff.name];
|
||||||
|
return def && def.update === 'mutable';
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
.filter((s) => s.fields.length > 0);
|
||||||
|
|
||||||
|
const handleChange = (fieldName: string, value: unknown) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||||
|
setErrors((prev) => {
|
||||||
|
const copy = { ...prev };
|
||||||
|
delete copy[fieldName];
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
for (const section of mutableSections) {
|
||||||
|
for (const ff of section.fields) {
|
||||||
|
const def = fields.properties[ff.name];
|
||||||
|
if (!def) continue;
|
||||||
|
const isRequired =
|
||||||
|
(def.type.type === 'string' ||
|
||||||
|
def.type.type === 'number' ||
|
||||||
|
def.type.type === 'enum' ||
|
||||||
|
def.type.type === 'objectId') &&
|
||||||
|
!('nullable' in def.type && def.type.nullable);
|
||||||
|
if (isRequired) {
|
||||||
|
const val = formData[ff.name];
|
||||||
|
if (val === undefined || val === null || val === '') {
|
||||||
|
newErrors[ff.name] = t('form.required', 'This field is required.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setErrors(newErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={onBack}>
|
||||||
|
{t('common.back', 'Back')}
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-xl font-semibold">{variant.label}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mutableSections.map((section, si) => (
|
||||||
|
<Card key={si}>
|
||||||
|
{section.title && (
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">{section.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent className={section.title ? 'pt-0' : ''}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{section.fields.map((ff) => {
|
||||||
|
const def = fields.properties[ff.name];
|
||||||
|
if (!def) return null;
|
||||||
|
return (
|
||||||
|
<FieldWidget
|
||||||
|
key={ff.name}
|
||||||
|
formField={ff}
|
||||||
|
field={def}
|
||||||
|
value={formData[ff.name]}
|
||||||
|
onChange={(v) => handleChange(ff.name, v)}
|
||||||
|
readOnly={false}
|
||||||
|
schema={schema}
|
||||||
|
error={errors[ff.name]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={onBack}>
|
||||||
|
{t('common.cancel', 'Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit}>
|
||||||
|
<Zap className="mr-2 h-4 w-4" />
|
||||||
|
{t('actions.execute', 'Execute')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionResultView({
|
||||||
|
schema,
|
||||||
|
variant,
|
||||||
|
props,
|
||||||
|
inputData,
|
||||||
|
onNewAction,
|
||||||
|
}: {
|
||||||
|
schema: Schema;
|
||||||
|
variant: ObjectVariant;
|
||||||
|
props: Record<string, unknown> | null;
|
||||||
|
inputData: Record<string, unknown>;
|
||||||
|
onNewAction: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const mergedData = useMemo(() => ({ ...inputData, ...(props ?? {}) }), [inputData, props]);
|
||||||
|
const hasData = variant.schemaName && Object.keys(mergedData).length > 0;
|
||||||
|
|
||||||
|
const visibleFields = useMemo(() => new Set(Object.keys(mergedData)), [mergedData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 pt-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CircleCheck className="h-5 w-5 text-emerald-500 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{variant.label}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('actions.executedSuccess', 'Action executed successfully.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasData && variant.schemaName && (
|
||||||
|
<DynamicView
|
||||||
|
schema={schema}
|
||||||
|
objectName={variant.schemaName}
|
||||||
|
viewName={variant.schemaName}
|
||||||
|
data={mergedData}
|
||||||
|
visibleFields={visibleFields}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasData && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||||
|
{t('actions.noAdditionalData', 'No additional data returned.')}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={onNewAction}>
|
||||||
|
<Zap className="mr-2 h-4 w-4" />
|
||||||
|
{t('actions.executeAnother', 'Execute another action')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
function ChartSizedContainer({
|
||||||
|
height,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
height: number;
|
||||||
|
children: (width: number, height: number) => React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [width, setWidth] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const w = entry.contentRect.width;
|
||||||
|
if (w > 0) setWidth(w);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
const w = el.getBoundingClientRect().width;
|
||||||
|
if (w > 0) setWidth(w);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={{ height }}>
|
||||||
|
{width > 0 && children(width, height)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Tooltip as UiTooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { getChartColor } from '@/components/ui/chart';
|
||||||
|
import { ChartTooltipContent } from '@/components/ui/chart';
|
||||||
|
import type { Chart as ChartSchema } from '../types/schema';
|
||||||
|
import type { Metric, Period } from '../types/metrics';
|
||||||
|
import {
|
||||||
|
bucketize,
|
||||||
|
bucketTimestamps,
|
||||||
|
getBucketCount,
|
||||||
|
seriesBucketValue,
|
||||||
|
formatTimeTick,
|
||||||
|
formatValue,
|
||||||
|
} from '../helpers';
|
||||||
|
|
||||||
|
interface DashboardChartProps {
|
||||||
|
chart: ChartSchema;
|
||||||
|
historySamples: Metric[];
|
||||||
|
historyWindow: { from: Date; to: Date };
|
||||||
|
period: Period;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardChart({ chart, historySamples, historyWindow, period }: DashboardChartProps) {
|
||||||
|
const { from, to } = historyWindow;
|
||||||
|
const bucketCount = getBucketCount(period);
|
||||||
|
const valueFormat = chart.valueFormat ?? 'number';
|
||||||
|
|
||||||
|
const data = useMemo(() => {
|
||||||
|
const buckets = bucketize(historySamples, from, to, bucketCount);
|
||||||
|
const timestamps = bucketTimestamps(from, to, bucketCount);
|
||||||
|
|
||||||
|
const points = timestamps.map((ts, i) => {
|
||||||
|
const point: Record<string, unknown> = {
|
||||||
|
time: ts.getTime(),
|
||||||
|
timeLabel: formatTimeTick(ts, period),
|
||||||
|
};
|
||||||
|
for (const series of chart.series) {
|
||||||
|
point[series.label] = seriesBucketValue(series, buckets[i]);
|
||||||
|
}
|
||||||
|
return point;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (chart.stacked) {
|
||||||
|
const lastSeen: Record<string, number> = {};
|
||||||
|
for (const point of points) {
|
||||||
|
for (const series of chart.series) {
|
||||||
|
const v = point[series.label];
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
lastSeen[series.label] = v;
|
||||||
|
} else {
|
||||||
|
point[series.label] = lastSeen[series.label] ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}, [historySamples, from, to, bucketCount, chart.series, chart.stacked, period]);
|
||||||
|
|
||||||
|
const tickFormatter = (value: number) => formatValue(value, valueFormat);
|
||||||
|
|
||||||
|
const tooltipFormatter = (value: number) => formatValue(value, valueFormat);
|
||||||
|
|
||||||
|
const renderChart = (chartWidth: number, chartHeight: number) => {
|
||||||
|
const commonProps = {
|
||||||
|
data,
|
||||||
|
width: chartWidth,
|
||||||
|
height: chartHeight,
|
||||||
|
margin: { top: 5, right: 10, left: 10, bottom: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const seriesElements = chart.series.map((s, i) => {
|
||||||
|
const color = getChartColor(i);
|
||||||
|
const key = s.label;
|
||||||
|
|
||||||
|
switch (chart.kind) {
|
||||||
|
case 'line':
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
key={key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={key}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'area':
|
||||||
|
return (
|
||||||
|
<Area
|
||||||
|
key={key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={key}
|
||||||
|
stroke={color}
|
||||||
|
fill={color}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
strokeWidth={2}
|
||||||
|
stackId={chart.stacked ? '1' : undefined}
|
||||||
|
isAnimationActive={false}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'bar':
|
||||||
|
return (
|
||||||
|
<Bar
|
||||||
|
key={key}
|
||||||
|
dataKey={key}
|
||||||
|
fill={color}
|
||||||
|
stackId={chart.stacked ? '1' : undefined}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const axes = (
|
||||||
|
<>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timeLabel"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={tickFormatter}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={60}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload, label }) => (
|
||||||
|
<ChartTooltipContent
|
||||||
|
active={active}
|
||||||
|
payload={payload}
|
||||||
|
label={label as string}
|
||||||
|
formatter={tooltipFormatter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: '12px' }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (chart.kind) {
|
||||||
|
case 'line':
|
||||||
|
return (
|
||||||
|
<LineChart {...commonProps}>
|
||||||
|
{axes}
|
||||||
|
{seriesElements}
|
||||||
|
</LineChart>
|
||||||
|
);
|
||||||
|
case 'area':
|
||||||
|
return (
|
||||||
|
<AreaChart {...commonProps}>
|
||||||
|
{axes}
|
||||||
|
{seriesElements}
|
||||||
|
</AreaChart>
|
||||||
|
);
|
||||||
|
case 'bar':
|
||||||
|
return (
|
||||||
|
<BarChart {...commonProps}>
|
||||||
|
{axes}
|
||||||
|
{seriesElements}
|
||||||
|
</BarChart>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CardTitle className="text-base">{chart.title}</CardTitle>
|
||||||
|
{chart.description && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<UiTooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
<p className="text-xs">{chart.description}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</UiTooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartSizedContainer height={288}>{(width, height) => renderChart(width, height)}</ChartSizedContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
import type { Dashboard } from '../types/schema';
|
||||||
|
import { useDashboardStore } from '../stores/dashboardStore';
|
||||||
|
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
|
||||||
|
import { useHistoryMetricsStore } from '../stores/historyMetricsStore';
|
||||||
|
import { collectHistoryMetricIds, collectLiveMetricIds, periodKey, periodWindow, deltaHistograms } from '../helpers';
|
||||||
|
import { StatCard } from './StatCard';
|
||||||
|
import { DashboardChart } from './DashboardChart';
|
||||||
|
import { PeriodSelector } from './PeriodSelector';
|
||||||
|
|
||||||
|
interface DashboardViewProps {
|
||||||
|
dashboardId: string;
|
||||||
|
section: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardView({ dashboardId, section }: DashboardViewProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
|
const period = useDashboardStore((s) => s.period);
|
||||||
|
const fetchHistory = useHistoryMetricsStore((s) => s.fetch);
|
||||||
|
const refreshHistory = useHistoryMetricsStore((s) => s.refresh);
|
||||||
|
const historyStatus = useHistoryMetricsStore((s) => s.status);
|
||||||
|
const historyCache = useHistoryMetricsStore((s) => s.cache);
|
||||||
|
const subscribeLive = useLiveMetricsStore((s) => s.subscribe);
|
||||||
|
const unsubscribeLive = useLiveMetricsStore((s) => s.unsubscribe);
|
||||||
|
const liveStatus = useLiveMetricsStore((s) => s.status);
|
||||||
|
const liveError = useLiveMetricsStore((s) => s.error);
|
||||||
|
|
||||||
|
const dashboards = useMemo<Dashboard[]>(() => schema?.dashboards ?? [], [schema]);
|
||||||
|
const dashboard = dashboards.find((d) => d.id === dashboardId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dashboard && dashboards.length > 0) {
|
||||||
|
navigate(`/${section}/Dashboard/${dashboards[0].id}`, { replace: true });
|
||||||
|
}
|
||||||
|
}, [dashboard, dashboards, navigate, section]);
|
||||||
|
|
||||||
|
const historyIds = useMemo(
|
||||||
|
() => (dashboard ? collectHistoryMetricIds(dashboard.cards, dashboard.charts) : new Set<string>()),
|
||||||
|
[dashboard],
|
||||||
|
);
|
||||||
|
const liveIds = useMemo(() => (dashboard ? collectLiveMetricIds(dashboard.cards) : new Set<string>()), [dashboard]);
|
||||||
|
|
||||||
|
const cacheKey = dashboard ? `${dashboard.id}|${periodKey(period)}` : '';
|
||||||
|
const [fetchVersion, setFetchVersion] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dashboard || historyIds.size === 0) return;
|
||||||
|
let cancelled = false;
|
||||||
|
fetchHistory(dashboard.id, period, historyIds).then(() => {
|
||||||
|
if (!cancelled) setFetchVersion((v) => v + 1);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [dashboard, period, historyIds, fetchHistory]);
|
||||||
|
|
||||||
|
const { historySamples, historyWindow } = useMemo(() => {
|
||||||
|
void fetchVersion;
|
||||||
|
const raw = historyCache.get(cacheKey)?.metrics ?? [];
|
||||||
|
return {
|
||||||
|
historySamples: deltaHistograms(raw),
|
||||||
|
historyWindow: periodWindow(period),
|
||||||
|
};
|
||||||
|
}, [historyCache, cacheKey, fetchVersion, period]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (liveIds.size > 0) {
|
||||||
|
subscribeLive(liveIds);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
unsubscribeLive();
|
||||||
|
};
|
||||||
|
}, [liveIds, subscribeLive, unsubscribeLive]);
|
||||||
|
|
||||||
|
const isLoading = historyStatus.get(cacheKey) === 'loading';
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
if (dashboard && historyIds.size > 0) {
|
||||||
|
refreshHistory(dashboard.id, period, historyIds).then(() => setFetchVersion((v) => v + 1));
|
||||||
|
}
|
||||||
|
}, [dashboard, period, historyIds, refreshHistory]);
|
||||||
|
|
||||||
|
if (!dashboard) {
|
||||||
|
if (dashboards.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8 text-muted-foreground">No dashboards configured.</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
{dashboards.length > 1 && (
|
||||||
|
<Tabs value={dashboardId} onValueChange={(id) => navigate(`/${section}/Dashboard/${id}`)}>
|
||||||
|
<TabsList>
|
||||||
|
{dashboards.map((d) => (
|
||||||
|
<TabsTrigger key={d.id} value={d.id}>
|
||||||
|
{d.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
{dashboards.length === 1 && <h1 className="text-xl font-semibold">{dashboard.label}</h1>}
|
||||||
|
|
||||||
|
<PeriodSelector onRefresh={handleRefresh} loading={isLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{liveStatus === 'error' && liveError && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
{liveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dashboard.cards && dashboard.cards.length > 0 && (
|
||||||
|
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(220px,1fr))]">
|
||||||
|
{dashboard.cards.map((card, i) => (
|
||||||
|
<StatCard
|
||||||
|
key={`${card.title}-${i}`}
|
||||||
|
card={card}
|
||||||
|
historySamples={historySamples}
|
||||||
|
historyWindow={historyWindow}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dashboard.charts && dashboard.charts.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{dashboard.charts.map((chart, i) => (
|
||||||
|
<DashboardChart
|
||||||
|
key={`${chart.title}-${i}`}
|
||||||
|
chart={chart}
|
||||||
|
historySamples={historySamples}
|
||||||
|
historyWindow={historyWindow}
|
||||||
|
period={period}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Calendar, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useDashboardStore } from '../stores/dashboardStore';
|
||||||
|
import type { PresetKey } from '../types/metrics';
|
||||||
|
import { presetLabel, PRESET_KEYS } from '../types/metrics';
|
||||||
|
|
||||||
|
interface PeriodSelectorProps {
|
||||||
|
onRefresh: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PeriodSelector({ onRefresh, loading }: PeriodSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const period = useDashboardStore((s) => s.period);
|
||||||
|
const setPreset = useDashboardStore((s) => s.setPreset);
|
||||||
|
const setPeriod = useDashboardStore((s) => s.setPeriod);
|
||||||
|
const [customOpen, setCustomOpen] = useState(false);
|
||||||
|
const [customFrom, setCustomFrom] = useState('');
|
||||||
|
const [customTo, setCustomTo] = useState('');
|
||||||
|
|
||||||
|
const currentValue = period.kind === 'preset' ? period.preset : 'custom';
|
||||||
|
|
||||||
|
const handleSelectChange = (value: string) => {
|
||||||
|
if (value === 'custom') {
|
||||||
|
setCustomOpen(true);
|
||||||
|
} else {
|
||||||
|
setPreset(value as PresetKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyCustom = () => {
|
||||||
|
if (customFrom && customTo) {
|
||||||
|
setPeriod({
|
||||||
|
kind: 'custom',
|
||||||
|
from: new Date(customFrom),
|
||||||
|
to: new Date(customTo),
|
||||||
|
});
|
||||||
|
setCustomOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Popover open={customOpen} onOpenChange={setCustomOpen}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={currentValue} onValueChange={handleSelectChange}>
|
||||||
|
<SelectTrigger className="w-44">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PRESET_KEYS.map((key) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{presetLabel(t, key)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="custom">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
|
{t('dashboard.customEllipsis', 'Custom...')}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<span />
|
||||||
|
</PopoverTrigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PopoverContent className="w-72 p-4" align="end">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium">{t('dashboard.customRange', 'Custom range')}</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="custom-from" className="text-xs">
|
||||||
|
{t('dashboard.from', 'From')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="custom-from"
|
||||||
|
type="datetime-local"
|
||||||
|
value={customFrom}
|
||||||
|
onChange={(e) => setCustomFrom(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="custom-to" className="text-xs">
|
||||||
|
{t('dashboard.to', 'To')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="custom-to"
|
||||||
|
type="datetime-local"
|
||||||
|
value={customTo}
|
||||||
|
onChange={(e) => setCustomTo(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="w-full" onClick={handleApplyCustom}>
|
||||||
|
{t('common.apply', 'Apply')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Button variant="outline" size="icon" onClick={onRefresh} disabled={loading} className="h-9 w-9">
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import * as LucideIcons from 'lucide-react';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
import { LineChart, Line } from 'recharts';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import type { Card as CardSchema } from '../types/schema';
|
||||||
|
import type { Metric } from '../types/metrics';
|
||||||
|
import { cardValue, formatValue, sparklineData, computeDelta } from '../helpers';
|
||||||
|
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
|
||||||
|
import { getChartColor } from '@/components/ui/chart';
|
||||||
|
|
||||||
|
const warnedIcons = new Set<string>();
|
||||||
|
|
||||||
|
function LucideIcon({ name, className }: { name: string; className?: string }) {
|
||||||
|
const formatted = name
|
||||||
|
.split('-')
|
||||||
|
.map((s) => s[0].toUpperCase() + s.slice(1))
|
||||||
|
.join('');
|
||||||
|
const IconComp = (LucideIcons as Record<string, unknown>)[formatted] as LucideIcons.LucideIcon | undefined;
|
||||||
|
if (!IconComp) {
|
||||||
|
if (import.meta.env.DEV && !warnedIcons.has(name)) {
|
||||||
|
warnedIcons.add(name);
|
||||||
|
console.warn(`Unknown icon name: "${name}"`);
|
||||||
|
}
|
||||||
|
return <LucideIcons.HelpCircle className={className} />;
|
||||||
|
}
|
||||||
|
return <IconComp className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
card: CardSchema;
|
||||||
|
historySamples: Metric[];
|
||||||
|
historyWindow: { from: Date; to: Date };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({ card, historySamples, historyWindow }: StatCardProps) {
|
||||||
|
const liveSnapshot = useLiveMetricsStore((s) => s.snapshot);
|
||||||
|
|
||||||
|
const value = useMemo(() => {
|
||||||
|
if (card.source === 'live') {
|
||||||
|
const liveSamples = card.metrics.map((id) => liveSnapshot.get(id)).filter((m): m is Metric => m !== undefined);
|
||||||
|
return cardValue(card, liveSamples);
|
||||||
|
}
|
||||||
|
return cardValue(card, historySamples);
|
||||||
|
}, [card, liveSnapshot, historySamples]);
|
||||||
|
|
||||||
|
const formattedValue = formatValue(value, card.format);
|
||||||
|
|
||||||
|
const { from, to } = historyWindow;
|
||||||
|
|
||||||
|
const sparkline = useMemo(() => {
|
||||||
|
if (card.source !== 'history' || !card.sparkline) return null;
|
||||||
|
return sparklineData(card, historySamples, from, to).map((v, i) => ({
|
||||||
|
v,
|
||||||
|
i,
|
||||||
|
}));
|
||||||
|
}, [card, historySamples, from, to]);
|
||||||
|
|
||||||
|
const delta = useMemo(() => {
|
||||||
|
if (card.source !== 'history' || !card.delta) return null;
|
||||||
|
return computeDelta(card, historySamples, from, to);
|
||||||
|
}, [card, historySamples, from, to]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LucideIcon name={card.icon} className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">{card.title}</span>
|
||||||
|
{card.description && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
<p className="text-xs">{card.description}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-2xl font-bold">{formattedValue}</div>
|
||||||
|
|
||||||
|
{(delta || sparkline) && (
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
{delta && (
|
||||||
|
<Badge variant="secondary" className="text-xs font-normal text-muted-foreground">
|
||||||
|
{delta.direction === 'up'
|
||||||
|
? `\u2191 ${Math.abs(delta.pct)}%`
|
||||||
|
: delta.direction === 'down'
|
||||||
|
? `\u2193 ${Math.abs(delta.pct)}%`
|
||||||
|
: '\u2013'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{sparkline && (
|
||||||
|
<LineChart width={64} height={32} data={sparkline}>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="v"
|
||||||
|
stroke={getChartColor(0)}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Metric, MetricId, Period } from './types/metrics';
|
||||||
|
import type { Card, Series, MetricFormat, Aggregate } from './types/schema';
|
||||||
|
import { PRESET_MS, BUCKET_CONFIG, CUSTOM_BUCKET_COUNT, SPARKLINE_BUCKET_COUNT } from './types/metrics';
|
||||||
|
|
||||||
|
export function metricToScalar(m: Metric): number {
|
||||||
|
if (m['@type'] === 'Histogram') {
|
||||||
|
return m.count === 0 ? 0 : m.sum / m.count;
|
||||||
|
}
|
||||||
|
return m.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deltaHistograms(samples: Metric[]): Metric[] {
|
||||||
|
const nonHistograms: Metric[] = [];
|
||||||
|
const byMetric = new Map<MetricId, Metric[]>();
|
||||||
|
|
||||||
|
for (const m of samples) {
|
||||||
|
if (m['@type'] !== 'Histogram') {
|
||||||
|
nonHistograms.push(m);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let list = byMetric.get(m.metric);
|
||||||
|
if (!list) {
|
||||||
|
list = [];
|
||||||
|
byMetric.set(m.metric, list);
|
||||||
|
}
|
||||||
|
list.push(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [...nonHistograms];
|
||||||
|
|
||||||
|
for (const [, records] of byMetric) {
|
||||||
|
records.sort((a, b) => {
|
||||||
|
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
|
||||||
|
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
|
||||||
|
return ta - tb;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 1; i < records.length; i++) {
|
||||||
|
const prev = records[i - 1] as Metric & { '@type': 'Histogram' };
|
||||||
|
const curr = records[i] as Metric & { '@type': 'Histogram' };
|
||||||
|
const deltaCount = curr.count - prev.count;
|
||||||
|
const deltaSum = curr.sum - prev.sum;
|
||||||
|
|
||||||
|
if (deltaCount <= 0 || deltaSum < 0) continue;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
'@type': 'Histogram',
|
||||||
|
metric: curr.metric,
|
||||||
|
count: deltaCount,
|
||||||
|
sum: deltaSum,
|
||||||
|
timestamp: curr.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function periodWindow(p: Period): { from: Date; to: Date } {
|
||||||
|
if (p.kind === 'custom') return { from: p.from, to: p.to };
|
||||||
|
const to = new Date();
|
||||||
|
const ms = PRESET_MS[p.preset];
|
||||||
|
return { from: new Date(to.getTime() - ms), to };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function periodKey(p: Period): string {
|
||||||
|
if (p.kind === 'custom') return `custom|${p.from.toISOString()}|${p.to.toISOString()}`;
|
||||||
|
return p.preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBucketCount(p: Period): number {
|
||||||
|
if (p.kind === 'custom') return CUSTOM_BUCKET_COUNT;
|
||||||
|
return BUCKET_CONFIG[p.preset];
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregate(values: number[], agg: Aggregate | undefined): number {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
const sum = values.reduce((a, b) => a + b, 0);
|
||||||
|
if (agg === 'avg') return sum / values.length;
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cardValue(card: Card, samples: Metric[]): number {
|
||||||
|
const values = samples.filter((m) => card.metrics.includes(m.metric)).map(metricToScalar);
|
||||||
|
return aggregate(values, card.aggregate);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seriesBucketValue(series: Series, bucketSamples: Metric[]): number | null {
|
||||||
|
const values = bucketSamples.filter((m) => series.metrics.includes(m.metric)).map(metricToScalar);
|
||||||
|
if (values.length === 0) return null;
|
||||||
|
return aggregate(values, series.aggregate);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bucketize(samples: Metric[], from: Date, to: Date, bucketCount: number): Metric[][] {
|
||||||
|
const buckets: Metric[][] = Array.from({ length: bucketCount }, () => []);
|
||||||
|
const span = to.getTime() - from.getTime();
|
||||||
|
if (span <= 0) return buckets;
|
||||||
|
for (const m of samples) {
|
||||||
|
if (!m.timestamp) continue;
|
||||||
|
const t = new Date(m.timestamp).getTime();
|
||||||
|
if (t < from.getTime() || t > to.getTime()) continue;
|
||||||
|
const idx = Math.min(bucketCount - 1, Math.floor(((t - from.getTime()) / span) * bucketCount));
|
||||||
|
buckets[idx].push(m);
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bucketTimestamps(from: Date, to: Date, bucketCount: number): Date[] {
|
||||||
|
const span = to.getTime() - from.getTime();
|
||||||
|
const width = span / bucketCount;
|
||||||
|
return Array.from({ length: bucketCount }, (_, i) => new Date(from.getTime() + width * (i + 0.5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeDelta(
|
||||||
|
card: Card,
|
||||||
|
samples: Metric[],
|
||||||
|
from: Date,
|
||||||
|
to: Date,
|
||||||
|
): { pct: number; direction: 'up' | 'down' | 'neutral' } | null {
|
||||||
|
const mid = new Date((from.getTime() + to.getTime()) / 2);
|
||||||
|
const firstHalf = samples.filter((m) => {
|
||||||
|
if (!m.timestamp) return false;
|
||||||
|
return new Date(m.timestamp).getTime() < mid.getTime();
|
||||||
|
});
|
||||||
|
const secondHalf = samples.filter((m) => {
|
||||||
|
if (!m.timestamp) return false;
|
||||||
|
return new Date(m.timestamp).getTime() >= mid.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = aggregate(firstHalf.filter((m) => card.metrics.includes(m.metric)).map(metricToScalar), card.aggregate);
|
||||||
|
const second = aggregate(
|
||||||
|
secondHalf.filter((m) => card.metrics.includes(m.metric)).map(metricToScalar),
|
||||||
|
card.aggregate,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (first === 0) return null;
|
||||||
|
const pct = ((second - first) / Math.abs(first)) * 100;
|
||||||
|
if (pct === 0) return { pct: 0, direction: 'neutral' };
|
||||||
|
return { pct: Math.round(pct * 10) / 10, direction: pct > 0 ? 'up' : 'down' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sparklineData(card: Card, samples: Metric[], from: Date, to: Date): number[] {
|
||||||
|
const buckets = bucketize(samples, from, to, SPARKLINE_BUCKET_COUNT);
|
||||||
|
return buckets.map((bucket) => {
|
||||||
|
const values = bucket.filter((m) => card.metrics.includes(m.metric)).map(metricToScalar);
|
||||||
|
return aggregate(values, card.aggregate);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatValue(value: number, format: MetricFormat): string {
|
||||||
|
switch (format) {
|
||||||
|
case 'number': {
|
||||||
|
if (Number.isInteger(value) && Math.abs(value) < 1000) {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
notation: Math.abs(value) >= 1000 ? 'compact' : 'standard',
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
case 'bytes': {
|
||||||
|
if (value === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
for (let i = units.length - 1; i >= 0; i--) {
|
||||||
|
const factor = 1024 ** i;
|
||||||
|
const v = value / factor;
|
||||||
|
if (v >= 1) {
|
||||||
|
const decimals = v < 10 ? 1 : 0;
|
||||||
|
return `${v.toFixed(decimals)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${value} B`;
|
||||||
|
}
|
||||||
|
case 'duration': {
|
||||||
|
if (value === 0) return '0 ms';
|
||||||
|
if (value < 1000) return `${Math.round(value)} ms`;
|
||||||
|
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
|
||||||
|
const m = Math.floor(value / 60_000);
|
||||||
|
const s = Math.floor((value % 60_000) / 1000);
|
||||||
|
if (m < 60) return s > 0 ? `${m} m ${s} s` : `${m} m`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
const rm = m % 60;
|
||||||
|
return rm > 0 ? `${h} h ${rm} m` : `${h} h`;
|
||||||
|
}
|
||||||
|
case 'percent':
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
default:
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTimeTick(date: Date, period: Period): string {
|
||||||
|
if (period.kind === 'preset') {
|
||||||
|
switch (period.preset) {
|
||||||
|
case '24h':
|
||||||
|
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
default:
|
||||||
|
return date.toLocaleDateString(undefined, { month: '2-digit', day: '2-digit' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const span = period.to.getTime() - period.from.getTime();
|
||||||
|
if (span <= 86_400_000) {
|
||||||
|
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString(undefined, { month: '2-digit', day: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectHistoryMetricIds(
|
||||||
|
cards: Card[] | undefined,
|
||||||
|
charts: import('./types/schema').Chart[] | undefined,
|
||||||
|
): Set<MetricId> {
|
||||||
|
const ids = new Set<MetricId>();
|
||||||
|
if (cards) {
|
||||||
|
for (const card of cards) {
|
||||||
|
if (card.source === 'history') {
|
||||||
|
for (const id of card.metrics) ids.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (charts) {
|
||||||
|
for (const chart of charts) {
|
||||||
|
for (const series of chart.series) {
|
||||||
|
for (const id of series.metrics) ids.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectLiveMetricIds(cards: Card[] | undefined): Set<MetricId> {
|
||||||
|
const ids = new Set<MetricId>();
|
||||||
|
if (cards) {
|
||||||
|
for (const card of cards) {
|
||||||
|
if (card.source === 'live') {
|
||||||
|
for (const id of card.metrics) ids.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import type { Period, PresetKey } from '../types/metrics';
|
||||||
|
|
||||||
|
interface DashboardState {
|
||||||
|
period: Period;
|
||||||
|
setPeriod: (period: Period) => void;
|
||||||
|
setPreset: (preset: PresetKey) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDashboardStore = create<DashboardState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
period: { kind: 'preset', preset: '24h' } as Period,
|
||||||
|
|
||||||
|
setPeriod: (period) => set({ period }),
|
||||||
|
setPreset: (preset) => set({ period: { kind: 'preset', preset } }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'dashboard.period',
|
||||||
|
partialize: (state) => ({ period: state.period }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { Metric, MetricId, Period } from '../types/metrics';
|
||||||
|
import { periodWindow, periodKey } from '../helpers';
|
||||||
|
import { getAccountId, jmapQueryAllAndGet } from '@/services/jmap/client';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
|
||||||
|
type FetchStatus = 'idle' | 'loading' | 'error';
|
||||||
|
const STALE_MS = 60_000;
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
metrics: Metric[];
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryMetricsState {
|
||||||
|
cache: Map<string, CacheEntry>;
|
||||||
|
status: Map<string, FetchStatus>;
|
||||||
|
error: Map<string, string>;
|
||||||
|
|
||||||
|
fetch: (dashboardId: string, period: Period, ids: Set<MetricId>) => Promise<Metric[]>;
|
||||||
|
invalidate: (dashboardId: string) => void;
|
||||||
|
refresh: (dashboardId: string, period: Period, ids: Set<MetricId>) => Promise<Metric[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMetrics(period: Period, ids: Set<MetricId>): Promise<Metric[]> {
|
||||||
|
if (ids.size === 0) return [];
|
||||||
|
|
||||||
|
const { from, to } = periodWindow(period);
|
||||||
|
const accountId = getAccountId('x:Metric');
|
||||||
|
const metricIds = Array.from(ids);
|
||||||
|
|
||||||
|
const result = await jmapQueryAllAndGet('x:Metric', accountId, {
|
||||||
|
filter: {
|
||||||
|
timestampIsGreaterThanOrEqual: from.toISOString(),
|
||||||
|
timestampIsLessThanOrEqual: to.toISOString(),
|
||||||
|
metric: metricIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.list as Metric[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useHistoryMetricsStore = create<HistoryMetricsState>()((set, get) => ({
|
||||||
|
cache: new Map(),
|
||||||
|
status: new Map(),
|
||||||
|
error: new Map(),
|
||||||
|
|
||||||
|
fetch: async (dashboardId, period, ids) => {
|
||||||
|
const key = `${dashboardId}|${periodKey(period)}`;
|
||||||
|
const existing = get().cache.get(key);
|
||||||
|
|
||||||
|
if (existing && Date.now() - existing.fetchedAt < STALE_MS) {
|
||||||
|
return existing.metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (get().status.get(key) === 'loading') {
|
||||||
|
return existing?.metrics ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
status: new Map(state.status).set(key, 'loading'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metrics = await fetchMetrics(period, ids);
|
||||||
|
set((state) => ({
|
||||||
|
cache: new Map(state.cache).set(key, { metrics, fetchedAt: Date.now() }),
|
||||||
|
status: new Map(state.status).set(key, 'idle'),
|
||||||
|
error: new Map([...state.error].filter(([k]) => k !== key)),
|
||||||
|
}));
|
||||||
|
return metrics;
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : i18n.t('dashboard.failedFetchMetrics', 'Failed to fetch metrics');
|
||||||
|
set((state) => ({
|
||||||
|
status: new Map(state.status).set(key, 'error'),
|
||||||
|
error: new Map(state.error).set(key, msg),
|
||||||
|
}));
|
||||||
|
return existing?.metrics ?? [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
invalidate: (dashboardId) => {
|
||||||
|
set((state) => {
|
||||||
|
const newCache = new Map(state.cache);
|
||||||
|
for (const key of newCache.keys()) {
|
||||||
|
if (key.startsWith(`${dashboardId}|`)) {
|
||||||
|
newCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { cache: newCache };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
refresh: async (dashboardId, period, ids) => {
|
||||||
|
const key = `${dashboardId}|${periodKey(period)}`;
|
||||||
|
set((state) => {
|
||||||
|
const newCache = new Map(state.cache);
|
||||||
|
newCache.delete(key);
|
||||||
|
return { cache: newCache };
|
||||||
|
});
|
||||||
|
return get().fetch(dashboardId, period, ids);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { MetricId, Metric } from '../types/metrics';
|
||||||
|
import { apiFetch } from '@/services/api';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
|
||||||
|
type LiveStatus = 'idle' | 'connecting' | 'open' | 'error' | 'closed';
|
||||||
|
|
||||||
|
interface LiveMetricsState {
|
||||||
|
snapshot: Map<MetricId, Metric>;
|
||||||
|
subscribedIds: Set<MetricId>;
|
||||||
|
status: LiveStatus;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
subscribe: (ids: Set<MetricId>) => void;
|
||||||
|
unsubscribe: () => void;
|
||||||
|
handleBatch: (batch: Metric[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventSource: EventSource | null = null;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const MAX_RECONNECT = 5;
|
||||||
|
const RECONNECT_DELAY = 2000;
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openStream(ids: Set<MetricId>) {
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
if (ids.size === 0) {
|
||||||
|
useLiveMetricsStore.setState({ status: 'idle', subscribedIds: ids });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
useLiveMetricsStore.setState({ status: 'connecting', subscribedIds: ids, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await apiFetch('/api/token/metrics');
|
||||||
|
const token = await tokenResponse.text();
|
||||||
|
|
||||||
|
const metricsParam = Array.from(ids).join(',');
|
||||||
|
const url = `${getOrigin()}/api/live/metrics?token=${encodeURIComponent(token)}&interval=30&metrics=${encodeURIComponent(metricsParam)}`;
|
||||||
|
|
||||||
|
const es = new EventSource(url);
|
||||||
|
eventSource = es;
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
|
||||||
|
es.addEventListener('metrics', (event) => {
|
||||||
|
try {
|
||||||
|
const batch = JSON.parse(event.data) as Metric[];
|
||||||
|
useLiveMetricsStore.getState().handleBatch(batch);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse live metrics:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onopen = () => {
|
||||||
|
useLiveMetricsStore.setState({ status: 'open' });
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
if (es !== eventSource) return;
|
||||||
|
es.close();
|
||||||
|
eventSource = null;
|
||||||
|
|
||||||
|
if (reconnectAttempts < MAX_RECONNECT) {
|
||||||
|
reconnectAttempts++;
|
||||||
|
useLiveMetricsStore.setState({ status: 'connecting' });
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
const currentIds = useLiveMetricsStore.getState().subscribedIds;
|
||||||
|
openStream(currentIds);
|
||||||
|
}, RECONNECT_DELAY);
|
||||||
|
} else {
|
||||||
|
useLiveMetricsStore.setState({
|
||||||
|
status: 'error',
|
||||||
|
error: i18n.t(
|
||||||
|
'dashboard.liveMetricsDisconnected',
|
||||||
|
'Live metrics stream disconnected after multiple retries.',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
useLiveMetricsStore.setState({
|
||||||
|
status: 'error',
|
||||||
|
error:
|
||||||
|
e instanceof Error ? e.message : i18n.t('dashboard.liveMetricsFailed', 'Failed to connect to live metrics.'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrigin(): string {
|
||||||
|
const envUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
|
||||||
|
if (envUrl && envUrl.length > 0) return envUrl.replace(/\/+$/, '');
|
||||||
|
return window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
let visibilityTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.hidden) {
|
||||||
|
visibilityTimer = setTimeout(() => {
|
||||||
|
if (eventSource) {
|
||||||
|
cleanup();
|
||||||
|
useLiveMetricsStore.setState({ status: 'closed' });
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
} else {
|
||||||
|
if (visibilityTimer) {
|
||||||
|
clearTimeout(visibilityTimer);
|
||||||
|
visibilityTimer = null;
|
||||||
|
}
|
||||||
|
const state = useLiveMetricsStore.getState();
|
||||||
|
if (state.status === 'closed' && state.subscribedIds.size > 0) {
|
||||||
|
openStream(state.subscribedIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLiveMetricsStore = create<LiveMetricsState>()((set) => ({
|
||||||
|
snapshot: new Map(),
|
||||||
|
subscribedIds: new Set(),
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
subscribe: (ids) => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
openStream(ids);
|
||||||
|
}, 200);
|
||||||
|
},
|
||||||
|
|
||||||
|
unsubscribe: () => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
cleanup();
|
||||||
|
set({ snapshot: new Map(), subscribedIds: new Set(), status: 'idle', error: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
handleBatch: (batch) => {
|
||||||
|
set((state) => {
|
||||||
|
const next = new Map(state.snapshot);
|
||||||
|
for (const m of batch) {
|
||||||
|
next.set(m.metric, m);
|
||||||
|
}
|
||||||
|
return { snapshot: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MetricId = string;
|
||||||
|
|
||||||
|
export type Metric =
|
||||||
|
| { '@type': 'Counter'; metric: MetricId; count: number; timestamp?: string }
|
||||||
|
| { '@type': 'Gauge'; metric: MetricId; count: number; timestamp?: string }
|
||||||
|
| { '@type': 'Histogram'; metric: MetricId; count: number; sum: number; timestamp?: string };
|
||||||
|
|
||||||
|
export type Period = { kind: 'preset'; preset: PresetKey } | { kind: 'custom'; from: Date; to: Date };
|
||||||
|
|
||||||
|
export type PresetKey = '24h' | '7d' | '30d' | '90d';
|
||||||
|
|
||||||
|
export const PRESET_MS: Record<PresetKey, number> = {
|
||||||
|
'24h': 86_400_000,
|
||||||
|
'7d': 7 * 86_400_000,
|
||||||
|
'30d': 30 * 86_400_000,
|
||||||
|
'90d': 90 * 86_400_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function presetLabel(t: (key: string, fallback: string) => string, key: PresetKey): string {
|
||||||
|
switch (key) {
|
||||||
|
case '24h':
|
||||||
|
return t('dashboard.preset24h', 'Last 24 hours');
|
||||||
|
case '7d':
|
||||||
|
return t('dashboard.preset7d', 'Last 7 days');
|
||||||
|
case '30d':
|
||||||
|
return t('dashboard.preset30d', 'Last 30 days');
|
||||||
|
case '90d':
|
||||||
|
return t('dashboard.preset90d', 'Last 90 days');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRESET_KEYS: PresetKey[] = ['24h', '7d', '30d', '90d'];
|
||||||
|
|
||||||
|
export const BUCKET_CONFIG: Record<PresetKey, number> = {
|
||||||
|
'24h': 48,
|
||||||
|
'7d': 56,
|
||||||
|
'30d': 60,
|
||||||
|
'90d': 90,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CUSTOM_BUCKET_COUNT = 60;
|
||||||
|
export const SPARKLINE_BUCKET_COUNT = 20;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Dashboard = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
cards?: Card[];
|
||||||
|
charts?: Chart[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Card = {
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
source: CardSource;
|
||||||
|
metrics: string[];
|
||||||
|
aggregate?: Aggregate;
|
||||||
|
format: MetricFormat;
|
||||||
|
description?: string;
|
||||||
|
sparkline?: boolean;
|
||||||
|
delta?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Chart = {
|
||||||
|
title: string;
|
||||||
|
kind: ChartKind;
|
||||||
|
series: Series[];
|
||||||
|
stacked?: boolean;
|
||||||
|
valueFormat?: MetricFormat;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Series = {
|
||||||
|
label: string;
|
||||||
|
metrics: string[];
|
||||||
|
aggregate?: Aggregate;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Aggregate = 'sum' | 'avg';
|
||||||
|
export type ChartKind = 'line' | 'area' | 'bar';
|
||||||
|
export type CardSource = 'live' | 'history';
|
||||||
|
export type MetricFormat = 'number' | 'bytes' | 'duration' | 'percent';
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Play, Square, RotateCcw, Plus, Check, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
import { apiFetch, getApiBaseUrl } from '@/services/api';
|
||||||
|
import type { TraceEvent } from '../types';
|
||||||
|
import { TraceTimeline } from './TraceTimeline';
|
||||||
|
|
||||||
|
const MAX_EVENTS = 1000;
|
||||||
|
const MAX_RECONNECT = 5;
|
||||||
|
const RECONNECT_DELAY = 2000;
|
||||||
|
|
||||||
|
interface KeyValueFilter {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveTracingState =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'starting'; keywords: string; filters: KeyValueFilter[] }
|
||||||
|
| { kind: 'streaming'; events: TraceEvent[]; anchorTimestamp: string; eventSource: EventSource }
|
||||||
|
| { kind: 'stopped'; events: TraceEvent[]; anchorTimestamp: string }
|
||||||
|
| { kind: 'error'; events: TraceEvent[]; error: string };
|
||||||
|
|
||||||
|
export function LiveTracingPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
|
const [state, setState] = useState<LiveTracingState>({ kind: 'idle' });
|
||||||
|
const [keywords, setKeywords] = useState('');
|
||||||
|
const [filters, setFilters] = useState<KeyValueFilter[]>([]);
|
||||||
|
const [addingFilter, setAddingFilter] = useState(false);
|
||||||
|
const [newFilterKey, setNewFilterKey] = useState('');
|
||||||
|
const [newFilterValue, setNewFilterValue] = useState('');
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
const eventsRef = useRef<TraceEvent[]>([]);
|
||||||
|
const anchorRef = useRef<string>('');
|
||||||
|
const reconnectAttempts = useRef(0);
|
||||||
|
|
||||||
|
const keyEnum = useMemo(() => schema?.enums?.['Key'] ?? [], [schema]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openStream = useCallback(async () => {
|
||||||
|
setState({ kind: 'starting', keywords, filters });
|
||||||
|
eventsRef.current = [];
|
||||||
|
anchorRef.current = '';
|
||||||
|
reconnectAttempts.current = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await apiFetch('/api/token/tracing');
|
||||||
|
const token = await tokenResponse.text();
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('token', token);
|
||||||
|
if (keywords.trim()) {
|
||||||
|
params.set('filter', keywords.trim());
|
||||||
|
}
|
||||||
|
for (const f of filters) {
|
||||||
|
params.set(f.key, f.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = getApiBaseUrl();
|
||||||
|
const url = `${baseUrl}/api/live/tracing?${params.toString()}`;
|
||||||
|
|
||||||
|
const connectEventSource = () => {
|
||||||
|
const es = new EventSource(url);
|
||||||
|
eventSourceRef.current = es;
|
||||||
|
|
||||||
|
es.addEventListener('trace', (event) => {
|
||||||
|
try {
|
||||||
|
const batch = JSON.parse(event.data) as TraceEvent[];
|
||||||
|
const currentEvents = eventsRef.current;
|
||||||
|
|
||||||
|
for (const evt of batch) {
|
||||||
|
currentEvents.push(evt);
|
||||||
|
if (!anchorRef.current) {
|
||||||
|
anchorRef.current = evt.timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentEvents.length > MAX_EVENTS) {
|
||||||
|
eventsRef.current = currentEvents.slice(currentEvents.length - MAX_EVENTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({
|
||||||
|
kind: 'streaming',
|
||||||
|
events: [...eventsRef.current],
|
||||||
|
anchorTimestamp: anchorRef.current,
|
||||||
|
eventSource: es,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse trace event:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onopen = () => {
|
||||||
|
reconnectAttempts.current = 0;
|
||||||
|
setState({
|
||||||
|
kind: 'streaming',
|
||||||
|
events: [...eventsRef.current],
|
||||||
|
anchorTimestamp: anchorRef.current || '',
|
||||||
|
eventSource: es,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
if (es !== eventSourceRef.current) return;
|
||||||
|
es.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
|
||||||
|
if (reconnectAttempts.current < MAX_RECONNECT) {
|
||||||
|
reconnectAttempts.current++;
|
||||||
|
setTimeout(connectEventSource, RECONNECT_DELAY);
|
||||||
|
} else {
|
||||||
|
setState({
|
||||||
|
kind: 'error',
|
||||||
|
events: [...eventsRef.current],
|
||||||
|
error: t('tracing.liveDisconnected', 'Live tracing stream disconnected after multiple retries.'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connectEventSource();
|
||||||
|
} catch (e) {
|
||||||
|
setState({
|
||||||
|
kind: 'error',
|
||||||
|
events: [],
|
||||||
|
error: e instanceof Error ? e.message : t('tracing.liveFailedStart', 'Failed to start live tracing.'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [keywords, filters, t]);
|
||||||
|
|
||||||
|
const stopStream = useCallback(() => {
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
setState((prev) => {
|
||||||
|
if (prev.kind === 'streaming') {
|
||||||
|
return {
|
||||||
|
kind: 'stopped',
|
||||||
|
events: prev.events,
|
||||||
|
anchorTimestamp: prev.anchorTimestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetToIdle = useCallback(() => {
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
eventsRef.current = [];
|
||||||
|
anchorRef.current = '';
|
||||||
|
setState({ kind: 'idle' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addFilter = useCallback(() => {
|
||||||
|
if (!newFilterKey || !newFilterValue.trim()) return;
|
||||||
|
const keyEntry = keyEnum.find((e) => e.name === newFilterKey);
|
||||||
|
setFilters((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
key: newFilterKey,
|
||||||
|
label: keyEntry?.label ?? newFilterKey,
|
||||||
|
value: newFilterValue.trim(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setNewFilterKey('');
|
||||||
|
setNewFilterValue('');
|
||||||
|
setAddingFilter(false);
|
||||||
|
}, [newFilterKey, newFilterValue, keyEnum]);
|
||||||
|
|
||||||
|
const removeFilter = useCallback((idx: number) => {
|
||||||
|
setFilters((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isIdle = state.kind === 'idle';
|
||||||
|
const isStarting = state.kind === 'starting';
|
||||||
|
const isStreaming = state.kind === 'streaming';
|
||||||
|
const isStopped = state.kind === 'stopped';
|
||||||
|
const isError = state.kind === 'error';
|
||||||
|
|
||||||
|
if (isIdle || isStarting) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6 pt-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">{t('tracing.liveTitle', 'Live Tracing')}</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
{t('tracing.liveSubtitle', 'Stream server events in real time.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">{t('tracing.keywordsLabel', 'Keywords (optional)')}</label>
|
||||||
|
<Input
|
||||||
|
placeholder={t('tracing.keywordsPlaceholder', 'Enter keywords to filter events...')}
|
||||||
|
value={keywords}
|
||||||
|
onChange={(e) => setKeywords(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filters.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{filters.map((f, i) => (
|
||||||
|
<Badge key={i} variant="secondary" className="gap-1">
|
||||||
|
{f.label} = {f.value}
|
||||||
|
<button onClick={() => removeFilter(i)} className="ml-1 hover:text-destructive">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addingFilter ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={newFilterKey} onValueChange={setNewFilterKey}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue placeholder={t('field.selectKey', 'Select key...')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-60">
|
||||||
|
{keyEnum.map((entry) => (
|
||||||
|
<SelectItem key={entry.name} value={entry.name}>
|
||||||
|
{entry.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
placeholder={t('tracing.valuePlaceholder', 'Value...')}
|
||||||
|
value={newFilterValue}
|
||||||
|
onChange={(e) => setNewFilterValue(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') addFilter();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button size="icon" variant="ghost" onClick={addFilter}>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => setAddingFilter(false)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setAddingFilter(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{t('tracing.addFilter', 'Add filter')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button className="w-full" onClick={openStream} disabled={isStarting}>
|
||||||
|
{isStarting ? (
|
||||||
|
<>
|
||||||
|
<span className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
{t('tracing.connecting', 'Connecting...')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
{t('tracing.startTracing', 'Start tracing')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = isStreaming ? state.events : isStopped ? state.events : isError ? state.events : [];
|
||||||
|
const anchor = isStreaming ? state.anchorTimestamp : isStopped ? state.anchorTimestamp : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-lg font-semibold">{t('tracing.liveTitle', 'Live Tracing')}</h2>
|
||||||
|
{isStreaming && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
</span>
|
||||||
|
{t('tracing.streamingEvents', 'Streaming events...')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isStopped && <span className="text-sm text-muted-foreground">{t('tracing.stopped', 'Stopped')}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{isStreaming && (
|
||||||
|
<Button variant="outline" onClick={stopStream}>
|
||||||
|
<Square className="mr-2 h-4 w-4" />
|
||||||
|
{t('tracing.stopTracing', 'Stop tracing')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(isStopped || isError) && (
|
||||||
|
<Button variant="outline" onClick={resetToIdle}>
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
{t('tracing.restart', 'Restart')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{state.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<TraceTimeline events={events} anchorTimestamp={anchor} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
{events.length === 1
|
||||||
|
? t('tracing.eventCount_one', '{{count}} event', { count: events.length })
|
||||||
|
: t('tracing.eventCount_other', '{{count}} events', { count: events.length })}
|
||||||
|
{events.length >= MAX_EVENTS && ' ' + t('tracing.bufferFull', '(buffer full)')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ArrowLeft, FileText, Clock, Timer, Activity, Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
import { jmapGet, getAccountId } from '@/services/jmap/client';
|
||||||
|
import { resolveObject } from '@/lib/schemaResolver';
|
||||||
|
import type { TraceEvent, TraceKeyValue, TraceValue } from '../types';
|
||||||
|
import { TraceTimeline } from './TraceTimeline';
|
||||||
|
|
||||||
|
import { jmapMapToArray } from '@/lib/jmapUtils';
|
||||||
|
|
||||||
|
function normalizeTraceEvents(raw: unknown): TraceEvent[] {
|
||||||
|
const events = jmapMapToArray<Record<string, unknown>>(raw);
|
||||||
|
return events.map((evt) => ({
|
||||||
|
event: String(evt.event ?? ''),
|
||||||
|
timestamp: String(evt.timestamp ?? ''),
|
||||||
|
keyValues: normalizeKeyValues(evt.keyValues),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKeyValues(raw: unknown): TraceKeyValue[] {
|
||||||
|
const kvs = jmapMapToArray<Record<string, unknown>>(raw);
|
||||||
|
return kvs.map((kv) => ({
|
||||||
|
key: String(kv.key ?? ''),
|
||||||
|
value: normalizeTraceValue(kv.value),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTraceValue(raw: unknown): TraceValue {
|
||||||
|
if (!raw || typeof raw !== 'object') return { '@type': 'Null' };
|
||||||
|
const obj = raw as Record<string, unknown>;
|
||||||
|
const type = obj['@type'] as string;
|
||||||
|
|
||||||
|
if (type === 'List') {
|
||||||
|
return { '@type': 'List', value: jmapMapToArray<unknown>(obj.value).map(normalizeTraceValue) };
|
||||||
|
}
|
||||||
|
if (type === 'Event') {
|
||||||
|
return { '@type': 'Event', event: String(obj.event ?? ''), value: normalizeKeyValues(obj.value) };
|
||||||
|
}
|
||||||
|
return raw as TraceValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TraceDetailViewProps {
|
||||||
|
viewName: string;
|
||||||
|
objectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
|
const [events, setEvents] = useState<TraceEvent[] | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!schema) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const resolved = resolveObject(schema!, viewName);
|
||||||
|
if (!resolved) throw new Error(t('view.couldNotResolve', 'Could not resolve object'));
|
||||||
|
|
||||||
|
const accountId = getAccountId(resolved.objectName);
|
||||||
|
const responses = await jmapGet(resolved.objectName, accountId, [objectId]);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const getResult = responses.find(([name]) => name.endsWith('/get'));
|
||||||
|
if (!getResult) throw new Error(t('view.noGetResponse', 'No get response'));
|
||||||
|
|
||||||
|
const data = getResult[1] as { list?: Array<{ id: string; events?: unknown }> };
|
||||||
|
const trace = data.list?.[0];
|
||||||
|
if (!trace) throw new Error(t('tracing.traceNotFound', 'Trace not found'));
|
||||||
|
|
||||||
|
setEvents(normalizeTraceEvents(trace.events));
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(e instanceof Error ? e.message : t('tracing.failedToLoadTrace', 'Failed to load trace'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [schema, viewName, objectId, t]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
{t('common.back', 'Back')}
|
||||||
|
</Button>
|
||||||
|
<div className="text-destructive p-4">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!events) return null;
|
||||||
|
|
||||||
|
const eventTypeEnum = schema?.enums?.['EventType'] ?? [];
|
||||||
|
const firstEvent = events[0];
|
||||||
|
const lastEvent = events[events.length - 1];
|
||||||
|
|
||||||
|
const spanLabel = firstEvent
|
||||||
|
? (eventTypeEnum.find((e) => e.name === firstEvent.event)?.label ?? firstEvent.event)
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
const dateStr = firstEvent
|
||||||
|
? new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'medium',
|
||||||
|
}).format(new Date(firstEvent.timestamp))
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
const durationMs =
|
||||||
|
firstEvent && lastEvent && events.length >= 2
|
||||||
|
? new Date(lastEvent.timestamp).getTime() - new Date(firstEvent.timestamp).getTime()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||||
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
||||||
|
const m = Math.floor(ms / 60_000);
|
||||||
|
const s = Math.floor((ms % 60_000) / 1000);
|
||||||
|
return `${m}m ${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate(-1)}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
{t('common.back', 'Back')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(180px,1fr))]">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
{t('tracing.spanType', 'Span Type')}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-lg font-semibold truncate">{spanLabel}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
{t('tracing.date', 'Date')}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-lg font-semibold">{dateStr}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Timer className="h-4 w-4" />
|
||||||
|
{t('tracing.duration', 'Duration')}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-lg font-semibold">{durationMs !== null ? formatDuration(durationMs) : '-'}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
{t('tracing.events', 'Events')}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-lg font-semibold">{events.length}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<TraceTimeline events={events} anchorTimestamp={firstEvent?.timestamp} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { CircleDot, CircleX, Clock } from 'lucide-react';
|
||||||
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
import type { TraceEvent, TraceKeyValue, TraceValue } from '../types';
|
||||||
|
|
||||||
|
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
|
||||||
|
|
||||||
|
function intToBase32(input: number | string): string {
|
||||||
|
let n: bigint;
|
||||||
|
try {
|
||||||
|
n = BigInt(typeof input === 'number' ? Math.floor(input) : input);
|
||||||
|
} catch {
|
||||||
|
return String(input);
|
||||||
|
}
|
||||||
|
if (n === 0n) return 'a';
|
||||||
|
const chars: string[] = [];
|
||||||
|
while (n > 0n) {
|
||||||
|
chars.push(BASE32_ALPHABET[Number(n % 32n)]);
|
||||||
|
n = n / 32n;
|
||||||
|
}
|
||||||
|
return chars.reverse().join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdKey(key: string): boolean {
|
||||||
|
return key === 'id' || key.endsWith('Id');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TraceTimelineProps {
|
||||||
|
events: TraceEvent[];
|
||||||
|
anchorTimestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TraceTimeline({ events, anchorTimestamp }: TraceTimelineProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const wasAtBottomRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (wasAtBottomRef.current) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [events.length]);
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
wasAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
||||||
|
{t('tracing.noEvents', 'No trace events to display.')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchorMs = anchorTimestamp ? new Date(anchorTimestamp).getTime() : new Date(events[0].timestamp).getTime();
|
||||||
|
|
||||||
|
const eventTypeEnum = schema?.enums?.['EventType'] ?? [];
|
||||||
|
const keyEnum = schema?.enums?.['Key'] ?? [];
|
||||||
|
|
||||||
|
function resolveEventLabel(eventType: string): { label: string; explanation?: string } {
|
||||||
|
const entry = eventTypeEnum.find((e) => e.name === eventType);
|
||||||
|
if (entry) return { label: entry.label, explanation: entry.explanation };
|
||||||
|
return { label: eventType };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveKeyLabel(key: string): string {
|
||||||
|
const entry = keyEnum.find((e) => e.name === key);
|
||||||
|
return entry?.label ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventIcon(eventType: string) {
|
||||||
|
if (
|
||||||
|
eventType.includes('error') ||
|
||||||
|
eventType.includes('failed') ||
|
||||||
|
eventType.includes('invalid') ||
|
||||||
|
eventType.includes('reject')
|
||||||
|
) {
|
||||||
|
return <CircleX className="h-4 w-4 text-red-500" />;
|
||||||
|
}
|
||||||
|
if (eventType.endsWith('-start') || eventType.endsWith('-end')) {
|
||||||
|
return <Clock className="h-4 w-4 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
return <CircleDot className="h-4 w-4 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAbsoluteTime(ts: string): string {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toLocaleTimeString(undefined, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
fractionalSecondDigits: 3,
|
||||||
|
} as Intl.DateTimeFormatOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(ts: string): string {
|
||||||
|
const ms = new Date(ts).getTime() - anchorMs;
|
||||||
|
if (ms <= 0) return '(+0 ms)';
|
||||||
|
if (ms < 1000) return `(+${Math.round(ms)} ms)`;
|
||||||
|
if (ms < 60_000) return `(+${(ms / 1000).toFixed(1)} s)`;
|
||||||
|
const m = Math.floor(ms / 60_000);
|
||||||
|
const s = Math.floor((ms % 60_000) / 1000);
|
||||||
|
return `(+${m} m ${s} s)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div ref={containerRef} onScroll={handleScroll} className="max-h-[70vh] overflow-y-auto scroll-smooth">
|
||||||
|
<div className="space-y-4 p-2">
|
||||||
|
{events.map((event, idx) => {
|
||||||
|
const { label, explanation } = resolveEventLabel(event.event);
|
||||||
|
const isFallback = !eventTypeEnum.find((e) => e.name === event.event);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className="flex gap-3">
|
||||||
|
<div className="mt-1 shrink-0">{eventIcon(event.event)}</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
{isFallback ? (
|
||||||
|
<span className="font-mono text-sm bg-muted px-1.5 py-0.5 rounded">{label}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-semibold">{label}</span>
|
||||||
|
)}
|
||||||
|
{explanation && <p className="text-xs text-muted-foreground mt-0.5">{explanation}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<div className="text-xs text-muted-foreground">{formatAbsoluteTime(event.timestamp)}</div>
|
||||||
|
<div className="text-xs text-muted-foreground/60">{formatRelativeTime(event.timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{event.keyValues.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{event.keyValues.map((kv, ki) => (
|
||||||
|
<TraceKeyValueRow
|
||||||
|
key={ki}
|
||||||
|
kv={kv}
|
||||||
|
resolveKeyLabel={resolveKeyLabel}
|
||||||
|
resolveEventLabel={resolveEventLabel}
|
||||||
|
depth={0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-card to-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TlsBoolean({ on }: { on: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <>{on ? t('tracing.enabled', 'enabled') : t('tracing.disabled', 'disabled')}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraceKeyValueRow({
|
||||||
|
kv,
|
||||||
|
resolveKeyLabel,
|
||||||
|
resolveEventLabel,
|
||||||
|
depth,
|
||||||
|
}: {
|
||||||
|
kv: TraceKeyValue;
|
||||||
|
resolveKeyLabel: (key: string) => string;
|
||||||
|
resolveEventLabel: (event: string) => { label: string; explanation?: string };
|
||||||
|
depth: number;
|
||||||
|
}) {
|
||||||
|
const label = resolveKeyLabel(kv.key);
|
||||||
|
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 text-sm" style={{ paddingLeft: depth * 16 }}>
|
||||||
|
<span className="shrink-0 text-muted-foreground text-xs min-w-24 text-right pt-0.5">{label}</span>
|
||||||
|
<span className="text-xs pt-0.5">
|
||||||
|
<TraceValueDisplay
|
||||||
|
keyName={kv.key}
|
||||||
|
value={kv.value}
|
||||||
|
resolveKeyLabel={resolveKeyLabel}
|
||||||
|
resolveEventLabel={resolveEventLabel}
|
||||||
|
viewToSection={viewToSection}
|
||||||
|
depth={depth}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraceValueDisplay({
|
||||||
|
keyName,
|
||||||
|
value,
|
||||||
|
resolveKeyLabel,
|
||||||
|
resolveEventLabel,
|
||||||
|
viewToSection,
|
||||||
|
depth,
|
||||||
|
}: {
|
||||||
|
keyName: string;
|
||||||
|
value: TraceValue;
|
||||||
|
resolveKeyLabel: (key: string) => string;
|
||||||
|
resolveEventLabel: (event: string) => { label: string; explanation?: string };
|
||||||
|
viewToSection: Record<string, string>;
|
||||||
|
depth: number;
|
||||||
|
}) {
|
||||||
|
switch (value['@type']) {
|
||||||
|
case 'String':
|
||||||
|
return <>{value.value}</>;
|
||||||
|
case 'UnsignedInt':
|
||||||
|
case 'Integer': {
|
||||||
|
const raw = value.value;
|
||||||
|
const numericInput =
|
||||||
|
typeof raw === 'object' && raw !== null && 'source' in (raw as Record<string, unknown>)
|
||||||
|
? ((raw as Record<string, unknown>).source as string)
|
||||||
|
: raw;
|
||||||
|
|
||||||
|
if (isIdKey(keyName)) {
|
||||||
|
const encoded = intToBase32(numericInput as number | string);
|
||||||
|
if (keyName === 'queueId') {
|
||||||
|
const section = viewToSection['x:QueuedMessage'] ?? 'Management';
|
||||||
|
return (
|
||||||
|
<Link to={`/${section}/x:QueuedMessage/${encoded}`} className="text-primary underline hover:no-underline">
|
||||||
|
{encoded}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="font-mono">{encoded}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayNum = typeof numericInput === 'number' ? numericInput : Number(numericInput);
|
||||||
|
return <>{displayNum.toLocaleString()}</>;
|
||||||
|
}
|
||||||
|
case 'Boolean': {
|
||||||
|
if (keyName === 'tls') {
|
||||||
|
return <TlsBoolean on={value.value} />;
|
||||||
|
}
|
||||||
|
return <>{String(value.value)}</>;
|
||||||
|
}
|
||||||
|
case 'Float':
|
||||||
|
return <>{value.value.toFixed(2)}</>;
|
||||||
|
case 'UTCDateTime':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'medium',
|
||||||
|
}).format(new Date(value.value))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'Duration': {
|
||||||
|
const ms = value.value;
|
||||||
|
if (ms < 1000) return <>{Math.round(ms)} ms</>;
|
||||||
|
if (ms < 60_000) return <>{(ms / 1000).toFixed(1)} s</>;
|
||||||
|
const m = Math.floor(ms / 60_000);
|
||||||
|
const s = Math.floor((ms % 60_000) / 1000);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{m} m {s} s
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'IpAddr':
|
||||||
|
return <>{value.value}</>;
|
||||||
|
case 'List': {
|
||||||
|
if (value.value.length <= 3) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{value.value.map((v, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 && ', '}
|
||||||
|
<TraceValueDisplay
|
||||||
|
keyName={keyName}
|
||||||
|
value={v}
|
||||||
|
resolveKeyLabel={resolveKeyLabel}
|
||||||
|
resolveEventLabel={resolveEventLabel}
|
||||||
|
viewToSection={viewToSection}
|
||||||
|
depth={depth}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{value.value.map((v, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<TraceValueDisplay
|
||||||
|
keyName={keyName}
|
||||||
|
value={v}
|
||||||
|
resolveKeyLabel={resolveKeyLabel}
|
||||||
|
resolveEventLabel={resolveEventLabel}
|
||||||
|
viewToSection={viewToSection}
|
||||||
|
depth={depth}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'Event': {
|
||||||
|
if (depth >= 2) {
|
||||||
|
return (
|
||||||
|
<pre className="text-xs bg-muted/30 p-2 rounded whitespace-pre-wrap">
|
||||||
|
{JSON.stringify({ event: value.event, values: value.value }, null, 2)}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { label, explanation } = resolveEventLabel(value.event);
|
||||||
|
return (
|
||||||
|
<div className="mt-1 border-l-2 border-muted pl-3 space-y-1">
|
||||||
|
<div className="text-xs font-semibold">{label}</div>
|
||||||
|
{explanation && <div className="text-xs text-muted-foreground">{explanation}</div>}
|
||||||
|
{value.value.map((kv, i) => (
|
||||||
|
<TraceKeyValueRow
|
||||||
|
key={i}
|
||||||
|
kv={kv}
|
||||||
|
resolveKeyLabel={resolveKeyLabel}
|
||||||
|
resolveEventLabel={resolveEventLabel}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'Null':
|
||||||
|
return <span className="text-muted-foreground">—</span>;
|
||||||
|
default:
|
||||||
|
return <>{JSON.stringify(value)}</>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Trace {
|
||||||
|
events: TraceEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraceEvent {
|
||||||
|
event: string;
|
||||||
|
keyValues: TraceKeyValue[];
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TraceKeyValue {
|
||||||
|
key: string;
|
||||||
|
value: TraceValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TraceValue =
|
||||||
|
| { '@type': 'String'; value: string }
|
||||||
|
| { '@type': 'UnsignedInt'; value: number }
|
||||||
|
| { '@type': 'Integer'; value: number }
|
||||||
|
| { '@type': 'Boolean'; value: boolean }
|
||||||
|
| { '@type': 'Float'; value: number }
|
||||||
|
| { '@type': 'UTCDateTime'; value: string }
|
||||||
|
| { '@type': 'Duration'; value: number }
|
||||||
|
| { '@type': 'IpAddr'; value: string }
|
||||||
|
| { '@type': 'List'; value: TraceValue[] }
|
||||||
|
| { '@type': 'Event'; event: string; value: TraceKeyValue[] }
|
||||||
|
| { '@type': 'Null' };
|
||||||
@@ -0,0 +1,678 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
StopCircle,
|
||||||
|
Loader2,
|
||||||
|
CircleCheck,
|
||||||
|
CircleX,
|
||||||
|
TriangleAlert,
|
||||||
|
ChevronRight,
|
||||||
|
Mail,
|
||||||
|
Globe,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
import { apiFetch, getApiBaseUrl } from '@/services/api';
|
||||||
|
import type { DeliveryStage, MX, ReportUri, StageSeverity } from './types';
|
||||||
|
import { stageSeverity } from './types';
|
||||||
|
|
||||||
|
function SimpleAlert({ variant, children }: { variant: 'default' | 'destructive'; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border p-4 ${
|
||||||
|
variant === 'destructive' ? 'border-destructive/50 bg-destructive/10 text-destructive' : 'border-border bg-card'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TraceState =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'starting'; target: string }
|
||||||
|
| { kind: 'running'; target: string; startedAt: Date; events: DeliveryStage[]; eventSource: EventSource }
|
||||||
|
| { kind: 'completed'; target: string; startedAt: Date; completedAt: Date; events: DeliveryStage[] }
|
||||||
|
| { kind: 'failed'; target: string; events: DeliveryStage[]; error: string };
|
||||||
|
|
||||||
|
function phaseKey(stage: DeliveryStage): string {
|
||||||
|
const t = stage.type;
|
||||||
|
if (t.startsWith('mxLookup')) return 'mxLookup';
|
||||||
|
if (t.startsWith('mtaStsFetch') || t === 'mtaStsNotFound') return 'mtaStsFetch';
|
||||||
|
if (t.startsWith('tlsRptLookup') || t === 'tlsRptNotFound') return 'tlsRptLookup';
|
||||||
|
if (t.startsWith('mtaStsVerify')) return 'mtaStsVerify';
|
||||||
|
if (t.startsWith('tlsaLookup') || t === 'tlsaNotFound') return 'tlsaLookup';
|
||||||
|
if (t.startsWith('ipLookup')) return 'ipLookup';
|
||||||
|
if (t.startsWith('connection')) return 'connection';
|
||||||
|
if (t.startsWith('readGreeting')) return 'readGreeting';
|
||||||
|
if (t.startsWith('ehlo')) return 'ehlo';
|
||||||
|
if (t.startsWith('startTls')) return 'startTls';
|
||||||
|
if (t.startsWith('daneVerify')) return 'daneVerify';
|
||||||
|
if (t.startsWith('mailFrom')) return 'mailFrom';
|
||||||
|
if (t.startsWith('rcptTo')) return 'rcptTo';
|
||||||
|
if (t.startsWith('quit')) return 'quit';
|
||||||
|
if (t === 'deliveryAttemptStart') return `attempt-${(stage as { hostname: string }).hostname}`;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TFn = (key: string, fallback: string, options?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
function phaseLabelFor(t: TFn, pk: string): string {
|
||||||
|
switch (pk) {
|
||||||
|
case 'mxLookup':
|
||||||
|
return t('deliveryTrace.phase.mxLookup', 'MX Lookup');
|
||||||
|
case 'mtaStsFetch':
|
||||||
|
return t('deliveryTrace.phase.mtaStsFetch', 'MTA-STS Policy Fetch');
|
||||||
|
case 'tlsRptLookup':
|
||||||
|
return t('deliveryTrace.phase.tlsRptLookup', 'TLS-RPT Lookup');
|
||||||
|
case 'mtaStsVerify':
|
||||||
|
return t('deliveryTrace.phase.mtaStsVerify', 'MTA-STS Verify');
|
||||||
|
case 'tlsaLookup':
|
||||||
|
return t('deliveryTrace.phase.tlsaLookup', 'TLSA / DANE Lookup');
|
||||||
|
case 'ipLookup':
|
||||||
|
return t('deliveryTrace.phase.ipLookup', 'IP Lookup');
|
||||||
|
case 'connection':
|
||||||
|
return t('deliveryTrace.phase.connection', 'TCP Connection');
|
||||||
|
case 'readGreeting':
|
||||||
|
return t('deliveryTrace.phase.readGreeting', 'Read Greeting');
|
||||||
|
case 'ehlo':
|
||||||
|
return 'EHLO';
|
||||||
|
case 'startTls':
|
||||||
|
return 'STARTTLS';
|
||||||
|
case 'daneVerify':
|
||||||
|
return t('deliveryTrace.phase.daneVerify', 'DANE Verify');
|
||||||
|
case 'mailFrom':
|
||||||
|
return 'MAIL FROM';
|
||||||
|
case 'rcptTo':
|
||||||
|
return 'RCPT TO';
|
||||||
|
case 'quit':
|
||||||
|
return 'QUIT';
|
||||||
|
default:
|
||||||
|
return pk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseLabel(t: TFn, stage: DeliveryStage): string {
|
||||||
|
if (stage.type === 'deliveryAttemptStart') {
|
||||||
|
return t('deliveryTrace.attemptLabel', 'Delivery Attempt: {{hostname}}', {
|
||||||
|
hostname: (stage as { hostname: string }).hostname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const pk = phaseKey(stage);
|
||||||
|
return phaseLabelFor(t, pk);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeverityIcon({ severity }: { severity: StageSeverity }) {
|
||||||
|
switch (severity) {
|
||||||
|
case 'pending':
|
||||||
|
return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />;
|
||||||
|
case 'ok':
|
||||||
|
return <CircleCheck className="h-4 w-4 text-emerald-500" />;
|
||||||
|
case 'warn':
|
||||||
|
return <TriangleAlert className="h-4 w-4 text-amber-500" />;
|
||||||
|
case 'error':
|
||||||
|
return <CircleX className="h-4 w-4 text-red-500" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsed(ms: number): string {
|
||||||
|
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||||
|
return `${(ms / 1000).toFixed(1)} s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventBodyText(t: TFn, stage: DeliveryStage): string | null {
|
||||||
|
const stageType = stage.type;
|
||||||
|
switch (stageType) {
|
||||||
|
case 'mxLookupSuccess': {
|
||||||
|
const total = stage.mxs.reduce((n, mx) => n + mx.exchanges.length, 0);
|
||||||
|
return total === 1
|
||||||
|
? t('deliveryTrace.body.mxLookupSuccess_one', 'Resolved {{count}} MX record', { count: total })
|
||||||
|
: t('deliveryTrace.body.mxLookupSuccess_other', 'Resolved {{count}} MX records', { count: total });
|
||||||
|
}
|
||||||
|
case 'mxLookupStart':
|
||||||
|
return t('deliveryTrace.body.mxLookupStart', 'Looking up MX for {{domain}}', { domain: stage.domain });
|
||||||
|
case 'mtaStsFetchSuccess':
|
||||||
|
return t('deliveryTrace.body.mtaStsFetchSuccess', 'Policy fetched successfully');
|
||||||
|
case 'mtaStsNotFound':
|
||||||
|
return t('deliveryTrace.body.mtaStsNotFound', 'No MTA-STS policy published');
|
||||||
|
case 'tlsRptLookupSuccess': {
|
||||||
|
const n = stage.rua.length;
|
||||||
|
return n === 1
|
||||||
|
? t('deliveryTrace.body.tlsRptLookupSuccess_one', 'Found {{count}} reporting URI', { count: n })
|
||||||
|
: t('deliveryTrace.body.tlsRptLookupSuccess_other', 'Found {{count}} reporting URIs', { count: n });
|
||||||
|
}
|
||||||
|
case 'tlsRptNotFound':
|
||||||
|
return t('deliveryTrace.body.tlsRptNotFound', 'No TLS-RPT record published');
|
||||||
|
case 'ipLookupSuccess': {
|
||||||
|
const ips = stage.remoteIps;
|
||||||
|
return ips.length <= 3
|
||||||
|
? ips.join(', ')
|
||||||
|
: t('deliveryTrace.body.ipLookupOverflow', '{{shown}} +{{extra}} more', {
|
||||||
|
shown: ips.slice(0, 3).join(', '),
|
||||||
|
extra: ips.length - 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'connectionStart':
|
||||||
|
return t('deliveryTrace.body.connectionStart', 'Connecting to {{ip}}', { ip: stage.remoteIp });
|
||||||
|
case 'connectionSuccess':
|
||||||
|
return t('deliveryTrace.body.connectionSuccess', 'Connected');
|
||||||
|
case 'tlsaLookupSuccess':
|
||||||
|
return t('deliveryTrace.body.tlsaLookupSuccess', 'TLSA record found');
|
||||||
|
case 'tlsaNotFound':
|
||||||
|
return stage.reason || t('deliveryTrace.body.tlsaNotFound', 'No TLSA record found');
|
||||||
|
case 'readGreetingSuccess':
|
||||||
|
return t('deliveryTrace.body.readGreetingSuccess', 'Server greeting received');
|
||||||
|
case 'ehloSuccess':
|
||||||
|
return t('deliveryTrace.body.ehloSuccess', 'EHLO accepted');
|
||||||
|
case 'startTlsSuccess':
|
||||||
|
return t('deliveryTrace.body.startTlsSuccess', 'TLS negotiated');
|
||||||
|
case 'daneVerifySuccess':
|
||||||
|
return t('deliveryTrace.body.daneVerifySuccess', 'DANE verification passed');
|
||||||
|
case 'mtaStsVerifySuccess':
|
||||||
|
return t('deliveryTrace.body.mtaStsVerifySuccess', 'MTA-STS hostname verified');
|
||||||
|
case 'mailFromSuccess':
|
||||||
|
return t('deliveryTrace.body.mailFromSuccess', 'Sender accepted');
|
||||||
|
case 'rcptToSuccess':
|
||||||
|
return t('deliveryTrace.body.rcptToSuccess', 'Recipient accepted');
|
||||||
|
case 'quitCompleted':
|
||||||
|
return t('deliveryTrace.body.quitCompleted', 'Session closed');
|
||||||
|
default:
|
||||||
|
if ('reason' in stage && typeof (stage as { reason?: string }).reason === 'string') {
|
||||||
|
return (stage as { reason: string }).reason;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function MxTable({ mxs }: { mxs: MX[] }) {
|
||||||
|
const sorted = [...mxs].sort((a, b) => a.preference - b.preference);
|
||||||
|
return (
|
||||||
|
<div className="mt-1 rounded border text-xs">
|
||||||
|
{sorted.map((mx, i) =>
|
||||||
|
mx.exchanges.map((ex, j) => (
|
||||||
|
<div key={`${i}-${j}`} className="flex gap-3 px-3 py-1 border-b last:border-b-0">
|
||||||
|
<span className="w-8 text-right text-muted-foreground">{mx.preference}</span>
|
||||||
|
<span>{ex}</span>
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportUris({ rua }: { rua: ReportUri[] }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{rua.map((uri, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs">
|
||||||
|
{uri.type === 'mail' ? (
|
||||||
|
<>
|
||||||
|
<Mail className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span>{uri.email}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Globe className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span>{uri.url}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpaqueObject({ data }: { data: Record<string, unknown> }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-1 rounded border bg-muted/30 p-2">
|
||||||
|
<dl className="space-y-1 text-xs">
|
||||||
|
{Object.entries(data).map(([k, v]) => (
|
||||||
|
<div key={k} className="flex gap-2">
|
||||||
|
<dt className="font-medium text-muted-foreground min-w-24">{k}</dt>
|
||||||
|
<dd className="break-all">
|
||||||
|
{typeof v === 'object' && v !== null ? (
|
||||||
|
<pre className="text-xs whitespace-pre-wrap">{JSON.stringify(v, null, 2)}</pre>
|
||||||
|
) : (
|
||||||
|
String(v)
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventRow({ stage }: { stage: DeliveryStage }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const severity = stageSeverity(stage);
|
||||||
|
const body = eventBodyText(t, stage);
|
||||||
|
const elapsed = 'elapsed' in stage ? (stage as { elapsed: number }).elapsed : null;
|
||||||
|
const hasDetails =
|
||||||
|
stage.type === 'mxLookupSuccess' ||
|
||||||
|
stage.type === 'mtaStsFetchSuccess' ||
|
||||||
|
stage.type === 'tlsRptLookupSuccess' ||
|
||||||
|
stage.type === 'tlsaLookupSuccess';
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="flex items-start gap-3 py-2">
|
||||||
|
<div className="mt-0.5 shrink-0">
|
||||||
|
<SeverityIcon severity={severity} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{phaseLabel(t, stage)}</span>
|
||||||
|
{elapsed !== null && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{formatElapsed(elapsed)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{body && (
|
||||||
|
<p className={`text-xs mt-0.5 ${severity === 'error' ? 'text-red-500' : 'text-muted-foreground'}`}>{body}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasDetails) return content;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible>
|
||||||
|
{content}
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground ml-7">
|
||||||
|
<ChevronRight className="h-3 w-3" />
|
||||||
|
{t('deliveryTrace.viewDetails', 'View details')}
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="ml-7">
|
||||||
|
{stage.type === 'mxLookupSuccess' && <MxTable mxs={stage.mxs} />}
|
||||||
|
{stage.type === 'mtaStsFetchSuccess' && <OpaqueObject data={stage.policy} />}
|
||||||
|
{stage.type === 'tlsRptLookupSuccess' && <ReportUris rua={stage.rua} />}
|
||||||
|
{stage.type === 'tlsaLookupSuccess' && <OpaqueObject data={stage.record} />}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useRelativeTime(startedAtMs: number | null): string {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (startedAtMs === null) return;
|
||||||
|
const id = setInterval(() => setTick((prev) => prev + 1), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [startedAtMs]);
|
||||||
|
|
||||||
|
if (startedAtMs === null) return '';
|
||||||
|
const s = tick;
|
||||||
|
if (s < 60) return t('deliveryTrace.startedAgoSec', 'Started {{s}}s ago', { s });
|
||||||
|
return t('deliveryTrace.startedAgoMin', 'Started {{m}}m {{s}}s ago', {
|
||||||
|
m: Math.floor(s / 60),
|
||||||
|
s: s % 60,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidTarget(target: string): boolean {
|
||||||
|
target = target.trim();
|
||||||
|
if (!target) return false;
|
||||||
|
if (target.includes('@')) {
|
||||||
|
const [local, domain] = target.split('@');
|
||||||
|
return local.length > 0 && domain.length > 0 && domain.includes('.');
|
||||||
|
}
|
||||||
|
return target.includes('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeliveryTracePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [inputValue, setInputValue] = useState(searchParams.get('target') ?? '');
|
||||||
|
const [inputError, setInputError] = useState<string | null>(null);
|
||||||
|
const [state, setState] = useState<TraceState>({ kind: 'idle' });
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const relTime = useRelativeTime(state.kind === 'running' ? state.startedAt.getTime() : null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startTrace = useCallback(async () => {
|
||||||
|
const target = inputValue.trim();
|
||||||
|
if (!isValidTarget(target)) {
|
||||||
|
setInputError(t('deliveryTrace.invalidTarget', 'Enter a valid email address or domain.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInputError(null);
|
||||||
|
|
||||||
|
setState({ kind: 'starting', target });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await apiFetch('/api/token/delivery');
|
||||||
|
const token = await tokenResponse.text();
|
||||||
|
|
||||||
|
const baseUrl = getApiBaseUrl();
|
||||||
|
const url = `${baseUrl}/api/live/delivery/${encodeURIComponent(target)}?token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
|
const es = new EventSource(url);
|
||||||
|
eventSourceRef.current = es;
|
||||||
|
|
||||||
|
const events: DeliveryStage[] = [];
|
||||||
|
const startedAt = new Date();
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
es.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
setState({
|
||||||
|
kind: 'failed',
|
||||||
|
target,
|
||||||
|
events: [...events],
|
||||||
|
error: t('deliveryTrace.timedOut', 'Trace timed out after 120 seconds.'),
|
||||||
|
});
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
es.addEventListener('event', (event) => {
|
||||||
|
try {
|
||||||
|
const batch = JSON.parse(event.data) as DeliveryStage[];
|
||||||
|
for (const stage of batch) {
|
||||||
|
if (stage.type === 'completed') {
|
||||||
|
es.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
setState({
|
||||||
|
kind: 'completed',
|
||||||
|
target,
|
||||||
|
startedAt,
|
||||||
|
completedAt: new Date(),
|
||||||
|
events: [...events],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events.push(stage);
|
||||||
|
}
|
||||||
|
setState({
|
||||||
|
kind: 'running',
|
||||||
|
target,
|
||||||
|
startedAt,
|
||||||
|
events: [...events],
|
||||||
|
eventSource: es,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse delivery event:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onopen = () => {
|
||||||
|
setState({
|
||||||
|
kind: 'running',
|
||||||
|
target,
|
||||||
|
startedAt,
|
||||||
|
events: [],
|
||||||
|
eventSource: es,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
es.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
setState((prev) => {
|
||||||
|
if (prev.kind === 'completed') return prev;
|
||||||
|
return {
|
||||||
|
kind: 'failed',
|
||||||
|
target,
|
||||||
|
events: prev.kind === 'running' ? prev.events : [],
|
||||||
|
error: t('deliveryTrace.connectionLost', 'Connection lost before trace completed.'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
setState({
|
||||||
|
kind: 'failed',
|
||||||
|
target,
|
||||||
|
events: [],
|
||||||
|
error: e instanceof Error ? e.message : t('deliveryTrace.failedToStart', 'Failed to start trace.'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [inputValue, t]);
|
||||||
|
|
||||||
|
const cancelTrace = useCallback(() => {
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
setState({ kind: 'idle' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetToIdle = useCallback(() => {
|
||||||
|
setState({ kind: 'idle' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rawEvents = useMemo(() => (state.kind === 'idle' || state.kind === 'starting' ? [] : state.events), [state]);
|
||||||
|
const pairedEvents = useMemo(() => {
|
||||||
|
const result: DeliveryStage[] = [];
|
||||||
|
const pendingByPhase = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const evt of rawEvents) {
|
||||||
|
const pk = phaseKey(evt);
|
||||||
|
const severity = stageSeverity(evt);
|
||||||
|
|
||||||
|
if (severity === 'pending') {
|
||||||
|
pendingByPhase.set(pk, result.length);
|
||||||
|
result.push(evt);
|
||||||
|
} else {
|
||||||
|
const pendingIdx = pendingByPhase.get(pk);
|
||||||
|
if (pendingIdx !== undefined) {
|
||||||
|
result[pendingIdx] = evt;
|
||||||
|
pendingByPhase.delete(pk);
|
||||||
|
} else {
|
||||||
|
result.push(evt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [rawEvents]);
|
||||||
|
|
||||||
|
if (state.kind === 'idle' || state.kind === 'starting') {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6 pt-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">{t('deliveryTrace.title', 'Delivery Trace')}</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
'deliveryTrace.subtitle',
|
||||||
|
'Run a real outbound SMTP delivery attempt and watch every DNS lookup, TLS handshake, and SMTP command as it happens.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
{t('deliveryTrace.targetLabel', 'Target address or domain')}
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder={t('deliveryTrace.targetPlaceholder', 'john@example.org or example.org')}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInputValue(e.target.value);
|
||||||
|
setInputError(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') startTrace();
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button onClick={startTrace} disabled={state.kind === 'starting'}>
|
||||||
|
{state.kind === 'starting' ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span className="ml-2">{t('deliveryTrace.startTrace', 'Start trace')}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{inputError && <p className="text-xs text-destructive">{inputError}</p>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = pairedEvents;
|
||||||
|
const target = state.target;
|
||||||
|
const isRunning = state.kind === 'running';
|
||||||
|
const isCompleted = state.kind === 'completed';
|
||||||
|
const isFailed = state.kind === 'failed';
|
||||||
|
|
||||||
|
const groups: { attempt: DeliveryStage | null; events: DeliveryStage[] }[] = [];
|
||||||
|
let currentGroup: { attempt: DeliveryStage | null; events: DeliveryStage[] } = {
|
||||||
|
attempt: null,
|
||||||
|
events: [],
|
||||||
|
};
|
||||||
|
for (const evt of events) {
|
||||||
|
if (evt.type === 'deliveryAttemptStart') {
|
||||||
|
if (currentGroup.events.length > 0 || currentGroup.attempt) {
|
||||||
|
groups.push(currentGroup);
|
||||||
|
}
|
||||||
|
currentGroup = { attempt: evt, events: [] };
|
||||||
|
} else {
|
||||||
|
currentGroup.events.push(evt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentGroup.events.length > 0 || currentGroup.attempt) {
|
||||||
|
groups.push(currentGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attemptCount = events.filter((e) => e.type === 'deliveryAttemptStart').length;
|
||||||
|
const lastEvent = events[events.length - 1];
|
||||||
|
const hasSuccessfulDelivery = lastEvent?.type === 'quitCompleted' || events.some((e) => e.type === 'rcptToSuccess');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{isRunning
|
||||||
|
? t('deliveryTrace.tracingDelivery', 'Tracing delivery to {{target}}', { target })
|
||||||
|
: isCompleted
|
||||||
|
? t('deliveryTrace.traceCompletedFor', 'Trace completed for {{target}}', { target })
|
||||||
|
: t('deliveryTrace.traceFailedFor', 'Trace failed for {{target}}', { target })}
|
||||||
|
</h2>
|
||||||
|
{isRunning && <p className="text-sm text-muted-foreground">{relTime}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{isRunning && (
|
||||||
|
<Button variant="outline" onClick={cancelTrace}>
|
||||||
|
<StopCircle className="mr-2 h-4 w-4" />
|
||||||
|
{t('deliveryTrace.cancelTrace', 'Cancel trace')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(isCompleted || isFailed) && (
|
||||||
|
<Button variant="outline" onClick={resetToIdle}>
|
||||||
|
{t('deliveryTrace.runAnother', 'Run another trace')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCompleted && (
|
||||||
|
<SimpleAlert variant="default">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasSuccessfulDelivery ? (
|
||||||
|
<CircleCheck className="h-4 w-4 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<CircleX className="h-4 w-4 text-red-500" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{hasSuccessfulDelivery
|
||||||
|
? t('deliveryTrace.deliverySuccessful', 'Delivery successful')
|
||||||
|
: t('deliveryTrace.deliveryFailed', 'Delivery failed')}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{attemptCount === 1
|
||||||
|
? t('deliveryTrace.attemptCount_one', '{{count}} attempt', { count: attemptCount })
|
||||||
|
: t('deliveryTrace.attemptCount_other', '{{count}} attempts', { count: attemptCount })}
|
||||||
|
{state.kind === 'completed' && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
{t('deliveryTrace.inDuration', 'in {{elapsed}}', {
|
||||||
|
elapsed: formatElapsed(state.completedAt.getTime() - state.startedAt.getTime()),
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SimpleAlert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFailed && (
|
||||||
|
<SimpleAlert variant="destructive">
|
||||||
|
<p className="text-sm">{state.error}</p>
|
||||||
|
</SimpleAlert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="space-y-0">
|
||||||
|
{groups.map((group, gi) => (
|
||||||
|
<div key={gi}>
|
||||||
|
{group.attempt && (
|
||||||
|
<div className="mt-3 mb-1">
|
||||||
|
<EventRow stage={group.attempt} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={group.attempt ? 'ml-6 border-l-2 border-muted/40 pl-4' : ''}>
|
||||||
|
{group.events.map((evt, ei) => (
|
||||||
|
<EventRow key={`${gi}-${ei}`} stage={evt} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isRunning && (
|
||||||
|
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="animate-pulse">.</span>
|
||||||
|
<span className="animate-pulse" style={{ animationDelay: '0.2s' }}>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
<span className="animate-pulse" style={{ animationDelay: '0.4s' }}>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{t('deliveryTrace.waiting', 'Waiting for more events')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DeliveryStage =
|
||||||
|
| { type: 'mxLookupStart'; domain: string }
|
||||||
|
| { type: 'mxLookupSuccess'; mxs: MX[]; elapsed: number }
|
||||||
|
| { type: 'mxLookupError'; reason: string; elapsed: number }
|
||||||
|
| { type: 'mtaStsFetchStart' }
|
||||||
|
| { type: 'mtaStsFetchSuccess'; policy: Policy; elapsed: number }
|
||||||
|
| { type: 'mtaStsFetchError'; reason: string; elapsed: number }
|
||||||
|
| { type: 'mtaStsNotFound'; elapsed: number }
|
||||||
|
| { type: 'tlsRptLookupStart' }
|
||||||
|
| { type: 'tlsRptLookupSuccess'; rua: ReportUri[]; elapsed: number }
|
||||||
|
| { type: 'tlsRptLookupError'; reason: string; elapsed: number }
|
||||||
|
| { type: 'tlsRptNotFound'; elapsed: number }
|
||||||
|
| { type: 'deliveryAttemptStart'; hostname: string }
|
||||||
|
| { type: 'mtaStsVerifySuccess' }
|
||||||
|
| { type: 'mtaStsVerifyError'; reason: string }
|
||||||
|
| { type: 'tlsaLookupStart' }
|
||||||
|
| { type: 'tlsaLookupSuccess'; record: Tlsa; elapsed: number }
|
||||||
|
| { type: 'tlsaNotFound'; elapsed: number; reason: string }
|
||||||
|
| { type: 'tlsaLookupError'; elapsed: number; reason: string }
|
||||||
|
| { type: 'ipLookupStart' }
|
||||||
|
| { type: 'ipLookupSuccess'; remoteIps: string[]; elapsed: number }
|
||||||
|
| { type: 'ipLookupError'; reason: string; elapsed: number }
|
||||||
|
| { type: 'connectionStart'; remoteIp: string }
|
||||||
|
| { type: 'connectionSuccess'; elapsed: number }
|
||||||
|
| { type: 'connectionError'; elapsed: number; reason: string }
|
||||||
|
| { type: 'readGreetingStart' }
|
||||||
|
| { type: 'readGreetingSuccess'; elapsed: number }
|
||||||
|
| { type: 'readGreetingError'; elapsed: number; reason: string }
|
||||||
|
| { type: 'ehloStart' }
|
||||||
|
| { type: 'ehloSuccess'; elapsed: number }
|
||||||
|
| { type: 'ehloError'; elapsed: number; reason: string }
|
||||||
|
| { type: 'startTlsStart' }
|
||||||
|
| { type: 'startTlsSuccess'; elapsed: number }
|
||||||
|
| { type: 'startTlsError'; elapsed: number; reason: string }
|
||||||
|
| { type: 'daneVerifySuccess' }
|
||||||
|
| { type: 'daneVerifyError'; reason: string }
|
||||||
|
| { type: 'mailFromStart' }
|
||||||
|
| { type: 'mailFromSuccess'; elapsed: number }
|
||||||
|
| { type: 'mailFromError'; reason: string; elapsed: number }
|
||||||
|
| { type: 'rcptToStart' }
|
||||||
|
| { type: 'rcptToSuccess'; elapsed: number }
|
||||||
|
| { type: 'rcptToError'; reason: string; elapsed: number }
|
||||||
|
| { type: 'quitStart' }
|
||||||
|
| { type: 'quitCompleted'; elapsed: number }
|
||||||
|
| { type: 'completed' };
|
||||||
|
|
||||||
|
export interface MX {
|
||||||
|
exchanges: string[];
|
||||||
|
preference: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportUri = { type: 'mail'; email: string } | { type: 'http'; url: string };
|
||||||
|
|
||||||
|
export type Policy = Record<string, unknown>;
|
||||||
|
export type Tlsa = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type StageSeverity = 'pending' | 'ok' | 'warn' | 'error';
|
||||||
|
|
||||||
|
export function stageSeverity(stage: DeliveryStage): StageSeverity {
|
||||||
|
const t = stage.type;
|
||||||
|
if (t === 'completed') return 'ok';
|
||||||
|
if (t === 'deliveryAttemptStart') return 'ok';
|
||||||
|
if (t.endsWith('Error')) return 'error';
|
||||||
|
if (t.endsWith('NotFound')) return 'warn';
|
||||||
|
if (t.endsWith('Start')) return 'pending';
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 5;
|
||||||
|
const TOAST_REMOVE_DELAY = 300;
|
||||||
|
const TOAST_AUTO_DISMISS_MS = 5000;
|
||||||
|
|
||||||
|
type ToastVariant = 'default' | 'destructive' | 'success' | 'warning' | 'info';
|
||||||
|
|
||||||
|
type ToasterToast = {
|
||||||
|
id: string;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
variant?: ToastVariant;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: 'ADD_TOAST';
|
||||||
|
toast: ToasterToast;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'UPDATE_TOAST';
|
||||||
|
toast: Partial<ToasterToast>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'DISMISS_TOAST';
|
||||||
|
toastId?: ToasterToast['id'];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'REMOVE_TOAST';
|
||||||
|
toastId?: ToasterToast['id'];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId);
|
||||||
|
dispatch({
|
||||||
|
type: 'REMOVE_TOAST',
|
||||||
|
toastId: toastId,
|
||||||
|
});
|
||||||
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'ADD_TOAST':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'UPDATE_TOAST':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'DISMISS_TOAST': {
|
||||||
|
const { toastId } = action;
|
||||||
|
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId);
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'REMOVE_TOAST':
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = [];
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] };
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action);
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, 'id'>;
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId();
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: 'UPDATE_TOAST',
|
||||||
|
toast: { ...props, id },
|
||||||
|
});
|
||||||
|
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'ADD_TOAST',
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(dismiss, TOAST_AUTO_DISMISS_MS);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState);
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState);
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast };
|
||||||
|
export type { ToasterToast, ToastVariant };
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
import { resolveObject } from '@/lib/schemaResolver';
|
||||||
|
|
||||||
|
export function usePermissions() {
|
||||||
|
const { permissions, edition, hasPermission, hasObjectPermission } = useAccountStore();
|
||||||
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
|
|
||||||
|
function canViewObject(viewName: string): boolean {
|
||||||
|
if (!schema) return false;
|
||||||
|
const resolved = resolveObject(schema, viewName);
|
||||||
|
if (!resolved) return false;
|
||||||
|
return hasObjectPermission(resolved.permissionPrefix, 'Get');
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCreateObject(viewName: string): boolean {
|
||||||
|
if (!schema) return false;
|
||||||
|
const resolved = resolveObject(schema, viewName);
|
||||||
|
if (!resolved) return false;
|
||||||
|
if (resolved.objectType.type === 'singleton') return false;
|
||||||
|
return hasObjectPermission(resolved.permissionPrefix, 'Create');
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUpdateObject(viewName: string): boolean {
|
||||||
|
if (!schema) return false;
|
||||||
|
const resolved = resolveObject(schema, viewName);
|
||||||
|
if (!resolved) return false;
|
||||||
|
return hasObjectPermission(resolved.permissionPrefix, 'Update');
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDestroyObject(viewName: string): boolean {
|
||||||
|
if (!schema) return false;
|
||||||
|
const resolved = resolveObject(schema, viewName);
|
||||||
|
if (!resolved) return false;
|
||||||
|
if (resolved.objectType.type === 'singleton') return false;
|
||||||
|
return hasObjectPermission(resolved.permissionPrefix, 'Destroy');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnterpriseHidden(enterprise?: boolean): boolean {
|
||||||
|
if (!enterprise) return false;
|
||||||
|
return edition === 'oss';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnterpriseDisabled(enterprise?: boolean): boolean {
|
||||||
|
if (!enterprise) return false;
|
||||||
|
return edition === 'community';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
permissions,
|
||||||
|
edition,
|
||||||
|
hasPermission,
|
||||||
|
hasObjectPermission,
|
||||||
|
canViewObject,
|
||||||
|
canCreateObject,
|
||||||
|
canUpdateObject,
|
||||||
|
canDestroyObject,
|
||||||
|
isEnterpriseHidden,
|
||||||
|
isEnterpriseDisabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
{
|
||||||
|
"accounts": "Accounts",
|
||||||
|
"actions": {
|
||||||
|
"actionFailed": "Action failed.",
|
||||||
|
"backToActions": "Back to actions",
|
||||||
|
"execute": "Execute",
|
||||||
|
"executeAnother": "Execute another action",
|
||||||
|
"executedSuccess": "Action executed successfully.",
|
||||||
|
"executing": "Executing {{label}}...",
|
||||||
|
"failedTitle": "Action failed",
|
||||||
|
"noAdditionalData": "No additional data returned.",
|
||||||
|
"otherGroup": "Other",
|
||||||
|
"title": "Actions"
|
||||||
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"clipboardBlocked": "Your browser blocked clipboard access.",
|
||||||
|
"complete": "Setup complete",
|
||||||
|
"configuredSuccessfully": "Stalwart has been configured successfully.",
|
||||||
|
"copyFailed": "Copy failed",
|
||||||
|
"credentialsCreated": "Your administrator account has been created. Write these down now: the password will not be shown again.",
|
||||||
|
"emptyForm": "Setup form is empty. The server did not return any bootstrap fields.",
|
||||||
|
"failedToComplete": "Failed to complete setup.",
|
||||||
|
"failedToLoad": "Failed to load bootstrap state.",
|
||||||
|
"finishSetup": "Finish setup",
|
||||||
|
"loadingSetup": "Loading setup...",
|
||||||
|
"nextStepBody": "restart Stalwart for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.",
|
||||||
|
"nextStepLabel": "Next step:",
|
||||||
|
"noConfirm": "The server did not confirm the update.",
|
||||||
|
"stepOf": "Step {{current}} of {{total}}",
|
||||||
|
"welcome": "Welcome to Stalwart",
|
||||||
|
"welcomeSubtitle": "Let's get your server set up."
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"apply": "Apply",
|
||||||
|
"back": "Back",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"clear": "Clear",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"continue": "Continue",
|
||||||
|
"create": "Create",
|
||||||
|
"delete": "Delete",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"next": "Next",
|
||||||
|
"no": "No",
|
||||||
|
"noResultsDot": "No results.",
|
||||||
|
"save": "Save",
|
||||||
|
"search": "Search",
|
||||||
|
"searchPlaceholder": "Search...",
|
||||||
|
"yes": "Yes",
|
||||||
|
"close": "Close"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"customEllipsis": "Custom...",
|
||||||
|
"customRange": "Custom range",
|
||||||
|
"failedFetchMetrics": "Failed to fetch metrics",
|
||||||
|
"from": "From",
|
||||||
|
"liveMetricsDisconnected": "Live metrics stream disconnected after multiple retries.",
|
||||||
|
"liveMetricsFailed": "Failed to connect to live metrics.",
|
||||||
|
"preset24h": "Last 24 hours",
|
||||||
|
"preset30d": "Last 30 days",
|
||||||
|
"preset7d": "Last 7 days",
|
||||||
|
"preset90d": "Last 90 days",
|
||||||
|
"to": "To"
|
||||||
|
},
|
||||||
|
"deliveryTrace": {
|
||||||
|
"attemptCount_one": "{{count}} attempt",
|
||||||
|
"attemptCount_other": "{{count}} attempts",
|
||||||
|
"attemptLabel": "Delivery Attempt: {{hostname}}",
|
||||||
|
"body": {
|
||||||
|
"connectionStart": "Connecting to {{ip}}",
|
||||||
|
"connectionSuccess": "Connected",
|
||||||
|
"daneVerifySuccess": "DANE verification passed",
|
||||||
|
"ehloSuccess": "EHLO accepted",
|
||||||
|
"ipLookupOverflow": "{{shown}} +{{extra}} more",
|
||||||
|
"mailFromSuccess": "Sender accepted",
|
||||||
|
"mtaStsFetchSuccess": "Policy fetched successfully",
|
||||||
|
"mtaStsNotFound": "No MTA-STS policy published",
|
||||||
|
"mtaStsVerifySuccess": "MTA-STS hostname verified",
|
||||||
|
"mxLookupStart": "Looking up MX for {{domain}}",
|
||||||
|
"mxLookupSuccess_one": "Resolved {{count}} MX record",
|
||||||
|
"mxLookupSuccess_other": "Resolved {{count}} MX records",
|
||||||
|
"quitCompleted": "Session closed",
|
||||||
|
"rcptToSuccess": "Recipient accepted",
|
||||||
|
"readGreetingSuccess": "Server greeting received",
|
||||||
|
"startTlsSuccess": "TLS negotiated",
|
||||||
|
"tlsRptLookupSuccess_one": "Found {{count}} reporting URI",
|
||||||
|
"tlsRptLookupSuccess_other": "Found {{count}} reporting URIs",
|
||||||
|
"tlsRptNotFound": "No TLS-RPT record published",
|
||||||
|
"tlsaLookupSuccess": "TLSA record found",
|
||||||
|
"tlsaNotFound": "No TLSA record found"
|
||||||
|
},
|
||||||
|
"cancelTrace": "Cancel trace",
|
||||||
|
"connectionLost": "Connection lost before trace completed.",
|
||||||
|
"deliveryFailed": "Delivery failed",
|
||||||
|
"deliverySuccessful": "Delivery successful",
|
||||||
|
"failedToStart": "Failed to start trace.",
|
||||||
|
"inDuration": "in {{elapsed}}",
|
||||||
|
"invalidTarget": "Enter a valid email address or domain.",
|
||||||
|
"phase": {
|
||||||
|
"connection": "TCP Connection",
|
||||||
|
"daneVerify": "DANE Verify",
|
||||||
|
"ipLookup": "IP Lookup",
|
||||||
|
"mtaStsFetch": "MTA-STS Policy Fetch",
|
||||||
|
"mtaStsVerify": "MTA-STS Verify",
|
||||||
|
"mxLookup": "MX Lookup",
|
||||||
|
"readGreeting": "Read Greeting",
|
||||||
|
"tlsRptLookup": "TLS-RPT Lookup",
|
||||||
|
"tlsaLookup": "TLSA / DANE Lookup"
|
||||||
|
},
|
||||||
|
"runAnother": "Run another trace",
|
||||||
|
"startTrace": "Start trace",
|
||||||
|
"startedAgoMin": "Started {{m}}m {{s}}s ago",
|
||||||
|
"startedAgoSec": "Started {{s}}s ago",
|
||||||
|
"subtitle": "Run a real outbound SMTP delivery attempt and watch every DNS lookup, TLS handshake, and SMTP command as it happens.",
|
||||||
|
"targetLabel": "Target address or domain",
|
||||||
|
"targetPlaceholder": "john@example.org or example.org",
|
||||||
|
"timedOut": "Trace timed out after 120 seconds.",
|
||||||
|
"title": "Delivery Trace",
|
||||||
|
"traceCompletedFor": "Trace completed for {{target}}",
|
||||||
|
"traceFailedFor": "Trace failed for {{target}}",
|
||||||
|
"tracingDelivery": "Tracing delivery to {{target}}",
|
||||||
|
"viewDetails": "View details",
|
||||||
|
"waiting": "Waiting for more events"
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"days": "Days",
|
||||||
|
"hours": "Hours",
|
||||||
|
"milliseconds": "Milliseconds",
|
||||||
|
"minutes": "Minutes",
|
||||||
|
"seconds": "Seconds"
|
||||||
|
},
|
||||||
|
"enterprise": {
|
||||||
|
"featureDisabled": "This feature requires an Enterprise license.",
|
||||||
|
"trialTitle": "Unlock Enterprise Features",
|
||||||
|
"trialDescription": "Get access to advanced features including multi-tenancy, AI-powered spam filtering, alerts, and more.",
|
||||||
|
"trialButton": "Start 30-Day Free Trial",
|
||||||
|
"requestTrial": "Request a free trial to unlock this feature.",
|
||||||
|
"ossHidden": "This feature is not available in the open-source edition."
|
||||||
|
},
|
||||||
|
"errorBoundary": {
|
||||||
|
"title": "Something went wrong",
|
||||||
|
"tryAgain": "Try Again"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"unexpectedError": "An unexpected error occurred.",
|
||||||
|
"viewNotFound": "View not found",
|
||||||
|
"loadSchemaFailed": "Failed to load the admin panel configuration.",
|
||||||
|
"notFound": "The requested resource was not found."
|
||||||
|
},
|
||||||
|
"expression": {
|
||||||
|
"addCondition": "Add condition",
|
||||||
|
"condition": "Condition",
|
||||||
|
"defaultValue": "Default value",
|
||||||
|
"else": "ELSE",
|
||||||
|
"if": "IF",
|
||||||
|
"result": "Result",
|
||||||
|
"then": "THEN",
|
||||||
|
"value": "Value"
|
||||||
|
},
|
||||||
|
"field": {
|
||||||
|
"add": "Add",
|
||||||
|
"addItem": "Add item",
|
||||||
|
"contentModified": "Content modified (will be saved as a new blob)",
|
||||||
|
"enable": "Enable",
|
||||||
|
"key": "Key...",
|
||||||
|
"loadingContent": "Loading content...",
|
||||||
|
"noMatches": "No matches",
|
||||||
|
"noPermissionToView": "You do not have permission to view {{name}}",
|
||||||
|
"none": "None",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"optional": "(optional)",
|
||||||
|
"required": "required",
|
||||||
|
"selectEllipsis": "Select...",
|
||||||
|
"selectKey": "Select key...",
|
||||||
|
"selectOptions": "Select options...",
|
||||||
|
"type": "Type",
|
||||||
|
"typeAndPressEnter": "Type and press Enter...",
|
||||||
|
"unknownObjectType": "Unknown object type: {{name}}"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"after": "After",
|
||||||
|
"all": "All",
|
||||||
|
"before": "Before"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"correctErrorsBelow": "Please correct the errors below.",
|
||||||
|
"createTitle": "Create {{name}}",
|
||||||
|
"createdServerGenerated": "The following values were generated by the server. Please save them now, they may not be retrievable later.",
|
||||||
|
"createdSuccess": "Created successfully",
|
||||||
|
"deleteDescription": "This action cannot be undone. This will permanently delete this {{name}}.",
|
||||||
|
"deleteTitle": "Delete {{name}}?",
|
||||||
|
"deletedSuccess": "{{name}} deleted successfully",
|
||||||
|
"editTitle": "Edit {{name}}",
|
||||||
|
"editTitleWithValue": "Edit {{name}}: {{value}}",
|
||||||
|
"failedToDelete": "Failed to delete.",
|
||||||
|
"failedToLoadData": "Failed to load data.",
|
||||||
|
"failedToSave": "Failed to save.",
|
||||||
|
"invalidValue": "Invalid value.",
|
||||||
|
"item": "item",
|
||||||
|
"leave": "Leave",
|
||||||
|
"loadingData": "Loading data...",
|
||||||
|
"loadingSchema": "Loading schema...",
|
||||||
|
"maxLengthIs": "Maximum length is {{max}}.",
|
||||||
|
"maxValueIs": "Maximum value is {{max}}.",
|
||||||
|
"minLengthIs": "Minimum length is {{min}}.",
|
||||||
|
"minValueIs": "Minimum value is {{min}}.",
|
||||||
|
"noChangesToSave": "No changes to save",
|
||||||
|
"objectNotFound": "Object not found.",
|
||||||
|
"otpCodeRequired": "Enter your current authenticator code to authorise this change.",
|
||||||
|
"required": "This field is required.",
|
||||||
|
"savedSuccess": "Saved successfully",
|
||||||
|
"selectType": "Select type...",
|
||||||
|
"stay": "Stay",
|
||||||
|
"unsavedChanges": "You have unsaved changes. Are you sure you want to leave?",
|
||||||
|
"unsavedChangesLabel": "Unsaved changes",
|
||||||
|
"unsavedChangesTitle": "Unsaved changes"
|
||||||
|
},
|
||||||
|
"globalSearch": {
|
||||||
|
"create": "Create",
|
||||||
|
"fields": "Fields",
|
||||||
|
"formSections": "Form Sections",
|
||||||
|
"list": "List",
|
||||||
|
"noResults": "No results found.",
|
||||||
|
"pages": "Pages",
|
||||||
|
"settings": "Settings"
|
||||||
|
},
|
||||||
|
"jmapErrors": {
|
||||||
|
"addressBookHasContents": "This address book has contacts. Remove them first.",
|
||||||
|
"alreadyExists": "An item with the same identifier already exists.",
|
||||||
|
"calendarHasEvent": "This calendar contains events. Remove them first.",
|
||||||
|
"forbidden": "You do not have permission to perform this action.",
|
||||||
|
"invalidForeignKey": "This item references another item that does not exist.",
|
||||||
|
"mailboxHasChild": "This mailbox has child mailboxes. Delete them first.",
|
||||||
|
"mailboxHasEmail": "This mailbox contains messages. Remove them first.",
|
||||||
|
"nodeHasChildren": "This item has children. Delete them first.",
|
||||||
|
"notFound": "The item was not found. It may have been already deleted.",
|
||||||
|
"objectIsLinked": "This item is referenced by other items and cannot be deleted.",
|
||||||
|
"overQuota": "The operation exceeds your storage quota.",
|
||||||
|
"primaryKeyViolation": "An item with the same key already exists.",
|
||||||
|
"rateLimit": "Too many requests. Please try again later.",
|
||||||
|
"singleton": "This item is a singleton and cannot be deleted.",
|
||||||
|
"tooLarge": "The item is too large.",
|
||||||
|
"unexpected": "Unexpected error ({{type}}).",
|
||||||
|
"validationFailed": "The data did not pass validation."
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"actionWithCount": "{{action}} ({{count}} {{name}})",
|
||||||
|
"actions": "Actions",
|
||||||
|
"allItemsSelected": "All {{total}} items matching filters are selected.",
|
||||||
|
"allPageSelected": "All {{count}} items on this page are selected.",
|
||||||
|
"bulkErrorGrouped": "{{count}} items: {{message}}",
|
||||||
|
"bulkErrorMore": "…and {{count}} more.",
|
||||||
|
"bulkFailDelete_one": "Failed to delete {{count}} item.",
|
||||||
|
"bulkFailDelete_other": "Failed to delete {{count}} items.",
|
||||||
|
"bulkFailUpdate_one": "Failed to update {{count}} item.",
|
||||||
|
"bulkFailUpdate_other": "Failed to update {{count}} items.",
|
||||||
|
"bulkMixedDelete": "{{success}} deleted, {{failed}} failed.",
|
||||||
|
"bulkMixedUpdate": "{{success}} updated, {{failed}} failed.",
|
||||||
|
"bulkSuccessDelete_one": "{{count}} item deleted successfully.",
|
||||||
|
"bulkSuccessDelete_other": "{{count}} items deleted successfully.",
|
||||||
|
"bulkSuccessUpdate_one": "{{count}} item updated successfully.",
|
||||||
|
"bulkSuccessUpdate_other": "{{count}} items updated successfully.",
|
||||||
|
"clearSelection": "Clear selection",
|
||||||
|
"confirmDescription": "Are you sure you want to proceed with: {{action}}?",
|
||||||
|
"confirmTitle": "Confirm Action",
|
||||||
|
"create": "Create {{name}}",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteNotConfirmed": "Delete failed: item was not confirmed as destroyed by the server.",
|
||||||
|
"filterPlaceholder": "Search {{label}}...",
|
||||||
|
"filters": "Filters",
|
||||||
|
"massAction": "Actions",
|
||||||
|
"next": "Next",
|
||||||
|
"noResults": "No results found",
|
||||||
|
"previous": "Previous",
|
||||||
|
"resetFilters": "Reset",
|
||||||
|
"searchFilters": "Search",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"selectAllMatching": "Select all {{total}} items matching filters",
|
||||||
|
"selectFilterPlaceholder": "Select {{label}}...",
|
||||||
|
"selectItem": "Select item",
|
||||||
|
"showing": "Showing {{from}}-{{to}} of {{total}} {{name}}",
|
||||||
|
"showingItems": "Showing {{count}} items",
|
||||||
|
"sort": "Sort",
|
||||||
|
"unknownError": "Unknown error"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"continue": "Continue",
|
||||||
|
"error": "An unexpected error occurred",
|
||||||
|
"prompt": "Enter your account name to continue",
|
||||||
|
"usernamePlaceholder": "user@example.com"
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"alt": "Logo",
|
||||||
|
"stalwartAlt": "Stalwart Logo"
|
||||||
|
},
|
||||||
|
"logout": "Logout",
|
||||||
|
"oauth": {
|
||||||
|
"backToLogin": "Back to login",
|
||||||
|
"discoveryFailed": "Discovery failed for \"{{username}}\": {{status}} {{statusText}}",
|
||||||
|
"error": "Authentication failed",
|
||||||
|
"missingData": "Missing OAuth session data. Please try logging in again.",
|
||||||
|
"missingParams": "Missing authorization code or state parameter",
|
||||||
|
"processing": "Completing sign in...",
|
||||||
|
"stateMismatch": "State parameter mismatch. Please try logging in again.",
|
||||||
|
"tokenExchangeFailed": "Token exchange failed: {{status}} {{statusText}}"
|
||||||
|
},
|
||||||
|
"otp": {
|
||||||
|
"codeIncorrect": "That code is incorrect. Make sure your authenticator clock is in sync.",
|
||||||
|
"confirmationCodeLabel": "Confirmation code",
|
||||||
|
"currentCodeLabel": "Current code",
|
||||||
|
"currentCodePrompt": "Enter your current 6-digit code to authorise any change to this account (including disabling two-factor authentication).",
|
||||||
|
"disable": "Disable",
|
||||||
|
"enterCodePrompt": "Enter the code shown in your authenticator.",
|
||||||
|
"qrCodeAlt": "TOTP QR code",
|
||||||
|
"scanDescription": "Scan the QR code below with Google Authenticator, 1Password, Authy, or any other TOTP app, then enter the 6-digit code it shows to confirm setup.",
|
||||||
|
"scanPrompt": "Scan with your authenticator app",
|
||||||
|
"setUp": "Set up TOTP",
|
||||||
|
"statusDisabled": "Two-factor authentication is not enabled.",
|
||||||
|
"statusEnabled": "Two-factor authentication is enabled."
|
||||||
|
},
|
||||||
|
"rate": {
|
||||||
|
"count": "Count",
|
||||||
|
"per": "per",
|
||||||
|
"periodValue": "Period value"
|
||||||
|
},
|
||||||
|
"sections": "Sections",
|
||||||
|
"toggleTheme": "Toggle theme",
|
||||||
|
"tracing": {
|
||||||
|
"addFilter": "Add filter",
|
||||||
|
"bufferFull": "(buffer full)",
|
||||||
|
"connecting": "Connecting...",
|
||||||
|
"date": "Date",
|
||||||
|
"disabled": "disabled",
|
||||||
|
"duration": "Duration",
|
||||||
|
"enabled": "enabled",
|
||||||
|
"eventCount_one": "{{count}} event",
|
||||||
|
"eventCount_other": "{{count}} events",
|
||||||
|
"events": "Events",
|
||||||
|
"failedToLoadTrace": "Failed to load trace",
|
||||||
|
"keywordsLabel": "Keywords (optional)",
|
||||||
|
"keywordsPlaceholder": "Enter keywords to filter events...",
|
||||||
|
"liveDisconnected": "Live tracing stream disconnected after multiple retries.",
|
||||||
|
"liveFailedStart": "Failed to start live tracing.",
|
||||||
|
"liveSubtitle": "Stream server events in real time.",
|
||||||
|
"liveTitle": "Live Tracing",
|
||||||
|
"noEvents": "No trace events to display.",
|
||||||
|
"restart": "Restart",
|
||||||
|
"spanType": "Span Type",
|
||||||
|
"startTracing": "Start tracing",
|
||||||
|
"stopTracing": "Stop tracing",
|
||||||
|
"stopped": "Stopped",
|
||||||
|
"streamingEvents": "Streaming events...",
|
||||||
|
"traceNotFound": "Trace not found",
|
||||||
|
"valuePlaceholder": "Value..."
|
||||||
|
},
|
||||||
|
"tryEnterprise": "Try Enterprise",
|
||||||
|
"userMenu": "User menu",
|
||||||
|
"view": {
|
||||||
|
"couldNotResolve": "Could not resolve object",
|
||||||
|
"failedToLoad": "Failed to load",
|
||||||
|
"noGetResponse": "No get response",
|
||||||
|
"objectNotFound": "Object not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import en from './en.json';
|
||||||
|
|
||||||
|
const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur'];
|
||||||
|
|
||||||
|
i18n.use(initReactI18next).init({
|
||||||
|
resources: { en: { translation: en } },
|
||||||
|
lng: navigator.language?.split('-')[0] || 'en',
|
||||||
|
fallbackLng: 'en',
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
export function setLocale(locale: string) {
|
||||||
|
const lang = locale.split('_')[0].split('-')[0];
|
||||||
|
i18n.changeLanguage(lang);
|
||||||
|
const isRtl = RTL_LANGUAGES.includes(lang);
|
||||||
|
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0.017 285.823);
|
||||||
|
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0.017 285.823);
|
||||||
|
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0.017 285.823);
|
||||||
|
|
||||||
|
--content-background: oklch(0.97 0.003 285.823);
|
||||||
|
|
||||||
|
--primary: oklch(0.205 0.017 285.823);
|
||||||
|
--primary-foreground: oklch(0.985 0.002 285.823);
|
||||||
|
|
||||||
|
--secondary: oklch(0.965 0.005 285.823);
|
||||||
|
--secondary-foreground: oklch(0.205 0.017 285.823);
|
||||||
|
|
||||||
|
--muted: oklch(0.93 0.005 285.823);
|
||||||
|
--muted-foreground: oklch(0.556 0.015 285.823);
|
||||||
|
|
||||||
|
--accent: oklch(0.965 0.005 285.823);
|
||||||
|
--accent-foreground: oklch(0.205 0.017 285.823);
|
||||||
|
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.985 0.002 285.823);
|
||||||
|
|
||||||
|
--border: oklch(0.922 0.007 285.823);
|
||||||
|
--input: oklch(0.922 0.007 285.823);
|
||||||
|
--ring: oklch(0.708 0.015 285.823);
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.141 0.005 285.823);
|
||||||
|
--foreground: oklch(0.985 0.002 285.823);
|
||||||
|
|
||||||
|
--card: oklch(0.205 0.007 285.823);
|
||||||
|
--card-foreground: oklch(0.985 0.002 285.823);
|
||||||
|
|
||||||
|
--popover: oklch(0.205 0.007 285.823);
|
||||||
|
--popover-foreground: oklch(0.985 0.002 285.823);
|
||||||
|
|
||||||
|
--content-background: oklch(0.115 0.005 285.823);
|
||||||
|
|
||||||
|
--primary: oklch(0.985 0.002 285.823);
|
||||||
|
--primary-foreground: oklch(0.205 0.017 285.823);
|
||||||
|
|
||||||
|
--secondary: oklch(0.274 0.009 285.823);
|
||||||
|
--secondary-foreground: oklch(0.985 0.002 285.823);
|
||||||
|
|
||||||
|
--muted: oklch(0.274 0.009 285.823);
|
||||||
|
--muted-foreground: oklch(0.708 0.015 285.823);
|
||||||
|
|
||||||
|
--accent: oklch(0.274 0.009 285.823);
|
||||||
|
--accent-foreground: oklch(0.985 0.002 285.823);
|
||||||
|
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.985 0.002 285.823);
|
||||||
|
|
||||||
|
--border: oklch(0.274 0.009 285.823);
|
||||||
|
--input: oklch(0.274 0.009 285.823);
|
||||||
|
--ring: oklch(0.553 0.013 285.823);
|
||||||
|
|
||||||
|
--chart-1: 220 70% 60%;
|
||||||
|
--chart-2: 160 60% 55%;
|
||||||
|
--chart-3: 30 80% 60%;
|
||||||
|
--chart-4: 280 65% 65%;
|
||||||
|
--chart-5: 340 75% 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-content-background: var(--content-background);
|
||||||
|
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
|
--color-chart-1: hsl(var(--chart-1));
|
||||||
|
--color-chart-2: hsl(var(--chart-2));
|
||||||
|
--color-chart-3: hsl(var(--chart-3));
|
||||||
|
--color-chart-4: hsl(var(--chart-4));
|
||||||
|
--color-chart-5: hsl(var(--chart-5));
|
||||||
|
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark input[type='number'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
let cached: string | undefined;
|
||||||
|
|
||||||
|
export function getBasePath(): string {
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
const base = document.querySelector('base')?.getAttribute('href') ?? '/';
|
||||||
|
cached = base.replace(/\/+$/, '');
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV;
|
||||||
|
|
||||||
|
function isEnabled(envVar: string | undefined): boolean {
|
||||||
|
if (!isDev || !envVar) return false;
|
||||||
|
const v = envVar.toLowerCase();
|
||||||
|
return v !== '' && v !== 'false' && v !== '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const debugJmap = isEnabled(import.meta.env.VITE_DEBUG_JMAP);
|
||||||
|
export const debugForms = isEnabled(import.meta.env.VITE_DEBUG_FORMS);
|
||||||
|
|
||||||
|
type JmapCall = [string, Record<string, unknown>, string];
|
||||||
|
|
||||||
|
function summarizeArgs(args: Record<string, unknown>): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (typeof args.accountId === 'string') {
|
||||||
|
parts.push(`account=${truncate(args.accountId, 12)}`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(args.ids)) {
|
||||||
|
parts.push(`ids=${args.ids.length}`);
|
||||||
|
} else if (args.ids === null) {
|
||||||
|
parts.push('ids=all');
|
||||||
|
}
|
||||||
|
if (args.create && typeof args.create === 'object') {
|
||||||
|
parts.push(`create=${Object.keys(args.create).length}`);
|
||||||
|
}
|
||||||
|
if (args.update && typeof args.update === 'object') {
|
||||||
|
parts.push(`update=${Object.keys(args.update).length}`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(args.destroy)) {
|
||||||
|
parts.push(`destroy=${args.destroy.length}`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(args.list)) {
|
||||||
|
parts.push(`list=${args.list.length}`);
|
||||||
|
}
|
||||||
|
if (typeof args.total === 'number') {
|
||||||
|
parts.push(`total=${args.total}`);
|
||||||
|
}
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, max: number): string {
|
||||||
|
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummaryRows(calls: unknown): Array<Record<string, string>> {
|
||||||
|
if (!Array.isArray(calls)) return [];
|
||||||
|
return calls.map((call, idx) => {
|
||||||
|
if (!Array.isArray(call) || call.length < 3) {
|
||||||
|
return { '#': String(idx), method: '?', callId: '?', summary: '' };
|
||||||
|
}
|
||||||
|
const [method, args, callId] = call as JmapCall;
|
||||||
|
return {
|
||||||
|
'#': String(idx),
|
||||||
|
method,
|
||||||
|
callId,
|
||||||
|
summary: args && typeof args === 'object' ? summarizeArgs(args) : '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logJmapExchange(methodCalls: unknown, methodResponses: unknown, durationMs?: number): void {
|
||||||
|
if (!debugJmap) return;
|
||||||
|
|
||||||
|
const hasError = Array.isArray(methodResponses) && methodResponses.some((r) => Array.isArray(r) && r[0] === 'error');
|
||||||
|
|
||||||
|
const methodNames = Array.isArray(methodCalls)
|
||||||
|
? methodCalls.map((c) => (Array.isArray(c) && typeof c[0] === 'string' ? c[0] : '?')).join(' · ')
|
||||||
|
: '?';
|
||||||
|
|
||||||
|
const durationLabel = typeof durationMs === 'number' ? ` (${Math.round(durationMs)}ms)` : '';
|
||||||
|
|
||||||
|
const headerColor = hasError ? '#dc2626' : '#2563eb';
|
||||||
|
|
||||||
|
console.groupCollapsed(
|
||||||
|
`%c[JMAP]%c ${methodNames}${durationLabel}${hasError ? ' (with errors)' : ''}`,
|
||||||
|
`color: ${headerColor}; font-weight: bold`,
|
||||||
|
'color: inherit',
|
||||||
|
);
|
||||||
|
|
||||||
|
console.groupCollapsed('%c→ request', 'color: #2563eb; font-weight: bold');
|
||||||
|
console.table(buildSummaryRows(methodCalls));
|
||||||
|
console.log(JSON.stringify(methodCalls, null, 2));
|
||||||
|
console.groupEnd();
|
||||||
|
|
||||||
|
console.groupCollapsed(
|
||||||
|
`%c← response${hasError ? ' (with errors)' : ''}`,
|
||||||
|
`color: ${hasError ? '#dc2626' : '#16a34a'}; font-weight: bold`,
|
||||||
|
);
|
||||||
|
console.table(buildSummaryRows(methodResponses));
|
||||||
|
console.log(JSON.stringify(methodResponses, null, 2));
|
||||||
|
console.groupEnd();
|
||||||
|
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logFormChange(label: string, data: unknown): void {
|
||||||
|
if (!debugForms) return;
|
||||||
|
console.groupCollapsed(`%c[Form]%c ${label}`, 'color: #9333ea; font-weight: bold', 'color: inherit');
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
bytesToHuman,
|
||||||
|
humanToBytes,
|
||||||
|
formatSize,
|
||||||
|
msToHuman,
|
||||||
|
humanToMs,
|
||||||
|
formatDuration,
|
||||||
|
SIZE_UNITS,
|
||||||
|
DURATION_UNITS,
|
||||||
|
} from './durationFormat';
|
||||||
|
|
||||||
|
describe('SIZE_UNITS', () => {
|
||||||
|
it('should contain the expected units in order', () => {
|
||||||
|
expect(SIZE_UNITS).toEqual(['B', 'KB', 'MB', 'GB', 'TB']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DURATION_UNITS', () => {
|
||||||
|
it('should contain the expected units in order', () => {
|
||||||
|
expect(DURATION_UNITS).toEqual(['ms', 's', 'min', 'h', 'd']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bytesToHuman', () => {
|
||||||
|
it('should return 0 B for 0 bytes', () => {
|
||||||
|
expect(bytesToHuman(0)).toEqual({ value: 0, unit: 'B' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep small values in bytes', () => {
|
||||||
|
expect(bytesToHuman(500)).toEqual({ value: 500, unit: 'B' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep 512 bytes as B', () => {
|
||||||
|
expect(bytesToHuman(512)).toEqual({ value: 512, unit: 'B' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 1024 bytes to 1 KB', () => {
|
||||||
|
expect(bytesToHuman(1024)).toEqual({ value: 1, unit: 'KB' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 1536 bytes to 1.5 KB', () => {
|
||||||
|
expect(bytesToHuman(1536)).toEqual({ value: 1.5, unit: 'KB' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 1500 bytes to 1.46 KB', () => {
|
||||||
|
expect(bytesToHuman(1500)).toEqual({ value: 1.46, unit: 'KB' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 1048576 bytes to 1 MB', () => {
|
||||||
|
expect(bytesToHuman(1048576)).toEqual({ value: 1, unit: 'MB' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 10485760 bytes to 10 MB', () => {
|
||||||
|
expect(bytesToHuman(10485760)).toEqual({ value: 10, unit: 'MB' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 1073741824 bytes to 1 GB', () => {
|
||||||
|
expect(bytesToHuman(1073741824)).toEqual({ value: 1, unit: 'GB' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 1099511627776 bytes to 1 TB', () => {
|
||||||
|
expect(bytesToHuman(1099511627776)).toEqual({ value: 1, unit: 'TB' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('humanToBytes', () => {
|
||||||
|
it('should convert 1 KB to 1024 bytes', () => {
|
||||||
|
expect(humanToBytes(1, 'KB')).toBe(1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 10 MB to 10485760 bytes', () => {
|
||||||
|
expect(humanToBytes(10, 'MB')).toBe(10485760);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 1.5 GB to 1610612736 bytes', () => {
|
||||||
|
expect(humanToBytes(1.5, 'GB')).toBe(1610612736);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 0 B to 0', () => {
|
||||||
|
expect(humanToBytes(0, 'B')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the raw value for an unknown unit', () => {
|
||||||
|
expect(humanToBytes(42, 'XYZ')).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatSize', () => {
|
||||||
|
it('should format 0 bytes as "0 B"', () => {
|
||||||
|
expect(formatSize(0)).toBe('0 B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format 1024 bytes as "1 KB"', () => {
|
||||||
|
expect(formatSize(1024)).toBe('1 KB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format 1572864 bytes as "1.5 MB"', () => {
|
||||||
|
expect(formatSize(1572864)).toBe('1.5 MB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format large values in TB', () => {
|
||||||
|
expect(formatSize(1099511627776)).toBe('1 TB');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('msToHuman', () => {
|
||||||
|
it('should return 0 ms for 0', () => {
|
||||||
|
expect(msToHuman(0)).toEqual({ value: 0, unit: 'ms' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep 500 as ms', () => {
|
||||||
|
expect(msToHuman(500)).toEqual({ value: 500, unit: 'ms' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 1000 ms to 1 s', () => {
|
||||||
|
expect(msToHuman(1000)).toEqual({ value: 1, unit: 's' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 1500 ms to 1.5 s', () => {
|
||||||
|
expect(msToHuman(1500)).toEqual({ value: 1.5, unit: 's' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 60000 ms to 1 min', () => {
|
||||||
|
expect(msToHuman(60000)).toEqual({ value: 1, unit: 'min' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 300000 ms to 5 min', () => {
|
||||||
|
expect(msToHuman(300000)).toEqual({ value: 5, unit: 'min' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 90000 ms to 1.5 min', () => {
|
||||||
|
expect(msToHuman(90000)).toEqual({ value: 1.5, unit: 'min' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 3600000 ms to 1 h', () => {
|
||||||
|
expect(msToHuman(3600000)).toEqual({ value: 1, unit: 'h' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 86400000 ms to 1 d', () => {
|
||||||
|
expect(msToHuman(86400000)).toEqual({ value: 1, unit: 'd' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('humanToMs', () => {
|
||||||
|
it('should convert 5 s to 5000 ms', () => {
|
||||||
|
expect(humanToMs(5, 's')).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 1 min to 60000 ms', () => {
|
||||||
|
expect(humanToMs(1, 'min')).toBe(60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 2 h to 7200000 ms', () => {
|
||||||
|
expect(humanToMs(2, 'h')).toBe(7200000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 1 d to 86400000 ms', () => {
|
||||||
|
expect(humanToMs(1, 'd')).toBe(86400000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the raw value for an unknown unit', () => {
|
||||||
|
expect(humanToMs(99, 'eons')).toBe(99);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDuration', () => {
|
||||||
|
it('should format 0 as "0ms"', () => {
|
||||||
|
expect(formatDuration(0)).toBe('0ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format 500 ms as "500ms"', () => {
|
||||||
|
expect(formatDuration(500)).toBe('500ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format 5000 ms as "5s"', () => {
|
||||||
|
expect(formatDuration(5000)).toBe('5s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format 65000 ms as "1m 5s"', () => {
|
||||||
|
expect(formatDuration(65000)).toBe('1m 5s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format 3661000 ms as "1h 1m 1s"', () => {
|
||||||
|
expect(formatDuration(3661000)).toBe('1h 1m 1s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format 86400000 ms as "1d"', () => {
|
||||||
|
expect(formatDuration(86400000)).toBe('1d');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format 90061500 ms as "1d 1h 1m 1s 500ms"', () => {
|
||||||
|
expect(formatDuration(90061500)).toBe('1d 1h 1m 1s 500ms');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('round-trip: bytes', () => {
|
||||||
|
it.each([1024, 1048576, 1073741824, 1099511627776])('bytesToHuman -> humanToBytes should recover %d', (original) => {
|
||||||
|
const { value, unit } = bytesToHuman(original);
|
||||||
|
expect(humanToBytes(value, unit)).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be close for non-exact values', () => {
|
||||||
|
const original = 123456789;
|
||||||
|
const { value, unit } = bytesToHuman(original);
|
||||||
|
const recovered = humanToBytes(value, unit);
|
||||||
|
expect(Math.abs(recovered - original) / original).toBeLessThan(0.01);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('round-trip: duration', () => {
|
||||||
|
it.each([1000, 60000, 3600000, 86400000])('msToHuman -> humanToMs should recover %d', (original) => {
|
||||||
|
const { value, unit } = msToHuman(original);
|
||||||
|
expect(humanToMs(value, unit)).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be close for non-exact values', () => {
|
||||||
|
const original = 123456;
|
||||||
|
const { value, unit } = msToHuman(original);
|
||||||
|
const recovered = humanToMs(value, unit);
|
||||||
|
expect(Math.abs(recovered - original) / original).toBeLessThan(0.01);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
|
||||||
|
|
||||||
|
const SIZE_FACTORS: Record<string, number> = {
|
||||||
|
B: 1,
|
||||||
|
KB: 1024,
|
||||||
|
MB: 1024 ** 2,
|
||||||
|
GB: 1024 ** 3,
|
||||||
|
TB: 1024 ** 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function bytesToHuman(bytes: number): { value: number; unit: string } {
|
||||||
|
if (bytes === 0) return { value: 0, unit: 'B' };
|
||||||
|
|
||||||
|
for (let i = SIZE_UNITS.length - 1; i >= 0; i--) {
|
||||||
|
const unit = SIZE_UNITS[i];
|
||||||
|
const factor = SIZE_FACTORS[unit];
|
||||||
|
const v = bytes / factor;
|
||||||
|
if (v >= 1) {
|
||||||
|
const rounded = Math.round(v * 100) / 100;
|
||||||
|
return { value: rounded, unit };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: bytes, unit: 'B' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function humanToBytes(value: number, unit: string): number {
|
||||||
|
const factor = SIZE_FACTORS[unit];
|
||||||
|
if (factor === undefined) return value;
|
||||||
|
return Math.round(value * factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSize(bytes: number): string {
|
||||||
|
const { value, unit } = bytesToHuman(bytes);
|
||||||
|
return `${value} ${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DURATION_UNITS = ['ms', 's', 'min', 'h', 'd'] as const;
|
||||||
|
|
||||||
|
const DURATION_FACTORS: Record<string, number> = {
|
||||||
|
ms: 1,
|
||||||
|
s: 1_000,
|
||||||
|
min: 60_000,
|
||||||
|
h: 3_600_000,
|
||||||
|
d: 86_400_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function msToHuman(ms: number): { value: number; unit: string } {
|
||||||
|
if (ms === 0) return { value: 0, unit: 'ms' };
|
||||||
|
|
||||||
|
for (let i = DURATION_UNITS.length - 1; i >= 0; i--) {
|
||||||
|
const unit = DURATION_UNITS[i];
|
||||||
|
const factor = DURATION_FACTORS[unit];
|
||||||
|
const v = ms / factor;
|
||||||
|
if (v >= 1) {
|
||||||
|
const rounded = Math.round(v * 100) / 100;
|
||||||
|
return { value: rounded, unit };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: ms, unit: 'ms' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function humanToMs(value: number, unit: string): number {
|
||||||
|
const factor = DURATION_FACTORS[unit];
|
||||||
|
if (factor === undefined) return value;
|
||||||
|
return Math.round(value * factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
if (ms === 0) return '0ms';
|
||||||
|
|
||||||
|
const d = Math.floor(ms / 86_400_000);
|
||||||
|
ms %= 86_400_000;
|
||||||
|
const h = Math.floor(ms / 3_600_000);
|
||||||
|
ms %= 3_600_000;
|
||||||
|
const m = Math.floor(ms / 60_000);
|
||||||
|
ms %= 60_000;
|
||||||
|
const s = Math.floor(ms / 1_000);
|
||||||
|
ms %= 1_000;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (d > 0) parts.push(`${d}d`);
|
||||||
|
if (h > 0) parts.push(`${h}h`);
|
||||||
|
if (m > 0) parts.push(`${m}m`);
|
||||||
|
if (s > 0) parts.push(`${s}s`);
|
||||||
|
if (ms > 0) parts.push(`${ms}ms`);
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { JmapSetError } from '@/types/jmap';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
|
||||||
|
export function friendlySetError(err: JmapSetError): string {
|
||||||
|
if (err.description) return err.description;
|
||||||
|
switch (err.type) {
|
||||||
|
case 'forbidden':
|
||||||
|
return i18n.t('jmapErrors.forbidden', 'You do not have permission to perform this action.');
|
||||||
|
case 'notFound':
|
||||||
|
return i18n.t('jmapErrors.notFound', 'The item was not found. It may have been already deleted.');
|
||||||
|
case 'objectIsLinked':
|
||||||
|
return i18n.t('jmapErrors.objectIsLinked', 'This item is referenced by other items and cannot be deleted.');
|
||||||
|
case 'invalidForeignKey':
|
||||||
|
return i18n.t('jmapErrors.invalidForeignKey', 'This item references another item that does not exist.');
|
||||||
|
case 'primaryKeyViolation':
|
||||||
|
return i18n.t('jmapErrors.primaryKeyViolation', 'An item with the same key already exists.');
|
||||||
|
case 'alreadyExists':
|
||||||
|
return i18n.t('jmapErrors.alreadyExists', 'An item with the same identifier already exists.');
|
||||||
|
case 'overQuota':
|
||||||
|
return i18n.t('jmapErrors.overQuota', 'The operation exceeds your storage quota.');
|
||||||
|
case 'tooLarge':
|
||||||
|
return i18n.t('jmapErrors.tooLarge', 'The item is too large.');
|
||||||
|
case 'rateLimit':
|
||||||
|
return i18n.t('jmapErrors.rateLimit', 'Too many requests. Please try again later.');
|
||||||
|
case 'singleton':
|
||||||
|
return i18n.t('jmapErrors.singleton', 'This item is a singleton and cannot be deleted.');
|
||||||
|
case 'mailboxHasChild':
|
||||||
|
return i18n.t('jmapErrors.mailboxHasChild', 'This mailbox has child mailboxes. Delete them first.');
|
||||||
|
case 'mailboxHasEmail':
|
||||||
|
return i18n.t('jmapErrors.mailboxHasEmail', 'This mailbox contains messages. Remove them first.');
|
||||||
|
case 'calendarHasEvent':
|
||||||
|
return i18n.t('jmapErrors.calendarHasEvent', 'This calendar contains events. Remove them first.');
|
||||||
|
case 'addressBookHasContents':
|
||||||
|
return i18n.t('jmapErrors.addressBookHasContents', 'This address book has contacts. Remove them first.');
|
||||||
|
case 'nodeHasChildren':
|
||||||
|
return i18n.t('jmapErrors.nodeHasChildren', 'This item has children. Delete them first.');
|
||||||
|
case 'validationFailed':
|
||||||
|
return i18n.t('jmapErrors.validationFailed', 'The data did not pass validation.');
|
||||||
|
default:
|
||||||
|
return i18n.t('jmapErrors.unexpected', 'Unexpected error ({{type}}).', { type: err.type });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { calculateJmapPatch, escapeJsonPointerToken } from './jmapPatch';
|
||||||
|
|
||||||
|
describe('calculateJmapPatch', () => {
|
||||||
|
it('detects changed primitives', () => {
|
||||||
|
const original = { name: 'Alice', age: 30, active: true };
|
||||||
|
const modified = { name: 'Bob', age: 31, active: false };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
name: 'Bob',
|
||||||
|
age: 31,
|
||||||
|
active: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects added fields', () => {
|
||||||
|
const original = { name: 'Alice' };
|
||||||
|
const modified = { name: 'Alice', email: 'alice@example.com' };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
email: 'alice@example.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects removed fields (set to null)', () => {
|
||||||
|
const original = { name: 'Alice', email: 'alice@example.com' };
|
||||||
|
const modified = { name: 'Alice' };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
email: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object when nothing changed', () => {
|
||||||
|
const original = { name: 'Alice', age: 30, active: true };
|
||||||
|
const modified = { name: 'Alice', age: 30, active: true };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object for two empty objects', () => {
|
||||||
|
expect(calculateJmapPatch({}, {})).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces JSON pointer paths for nested changes', () => {
|
||||||
|
const original = { address: { city: 'NYC', zip: '10001' } };
|
||||||
|
const modified = { address: { city: 'LA', zip: '10001' } };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'address/city': 'LA',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles deeply nested changes', () => {
|
||||||
|
const original = { a: { b: { c: { d: 1 } } } };
|
||||||
|
const modified = { a: { b: { c: { d: 2 } } } };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'a/b/c/d': 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects added nested fields', () => {
|
||||||
|
const original = { address: { city: 'NYC' } };
|
||||||
|
const modified = { address: { city: 'NYC', zip: '10001' } };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'address/zip': '10001',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects removed nested fields', () => {
|
||||||
|
const original = { address: { city: 'NYC', zip: '10001' } };
|
||||||
|
const modified = { address: { city: 'NYC' } };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'address/zip': null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects set additions', () => {
|
||||||
|
const original = { keywords: { important: true } };
|
||||||
|
const modified = { keywords: { important: true, urgent: true } };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'keywords/urgent': true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects set removals', () => {
|
||||||
|
const original = { keywords: { important: true, urgent: true } };
|
||||||
|
const modified = { keywords: { important: true } };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'keywords/urgent': null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles mixed set changes', () => {
|
||||||
|
const original = { keywords: { important: true, draft: true } };
|
||||||
|
const modified = { keywords: { important: true, urgent: true } };
|
||||||
|
const patch = calculateJmapPatch(original, modified);
|
||||||
|
expect(patch).toEqual({
|
||||||
|
'keywords/draft': null,
|
||||||
|
'keywords/urgent': true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects objectList item additions', () => {
|
||||||
|
const original = {
|
||||||
|
emailAddresses: {
|
||||||
|
'0': { email: 'a@test.com', type: 'work' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const modified = {
|
||||||
|
emailAddresses: {
|
||||||
|
'0': { email: 'a@test.com', type: 'work' },
|
||||||
|
'1': { email: 'b@test.com', type: 'home' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'emailAddresses/1': { email: 'b@test.com', type: 'home' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects objectList item removals', () => {
|
||||||
|
const original = {
|
||||||
|
emailAddresses: {
|
||||||
|
'0': { email: 'a@test.com', type: 'work' },
|
||||||
|
'1': { email: 'b@test.com', type: 'home' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const modified = {
|
||||||
|
emailAddresses: {
|
||||||
|
'0': { email: 'a@test.com', type: 'work' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'emailAddresses/1': null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects objectList item field updates', () => {
|
||||||
|
const original = {
|
||||||
|
emailAddresses: {
|
||||||
|
'0': { email: 'a@test.com', type: 'work' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const modified = {
|
||||||
|
emailAddresses: {
|
||||||
|
'0': { email: 'a@test.com', type: 'home' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'emailAddresses/0/type': 'home',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects map key additions', () => {
|
||||||
|
const original = { mailboxIds: { inbox1: true } };
|
||||||
|
const modified = { mailboxIds: { inbox1: true, sent1: true } };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'mailboxIds/sent1': true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects map key removals', () => {
|
||||||
|
const original = { mailboxIds: { inbox1: true, sent1: true } };
|
||||||
|
const modified = { mailboxIds: { inbox1: true } };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'mailboxIds/sent1': null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects map value changes', () => {
|
||||||
|
const original = { settings: { theme: 'dark' } };
|
||||||
|
const modified = { settings: { theme: 'light' } };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'settings/theme': 'light',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles map with object values', () => {
|
||||||
|
const original = {
|
||||||
|
accounts: {
|
||||||
|
acc1: { name: 'Account 1', isActive: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const modified = {
|
||||||
|
accounts: {
|
||||||
|
acc1: { name: 'Account 1', isActive: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'accounts/acc1/isActive': false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles map with nested object value additions', () => {
|
||||||
|
const original = { accounts: {} };
|
||||||
|
const modified = {
|
||||||
|
accounts: {
|
||||||
|
acc1: { name: 'Account 1', isActive: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'accounts/acc1': { name: 'Account 1', isActive: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes @type in patch', () => {
|
||||||
|
const original = { '@type': 'Mailbox', name: 'Inbox' };
|
||||||
|
const modified = { '@type': 'MailboxChanged', name: 'Inbox' };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'@type': 'MailboxChanged',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include @type when unchanged', () => {
|
||||||
|
const original = { '@type': 'Mailbox', name: 'Inbox' };
|
||||||
|
const modified = { '@type': 'Mailbox', name: 'Sent' };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
name: 'Sent',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not null out fields from the previous variant on top-level @type change', () => {
|
||||||
|
const original = {
|
||||||
|
'@type': 'MaxMind',
|
||||||
|
asnUrls: ['https://example.com/asn.csv'],
|
||||||
|
expires: 86400,
|
||||||
|
geoUrls: ['https://example.com/geo.csv'],
|
||||||
|
httpAuth: null,
|
||||||
|
httpHeaders: {},
|
||||||
|
maxSize: 104857600,
|
||||||
|
timeout: 30000,
|
||||||
|
};
|
||||||
|
const modified = { '@type': 'Disabled' };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'@type': 'Disabled',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the new variant's defaults on top-level @type change", () => {
|
||||||
|
const original = {
|
||||||
|
'@type': 'VariantA',
|
||||||
|
onlyOnA: 'x',
|
||||||
|
shared: 1,
|
||||||
|
};
|
||||||
|
const modified = {
|
||||||
|
'@type': 'VariantB',
|
||||||
|
onlyOnB: 'y',
|
||||||
|
shared: 2,
|
||||||
|
};
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'@type': 'VariantB',
|
||||||
|
onlyOnB: 'y',
|
||||||
|
shared: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces nested object atomically when its @type changes', () => {
|
||||||
|
const original = {
|
||||||
|
certificateManagement: { '@type': 'Manual' },
|
||||||
|
};
|
||||||
|
const modified = {
|
||||||
|
certificateManagement: {
|
||||||
|
'@type': 'Automatic',
|
||||||
|
acmeProviderId: 'letsencrypt',
|
||||||
|
subjectAlternativeNames: { example: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
certificateManagement: {
|
||||||
|
'@type': 'Automatic',
|
||||||
|
acmeProviderId: 'letsencrypt',
|
||||||
|
subjectAlternativeNames: { example: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not produce nested/@type patch entries on variant change', () => {
|
||||||
|
const original = {
|
||||||
|
dnsManagement: { '@type': 'Manual' },
|
||||||
|
};
|
||||||
|
const modified = {
|
||||||
|
dnsManagement: {
|
||||||
|
'@type': 'Automatic',
|
||||||
|
publishRecords: { dkim: true, mx: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const patch = calculateJmapPatch(original, modified);
|
||||||
|
expect(Object.keys(patch)).toEqual(['dnsManagement']);
|
||||||
|
expect(patch['dnsManagement/@type']).toBeUndefined();
|
||||||
|
expect(patch['dnsManagement/publishRecords']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('patches nested object properties normally when @type is unchanged', () => {
|
||||||
|
const original = {
|
||||||
|
dnsManagement: {
|
||||||
|
'@type': 'Automatic',
|
||||||
|
publishRecords: { dkim: true, mx: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const modified = {
|
||||||
|
dnsManagement: {
|
||||||
|
'@type': 'Automatic',
|
||||||
|
publishRecords: { dkim: true, mx: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'dnsManagement/publishRecords/mx': true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces atomically when @type appears in modified but not original', () => {
|
||||||
|
const original = {
|
||||||
|
certificateManagement: {},
|
||||||
|
};
|
||||||
|
const modified = {
|
||||||
|
certificateManagement: { '@type': 'Automatic', acmeProviderId: 'le' },
|
||||||
|
};
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
certificateManagement: { '@type': 'Automatic', acmeProviderId: 'le' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces atomically when @type appears in original but not modified', () => {
|
||||||
|
const original = {
|
||||||
|
certificateManagement: { '@type': 'Manual' },
|
||||||
|
};
|
||||||
|
const modified = {
|
||||||
|
certificateManagement: {},
|
||||||
|
};
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
certificateManagement: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles deeply nested variant change (inside another nested object)', () => {
|
||||||
|
const original = {
|
||||||
|
model: {
|
||||||
|
outer: 'constant',
|
||||||
|
inner: { '@type': 'TypeA', a: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const modified = {
|
||||||
|
model: {
|
||||||
|
outer: 'constant',
|
||||||
|
inner: { '@type': 'TypeB', b: 2 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'model/inner': { '@type': 'TypeB', b: 2 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only replaces the variant-changed sub-object, not siblings', () => {
|
||||||
|
const original = {
|
||||||
|
a: { '@type': 'X' },
|
||||||
|
b: { foo: 'bar' },
|
||||||
|
c: 1,
|
||||||
|
};
|
||||||
|
const modified = {
|
||||||
|
a: { '@type': 'Y', value: 42 },
|
||||||
|
b: { foo: 'baz' },
|
||||||
|
c: 2,
|
||||||
|
};
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
a: { '@type': 'Y', value: 42 },
|
||||||
|
'b/foo': 'baz',
|
||||||
|
c: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores id field', () => {
|
||||||
|
const original = { id: '123', name: 'Alice' };
|
||||||
|
const modified = { id: '456', name: 'Bob' };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
name: 'Bob',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores id field even when added', () => {
|
||||||
|
const original = { name: 'Alice' };
|
||||||
|
const modified = { id: '123', name: 'Alice' };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores id field even when removed', () => {
|
||||||
|
const original = { id: '123', name: 'Alice' };
|
||||||
|
const modified = { name: 'Alice' };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null to value', () => {
|
||||||
|
const original = { name: null };
|
||||||
|
const modified = { name: 'Alice' };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
name: 'Alice',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles value to null', () => {
|
||||||
|
const original = { name: 'Alice' };
|
||||||
|
const modified = { name: null };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
name: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats arrays as atomic', () => {
|
||||||
|
const original = { tags: ['a', 'b'] };
|
||||||
|
const modified = { tags: ['a', 'c'] };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
tags: ['a', 'c'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for identical arrays', () => {
|
||||||
|
const original = { tags: ['a', 'b'] };
|
||||||
|
const modified = { tags: ['a', 'b'] };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty objects', () => {
|
||||||
|
const original = { data: {} };
|
||||||
|
const modified = { data: {} };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles transition from primitive to object', () => {
|
||||||
|
const original = { field: 'string' };
|
||||||
|
const modified = { field: { nested: true } };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
field: { nested: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles transition from object to primitive', () => {
|
||||||
|
const original = { field: { nested: true } };
|
||||||
|
const modified = { field: 'string' };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
field: 'string',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles transition from array to object', () => {
|
||||||
|
const original = { field: [1, 2, 3] };
|
||||||
|
const modified = { field: { key: 'value' } };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
field: { key: 'value' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles transition from object to array', () => {
|
||||||
|
const original = { field: { key: 'value' } };
|
||||||
|
const modified = { field: [1, 2, 3] };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
field: [1, 2, 3],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('escapeJsonPointerToken', () => {
|
||||||
|
it('escapes tilde', () => {
|
||||||
|
expect(escapeJsonPointerToken('a~b')).toBe('a~0b');
|
||||||
|
});
|
||||||
|
it('escapes slash', () => {
|
||||||
|
expect(escapeJsonPointerToken('a/b')).toBe('a~1b');
|
||||||
|
});
|
||||||
|
it('escapes both, tilde first', () => {
|
||||||
|
expect(escapeJsonPointerToken('a~/b')).toBe('a~0~1b');
|
||||||
|
});
|
||||||
|
it('leaves unrelated characters alone', () => {
|
||||||
|
expect(escapeJsonPointerToken('plain')).toBe('plain');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes user-supplied map keys containing slashes in patches', () => {
|
||||||
|
const original = { headers: { 'X-Plain': 'old' } };
|
||||||
|
const modified = { headers: { 'X-Plain': 'old', 'X/Slashed': 'value' } };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'headers/X~1Slashed': 'value',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes user-supplied map keys containing tildes in patches', () => {
|
||||||
|
const original = { headers: {} };
|
||||||
|
const modified = { headers: { 'with~tilde': 'yes' } };
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
'headers/with~0tilde': 'yes',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple changes at different nesting levels', () => {
|
||||||
|
const original = {
|
||||||
|
name: 'Test',
|
||||||
|
nested: {
|
||||||
|
a: 1,
|
||||||
|
b: { c: 2, d: 3 },
|
||||||
|
},
|
||||||
|
other: 'unchanged',
|
||||||
|
};
|
||||||
|
const modified = {
|
||||||
|
name: 'Updated',
|
||||||
|
nested: {
|
||||||
|
a: 1,
|
||||||
|
b: { c: 99, d: 3 },
|
||||||
|
},
|
||||||
|
other: 'unchanged',
|
||||||
|
};
|
||||||
|
expect(calculateJmapPatch(original, modified)).toEqual({
|
||||||
|
name: 'Updated',
|
||||||
|
'nested/b/c': 99,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function calculateJmapPatch(
|
||||||
|
original: Record<string, unknown>,
|
||||||
|
modified: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
const allKeys = new Set([...Object.keys(original), ...Object.keys(modified)]);
|
||||||
|
|
||||||
|
const variantChanged = '@type' in original && '@type' in modified && original['@type'] !== modified['@type'];
|
||||||
|
|
||||||
|
for (const key of allKeys) {
|
||||||
|
if (key === 'id') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origVal = original[key];
|
||||||
|
const modVal = modified[key];
|
||||||
|
|
||||||
|
if (!(key in original)) {
|
||||||
|
patch[key] = modVal;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(key in modified)) {
|
||||||
|
if (variantChanged) continue;
|
||||||
|
patch[key] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
diffValues(key, origVal, modVal, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeJsonPointerToken(key: string): string {
|
||||||
|
return key.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffValues(prefix: string, origVal: unknown, modVal: unknown, patch: Record<string, unknown>): void {
|
||||||
|
if (isPlainObject(origVal) && isPlainObject(modVal)) {
|
||||||
|
const origType = origVal['@type'];
|
||||||
|
const modType = modVal['@type'];
|
||||||
|
if (origType !== modType) {
|
||||||
|
patch[prefix] = modVal;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSubKeys = new Set([...Object.keys(origVal), ...Object.keys(modVal)]);
|
||||||
|
|
||||||
|
for (const subKey of allSubKeys) {
|
||||||
|
const path = `${prefix}/${escapeJsonPointerToken(subKey)}`;
|
||||||
|
const origSub = origVal[subKey];
|
||||||
|
const modSub = modVal[subKey];
|
||||||
|
|
||||||
|
if (!(subKey in origVal)) {
|
||||||
|
patch[path] = modSub;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(subKey in modVal)) {
|
||||||
|
patch[path] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
diffValues(path, origSub, modSub, patch);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(origVal) && Array.isArray(modVal)) {
|
||||||
|
if (JSON.stringify(origVal) !== JSON.stringify(modVal)) {
|
||||||
|
patch[prefix] = modVal;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (origVal !== modVal) {
|
||||||
|
patch[prefix] = modVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function jmapMapToArray<T>(val: unknown): T[] {
|
||||||
|
if (Array.isArray(val)) return val as T[];
|
||||||
|
if (val && typeof val === 'object') {
|
||||||
|
return Object.entries(val as Record<string, T>)
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.map(([, v]) => v);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Layout, LayoutItem, LayoutSubItem, Schema } from '@/types/schema';
|
||||||
|
import { resolveObject } from '@/lib/schemaResolver';
|
||||||
|
|
||||||
|
function findFirstSubLink(items: LayoutSubItem[]): string | null {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type === 'link') return item.viewName;
|
||||||
|
if (item.type === 'container') {
|
||||||
|
const found = findFirstSubLink(item.items);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findFirstLinkInLayout(items: LayoutItem[] | Layout): string | null {
|
||||||
|
const list = Array.isArray(items) ? items : items.items;
|
||||||
|
for (const item of list) {
|
||||||
|
if ('link' in item) return item.link.viewName;
|
||||||
|
if ('container' in item) {
|
||||||
|
const found = findFirstSubLink(item.container.items);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CanGet = (permissionPrefix: string) => boolean;
|
||||||
|
export type HasPermission = (permission: string) => boolean;
|
||||||
|
|
||||||
|
interface SpecialLinkInfo {
|
||||||
|
visible: boolean;
|
||||||
|
enterprise: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSpecialLink(
|
||||||
|
viewName: string,
|
||||||
|
edition: string,
|
||||||
|
hasPerm?: HasPermission,
|
||||||
|
canGet?: CanGet,
|
||||||
|
): SpecialLinkInfo | null {
|
||||||
|
if (viewName.startsWith('Dashboard/') || viewName === 'CustomComponent/Dashboard') {
|
||||||
|
if (edition === 'oss') return { visible: false, enterprise: true };
|
||||||
|
const hasLiveMetrics = hasPerm ? hasPerm('liveMetrics') : true;
|
||||||
|
const hasTraceGet = canGet ? canGet('sysTrace') : true;
|
||||||
|
return { visible: hasLiveMetrics && hasTraceGet, enterprise: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewName === 'CustomComponent/LiveDelivery') {
|
||||||
|
const allowed = hasPerm ? hasPerm('liveDeliveryTest') : true;
|
||||||
|
return { visible: allowed, enterprise: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewName === 'CustomComponent/LiveTracing') {
|
||||||
|
if (edition === 'oss') return { visible: false, enterprise: true };
|
||||||
|
const allowed = hasPerm ? hasPerm('liveTracing') : true;
|
||||||
|
return { visible: allowed, enterprise: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewName.startsWith('CustomComponent/')) {
|
||||||
|
return { visible: true, enterprise: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLinkVisible(
|
||||||
|
schema: Schema,
|
||||||
|
viewName: string,
|
||||||
|
edition: string,
|
||||||
|
canGet: CanGet,
|
||||||
|
hasPerm?: HasPermission,
|
||||||
|
): boolean {
|
||||||
|
const special = checkSpecialLink(viewName, edition, hasPerm, canGet);
|
||||||
|
if (special !== null) return special.visible;
|
||||||
|
|
||||||
|
const obj = schema.objects[viewName];
|
||||||
|
if (!obj) return false;
|
||||||
|
const resolved = resolveObject(schema, viewName);
|
||||||
|
if (!resolved) return false;
|
||||||
|
|
||||||
|
if (!canGet(resolved.permissionPrefix)) return false;
|
||||||
|
|
||||||
|
if (resolved.enterprise && edition === 'oss') return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLinkEnterprise(schema: Schema, viewName: string, edition: string): boolean {
|
||||||
|
const special = checkSpecialLink(viewName, edition);
|
||||||
|
if (special !== null) return special.enterprise;
|
||||||
|
|
||||||
|
const obj = schema.objects[viewName];
|
||||||
|
if (!obj) return false;
|
||||||
|
if (obj.type === 'view') {
|
||||||
|
const parent = schema.objects[obj.objectName];
|
||||||
|
return parent?.type !== 'view' && parent?.enterprise === true;
|
||||||
|
}
|
||||||
|
return obj.enterprise === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFirstVisibleSubLink(
|
||||||
|
schema: Schema,
|
||||||
|
items: LayoutSubItem[],
|
||||||
|
edition: string,
|
||||||
|
canGet: CanGet,
|
||||||
|
hasPerm?: HasPermission,
|
||||||
|
): string | null {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type === 'link') {
|
||||||
|
if (isLinkVisible(schema, item.viewName, edition, canGet, hasPerm)) return item.viewName;
|
||||||
|
} else if (item.type === 'container') {
|
||||||
|
const found = findFirstVisibleSubLink(schema, item.items, edition, canGet, hasPerm);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findFirstVisibleLinkInLayout(
|
||||||
|
schema: Schema,
|
||||||
|
layout: Layout,
|
||||||
|
edition: string,
|
||||||
|
canGet: CanGet,
|
||||||
|
hasPerm?: HasPermission,
|
||||||
|
): string | null {
|
||||||
|
for (const item of layout.items) {
|
||||||
|
if ('link' in item) {
|
||||||
|
if (isLinkVisible(schema, item.link.viewName, edition, canGet, hasPerm)) return item.link.viewName;
|
||||||
|
} else if ('container' in item) {
|
||||||
|
const found = findFirstVisibleSubLink(schema, item.container.items, edition, canGet, hasPerm);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLinkAccessible(
|
||||||
|
schema: Schema,
|
||||||
|
viewName: string,
|
||||||
|
edition: string,
|
||||||
|
canGet: CanGet,
|
||||||
|
hasPerm?: HasPermission,
|
||||||
|
): boolean {
|
||||||
|
if (!isLinkVisible(schema, viewName, edition, canGet, hasPerm)) return false;
|
||||||
|
if (edition === 'community' && isLinkEnterprise(schema, viewName, edition)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFirstAccessibleSubLink(
|
||||||
|
schema: Schema,
|
||||||
|
items: LayoutSubItem[],
|
||||||
|
edition: string,
|
||||||
|
canGet: CanGet,
|
||||||
|
hasPerm?: HasPermission,
|
||||||
|
): string | null {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type === 'link') {
|
||||||
|
if (isLinkAccessible(schema, item.viewName, edition, canGet, hasPerm)) return item.viewName;
|
||||||
|
} else if (item.type === 'container') {
|
||||||
|
const found = findFirstAccessibleSubLink(schema, item.items, edition, canGet, hasPerm);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findFirstAccessibleLinkInLayout(
|
||||||
|
schema: Schema,
|
||||||
|
layout: Layout,
|
||||||
|
edition: string,
|
||||||
|
canGet: CanGet,
|
||||||
|
hasPerm?: HasPermission,
|
||||||
|
): string | null {
|
||||||
|
for (const item of layout.items) {
|
||||||
|
if ('link' in item) {
|
||||||
|
if (isLinkAccessible(schema, item.link.viewName, edition, canGet, hasPerm)) return item.link.viewName;
|
||||||
|
} else if ('container' in item) {
|
||||||
|
const found = findFirstAccessibleSubLink(schema, item.container.items, edition, canGet, hasPerm);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleLayouts(schema: Schema, edition: string, canGet: CanGet, hasPerm?: HasPermission): Layout[] {
|
||||||
|
return schema.layouts.filter(
|
||||||
|
(layout) => findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPerm) !== null,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { resolveObject, resolveList, getDisplayProperty } from '@/lib/schemaResolver';
|
||||||
|
import { jmapGetBatched, jmapQueryAllAndGet, getAccountId } from '@/services/jmap/client';
|
||||||
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
|
import { useCacheStore, type ObjectListEntry } from '@/stores/cacheStore';
|
||||||
|
import type { Schema } from '@/types/schema';
|
||||||
|
|
||||||
|
interface PendingBatch {
|
||||||
|
ids: Set<string>;
|
||||||
|
displayProp: string;
|
||||||
|
schema: Schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingByType = new Map<string, PendingBatch>();
|
||||||
|
|
||||||
|
function requestObjectLabel(parentObjectName: string, viewOrObjectName: string, id: string, schema: Schema): void {
|
||||||
|
const existing = pendingByType.get(parentObjectName);
|
||||||
|
if (existing) {
|
||||||
|
existing.ids.add(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch: PendingBatch = {
|
||||||
|
ids: new Set([id]),
|
||||||
|
displayProp: getDisplayProperty(schema, viewOrObjectName),
|
||||||
|
schema,
|
||||||
|
};
|
||||||
|
pendingByType.set(parentObjectName, batch);
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const taken = pendingByType.get(parentObjectName);
|
||||||
|
if (taken !== batch) return;
|
||||||
|
pendingByType.delete(parentObjectName);
|
||||||
|
void executeBatch(parentObjectName, taken);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeBatch(parentObjectName: string, batch: PendingBatch): Promise<void> {
|
||||||
|
const ids = Array.from(batch.ids);
|
||||||
|
const setDisplayNames = useCacheStore.getState().setDisplayNames;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accountId = getAccountId(parentObjectName);
|
||||||
|
const list = await jmapGetBatched(parentObjectName, accountId, ids, ['id', batch.displayProp]);
|
||||||
|
const entries: Record<string, string> = {};
|
||||||
|
for (const item of list) {
|
||||||
|
const itemId = item.id as string;
|
||||||
|
if (!itemId) continue;
|
||||||
|
entries[itemId] = (item[batch.displayProp] as string) ?? itemId;
|
||||||
|
}
|
||||||
|
for (const id of ids) {
|
||||||
|
if (!(id in entries)) entries[id] = id;
|
||||||
|
}
|
||||||
|
setDisplayNames(parentObjectName, entries);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to batch-fetch labels for', parentObjectName, err);
|
||||||
|
const fallback: Record<string, string> = {};
|
||||||
|
for (const id of ids) fallback[id] = id;
|
||||||
|
setDisplayNames(parentObjectName, fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ObjectOption = ObjectListEntry;
|
||||||
|
|
||||||
|
async function fetchObjectList(viewOrObjectName: string, schema: Schema): Promise<ObjectOption[]> {
|
||||||
|
const resolved = resolveObject(schema, viewOrObjectName);
|
||||||
|
const objectName = resolved?.objectName ?? viewOrObjectName;
|
||||||
|
const list = resolveList(schema, viewOrObjectName, objectName);
|
||||||
|
const filtersStatic = list?.filtersStatic;
|
||||||
|
|
||||||
|
const accountId = getAccountId(objectName);
|
||||||
|
const displayProp = getDisplayProperty(schema, viewOrObjectName);
|
||||||
|
|
||||||
|
let items: Array<Record<string, unknown>> = [];
|
||||||
|
try {
|
||||||
|
const result = await jmapQueryAllAndGet(
|
||||||
|
objectName,
|
||||||
|
accountId,
|
||||||
|
{ filter: filtersStatic && Object.keys(filtersStatic).length > 0 ? filtersStatic : undefined },
|
||||||
|
['id', displayProp],
|
||||||
|
);
|
||||||
|
items = result.list;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('JMAP error fetching', viewOrObjectName, err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map((item) => ({
|
||||||
|
id: item.id as string,
|
||||||
|
label: (item[displayProp] as string) ?? (item.id as string),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useObjectList(viewOrObjectName: string, schema: Schema) {
|
||||||
|
const cached = useCacheStore((s) => s.objectLists[viewOrObjectName]);
|
||||||
|
const setObjectList = useCacheStore((s) => s.setObjectList);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const ensureLoaded = useCallback(async () => {
|
||||||
|
if (cached) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const opts = await fetchObjectList(viewOrObjectName, schema);
|
||||||
|
setObjectList(viewOrObjectName, opts);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch objects for', viewOrObjectName, err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [cached, viewOrObjectName, schema, setObjectList]);
|
||||||
|
|
||||||
|
return { options: cached ?? [], loading, hasLoaded: cached != null, ensureLoaded };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useObjectLabel(
|
||||||
|
viewOrObjectName: string,
|
||||||
|
id: string | null | undefined,
|
||||||
|
schema: Schema,
|
||||||
|
): { label: string | null; loading: boolean } {
|
||||||
|
const resolved = resolveObject(schema, viewOrObjectName);
|
||||||
|
const parentObjectName = resolved?.objectName ?? viewOrObjectName;
|
||||||
|
const cachedLabel = useCacheStore((s) => (id ? s.displayNames[parentObjectName]?.[id] : undefined));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
if (cachedLabel != null) return;
|
||||||
|
requestObjectLabel(parentObjectName, viewOrObjectName, id, schema);
|
||||||
|
}, [id, cachedLabel, parentObjectName, viewOrObjectName, schema]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: cachedLabel ?? null,
|
||||||
|
loading: !!id && cachedLabel == null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function objectSupportsSearch(schema: Schema, objectName: string): boolean {
|
||||||
|
const resolved = resolveObject(schema, objectName);
|
||||||
|
if (!resolved) return false;
|
||||||
|
const list = resolveList(schema, objectName, resolved.objectName);
|
||||||
|
return list?.filters?.some((f) => f.type === 'text') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNoPermissionMessage(schema: Schema, viewOrObjectName: string): string | null {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||||
|
const resolved = resolveObject(schema, viewOrObjectName);
|
||||||
|
if (!resolved) return null;
|
||||||
|
const prefix = resolved.permissionPrefix;
|
||||||
|
if (hasObjectPermission(prefix, 'Get') && hasObjectPermission(prefix, 'Query')) return null;
|
||||||
|
const list = resolveList(schema, viewOrObjectName, resolved.objectName);
|
||||||
|
const name = list?.pluralName ?? resolved.objectName;
|
||||||
|
return t('field.noPermissionToView', 'You do not have permission to view {{name}}', { name });
|
||||||
|
}
|
||||||
@@ -0,0 +1,867 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { Schema } from '@/types/schema';
|
||||||
|
import {
|
||||||
|
resolveObject,
|
||||||
|
resolveSchema,
|
||||||
|
resolveList,
|
||||||
|
resolveForm,
|
||||||
|
resolveVariantForm,
|
||||||
|
deepMerge,
|
||||||
|
buildCreateDefaults,
|
||||||
|
buildEmbeddedDefaults,
|
||||||
|
} from './schemaResolver';
|
||||||
|
import { getDisplayProperty } from './schemaResolver';
|
||||||
|
|
||||||
|
const schema: Schema = {
|
||||||
|
objects: {
|
||||||
|
'x:Domain': {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Mail domain',
|
||||||
|
permissionPrefix: 'sysDomain',
|
||||||
|
},
|
||||||
|
'x:Auth': {
|
||||||
|
type: 'singleton',
|
||||||
|
description: 'Authentication settings',
|
||||||
|
permissionPrefix: 'sysAuth',
|
||||||
|
},
|
||||||
|
'x:Account': {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Account',
|
||||||
|
permissionPrefix: 'sysAccount',
|
||||||
|
},
|
||||||
|
'x:Account/User': {
|
||||||
|
type: 'view',
|
||||||
|
objectName: 'x:Account',
|
||||||
|
},
|
||||||
|
'x:Account/Group': {
|
||||||
|
type: 'view',
|
||||||
|
objectName: 'x:Account',
|
||||||
|
},
|
||||||
|
'x:Credential': {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Credential',
|
||||||
|
permissionPrefix: 'sysCredential',
|
||||||
|
},
|
||||||
|
'x:Credential/ApiKey': {
|
||||||
|
type: 'view',
|
||||||
|
objectName: 'x:Credential',
|
||||||
|
},
|
||||||
|
'x:Tenant': {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Tenant',
|
||||||
|
permissionPrefix: 'sysTenant',
|
||||||
|
enterprise: true,
|
||||||
|
},
|
||||||
|
'x:Bad/ViewOfView': {
|
||||||
|
type: 'view',
|
||||||
|
objectName: 'x:Account/User',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
schemas: {
|
||||||
|
'x:Domain': {
|
||||||
|
type: 'single',
|
||||||
|
schemaName: 'DomainFields',
|
||||||
|
},
|
||||||
|
'x:Auth': {
|
||||||
|
type: 'single',
|
||||||
|
schemaName: 'AuthFields',
|
||||||
|
},
|
||||||
|
'x:Account': {
|
||||||
|
type: 'multiple',
|
||||||
|
variants: [
|
||||||
|
{ name: 'User', label: 'User Account', schemaName: 'UserFields' },
|
||||||
|
{ name: 'Group', label: 'Group Account', schemaName: 'GroupFields' },
|
||||||
|
{ name: 'External', label: 'External Account' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'x:Credential': {
|
||||||
|
type: 'single',
|
||||||
|
schemaName: 'CredentialFields',
|
||||||
|
},
|
||||||
|
'x:Tenant': {
|
||||||
|
type: 'single',
|
||||||
|
schemaName: 'TenantFields',
|
||||||
|
},
|
||||||
|
'x:Orphan': {
|
||||||
|
type: 'single',
|
||||||
|
schemaName: 'MissingFields',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
fields: {
|
||||||
|
DomainFields: {
|
||||||
|
properties: {
|
||||||
|
domainName: {
|
||||||
|
description: 'Domain name',
|
||||||
|
type: { type: 'string', format: 'string' },
|
||||||
|
update: 'immutable',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaults: { domainName: 'example.com', active: true },
|
||||||
|
},
|
||||||
|
AuthFields: {
|
||||||
|
properties: {
|
||||||
|
method: {
|
||||||
|
description: 'Auth method',
|
||||||
|
type: { type: 'string', format: 'string' },
|
||||||
|
update: 'mutable',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaults: { method: 'password' },
|
||||||
|
},
|
||||||
|
UserFields: {
|
||||||
|
properties: {
|
||||||
|
email: {
|
||||||
|
description: 'Email',
|
||||||
|
type: { type: 'string', format: 'emailAddress' },
|
||||||
|
update: 'mutable',
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
description: 'Role',
|
||||||
|
type: { type: 'string', format: 'string' },
|
||||||
|
update: 'mutable',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
description: 'Settings',
|
||||||
|
type: { type: 'object', objectName: 'x:UserSettings' },
|
||||||
|
update: 'mutable',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaults: { email: '', quota: 1000, settings: { lang: 'en' } },
|
||||||
|
},
|
||||||
|
GroupFields: {
|
||||||
|
properties: {
|
||||||
|
groupName: {
|
||||||
|
description: 'Group name',
|
||||||
|
type: { type: 'string', format: 'string' },
|
||||||
|
update: 'mutable',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaults: { groupName: '' },
|
||||||
|
},
|
||||||
|
CredentialFields: {
|
||||||
|
properties: {
|
||||||
|
token: {
|
||||||
|
description: 'Token',
|
||||||
|
type: { type: 'string', format: 'secret' },
|
||||||
|
update: 'serverSet',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaults: { expiresIn: 3600 },
|
||||||
|
},
|
||||||
|
TenantFields: {
|
||||||
|
properties: {
|
||||||
|
tenantName: {
|
||||||
|
description: 'Tenant name',
|
||||||
|
type: { type: 'string', format: 'string' },
|
||||||
|
update: 'mutable',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AccountBaseFields: {
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
forms: {
|
||||||
|
'x:Domain': {
|
||||||
|
title: 'Domain Form',
|
||||||
|
sections: [{ fields: [{ name: 'domainName', label: 'Domain' }] }],
|
||||||
|
},
|
||||||
|
'x:Account/User': {
|
||||||
|
title: 'User Form',
|
||||||
|
sections: [{ fields: [{ name: 'email', label: 'Email' }] }],
|
||||||
|
},
|
||||||
|
'x:Account': {
|
||||||
|
title: 'Account Form',
|
||||||
|
sections: [{ fields: [{ name: 'name', label: 'Name' }] }],
|
||||||
|
},
|
||||||
|
UserFields: {
|
||||||
|
title: 'User Schema Form',
|
||||||
|
sections: [{ fields: [{ name: 'email', label: 'Email' }] }],
|
||||||
|
},
|
||||||
|
GroupFields: {
|
||||||
|
title: 'Group Schema Form',
|
||||||
|
sections: [{ fields: [{ name: 'name', label: 'Name' }] }],
|
||||||
|
},
|
||||||
|
CredentialFields: {
|
||||||
|
title: 'Credential Schema Form',
|
||||||
|
sections: [{ fields: [{ name: 'token', label: 'Token' }] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
lists: {
|
||||||
|
'x:Domain': {
|
||||||
|
title: 'Domains',
|
||||||
|
subtitle: 'All domains',
|
||||||
|
labelProperty: 'domainName',
|
||||||
|
singularName: 'domain',
|
||||||
|
pluralName: 'domains',
|
||||||
|
columns: [
|
||||||
|
{ name: 'domainName', label: 'Domain' },
|
||||||
|
{ name: 'active', label: 'Active' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'x:Account/User': {
|
||||||
|
title: 'Users',
|
||||||
|
subtitle: 'User accounts',
|
||||||
|
singularName: 'user',
|
||||||
|
pluralName: 'users',
|
||||||
|
columns: [{ name: 'email', label: 'Email' }],
|
||||||
|
filtersStatic: { role: 'user', settings: { theme: 'dark', notifications: true } },
|
||||||
|
},
|
||||||
|
'x:Account': {
|
||||||
|
title: 'Accounts',
|
||||||
|
subtitle: 'All accounts',
|
||||||
|
singularName: 'account',
|
||||||
|
pluralName: 'accounts',
|
||||||
|
columns: [{ name: 'name', label: 'Name' }],
|
||||||
|
},
|
||||||
|
'x:Credential': {
|
||||||
|
title: 'Credentials',
|
||||||
|
subtitle: 'All credentials',
|
||||||
|
singularName: 'credential',
|
||||||
|
pluralName: 'credentials',
|
||||||
|
columns: [{ name: 'token', label: 'Token' }],
|
||||||
|
},
|
||||||
|
'x:NoLabel': {
|
||||||
|
title: 'No Label',
|
||||||
|
subtitle: '',
|
||||||
|
singularName: 'item',
|
||||||
|
pluralName: 'items',
|
||||||
|
columns: [{ name: 'title', label: 'Title' }],
|
||||||
|
},
|
||||||
|
'x:Empty': {
|
||||||
|
title: 'Empty',
|
||||||
|
subtitle: '',
|
||||||
|
singularName: 'thing',
|
||||||
|
pluralName: 'things',
|
||||||
|
columns: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
enums: {},
|
||||||
|
dashboards: [],
|
||||||
|
layouts: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('resolveObject', () => {
|
||||||
|
it('resolves a regular object', () => {
|
||||||
|
const result = resolveObject(schema, 'x:Domain');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.viewName).toBe('x:Domain');
|
||||||
|
expect(result!.objectName).toBe('x:Domain');
|
||||||
|
expect(result!.objectType.type).toBe('object');
|
||||||
|
expect(result!.permissionPrefix).toBe('sysDomain');
|
||||||
|
expect(result!.enterprise).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a singleton', () => {
|
||||||
|
const result = resolveObject(schema, 'x:Auth');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.viewName).toBe('x:Auth');
|
||||||
|
expect(result!.objectName).toBe('x:Auth');
|
||||||
|
expect(result!.objectType.type).toBe('singleton');
|
||||||
|
expect(result!.permissionPrefix).toBe('sysAuth');
|
||||||
|
expect(result!.enterprise).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a view pointing to a parent object', () => {
|
||||||
|
const result = resolveObject(schema, 'x:Account/User');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.viewName).toBe('x:Account/User');
|
||||||
|
expect(result!.objectName).toBe('x:Account');
|
||||||
|
expect(result!.objectType.type).toBe('object');
|
||||||
|
expect(result!.permissionPrefix).toBe('sysAccount');
|
||||||
|
expect(result!.enterprise).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown viewName', () => {
|
||||||
|
expect(resolveObject(schema, 'x:NonExistent')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for enterprise flag', () => {
|
||||||
|
const result = resolveObject(schema, 'x:Tenant');
|
||||||
|
expect(result!.enterprise).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when a view points to another view', () => {
|
||||||
|
const result = resolveObject(schema, 'x:Bad/ViewOfView');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when a view points to a missing parent', () => {
|
||||||
|
const badSchema: Schema = {
|
||||||
|
...schema,
|
||||||
|
objects: {
|
||||||
|
'x:Dangling': { type: 'view', objectName: 'x:Gone' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(resolveObject(badSchema, 'x:Dangling')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveSchema', () => {
|
||||||
|
it('resolves a single schema', () => {
|
||||||
|
const result = resolveSchema(schema, 'x:Domain')!;
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result.type).toBe('single');
|
||||||
|
if (result.type === 'single') {
|
||||||
|
expect(result.schemaName).toBe('DomainFields');
|
||||||
|
expect(result.fields.properties).toHaveProperty('domainName');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a multiple schema with variants', () => {
|
||||||
|
const result = resolveSchema(schema, 'x:Account')!;
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result.type).toBe('multiple');
|
||||||
|
if (result.type === 'multiple') {
|
||||||
|
expect(result.variants).toHaveLength(3);
|
||||||
|
expect(result.variants[0].name).toBe('User');
|
||||||
|
expect(result.variants[0].label).toBe('User Account');
|
||||||
|
expect(result.variants[0].fields).not.toBeNull();
|
||||||
|
expect(result.variants[0].fields!.properties).toHaveProperty('email');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets fields to null for variant without schemaName', () => {
|
||||||
|
const result = resolveSchema(schema, 'x:Account')!;
|
||||||
|
if (result.type === 'multiple') {
|
||||||
|
const ext = result.variants.find((v) => v.name === 'External');
|
||||||
|
expect(ext).toBeDefined();
|
||||||
|
expect(ext!.fields).toBeNull();
|
||||||
|
expect(ext!.schemaName).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown objectName', () => {
|
||||||
|
expect(resolveSchema(schema, 'x:Unknown')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when single schema references missing fields', () => {
|
||||||
|
expect(resolveSchema(schema, 'x:Orphan')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fields with defaults for single schema', () => {
|
||||||
|
const result = resolveSchema(schema, 'x:Domain')!;
|
||||||
|
if (result.type === 'single') {
|
||||||
|
expect(result.fields.defaults).toEqual({ domainName: 'example.com', active: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves schemaName on variant entries', () => {
|
||||||
|
const result = resolveSchema(schema, 'x:Account')!;
|
||||||
|
if (result.type === 'multiple') {
|
||||||
|
expect(result.variants[0].schemaName).toBe('UserFields');
|
||||||
|
expect(result.variants[1].schemaName).toBe('GroupFields');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveList', () => {
|
||||||
|
it('returns view-specific list when it exists', () => {
|
||||||
|
const list = resolveList(schema, 'x:Account/User', 'x:Account');
|
||||||
|
expect(list).not.toBeNull();
|
||||||
|
expect(list!.title).toBe('Users');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to parent objectName list', () => {
|
||||||
|
const list = resolveList(schema, 'x:Account/Group', 'x:Account');
|
||||||
|
expect(list).not.toBeNull();
|
||||||
|
expect(list!.title).toBe('Accounts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when neither view nor object list exists', () => {
|
||||||
|
const list = resolveList(schema, 'x:NoView', 'x:NoObject');
|
||||||
|
expect(list).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the list for a direct object', () => {
|
||||||
|
const list = resolveList(schema, 'x:Domain', 'x:Domain');
|
||||||
|
expect(list).not.toBeNull();
|
||||||
|
expect(list!.title).toBe('Domains');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers viewName over objectName', () => {
|
||||||
|
const list = resolveList(schema, 'x:Account/User', 'x:Account');
|
||||||
|
expect(list!.singularName).toBe('user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveForm', () => {
|
||||||
|
it('returns view-specific form', () => {
|
||||||
|
const form = resolveForm(schema, 'x:Account/User', 'x:Account', 'UserFields');
|
||||||
|
expect(form).not.toBeNull();
|
||||||
|
expect(form!.title).toBe('User Form');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to objectName form', () => {
|
||||||
|
const form = resolveForm(schema, 'x:Account/Group', 'x:Account', 'GroupFields');
|
||||||
|
expect(form).not.toBeNull();
|
||||||
|
expect(form!.title).toBe('Account Form');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to schemaName form', () => {
|
||||||
|
const smallSchema: Schema = {
|
||||||
|
...schema,
|
||||||
|
forms: {
|
||||||
|
CredentialFields: schema.forms['CredentialFields'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const form = resolveForm(smallSchema, 'x:Credential/ApiKey', 'x:Credential', 'CredentialFields');
|
||||||
|
expect(form).not.toBeNull();
|
||||||
|
expect(form!.title).toBe('Credential Schema Form');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no form found at any level', () => {
|
||||||
|
const form = resolveForm(schema, 'x:NoView', 'x:NoObject', 'NoSchema');
|
||||||
|
expect(form).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses viewName form even when objectName and schemaName also exist', () => {
|
||||||
|
const form = resolveForm(schema, 'x:Account/User', 'x:Account', 'UserFields');
|
||||||
|
expect(form!.title).toBe('User Form');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns domain form for direct object', () => {
|
||||||
|
const form = resolveForm(schema, 'x:Domain', 'x:Domain', 'DomainFields');
|
||||||
|
expect(form!.title).toBe('Domain Form');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveVariantForm', () => {
|
||||||
|
it('returns the variant-specific form by schemaName', () => {
|
||||||
|
const form = resolveVariantForm(schema, 'x:Account/User', 'x:Account', 'UserFields');
|
||||||
|
expect(form).not.toBeNull();
|
||||||
|
expect(form!.title).toBe('User Schema Form');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fall back to the parent form even if it exists', () => {
|
||||||
|
const form = resolveVariantForm(schema, 'x:Account/Group', 'x:Account', 'GroupFields');
|
||||||
|
expect(form).not.toBeNull();
|
||||||
|
expect(form!.title).toBe('Group Schema Form');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when variantSchemaName is undefined', () => {
|
||||||
|
const form = resolveVariantForm(schema, 'x:NoView', 'x:NoObject');
|
||||||
|
expect(form).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when variantSchemaName is provided but has no form', () => {
|
||||||
|
const form = resolveVariantForm(schema, 'x:NoView', 'x:NoObject', 'NonExistentSchema');
|
||||||
|
expect(form).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deepMerge', () => {
|
||||||
|
it('merges flat objects', () => {
|
||||||
|
expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('source overrides target for same key', () => {
|
||||||
|
expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deeply merges nested objects', () => {
|
||||||
|
const target = { x: { a: 1, b: 2 } };
|
||||||
|
const source = { x: { b: 3, c: 4 } };
|
||||||
|
expect(deepMerge(target, source)).toEqual({ x: { a: 1, b: 3, c: 4 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces arrays instead of merging them', () => {
|
||||||
|
const target = { arr: [1, 2, 3] };
|
||||||
|
const source = { arr: [4, 5] };
|
||||||
|
expect(deepMerge(target, source)).toEqual({ arr: [4, 5] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null source values (replaces target)', () => {
|
||||||
|
const target: Record<string, unknown> = { a: { nested: 1 } };
|
||||||
|
const source: Record<string, unknown> = { a: null };
|
||||||
|
expect(deepMerge(target, source)).toEqual({ a: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null target values (replaced by source object)', () => {
|
||||||
|
const target: Record<string, unknown> = { a: null };
|
||||||
|
const source: Record<string, unknown> = { a: { nested: 1 } };
|
||||||
|
expect(deepMerge(target, source)).toEqual({ a: { nested: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty source object', () => {
|
||||||
|
expect(deepMerge({ a: 1 }, {})).toEqual({ a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty target object', () => {
|
||||||
|
expect(deepMerge({}, { a: 1 })).toEqual({ a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles both empty', () => {
|
||||||
|
expect(deepMerge({}, {})).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deeply merges 3 levels', () => {
|
||||||
|
const target = { l1: { l2: { l3: 'original', keep: true } } };
|
||||||
|
const source = { l1: { l2: { l3: 'updated', added: 42 } } };
|
||||||
|
expect(deepMerge(target, source)).toEqual({
|
||||||
|
l1: { l2: { l3: 'updated', keep: true, added: 42 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate target', () => {
|
||||||
|
const target = { a: 1, nested: { b: 2 } };
|
||||||
|
const original = { ...target, nested: { ...target.nested } };
|
||||||
|
deepMerge(target, { a: 99, nested: { c: 3 } });
|
||||||
|
expect(target).toEqual(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate source', () => {
|
||||||
|
const source = { a: 1 };
|
||||||
|
const copy = { ...source };
|
||||||
|
deepMerge({}, source);
|
||||||
|
expect(source).toEqual(copy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces string with object', () => {
|
||||||
|
const target: Record<string, unknown> = { a: 'hello' };
|
||||||
|
const source: Record<string, unknown> = { a: { nested: 1 } };
|
||||||
|
expect(deepMerge(target, source)).toEqual({ a: { nested: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces object with string', () => {
|
||||||
|
const target: Record<string, unknown> = { a: { nested: 1 } };
|
||||||
|
const source: Record<string, unknown> = { a: 'hello' };
|
||||||
|
expect(deepMerge(target, source)).toEqual({ a: 'hello' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const embeddedSchema: Schema = {
|
||||||
|
objects: {
|
||||||
|
'x:SpamClassifier': {
|
||||||
|
type: 'singleton',
|
||||||
|
description: 'Spam classifier',
|
||||||
|
permissionPrefix: 'sysSpam',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schemas: {
|
||||||
|
'x:Model': {
|
||||||
|
type: 'multiple',
|
||||||
|
variants: [
|
||||||
|
{ name: 'FtrlFh', label: 'FTRL FH', schemaName: 'x:FtrlFh' },
|
||||||
|
{ name: 'FtrlCcfh', label: 'FTRL CCFH', schemaName: 'x:FtrlCcfh' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'x:FtrlParameters': {
|
||||||
|
type: 'single',
|
||||||
|
schemaName: 'x:FtrlParameters',
|
||||||
|
},
|
||||||
|
'x:CertManagement': {
|
||||||
|
type: 'multiple',
|
||||||
|
variants: [
|
||||||
|
{ name: 'Manual', label: 'Manual' },
|
||||||
|
{ name: 'Automatic', label: 'Automatic', schemaName: 'x:CertAuto' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
'x:FtrlFh': {
|
||||||
|
properties: {
|
||||||
|
learningRate: {
|
||||||
|
description: '',
|
||||||
|
type: { type: 'number', format: 'float' },
|
||||||
|
update: 'mutable',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
learningRate: 0.01,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'x:FtrlCcfh': {
|
||||||
|
properties: {
|
||||||
|
featureL2Normalize: {
|
||||||
|
description: '',
|
||||||
|
type: { type: 'boolean' },
|
||||||
|
update: 'mutable',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
description: '',
|
||||||
|
type: { type: 'object', objectName: 'x:FtrlParameters' },
|
||||||
|
update: 'mutable',
|
||||||
|
},
|
||||||
|
indicatorParameters: {
|
||||||
|
description: '',
|
||||||
|
type: { type: 'object', objectName: 'x:FtrlParameters' },
|
||||||
|
update: 'mutable',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
featureL2Normalize: true,
|
||||||
|
parameters: { numFeatures: '20' },
|
||||||
|
indicatorParameters: { numFeatures: '18' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'x:FtrlParameters': {
|
||||||
|
properties: {
|
||||||
|
alpha: { description: '', type: { type: 'number', format: 'float' }, update: 'mutable' },
|
||||||
|
beta: { description: '', type: { type: 'number', format: 'float' }, update: 'mutable' },
|
||||||
|
l1Ratio: { description: '', type: { type: 'number', format: 'float' }, update: 'mutable' },
|
||||||
|
l2Ratio: { description: '', type: { type: 'number', format: 'float' }, update: 'mutable' },
|
||||||
|
numFeatures: { description: '', type: { type: 'string', format: 'string' }, update: 'mutable' },
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
alpha: 2,
|
||||||
|
beta: 1,
|
||||||
|
l1Ratio: 0.001,
|
||||||
|
l2Ratio: 0.0001,
|
||||||
|
numFeatures: '50',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'x:CertAuto': {
|
||||||
|
properties: {
|
||||||
|
acmeProviderId: { description: '', type: { type: 'string', format: 'string' }, update: 'mutable' },
|
||||||
|
},
|
||||||
|
defaults: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
forms: {},
|
||||||
|
lists: {},
|
||||||
|
enums: {},
|
||||||
|
dashboards: [],
|
||||||
|
layouts: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildEmbeddedDefaults', () => {
|
||||||
|
it('returns child schema defaults for a single object', () => {
|
||||||
|
const result = buildEmbeddedDefaults(embeddedSchema, 'x:FtrlParameters');
|
||||||
|
expect(result).toEqual({
|
||||||
|
alpha: 2,
|
||||||
|
beta: 1,
|
||||||
|
l1Ratio: 0.001,
|
||||||
|
l2Ratio: 0.0001,
|
||||||
|
numFeatures: '50',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns variant defaults with @type for a multi-variant object', () => {
|
||||||
|
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model', {}, 'FtrlFh');
|
||||||
|
expect(result).toEqual({
|
||||||
|
'@type': 'FtrlFh',
|
||||||
|
learningRate: 0.01,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively merges parent overrides into child defaults for embedded fields', () => {
|
||||||
|
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model', {}, 'FtrlCcfh');
|
||||||
|
expect(result).toEqual({
|
||||||
|
'@type': 'FtrlCcfh',
|
||||||
|
featureL2Normalize: true,
|
||||||
|
parameters: {
|
||||||
|
alpha: 2,
|
||||||
|
beta: 1,
|
||||||
|
l1Ratio: 0.001,
|
||||||
|
l2Ratio: 0.0001,
|
||||||
|
numFeatures: '20',
|
||||||
|
},
|
||||||
|
indicatorParameters: {
|
||||||
|
alpha: 2,
|
||||||
|
beta: 1,
|
||||||
|
l1Ratio: 0.001,
|
||||||
|
l2Ratio: 0.0001,
|
||||||
|
numFeatures: '18',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies caller-supplied parent overrides on top of everything', () => {
|
||||||
|
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model', { featureL2Normalize: false }, 'FtrlCcfh');
|
||||||
|
expect(result.featureL2Normalize).toBe(false);
|
||||||
|
expect(result.parameters).toMatchObject({ alpha: 2, numFeatures: '20' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a variant with no schemaName', () => {
|
||||||
|
const result = buildEmbeddedDefaults(embeddedSchema, 'x:CertManagement', {}, 'Manual');
|
||||||
|
expect(result).toEqual({ '@type': 'Manual' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a variant with empty defaults', () => {
|
||||||
|
const result = buildEmbeddedDefaults(embeddedSchema, 'x:CertManagement', {}, 'Automatic');
|
||||||
|
expect(result).toEqual({ '@type': 'Automatic' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns parentOverrides when objectName is not in schema', () => {
|
||||||
|
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Unknown', { foo: 'bar' });
|
||||||
|
expect(result).toEqual({ foo: 'bar' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to first variant when variantName is not provided', () => {
|
||||||
|
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model');
|
||||||
|
expect(result['@type']).toBe('FtrlFh');
|
||||||
|
expect(result.learningRate).toBe(0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parent override only mentions some keys -- others come from child', () => {
|
||||||
|
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model', {}, 'FtrlCcfh');
|
||||||
|
const params = result.parameters as Record<string, unknown>;
|
||||||
|
expect(Object.keys(params).sort()).toEqual(['alpha', 'beta', 'l1Ratio', 'l2Ratio', 'numFeatures']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parent overrides take precedence for matching nested keys', () => {
|
||||||
|
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model', {}, 'FtrlCcfh');
|
||||||
|
expect((result.parameters as Record<string, unknown>).numFeatures).toBe('20');
|
||||||
|
expect((result.indicatorParameters as Record<string, unknown>).numFeatures).toBe('18');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildCreateDefaults', () => {
|
||||||
|
it('returns single schema defaults', () => {
|
||||||
|
const ro = resolveObject(schema, 'x:Domain')!;
|
||||||
|
const rs = resolveSchema(schema, 'x:Domain')!;
|
||||||
|
const defaults = buildCreateDefaults(schema, ro, rs);
|
||||||
|
expect(defaults).toEqual({ domainName: 'example.com', active: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns multi-variant defaults with @type', () => {
|
||||||
|
const ro = resolveObject(schema, 'x:Account/User')!;
|
||||||
|
const rs = resolveSchema(schema, 'x:Account')!;
|
||||||
|
const defaults = buildCreateDefaults(schema, ro, rs, 'User');
|
||||||
|
expect(defaults['@type']).toBe('User');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes variant field defaults for multi-variant', () => {
|
||||||
|
const ro = resolveObject(schema, 'x:Account/User')!;
|
||||||
|
const rs = resolveSchema(schema, 'x:Account')!;
|
||||||
|
const defaults = buildCreateDefaults(schema, ro, rs, 'User');
|
||||||
|
expect(defaults).toHaveProperty('email', '');
|
||||||
|
expect(defaults).toHaveProperty('quota', 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets @type even when variant has no fields defaults', () => {
|
||||||
|
const ro = resolveObject(schema, 'x:Account/Group')!;
|
||||||
|
const rs = resolveSchema(schema, 'x:Account')!;
|
||||||
|
const defaults = buildCreateDefaults(schema, ro, rs, 'External');
|
||||||
|
expect(defaults['@type']).toBe('External');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies parent schema defaults on top of variant defaults', () => {
|
||||||
|
const ro = resolveObject(schema, 'x:Credential/ApiKey')!;
|
||||||
|
const rs = resolveSchema(schema, 'x:Credential')!;
|
||||||
|
const defaults = buildCreateDefaults(schema, ro, rs);
|
||||||
|
expect(defaults).toHaveProperty('expiresIn', 3600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies filtersStatic field values on top of everything', () => {
|
||||||
|
const ro = resolveObject(schema, 'x:Account/User')!;
|
||||||
|
const rs = resolveSchema(schema, 'x:Account')!;
|
||||||
|
const list = resolveList(schema, 'x:Account/User', 'x:Account');
|
||||||
|
const defaults = buildCreateDefaults(schema, ro, rs, 'User', list?.filtersStatic);
|
||||||
|
expect(defaults).toHaveProperty('role', 'user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deep-merges nested filtersStatic values with variant defaults', () => {
|
||||||
|
const ro = resolveObject(schema, 'x:Account/User')!;
|
||||||
|
const rs = resolveSchema(schema, 'x:Account')!;
|
||||||
|
const list = resolveList(schema, 'x:Account/User', 'x:Account');
|
||||||
|
const defaults = buildCreateDefaults(schema, ro, rs, 'User', list?.filtersStatic);
|
||||||
|
expect(defaults.settings).toEqual({
|
||||||
|
lang: 'en',
|
||||||
|
theme: 'dark',
|
||||||
|
notifications: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object when schema has no defaults', () => {
|
||||||
|
const ro = resolveObject(schema, 'x:Tenant')!;
|
||||||
|
const rs = resolveSchema(schema, 'x:Tenant')!;
|
||||||
|
const defaults = buildCreateDefaults(schema, ro, rs);
|
||||||
|
expect(defaults).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not set @type for single schema', () => {
|
||||||
|
const ro = resolveObject(schema, 'x:Domain')!;
|
||||||
|
const rs = resolveSchema(schema, 'x:Domain')!;
|
||||||
|
const defaults = buildCreateDefaults(schema, ro, rs);
|
||||||
|
expect(defaults).not.toHaveProperty('@type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not set @type for multiple schema when variantName is not provided', () => {
|
||||||
|
const ro = resolveObject(schema, 'x:Account/User')!;
|
||||||
|
const rs = resolveSchema(schema, 'x:Account')!;
|
||||||
|
const defaults = buildCreateDefaults(schema, ro, rs);
|
||||||
|
expect(defaults).not.toHaveProperty('@type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merge order: child defaults < parent defaults < filtersStatic', () => {
|
||||||
|
const custom: Schema = {
|
||||||
|
...schema,
|
||||||
|
objects: {
|
||||||
|
'x:Parent': {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Parent',
|
||||||
|
permissionPrefix: 'p',
|
||||||
|
},
|
||||||
|
'x:Parent/Child': {
|
||||||
|
type: 'view',
|
||||||
|
objectName: 'x:Parent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schemas: {
|
||||||
|
'x:Parent': {
|
||||||
|
type: 'single',
|
||||||
|
schemaName: 'ParentFields',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
ParentFields: {
|
||||||
|
properties: {
|
||||||
|
priority: { description: '', type: { type: 'string', format: 'string' }, update: 'mutable' },
|
||||||
|
fromParent: { description: '', type: { type: 'boolean' }, update: 'mutable' },
|
||||||
|
},
|
||||||
|
defaults: { priority: 'parent', fromParent: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lists: {
|
||||||
|
'x:Parent/Child': {
|
||||||
|
title: 'Children',
|
||||||
|
subtitle: '',
|
||||||
|
singularName: 'child',
|
||||||
|
pluralName: 'children',
|
||||||
|
columns: [],
|
||||||
|
filtersStatic: { priority: 'filter' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ro = resolveObject(custom, 'x:Parent/Child')!;
|
||||||
|
const rs = resolveSchema(custom, 'x:Parent')!;
|
||||||
|
const defaults = buildCreateDefaults(custom, ro, rs, undefined, custom.lists['x:Parent/Child'].filtersStatic);
|
||||||
|
expect(defaults.priority).toBe('filter');
|
||||||
|
expect(defaults.fromParent).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDisplayProperty', () => {
|
||||||
|
it('returns labelProperty when defined', () => {
|
||||||
|
expect(getDisplayProperty(schema, 'x:Domain')).toBe('domainName');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns first column name when no labelProperty', () => {
|
||||||
|
expect(getDisplayProperty(schema, 'x:Account/User')).toBe('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to "name" when no list exists', () => {
|
||||||
|
expect(getDisplayProperty(schema, 'x:NonExistent')).toBe('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to "name" when columns are empty', () => {
|
||||||
|
expect(getDisplayProperty(schema, 'x:Empty')).toBe('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns first column when labelProperty is absent but columns exist', () => {
|
||||||
|
expect(getDisplayProperty(schema, 'x:NoLabel')).toBe('title');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Schema, ObjectTypeObject, ObjectTypeSingleton, Fields, Form, List } from '@/types/schema';
|
||||||
|
|
||||||
|
export interface ResolvedObject {
|
||||||
|
viewName: string;
|
||||||
|
objectName: string;
|
||||||
|
objectType: ObjectTypeObject | ObjectTypeSingleton;
|
||||||
|
permissionPrefix: string;
|
||||||
|
enterprise: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveObject(schema: Schema, viewName: string): ResolvedObject | null {
|
||||||
|
const entry = schema.objects[viewName];
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
if (entry.type === 'view') {
|
||||||
|
const parent = schema.objects[entry.objectName];
|
||||||
|
if (!parent || parent.type === 'view') return null;
|
||||||
|
return {
|
||||||
|
viewName,
|
||||||
|
objectName: entry.objectName,
|
||||||
|
objectType: parent,
|
||||||
|
permissionPrefix: parent.permissionPrefix,
|
||||||
|
enterprise: parent.enterprise ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
viewName,
|
||||||
|
objectName: viewName,
|
||||||
|
objectType: entry,
|
||||||
|
permissionPrefix: entry.permissionPrefix,
|
||||||
|
enterprise: entry.enterprise ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedSingleSchema {
|
||||||
|
type: 'single';
|
||||||
|
schemaName: string;
|
||||||
|
fields: Fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedMultipleSchema {
|
||||||
|
type: 'multiple';
|
||||||
|
variants: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
schemaName?: string;
|
||||||
|
fields: Fields | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolvedSchema = ResolvedSingleSchema | ResolvedMultipleSchema;
|
||||||
|
|
||||||
|
export function resolveSchema(schema: Schema, objectName: string): ResolvedSchema | null {
|
||||||
|
const schemaEntry = schema.schemas[objectName];
|
||||||
|
if (!schemaEntry) return null;
|
||||||
|
|
||||||
|
if (schemaEntry.type === 'single') {
|
||||||
|
const fields = schema.fields[schemaEntry.schemaName];
|
||||||
|
if (!fields) return null;
|
||||||
|
return { type: 'single', schemaName: schemaEntry.schemaName, fields };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'multiple',
|
||||||
|
variants: schemaEntry.variants.map((v) => ({
|
||||||
|
name: v.name,
|
||||||
|
label: v.label,
|
||||||
|
schemaName: v.schemaName,
|
||||||
|
fields: v.schemaName ? (schema.fields[v.schemaName] ?? null) : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveList(schema: Schema, viewName: string, objectName: string): List | null {
|
||||||
|
return schema.lists[viewName] ?? schema.lists[objectName] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveForm(schema: Schema, viewName: string, objectName: string, schemaName: string): Form | null {
|
||||||
|
return schema.forms[viewName] ?? schema.forms[objectName] ?? schema.forms[schemaName] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveVariantForm(
|
||||||
|
schema: Schema,
|
||||||
|
_viewName: string,
|
||||||
|
_objectName: string,
|
||||||
|
variantSchemaName?: string,
|
||||||
|
): Form | null {
|
||||||
|
if (variantSchemaName && schema.forms[variantSchemaName]) {
|
||||||
|
return schema.forms[variantSchemaName];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const result = { ...target };
|
||||||
|
for (const key of Object.keys(source)) {
|
||||||
|
const sourceVal = source[key];
|
||||||
|
const targetVal = result[key];
|
||||||
|
if (
|
||||||
|
sourceVal &&
|
||||||
|
typeof sourceVal === 'object' &&
|
||||||
|
!Array.isArray(sourceVal) &&
|
||||||
|
targetVal &&
|
||||||
|
typeof targetVal === 'object' &&
|
||||||
|
!Array.isArray(targetVal)
|
||||||
|
) {
|
||||||
|
result[key] = deepMerge(targetVal as Record<string, unknown>, sourceVal as Record<string, unknown>);
|
||||||
|
} else {
|
||||||
|
result[key] = sourceVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCreateDefaults(
|
||||||
|
schema: Schema,
|
||||||
|
resolvedObject: ResolvedObject,
|
||||||
|
resolvedSchema: ResolvedSchema,
|
||||||
|
variantName?: string,
|
||||||
|
filtersStatic?: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
let defaults: Record<string, unknown> = {};
|
||||||
|
let activeFields: Fields | null = null;
|
||||||
|
|
||||||
|
if (resolvedSchema.type === 'single') {
|
||||||
|
activeFields = resolvedSchema.fields;
|
||||||
|
const childDefaults = resolvedSchema.fields.defaults ?? {};
|
||||||
|
defaults = deepMerge(defaults, childDefaults);
|
||||||
|
} else if (resolvedSchema.type === 'multiple' && variantName) {
|
||||||
|
const variant = resolvedSchema.variants.find((v) => v.name === variantName);
|
||||||
|
activeFields = variant?.fields ?? null;
|
||||||
|
if (variant?.fields?.defaults) {
|
||||||
|
defaults = deepMerge(defaults, variant.fields.defaults);
|
||||||
|
}
|
||||||
|
defaults['@type'] = variantName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentSchemaEntry = schema.schemas[resolvedObject.objectName];
|
||||||
|
if (parentSchemaEntry?.type === 'single') {
|
||||||
|
const parentFields = schema.fields[parentSchemaEntry.schemaName];
|
||||||
|
if (parentFields?.defaults) {
|
||||||
|
defaults = deepMerge(defaults, parentFields.defaults);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeFields) {
|
||||||
|
for (const [propName, propDef] of Object.entries(activeFields.properties)) {
|
||||||
|
const t = propDef.type;
|
||||||
|
if (t.type !== 'object') continue;
|
||||||
|
|
||||||
|
if (defaults[propName] === null) continue;
|
||||||
|
|
||||||
|
const parentChildOverride =
|
||||||
|
defaults[propName] && typeof defaults[propName] === 'object' && !Array.isArray(defaults[propName])
|
||||||
|
? (defaults[propName] as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const nestedSchemaEntry = schema.schemas[t.objectName];
|
||||||
|
let nestedVariant: string | undefined;
|
||||||
|
if (nestedSchemaEntry?.type === 'multiple') {
|
||||||
|
nestedVariant = (parentChildOverride['@type'] as string | undefined) ?? nestedSchemaEntry.variants[0]?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = buildEmbeddedDefaults(schema, t.objectName, parentChildOverride, nestedVariant);
|
||||||
|
|
||||||
|
if (Object.keys(nested).length > 0) {
|
||||||
|
defaults[propName] = nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtersStatic && Object.keys(filtersStatic).length > 0) {
|
||||||
|
const validKeys = new Set<string>(['@type']);
|
||||||
|
if (activeFields) {
|
||||||
|
for (const k of Object.keys(activeFields.properties)) validKeys.add(k);
|
||||||
|
}
|
||||||
|
if (resolvedSchema.type === 'multiple') {
|
||||||
|
const parentSchemaName =
|
||||||
|
parentSchemaEntry?.type === 'single' ? parentSchemaEntry.schemaName : resolvedObject.objectName;
|
||||||
|
const parentProps = schema.fields[parentSchemaName]?.properties;
|
||||||
|
if (parentProps) {
|
||||||
|
for (const k of Object.keys(parentProps)) validKeys.add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(filtersStatic)) {
|
||||||
|
if (validKeys.has(k)) filtered[k] = v;
|
||||||
|
}
|
||||||
|
if (Object.keys(filtered).length > 0) {
|
||||||
|
defaults = deepMerge(defaults, filtered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEmbeddedDefaults(
|
||||||
|
schema: Schema,
|
||||||
|
objectName: string,
|
||||||
|
parentOverrides: Record<string, unknown> = {},
|
||||||
|
variantName?: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const schemaEntry = schema.schemas[objectName];
|
||||||
|
if (!schemaEntry) return { ...parentOverrides };
|
||||||
|
|
||||||
|
let fields: Fields | null = null;
|
||||||
|
let result: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (schemaEntry.type === 'single') {
|
||||||
|
fields = schema.fields[schemaEntry.schemaName] ?? null;
|
||||||
|
} else {
|
||||||
|
const variant = variantName ? schemaEntry.variants.find((v) => v.name === variantName) : schemaEntry.variants[0];
|
||||||
|
if (!variant) return { '@type': variantName ?? '', ...parentOverrides };
|
||||||
|
result['@type'] = variant.name;
|
||||||
|
if (variant.schemaName) {
|
||||||
|
fields = schema.fields[variant.schemaName] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields) {
|
||||||
|
if (fields.defaults) {
|
||||||
|
result = deepMerge(result, fields.defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [propName, propDef] of Object.entries(fields.properties)) {
|
||||||
|
const t = propDef.type;
|
||||||
|
if (t.type !== 'object') continue;
|
||||||
|
|
||||||
|
const parentChildOverride =
|
||||||
|
result[propName] && typeof result[propName] === 'object' && !Array.isArray(result[propName])
|
||||||
|
? (result[propName] as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const nestedSchemaEntry = schema.schemas[t.objectName];
|
||||||
|
let nestedVariant: string | undefined;
|
||||||
|
if (nestedSchemaEntry?.type === 'multiple') {
|
||||||
|
nestedVariant = (parentChildOverride['@type'] as string | undefined) ?? nestedSchemaEntry.variants[0]?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedDefaults = buildEmbeddedDefaults(schema, t.objectName, parentChildOverride, nestedVariant);
|
||||||
|
|
||||||
|
if (Object.keys(nestedDefaults).length > 0) {
|
||||||
|
result[propName] = nestedDefaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(parentOverrides).length > 0) {
|
||||||
|
result = deepMerge(result, parentOverrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayProperty(schema: Schema, objectName: string): string {
|
||||||
|
const list = schema.lists[objectName];
|
||||||
|
if (list?.labelProperty) return list.labelProperty;
|
||||||
|
if (list?.columns?.[0]) return list.columns[0].name;
|
||||||
|
return 'name';
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||||
|
import './i18n';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import OAuthCallback from './pages/OAuthCallback';
|
||||||
|
import AdminPanel from './pages/AdminPanel';
|
||||||
|
import NotFound from './pages/NotFound';
|
||||||
|
import { ProtectedRoute } from './components/layout/ProtectedRoute';
|
||||||
|
import { getBasePath } from './lib/basePath';
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
const persisted = localStorage.getItem('stalwart-ui');
|
||||||
|
if (persisted) {
|
||||||
|
const parsed = JSON.parse(persisted);
|
||||||
|
const theme = parsed?.state?.theme;
|
||||||
|
if (theme === 'dark' || theme === 'light') {
|
||||||
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
} catch {}
|
||||||
|
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
||||||
|
document.documentElement.classList.toggle('dark', !!prefersDark);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const basePath = getBasePath();
|
||||||
|
|
||||||
|
const router = createBrowserRouter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: <App />,
|
||||||
|
errorElement: <NotFound />,
|
||||||
|
children: [
|
||||||
|
{ path: 'login', element: <LoginPage /> },
|
||||||
|
{ path: 'oauth/callback', element: <OAuthCallback /> },
|
||||||
|
{
|
||||||
|
path: ':section/*',
|
||||||
|
element: (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdminPanel />
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdminPanel />
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ basename: basePath },
|
||||||
|
);
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
import { fetchSession, fetchSchema, fetchAccountInfo } from '@/services/jmap/client';
|
||||||
|
import { setLocale } from '@/i18n';
|
||||||
|
import { TopBar } from '@/components/layout/TopBar';
|
||||||
|
import { Sidebar } from '@/components/layout/Sidebar';
|
||||||
|
import { MainContent } from '@/components/layout/MainContent';
|
||||||
|
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||||
|
import { BootstrapWizard } from '@/components/bootstrap/BootstrapWizard';
|
||||||
|
import {
|
||||||
|
findFirstAccessibleLinkInLayout,
|
||||||
|
findFirstVisibleLinkInLayout,
|
||||||
|
isLinkAccessible,
|
||||||
|
visibleLayouts,
|
||||||
|
} from '@/lib/layout';
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function AdminPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { section, '*': splat } = useParams<{
|
||||||
|
section?: string;
|
||||||
|
'*'?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let viewName: string | undefined;
|
||||||
|
let id: string | undefined;
|
||||||
|
if (splat) {
|
||||||
|
const parts = splat.split('/');
|
||||||
|
if (parts[parts.length - 1] === 'new') {
|
||||||
|
id = 'new';
|
||||||
|
viewName = parts.slice(0, -1).join('/');
|
||||||
|
} else if (parts[parts.length - 1] === 'singleton') {
|
||||||
|
id = 'singleton';
|
||||||
|
viewName = parts.slice(0, -1).join('/');
|
||||||
|
} else {
|
||||||
|
const schemaStore = useSchemaStore.getState();
|
||||||
|
const fullAsView = parts.join('/');
|
||||||
|
if (schemaStore.schema?.objects[fullAsView]) {
|
||||||
|
viewName = fullAsView;
|
||||||
|
} else if (parts.length > 1) {
|
||||||
|
const candidateView = parts.slice(0, -1).join('/');
|
||||||
|
if (schemaStore.schema?.objects[candidateView]) {
|
||||||
|
viewName = candidateView;
|
||||||
|
id = parts[parts.length - 1];
|
||||||
|
} else {
|
||||||
|
viewName = fullAsView;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
viewName = fullAsView;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSchemaLoaded = useSchemaStore((s) => s.isLoaded);
|
||||||
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
|
const setSchema = useSchemaStore((s) => s.setSchema);
|
||||||
|
const setAccountInfo = useAccountStore((s) => s.setAccountInfo);
|
||||||
|
const edition = useAccountStore((s) => s.edition);
|
||||||
|
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||||
|
const setSession = useAuthStore((s) => s.setSession);
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||||
|
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||||
|
const { canViewObject } = usePermissions();
|
||||||
|
|
||||||
|
const [initError, setInitError] = useState<string | null>(null);
|
||||||
|
const [initializing, setInitializing] = useState(!isSchemaLoaded);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
|
||||||
|
if (bypassToken && !accessToken) {
|
||||||
|
useAuthStore.getState().setTokens(bypassToken, '', 86400, '');
|
||||||
|
}
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSchemaLoaded) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const session = await fetchSession();
|
||||||
|
const accounts: Record<string, { name: string; isPersonal: boolean }> = {};
|
||||||
|
for (const [accountId, info] of Object.entries(
|
||||||
|
session.accounts as Record<string, { name: string; isPersonal: boolean }>,
|
||||||
|
)) {
|
||||||
|
accounts[accountId] = { name: info.name, isPersonal: info.isPersonal };
|
||||||
|
}
|
||||||
|
const primaryAccounts = session.primaryAccounts as Record<string, string>;
|
||||||
|
const primaryAccountId =
|
||||||
|
primaryAccounts['urn:ietf:params:jmap:core'] ?? Object.values(primaryAccounts)[0] ?? Object.keys(accounts)[0];
|
||||||
|
const apiUrl = (session.apiUrl as string) || '/jmap';
|
||||||
|
|
||||||
|
const capabilities = session.capabilities as Record<string, Record<string, unknown>> | undefined;
|
||||||
|
const coreCapability = capabilities?.['urn:ietf:params:jmap:core'];
|
||||||
|
const maxObjectsInGet =
|
||||||
|
typeof coreCapability?.maxObjectsInGet === 'number' ? coreCapability.maxObjectsInGet : undefined;
|
||||||
|
const maxObjectsInSet =
|
||||||
|
typeof coreCapability?.maxObjectsInSet === 'number' ? coreCapability.maxObjectsInSet : undefined;
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
setSession(accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet);
|
||||||
|
|
||||||
|
const [schemaData, accountData] = await Promise.all([fetchSchema(), fetchAccountInfo()]);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setSchema(schemaData);
|
||||||
|
|
||||||
|
setAccountInfo(accountData.permissions, accountData.edition, accountData.locale);
|
||||||
|
setLocale(accountData.locale);
|
||||||
|
|
||||||
|
setInitializing(false);
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error('Initialization failed:', err);
|
||||||
|
setInitError(err instanceof Error ? err.message : t('errors.unexpectedError'));
|
||||||
|
setInitializing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isSchemaLoaded, setSession, setSchema, setAccountInfo, t]);
|
||||||
|
|
||||||
|
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||||
|
const isBootstrapMode = useMemo(() => {
|
||||||
|
if (!schema) return false;
|
||||||
|
if (!canViewObject('x:Bootstrap')) return false;
|
||||||
|
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
||||||
|
const hasPerm = (perm: string) => hasPermission(perm);
|
||||||
|
return visibleLayouts(schema, edition, canGet, hasPerm).length === 0;
|
||||||
|
}, [schema, canViewObject, edition, hasObjectPermission, hasPermission]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!schema) return;
|
||||||
|
if (isBootstrapMode) return;
|
||||||
|
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
||||||
|
const hasPerm = (perm: string) => hasPermission(perm);
|
||||||
|
const layouts = visibleLayouts(schema, edition, canGet, hasPerm);
|
||||||
|
const pickDefault = (): { layoutName: string; link: string | null } | null => {
|
||||||
|
for (const layout of layouts) {
|
||||||
|
const link = findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPerm);
|
||||||
|
if (link) return { layoutName: layout.name, link };
|
||||||
|
}
|
||||||
|
if (layouts[0]) {
|
||||||
|
return {
|
||||||
|
layoutName: layouts[0].name,
|
||||||
|
link: findFirstVisibleLinkInLayout(schema, layouts[0], edition, canGet, hasPerm),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (section && viewName && !isLinkAccessible(schema, viewName, edition, canGet, hasPerm)) {
|
||||||
|
const fallback = pickDefault();
|
||||||
|
if (fallback && (fallback.layoutName !== section || fallback.link !== viewName)) {
|
||||||
|
setActiveSection(fallback.layoutName);
|
||||||
|
if (fallback.link) {
|
||||||
|
navigate(`/${fallback.layoutName}/${fallback.link}`, { replace: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section) {
|
||||||
|
setActiveSection(section);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = pickDefault();
|
||||||
|
if (target) {
|
||||||
|
setActiveSection(target.layoutName);
|
||||||
|
if (target.link) {
|
||||||
|
navigate(`/${target.layoutName}/${target.link}`, { replace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
section,
|
||||||
|
viewName,
|
||||||
|
schema,
|
||||||
|
setActiveSection,
|
||||||
|
navigate,
|
||||||
|
edition,
|
||||||
|
hasObjectPermission,
|
||||||
|
hasPermission,
|
||||||
|
isBootstrapMode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (initializing) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-muted-foreground">{t('common.loading')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initError) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-destructive">{t('errors.loadSchemaFailed')}</h2>
|
||||||
|
<p className="mt-2 text-muted-foreground">{initError}</p>
|
||||||
|
<button
|
||||||
|
className="mt-4 rounded bg-primary px-4 py-2 text-primary-foreground hover:opacity-90"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
{t('common.continue')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema) return null;
|
||||||
|
|
||||||
|
if (isBootstrapMode) {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<BootstrapWizard />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<TopBar />
|
||||||
|
<div className="flex flex-1">
|
||||||
|
<Sidebar />
|
||||||
|
<main
|
||||||
|
className={`flex-1 overflow-auto bg-content-background p-6 transition-all ${sidebarOpen ? 'md:ml-64' : ''}`}
|
||||||
|
>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<MainContent viewName={viewName} id={id} section={section} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type FormEvent, useState } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ArrowRight, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import Logo from '@/components/common/Logo';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { startAuthFlow } from '@/services/auth/oauth';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const location = useLocation();
|
||||||
|
const originalPath = (location.state as { from?: string } | null)?.from ?? null;
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = username.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startAuthFlow(trimmed, originalPath);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t('login.error', 'An unexpected error occurred'));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-content-background px-4">
|
||||||
|
<Card className="w-full max-w-sm shadow-sm">
|
||||||
|
<CardHeader className="items-center text-center">
|
||||||
|
<Logo />
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
{t('login.prompt', 'Enter your account name to continue')}
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
placeholder={t('login.usernamePlaceholder', 'user@example.com')}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
aria-label={t('login.prompt', 'Enter your account name to continue')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={loading || !username.trim()}>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t('login.continue', 'Continue')}
|
||||||
|
<ArrowRight />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-4xl font-bold">404</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">{t('errors.notFound')}</p>
|
||||||
|
<Link to="/" className="mt-4 inline-block text-primary underline hover:no-underline">
|
||||||
|
{t('common.back')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import { getBasePath } from '@/lib/basePath';
|
||||||
|
import { exchangeCode, getStoredOAuthData, clearStoredOAuthData, getOAuthRedirectUri } from '@/services/auth/oauth';
|
||||||
|
|
||||||
|
export default function OAuthCallback() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function handleCallback() {
|
||||||
|
try {
|
||||||
|
const code = searchParams.get('code');
|
||||||
|
const stateParam = searchParams.get('state');
|
||||||
|
const errorParam = searchParams.get('error');
|
||||||
|
const errorDescription = searchParams.get('error_description');
|
||||||
|
|
||||||
|
if (errorParam) {
|
||||||
|
throw new Error(errorDescription || errorParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || !stateParam) {
|
||||||
|
throw new Error(t('oauth.missingParams', 'Missing authorization code or state parameter'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { codeVerifier, tokenEndpoint, state, returnUrl } = getStoredOAuthData();
|
||||||
|
|
||||||
|
if (!state || state !== stateParam) {
|
||||||
|
throw new Error(t('oauth.stateMismatch', 'State parameter mismatch. Please try logging in again.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!codeVerifier || !tokenEndpoint) {
|
||||||
|
throw new Error(t('oauth.missingData', 'Missing OAuth session data. Please try logging in again.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = getOAuthRedirectUri();
|
||||||
|
const tokenResponse = await exchangeCode(code, codeVerifier, tokenEndpoint, redirectUri);
|
||||||
|
|
||||||
|
useAuthStore
|
||||||
|
.getState()
|
||||||
|
.setTokens(tokenResponse.access_token, tokenResponse.refresh_token, tokenResponse.expires_in, tokenEndpoint);
|
||||||
|
|
||||||
|
clearStoredOAuthData();
|
||||||
|
|
||||||
|
const basePath = getBasePath();
|
||||||
|
const stripBase = (p: string) => (basePath && p.startsWith(basePath) ? p.slice(basePath.length) || '/' : p);
|
||||||
|
const candidate = returnUrl ? stripBase(returnUrl) : '/';
|
||||||
|
const isAuthPath =
|
||||||
|
candidate === '/login' ||
|
||||||
|
candidate.startsWith('/login?') ||
|
||||||
|
candidate === '/oauth/callback' ||
|
||||||
|
candidate.startsWith('/oauth/callback?');
|
||||||
|
const destination = isAuthPath ? '/' : candidate;
|
||||||
|
navigate(destination, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
clearStoredOAuthData();
|
||||||
|
setError(err instanceof Error ? err.message : t('oauth.error', 'Authentication failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCallback();
|
||||||
|
}, [navigate, searchParams, t]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||||
|
<div className="w-full max-w-sm space-y-4 text-center">
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<a href={`${getBasePath()}/login`} className="text-sm text-primary underline-offset-4 hover:underline">
|
||||||
|
{t('oauth.backToLogin', 'Back to login')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">{t('oauth.processing', 'Completing sign in...')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import { getBasePath } from '@/lib/basePath';
|
||||||
|
|
||||||
|
export function getApiBaseUrl(): string {
|
||||||
|
const envUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
|
||||||
|
if (envUrl && envUrl.length > 0) {
|
||||||
|
return envUrl.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
return window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
body: unknown;
|
||||||
|
|
||||||
|
constructor(status: number, statusText: string, body: unknown) {
|
||||||
|
super(`API error ${status}: ${statusText}`);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.statusText = statusText;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
export async function refreshAccessToken(): Promise<void> {
|
||||||
|
if (refreshPromise) {
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
const { refreshToken, tokenEndpoint, logout } = useAuthStore.getState();
|
||||||
|
|
||||||
|
if (!refreshToken || !tokenEndpoint) {
|
||||||
|
logout();
|
||||||
|
window.location.href = `${getBasePath()}/login`;
|
||||||
|
throw new Error('No refresh token or token endpoint available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = (import.meta.env.VITE_OAUTH_CLIENT_ID as string) || 'stalwart-webui';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(tokenEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_id: clientId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Token refresh failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
useAuthStore
|
||||||
|
.getState()
|
||||||
|
.setTokens(data.access_token, data.refresh_token ?? refreshToken, data.expires_in, tokenEndpoint);
|
||||||
|
} catch (error) {
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
window.location.href = `${getBasePath()}/login`;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshPromise;
|
||||||
|
} finally {
|
||||||
|
refreshPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch(path: string, options?: RequestInit): Promise<Response> {
|
||||||
|
const store = useAuthStore.getState();
|
||||||
|
|
||||||
|
if (store.isTokenExpiringSoon() && store.refreshToken) {
|
||||||
|
await refreshAccessToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeRequest = async (): Promise<Response> => {
|
||||||
|
const { accessToken } = useAuthStore.getState();
|
||||||
|
const url = `${getApiBaseUrl()}${path}`;
|
||||||
|
|
||||||
|
const headers = new Headers(options?.headers);
|
||||||
|
if (accessToken) {
|
||||||
|
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = await makeRequest();
|
||||||
|
|
||||||
|
if (response.status === 401 && useAuthStore.getState().refreshToken) {
|
||||||
|
try {
|
||||||
|
await refreshAccessToken();
|
||||||
|
response = await makeRequest();
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(401, 'Unauthorized', null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let body: unknown = null;
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
} catch {}
|
||||||
|
throw new ApiError(response.status, response.statusText, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { generateCodeVerifier, generateCodeChallenge } from './oauth';
|
||||||
|
|
||||||
|
const UNRESERVED_RE = /^[A-Za-z0-9\-._~]+$/;
|
||||||
|
|
||||||
|
describe('generateCodeVerifier', () => {
|
||||||
|
it('defaults to 64 characters', () => {
|
||||||
|
const verifier = generateCodeVerifier();
|
||||||
|
expect(verifier).toHaveLength(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a custom length', () => {
|
||||||
|
expect(generateCodeVerifier(43)).toHaveLength(43);
|
||||||
|
expect(generateCodeVerifier(128)).toHaveLength(128);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects lengths outside RFC 7636 range', () => {
|
||||||
|
expect(() => generateCodeVerifier(42)).toThrow();
|
||||||
|
expect(() => generateCodeVerifier(129)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns different values on each call', () => {
|
||||||
|
const a = generateCodeVerifier();
|
||||||
|
const b = generateCodeVerifier();
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only contains RFC 3986 unreserved characters', () => {
|
||||||
|
const verifier = generateCodeVerifier();
|
||||||
|
expect(verifier).toMatch(UNRESERVED_RE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consistently produces unreserved-only output across multiple calls', () => {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const v = generateCodeVerifier();
|
||||||
|
expect(v).toMatch(UNRESERVED_RE);
|
||||||
|
expect(v).toHaveLength(64);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exercises every character in the alphabet given enough samples', () => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
for (const ch of generateCodeVerifier(128)) seen.add(ch);
|
||||||
|
}
|
||||||
|
for (const ch of '-._~') {
|
||||||
|
expect(seen.has(ch)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateCodeChallenge', () => {
|
||||||
|
it('returns a base64url string without +, /, or =', async () => {
|
||||||
|
const { challenge } = await generateCodeChallenge('test-verifier');
|
||||||
|
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces consistent output for same input', async () => {
|
||||||
|
const a = await generateCodeChallenge('same-input');
|
||||||
|
const b = await generateCodeChallenge('same-input');
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces different output for different inputs', async () => {
|
||||||
|
const a = await generateCodeChallenge('input-one');
|
||||||
|
const b = await generateCodeChallenge('input-two');
|
||||||
|
expect(a.challenge).not.toBe(b.challenge);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a non-empty string', async () => {
|
||||||
|
const { challenge } = await generateCodeChallenge('anything');
|
||||||
|
expect(challenge.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a SHA-256 sized output (43 base64url chars for 32 bytes) when S256 is used', async () => {
|
||||||
|
const { challenge, method } = await generateCodeChallenge('test');
|
||||||
|
expect(method).toBe('S256');
|
||||||
|
expect(challenge).toHaveLength(43);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to plain when crypto.subtle is unavailable', async () => {
|
||||||
|
const originalSubtle = crypto.subtle;
|
||||||
|
Object.defineProperty(crypto, 'subtle', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => undefined,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const { challenge, method } = await generateCodeChallenge('plain-verifier');
|
||||||
|
expect(method).toBe('plain');
|
||||||
|
expect(challenge).toBe('plain-verifier');
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(crypto, 'subtle', {
|
||||||
|
configurable: true,
|
||||||
|
value: originalSubtle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getApiBaseUrl } from '@/services/api';
|
||||||
|
import { getBasePath } from '@/lib/basePath';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
|
||||||
|
const CLIENT_ID = (import.meta.env.VITE_OAUTH_CLIENT_ID as string) || 'stalwart-webui';
|
||||||
|
const SCOPES = import.meta.env.VITE_OAUTH_SCOPES as string | undefined;
|
||||||
|
|
||||||
|
const SESSION_PREFIX = 'stalwart-oauth-';
|
||||||
|
|
||||||
|
interface DiscoveryResponse {
|
||||||
|
authorization_endpoint: string;
|
||||||
|
token_endpoint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discover(username: string): Promise<DiscoveryResponse> {
|
||||||
|
const url = `${getApiBaseUrl()}/api/discover/${encodeURIComponent(username)}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
i18n.t('oauth.discoveryFailed', 'Discovery failed for "{{username}}": {{status}} {{statusText}}', {
|
||||||
|
username,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<DiscoveryResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNRESERVED = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||||
|
|
||||||
|
export function generateCodeVerifier(length: number = 64): string {
|
||||||
|
if (length < 43 || length > 128) {
|
||||||
|
throw new Error(`code_verifier length must be 43-128, got ${length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
while (out.length < length) {
|
||||||
|
const buf = new Uint8Array(length * 2);
|
||||||
|
crypto.getRandomValues(buf);
|
||||||
|
for (let i = 0; i < buf.length && out.length < length; i++) {
|
||||||
|
const b = buf[i];
|
||||||
|
if (b < 198) {
|
||||||
|
out.push(UNRESERVED[b % 66]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCodeChallenge(
|
||||||
|
verifier: string,
|
||||||
|
): Promise<{ challenge: string; method: 'S256' | 'plain' }> {
|
||||||
|
if (typeof crypto === 'undefined' || typeof crypto.subtle === 'undefined') {
|
||||||
|
return { challenge: verifier, method: 'plain' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(verifier);
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
return { challenge: base64UrlEncode(new Uint8Array(digest)), method: 'S256' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateState(): string {
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlEncode(bytes: Uint8Array): string {
|
||||||
|
const binString = Array.from(bytes, (b) => String.fromCodePoint(b)).join('');
|
||||||
|
return btoa(binString).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeCode(
|
||||||
|
code: string,
|
||||||
|
codeVerifier: string,
|
||||||
|
tokenEndpoint: string,
|
||||||
|
redirectUri: string,
|
||||||
|
): Promise<TokenResponse> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(tokenEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
i18n.t('oauth.tokenExchangeFailed', 'Token exchange failed: {{status}} {{statusText}}', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<TokenResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRedirectUri(): string {
|
||||||
|
const basePath = getBasePath();
|
||||||
|
return `${window.location.origin}${basePath}/oauth/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startAuthFlow(username: string, returnUrl?: string | null): Promise<void> {
|
||||||
|
const { authorization_endpoint, token_endpoint } = await discover(username);
|
||||||
|
|
||||||
|
const codeVerifier = generateCodeVerifier();
|
||||||
|
const { challenge: codeChallenge, method: codeChallengeMethod } = await generateCodeChallenge(codeVerifier);
|
||||||
|
const state = generateState();
|
||||||
|
|
||||||
|
const candidate = returnUrl ?? window.location.pathname + window.location.search;
|
||||||
|
const basePath = getBasePath();
|
||||||
|
const stripped = candidate.startsWith(basePath) ? candidate.slice(basePath.length) : candidate;
|
||||||
|
const isAuthPath =
|
||||||
|
stripped === '/login' ||
|
||||||
|
stripped.startsWith('/login?') ||
|
||||||
|
stripped === '/oauth/callback' ||
|
||||||
|
stripped.startsWith('/oauth/callback?');
|
||||||
|
const safeReturnUrl = isAuthPath ? '' : candidate;
|
||||||
|
|
||||||
|
sessionStorage.setItem(`${SESSION_PREFIX}code_verifier`, codeVerifier);
|
||||||
|
sessionStorage.setItem(`${SESSION_PREFIX}token_endpoint`, token_endpoint);
|
||||||
|
sessionStorage.setItem(`${SESSION_PREFIX}state`, state);
|
||||||
|
sessionStorage.setItem(`${SESSION_PREFIX}return_url`, safeReturnUrl);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
redirect_uri: getRedirectUri(),
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: codeChallengeMethod,
|
||||||
|
state,
|
||||||
|
login_hint: username,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (SCOPES && SCOPES.length > 0) {
|
||||||
|
params.set('scope', SCOPES);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = `${authorization_endpoint}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredOAuthData() {
|
||||||
|
return {
|
||||||
|
codeVerifier: sessionStorage.getItem(`${SESSION_PREFIX}code_verifier`),
|
||||||
|
tokenEndpoint: sessionStorage.getItem(`${SESSION_PREFIX}token_endpoint`),
|
||||||
|
state: sessionStorage.getItem(`${SESSION_PREFIX}state`),
|
||||||
|
returnUrl: sessionStorage.getItem(`${SESSION_PREFIX}return_url`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredOAuthData(): void {
|
||||||
|
sessionStorage.removeItem(`${SESSION_PREFIX}code_verifier`);
|
||||||
|
sessionStorage.removeItem(`${SESSION_PREFIX}token_endpoint`);
|
||||||
|
sessionStorage.removeItem(`${SESSION_PREFIX}state`);
|
||||||
|
sessionStorage.removeItem(`${SESSION_PREFIX}return_url`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOAuthRedirectUri(): string {
|
||||||
|
return getRedirectUri();
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import { apiFetch } from '@/services/api';
|
||||||
|
import { logJmapExchange } from '@/lib/debug';
|
||||||
|
import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap';
|
||||||
|
import type { Schema } from '@/types/schema';
|
||||||
|
|
||||||
|
const JMAP_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
|
||||||
|
|
||||||
|
export function getAccountId(objectType: string): string {
|
||||||
|
const { primaryAccountId, activeAccountId } = useAuthStore.getState();
|
||||||
|
if (objectType.startsWith('x:')) {
|
||||||
|
if (!primaryAccountId) throw new Error('No primary account ID available');
|
||||||
|
return primaryAccountId;
|
||||||
|
}
|
||||||
|
if (!activeAccountId) throw new Error('No active account ID available');
|
||||||
|
return activeAccountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function jmapRequest(methodCalls: JmapMethodCall[], signal?: AbortSignal): Promise<JmapMethodResponse[]> {
|
||||||
|
const { apiUrl } = useAuthStore.getState();
|
||||||
|
let path = apiUrl || '/jmap';
|
||||||
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||||
|
try {
|
||||||
|
path = new URL(path).pathname;
|
||||||
|
} catch {
|
||||||
|
path = '/jmap';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const response = await apiFetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
using: JMAP_USING,
|
||||||
|
methodCalls,
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = (await response.json()) as JmapResponse;
|
||||||
|
|
||||||
|
logJmapExchange(methodCalls, data.methodResponses, performance.now() - startedAt);
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
for (const [methodName, result, callId] of data.methodResponses) {
|
||||||
|
if (methodName === 'error') {
|
||||||
|
console.error(`JMAP error [${callId}]:`, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.methodResponses;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function jmapGet(
|
||||||
|
objectType: string,
|
||||||
|
accountId: string,
|
||||||
|
ids: string[] | null,
|
||||||
|
properties?: string[],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<JmapMethodResponse[]> {
|
||||||
|
const args: Record<string, unknown> = { accountId, ids };
|
||||||
|
if (properties) {
|
||||||
|
args.properties = properties;
|
||||||
|
}
|
||||||
|
return jmapRequest([[`${objectType}/get`, args, '0']], signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JmapQueryOptions {
|
||||||
|
filter?: Record<string, unknown>;
|
||||||
|
sort?: Record<string, unknown>[];
|
||||||
|
limit?: number;
|
||||||
|
position?: number;
|
||||||
|
anchor?: string;
|
||||||
|
anchorOffset?: number;
|
||||||
|
calculateTotal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function jmapQuery(
|
||||||
|
objectType: string,
|
||||||
|
accountId: string,
|
||||||
|
options: JmapQueryOptions = {},
|
||||||
|
): Promise<JmapMethodResponse[]> {
|
||||||
|
const args: Record<string, unknown> = { accountId, ...options };
|
||||||
|
return jmapRequest([[`${objectType}/query`, args, '0']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JmapSetOptions {
|
||||||
|
create?: Record<string, Record<string, unknown>>;
|
||||||
|
update?: Record<string, Record<string, unknown>>;
|
||||||
|
destroy?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function jmapSet(
|
||||||
|
objectType: string,
|
||||||
|
accountId: string,
|
||||||
|
options: JmapSetOptions = {},
|
||||||
|
): Promise<JmapMethodResponse[]> {
|
||||||
|
const args: Record<string, unknown> = { accountId, ...options };
|
||||||
|
return jmapRequest([[`${objectType}/set`, args, '0']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function jmapQueryAndGet(
|
||||||
|
objectType: string,
|
||||||
|
accountId: string,
|
||||||
|
queryOptions: JmapQueryOptions = {},
|
||||||
|
properties?: string[],
|
||||||
|
): Promise<JmapMethodResponse[]> {
|
||||||
|
const queryArgs: Record<string, unknown> = {
|
||||||
|
accountId,
|
||||||
|
...queryOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArgs: Record<string, unknown> = {
|
||||||
|
accountId,
|
||||||
|
'#ids': {
|
||||||
|
resultOf: '0',
|
||||||
|
name: `${objectType}/query`,
|
||||||
|
path: '/ids',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (properties) {
|
||||||
|
getArgs.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
return jmapRequest([
|
||||||
|
[`${objectType}/query`, queryArgs, '0'],
|
||||||
|
[`${objectType}/get`, getArgs, '1'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function jmapGetBatched(
|
||||||
|
objectType: string,
|
||||||
|
accountId: string,
|
||||||
|
ids: string[],
|
||||||
|
properties?: string[],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Record<string, unknown>[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
|
const batchSize = useAuthStore.getState().maxObjectsInGet;
|
||||||
|
const allItems: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
|
for (let offset = 0; offset < ids.length; offset += batchSize) {
|
||||||
|
if (signal?.aborted) break;
|
||||||
|
const batchIds = ids.slice(offset, offset + batchSize);
|
||||||
|
const args: Record<string, unknown> = { accountId, ids: batchIds };
|
||||||
|
if (properties) args.properties = properties;
|
||||||
|
const responses = await jmapRequest([[`${objectType}/get`, args, '0']], signal);
|
||||||
|
const result = responses[0];
|
||||||
|
if (result[0] === 'error') {
|
||||||
|
throw new Error(`JMAP ${objectType}/get error: ${(result[1] as Record<string, unknown>).type ?? 'unknown'}`);
|
||||||
|
}
|
||||||
|
const list = (result[1] as { list?: Record<string, unknown>[] }).list ?? [];
|
||||||
|
allItems.push(...list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function jmapQueryAll(
|
||||||
|
objectType: string,
|
||||||
|
accountId: string,
|
||||||
|
queryOptions: JmapQueryOptions = {},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const allIds: string[] = [];
|
||||||
|
let anchor: string | undefined;
|
||||||
|
const MAX_PAGES = 1000;
|
||||||
|
for (let page = 0; page < MAX_PAGES; page++) {
|
||||||
|
if (signal?.aborted) break;
|
||||||
|
|
||||||
|
const args: Record<string, unknown> = { accountId, ...queryOptions };
|
||||||
|
if (anchor) {
|
||||||
|
delete args.position;
|
||||||
|
args.anchor = anchor;
|
||||||
|
args.anchorOffset = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = await jmapRequest([[`${objectType}/query`, args, '0']], signal);
|
||||||
|
const result = responses[0];
|
||||||
|
if (result[0] === 'error') {
|
||||||
|
throw new Error(`JMAP ${objectType}/query error: ${(result[1] as Record<string, unknown>).type ?? 'unknown'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result[1] as unknown as JmapQueryResponse;
|
||||||
|
const pageIds = data.ids ?? [];
|
||||||
|
if (pageIds.length === 0) break;
|
||||||
|
|
||||||
|
allIds.push(...pageIds);
|
||||||
|
|
||||||
|
if (data.limit === undefined || data.limit === null) break;
|
||||||
|
|
||||||
|
anchor = pageIds[pageIds.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return allIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function jmapQueryAllAndGet(
|
||||||
|
objectType: string,
|
||||||
|
accountId: string,
|
||||||
|
queryOptions: JmapQueryOptions = {},
|
||||||
|
properties?: string[],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ ids: string[]; list: Record<string, unknown>[] }> {
|
||||||
|
const ids = await jmapQueryAll(objectType, accountId, queryOptions, signal);
|
||||||
|
if (ids.length === 0) return { ids, list: [] };
|
||||||
|
const list = await jmapGetBatched(objectType, accountId, ids, properties, signal);
|
||||||
|
return { ids, list };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSession(): Promise<Record<string, unknown>> {
|
||||||
|
const response = await apiFetch('/jmap/session');
|
||||||
|
return response.json() as Promise<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSchema(): Promise<Schema> {
|
||||||
|
const response = await apiFetch('/api/schema');
|
||||||
|
return response.json() as Promise<Schema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountInfoResponse {
|
||||||
|
permissions: string[];
|
||||||
|
edition: 'enterprise' | 'community' | 'oss';
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAccountInfo(): Promise<AccountInfoResponse> {
|
||||||
|
const response = await apiFetch('/api/account');
|
||||||
|
return response.json() as Promise<AccountInfoResponse>;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user