Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8804c944ce | ||
|
|
eda20cbe2f | ||
|
|
41b3824894 | ||
|
|
f5524eb45a | ||
|
|
637d8bb286 | ||
|
|
410a01700b | ||
|
|
26a3878faa | ||
|
|
503cae465f | ||
|
|
9cd21a2d34 | ||
|
|
d9b073ad6c | ||
|
|
face5b4161 | ||
|
|
93b10c3ed1 | ||
|
|
518ec4059a | ||
|
|
3241dea5e4 | ||
|
|
0aa92c394c | ||
|
|
09910a7bf9 | ||
|
|
95053d7dc8 | ||
|
|
52123bce29 | ||
|
|
40164a6652 | ||
|
|
7fab3540ea | ||
|
|
18264a5b76 | ||
|
|
adca6d8730 | ||
|
|
f4c8f8f21c | ||
|
|
38abba39f8 | ||
|
|
101b24b361 | ||
|
|
4a6340e83f | ||
|
|
137e6c6ed9 | ||
|
|
4c953e599d | ||
|
|
8cab61a9c5 | ||
|
|
d84784c9d9 | ||
|
|
c40d1c69d8 | ||
|
|
a4b324bb8b | ||
|
|
08382472f4 | ||
|
|
75823ebb21 | ||
|
|
0dec14c3f8 | ||
|
|
1ab574a42a | ||
|
|
d1284de415 | ||
|
|
3189be2de0 | ||
|
|
eda9c9c8bf | ||
|
|
4e089d9b6b | ||
|
|
8b5b9c8f8e | ||
|
|
bd00b4e922 | ||
|
|
8c1e826d63 | ||
|
|
7a96fa6786 | ||
|
|
189e270785 | ||
|
|
3e08dcaff5 | ||
|
|
2ac3526da9 | ||
|
|
3dda6d1527 | ||
|
|
3113e6309d | ||
|
|
21b183327c | ||
|
|
da2c99990f | ||
|
|
25a38cd560 | ||
|
|
fe24a9bec0 | ||
|
|
b9489a795b | ||
|
|
49b3d60378 | ||
|
|
532e560106 | ||
|
|
0a417c5123 | ||
|
|
0e41f4d18e | ||
|
|
a7e69eefa6 | ||
|
|
bfdf8f4ba2 | ||
|
|
87cabf3886 | ||
|
|
aa48b90323 | ||
|
|
231f2ec3ee | ||
|
|
5127a3bf8a | ||
|
|
e4223d8cb4 | ||
|
|
b1849230dc | ||
|
|
40c04555b4 | ||
|
|
5dac3490ee | ||
|
|
868dd5e877 | ||
|
|
eec7f2ada5 | ||
|
|
2ac9a6b90e | ||
|
|
dca6082039 | ||
|
|
544ee36a1e | ||
|
|
5152b07a9a | ||
|
|
2a872d2ccf | ||
|
|
0a8dfb44be | ||
|
|
09d43f1e9b | ||
|
|
272502e0a3 | ||
|
|
13c21fc6d5 | ||
|
|
23bef766ca | ||
|
|
0fb734a6c7 | ||
|
|
3f31a115c9 | ||
|
|
149524376b | ||
|
|
956212031a | ||
|
|
6d3ea6c6b1 | ||
|
|
c15cb22be4 | ||
|
|
bd16e64e51 | ||
|
|
8fde771ff2 | ||
|
|
da5eaf0558 | ||
|
|
eb57250409 | ||
|
|
a657ad1b93 | ||
|
|
7c97297ce6 | ||
|
|
4739f9ed92 | ||
|
|
a83e75a79f | ||
|
|
f18f3012aa | ||
|
|
5eea77346a | ||
|
|
ef99135cae | ||
|
|
5ce2a5acf7 | ||
|
|
e080a6e061 | ||
|
|
574de03e42 | ||
|
|
a7fda8bd6b | ||
|
|
001a1f3a15 | ||
|
|
782dde0573 | ||
|
|
cbe77f9e4a | ||
|
|
e9b9efa084 | ||
|
|
dee0f7fbe3 | ||
|
|
ba6b4472d2 | ||
|
|
612fd796f3 | ||
|
|
a3376b0a7d | ||
|
|
2344c96651 | ||
|
|
68f0ca3629 | ||
|
|
47fcf1fe08 | ||
|
|
27ba55fbf6 | ||
|
|
35e0ef74b9 | ||
|
|
cfb3a308cd |
@@ -0,0 +1,30 @@
|
||||
# Schema fidelity
|
||||
|
||||
Stay schema-driven: the server's JSON schema is the single source of truth for what forms, fields, filters, and columns exist. This is why upstream (stalwartlabs/webui) rejects PRs that hardcode exceptions instead of fixing the schema/server — see [SCHEMA_DEVIATIONS.md](../../SCHEMA_DEVIATIONS.md) for the full rationale and the tracked list.
|
||||
|
||||
## When this applies
|
||||
|
||||
- Adding or changing anything in `DynamicList.tsx`, `FieldWidget.tsx`, `DynamicForm.tsx`, `layout.ts`, `schemaResolver.ts`, or any `src/lib/*Columns.ts` / `*Filters.ts` file.
|
||||
- Adding a new list column, filter, form field, or navigation entry.
|
||||
- Any change driven by a specific object/view name (`viewName === 'x:...'`, `objectName === 'x:...'`).
|
||||
|
||||
## Rules
|
||||
|
||||
- **NEVER** add a hardcoded `viewName === '...'` / `objectName === '...'` branch, a synthetic/computed column, or a client-side filter/sort workaround without checking first whether the schema already supports it.
|
||||
- Widget-level special cases for object types the schema itself designates (e.g. `x:OtpAuth`, `x:Expression`, `x:Rate`) are fine — that pattern already exists upstream and just renders real schema data with a dedicated widget.
|
||||
- **CRITICAL**: if something genuinely cannot be done through the schema because the server (`stalwartlabs/stalwart`) or upstream webui doesn't support it yet, it must become a tracked deviation, not a silent workaround:
|
||||
1. Add an entry to `SCHEMA_DEVIATIONS.md` (id, file location, what it does, why it's needed, the ideal server-side fix, status `🟡 Workaround` or `🔵 Upstream tracked`).
|
||||
2. Tag the code with `// SCHEMA-DEVIATION: <id>` pointing at that entry.
|
||||
3. Prefer filing (or pointing the user to file) an issue against `stalwartlabs/stalwart` or `stalwartlabs/webui` for the ideal fix.
|
||||
- **CRITICAL**: `src/types/schema.ts` must **never** be edited for a deviation, not even to add an extra optional field — it must stay alignable with upstream's copy of the file. If a deviation needs to carry extra data on an official schema shape, declare the augmented type as an intersection (`OfficialType & { extra?: ... }`) in the deviation's own module, or in `src/lib/schemaDeviationTypes.ts`, and import it only where the deviation is used.
|
||||
- Never remove a `SCHEMA-DEVIATION` tag or its `SCHEMA_DEVIATIONS.md` entry without confirming the underlying server/schema capability actually landed.
|
||||
- Pure client-side/presentational logic (theming, dark mode, responsive layout, sidebar UX, appearance settings) is not a schema concern — no deviation tracking needed for those.
|
||||
|
||||
## Example
|
||||
|
||||
```ts
|
||||
// SCHEMA-DEVIATION: log-client-filters (see SCHEMA_DEVIATIONS.md)
|
||||
//
|
||||
// The Stalwart JMAP backend rejects `level`/`event` as filter conditions on
|
||||
// `x:Log/query` (`unsupportedFilter`) ... [why + ideal fix]
|
||||
```
|
||||
@@ -1,4 +1,4 @@
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
VITE_API_BASE_URL=http://mx.astralmail.org:8080
|
||||
VITE_OAUTH_CLIENT_ID=stalwart-webui
|
||||
#VITE_ACCESS_TOKEN=OPEN_SESAME
|
||||
VITE_OAUTH_SCOPES=
|
||||
|
||||
@@ -23,8 +23,27 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
.ignore
|
||||
scripts/
|
||||
scripts/*
|
||||
!scripts/dev-token.ps1
|
||||
!scripts/dev-token.sh
|
||||
!scripts/dev-server-init.ps1
|
||||
!scripts/dev-server-init.sh
|
||||
!scripts/dev-seed.ps1
|
||||
!scripts/dev-seed.sh
|
||||
*.md
|
||||
!README.md
|
||||
!CHANGELOG.md
|
||||
!AGENTS.md
|
||||
!CLAUDE.md
|
||||
!SCHEMA_DEVIATIONS.md
|
||||
!DEVELOPMENT.md
|
||||
!.agents/rules/*.md
|
||||
/SPEC-*
|
||||
# Tool-generated artifacts during agent sessions
|
||||
.playwright-mcp/
|
||||
outputs/
|
||||
.vite_*.log
|
||||
.tmp_*
|
||||
*.py
|
||||
webui.zip
|
||||
release_body.md
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Stalwart WebUI Fork
|
||||
|
||||
Community fork of [stalwartlabs/webui](https://github.com/stalwartlabs/webui), a schema-driven admin panel for [Stalwart](https://stalw.art). The server's JSON schema (fetched from `/api/schema`) is the single source of truth for forms, fields, filters, columns, and navigation.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React 19 + Vite + TypeScript, Zustand stores, JMAP (RFC 8620) for all data operations.
|
||||
|
||||
## Commands
|
||||
|
||||
- `npm run dev:server` - Start a local disposable Stalwart test server (Docker); `npm run dev:server:down` to stop it
|
||||
- `bash scripts/dev-server-init.sh` (or `pwsh ./scripts/dev-server-init.ps1`) - One-time setup of that server (bootstrap, dev admin account, 3h default token lifetime); idempotent
|
||||
- `bash scripts/dev-seed.sh` (or `pwsh ./scripts/dev-seed.ps1`) - Optional: seed sample Users/Groups/Mailing Lists/Roles for testing; idempotent
|
||||
- `bash scripts/dev-token.sh [duration_seconds]` (or `pwsh ./scripts/dev-token.ps1 [-DurationSeconds N]`) - Get a dev access token from that server, 3h by default
|
||||
- `npm run dev` - Dev server (proxies `/api` and `/jmap` to the test server)
|
||||
- `npm run typecheck` - `tsc --noEmit`
|
||||
- `npm run lint` - ESLint
|
||||
- `npm run test` - Vitest (`npm run test:watch` to watch)
|
||||
- `npm run build` - `tsc -b && vite build`
|
||||
|
||||
Full local dev workflow, including how to run it end-to-end without a browser: [DEVELOPMENT.md](DEVELOPMENT.md).
|
||||
|
||||
## Rules
|
||||
|
||||
The detailed rules live in `.agents/rules/`. Read the relevant file before acting:
|
||||
|
||||
- **Schema fidelity** - [.agents/rules/schema-fidelity.md](.agents/rules/schema-fidelity.md) - Stay schema-driven; how to handle cases the schema can't cover yet
|
||||
|
||||
## Universal Rules
|
||||
|
||||
- This applies to every AI coding agent working in this repo (Claude Code, Codex, Kimi, or any other) — not just one tool.
|
||||
- Never hardcode object types, field names, filters, or columns as a shortcut. Read `.agents/rules/schema-fidelity.md` before adding anything that touches lists, forms, or navigation.
|
||||
- **CRITICAL**: `src/types/schema.ts` mirrors the official server/webui schema contract and must never be edited to accommodate a deviation, not even to add an optional field. Deviation-only type augmentations go in `src/lib/schemaDeviationTypes.ts` (or the deviation's own module) as an intersection with the official type — see `.agents/rules/schema-fidelity.md`.
|
||||
- Any client-side workaround for something the official schema doesn't support yet must be documented in `SCHEMA_DEVIATIONS.md` and tagged `// SCHEMA-DEVIATION: <id>` in code. Never add one silently.
|
||||
@@ -2,7 +2,193 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [0.1.0] - 2026-04-20
|
||||
## [1.1.3] - 2026-08-01
|
||||
|
||||
### Fixed
|
||||
- Column sort headers now cycle unsorted → ascending → descending → unsorted, so a third click clears the sort and restores the list's default order (previously stuck alternating between ascending and descending).
|
||||
|
||||
## [1.1.2] - 2026-08-01
|
||||
|
||||
### Added
|
||||
- Brand-accurate "Stalwart" color theme (red/pink accent from the official site tokens). The previous theme named "Stalwart" was only the neutral black/white look and is now labeled "Default".
|
||||
- Changelog page in the header user dropdown, rendering this repository's `CHANGELOG.md` so release notes stay in sync with every release.
|
||||
- Usage/Quota column on the Groups list (same synthetic column as Accounts); unlimited quota renders as ∞ instead of the word "Unlimited".
|
||||
- Aliases count column on Accounts, Groups, Mailing Lists, and Domains (resolved from each object's real `aliases` property).
|
||||
- Enabled Permissions and Disabled Permissions count columns on the Roles list.
|
||||
- Client-side column sorting on Accounts, Groups, Mailing Lists, Roles, and Domains for the most useful columns (Email/Name/Description, Usage/Quota, Aliases, permission counts, Domain Name, Enabled). The backend rejects sort on these properties (`unsupportedSort`), so lists fetch then sort/paginate locally when a sortable header is clicked — same approach as mailbox hierarchy sort.
|
||||
- `scripts/dev-seed` (`.sh` / `.ps1`) to populate the local test server with sample Users, Groups, Mailing Lists, and Roles after `dev-server-init` (idempotent; documented in DEVELOPMENT.md).
|
||||
|
||||
### Changed
|
||||
- Default color theme is now the new Stalwart theme (previously Ocean since 1.0.9); existing saved preferences are unaffected.
|
||||
- Client-side sort is driven by a shared `clientSortable` column flag instead of hardcoded view/column maps, so any list can opt in without touching `DynamicList` branching.
|
||||
|
||||
### Fixed
|
||||
- Sidebar: expanding a collapsible group (e.g. Directory) and then navigating to an unrelated top-level link (e.g. Cluster) now collapses that group instead of leaving it open.
|
||||
- Vite respects the `$PORT` environment variable so preview/dev tooling that assigns a free port can bind the same port Vite actually listens on.
|
||||
|
||||
## [1.1.1] - 2026-08-01
|
||||
|
||||
### Fixed
|
||||
- Mobile sidebar no longer snaps shut when switching between Management/Settings/Account — it now stays open so you can pick a page in the new section, and only closes once you actually navigate to one.
|
||||
- Favicon replaced with upstream's original: this fork's had drifted to a mis-cropped export (visible padding around the logo, lower color depth) despite showing the same logo and color.
|
||||
|
||||
### Changed
|
||||
- The "Active WebUI" card (Settings > Web Applications) now also shows the resource URL the active WebUI was installed from, alongside its description and version.
|
||||
|
||||
## [1.1.0] - 2026-07-30
|
||||
|
||||
### Fixed
|
||||
- Mobile admin shell and lists: wide tables no longer expand/clip the page. The shared ScrollArea constrains content width, tables scroll horizontally inside their card, and list Create / pagination actions stay visible in the mobile viewport.
|
||||
- Form and detail layouts wrap more cleanly on narrow screens (action bars, label/value rows).
|
||||
- Date/time picker in dark mode: replaced the native time input (invisible clock icon + always-light popup) with themed hour/minute selects and a visible Clock icon; set `color-scheme` on light/dark themes so remaining native date controls follow the UI.
|
||||
- Empty date/time fields open prefilled with the current date and time so the calendar and hour/minute selects start on a useful default.
|
||||
|
||||
### Changed
|
||||
- Negative account disk-usage values (stale Stalwart quota counters) are shown in red with an info tooltip that explains how to recalculate usage via Tasks (Perform account maintenance operations → Recalculate storage quota usage, or store-wide Reset all user quotas).
|
||||
|
||||
## [1.0.9] - 2026-07-30
|
||||
|
||||
### Added
|
||||
- Level and Event filters on the Log Entries list, applied client-side since the backend doesn't support filtering on these properties yet. Event uses a searchable combobox given its ~600 possible values.
|
||||
- Rate-limited manual Refresh button on the Log Entries list (one click per 5 seconds) ([webui#8](https://github.com/stalwartlabs/webui/issues/8)).
|
||||
- Role and Usage/Quota columns on the Accounts list, replacing Created At ([webui#12](https://github.com/stalwartlabs/webui/issues/12)).
|
||||
- Mailbox hierarchy: mailboxes are now indented under their parent instead of shown as a flat list ([webui#16](https://github.com/stalwartlabs/webui/issues/16)).
|
||||
- Three additional color themes — Rose, Amber, Teal — alongside Stalwart/Ocean/Forest/Violet.
|
||||
- "Remember last visited page" per section (localStorage), so switching sections returns to where you left off.
|
||||
- Username and email shown directly in the TopBar user menu trigger.
|
||||
- "Active WebUI" info card and column in the Web Applications list, showing which web app is currently serving the admin UI.
|
||||
- Backend/provider icons across the variant selectors (DNS providers, storage/directory backends, Redis/Valkey).
|
||||
|
||||
### Changed
|
||||
- Default color theme is now Ocean with square corners (previously Stalwart with rounded corners); existing saved preferences are unaffected.
|
||||
- Active account is preserved across page reloads instead of resetting to the primary account, and switching accounts fully remounts the current view (and clears cached display-name/list lookups) so account-scoped data refreshes immediately instead of requiring a tab switch first ([webui#17](https://github.com/stalwartlabs/webui/issues/17); reviewed against upstream's own fix for the same issue in [stalwartlabs/webui@189e270](https://github.com/stalwartlabs/webui/commit/189e270785a6953a99d11c958fd52daa94e1f5c7) and aligned with it).
|
||||
- WebUI label renamed to "Stalwart WebUI Fork" to distinguish this fork from upstream.
|
||||
- Appearance settings moved from the sidebar to the header user dropdown.
|
||||
- Dynamic page titles prefixed with "Stalwart |".
|
||||
- x:Application list layout aligned with Domains (Description first, Enabled second).
|
||||
- Updated Vite to 8.2.0, `@vitejs/plugin-react` to 6.0.5, and `lucide-react` to 1.28.0.
|
||||
|
||||
### Fixed
|
||||
- Custom logos no longer flash the default Stalwart logo while loading. Loading is encapsulated in `logoCache` (shared fetch + AbortController + blob URL revoke), keeping `uiStore` free of logo state while still caching across TopBar/Login remounts.
|
||||
- Icon/label alignment in backend select triggers.
|
||||
- Web Applications list shows an Enabled column again.
|
||||
- Appearance Corners preview: only the Rounded choice forces rounded radius on its card and sample; Square stays sharp even when the global theme is square.
|
||||
- Removed the experimental PWA/service worker: it precached `index.html` with `<base href="/">`, which broke Stalwart's mount-path rewrite (`/admin`, `/account`) and produced a blank UI. Aligns with upstream webui, which does not ship a service worker.
|
||||
- iOS home-screen Web App support without a service worker: `apple-touch-icon` plus `apple-mobile-web-app-title` set to "Stalwart".
|
||||
|
||||
### Known limitations (confirmed backend-side, not fixable from this fork)
|
||||
- Level/Event filters on Log Entries are client-side only (see Added above) because the backend's JMAP query engine rejects `level`/`event` as filter conditions ([webui#15](https://github.com/stalwartlabs/webui/issues/15)).
|
||||
- Text filters across the admin API (Spam Rules & Scores, Accounts, Domains, ...) only support exact match, not partial/glob/wildcard search — confirmed systemic across the whole `x:` filter engine, requires a server-side change.
|
||||
|
||||
## [1.0.8] - 2026-07-29
|
||||
|
||||
### Added
|
||||
- Appearance settings page (linked at the bottom of the sidebar) with light/dark mode, four selectable color themes (Stalwart, Ocean, Forest, Violet) and a rounded/square corners option that applies globally across all themes.
|
||||
- Light/dark toggle on the login pages.
|
||||
- Dynamic document titles per page, mirroring the sidebar navigation labels.
|
||||
- Square corners toggle for a border-radius-free interface.
|
||||
- Full-width sidebar hover with a separated footer in square mode.
|
||||
|
||||
### Changed
|
||||
- The light/dark toggle is a single-click button again, in the top bar and on the login pages.
|
||||
- The logout menu item is marked as destructive.
|
||||
- Updated react-router-dom to 7.18.2 and migrated the date picker to `@daypicker/react` 10 (the new react-day-picker package name).
|
||||
|
||||
### Fixed
|
||||
- Section URLs without a view (e.g. /admin) redirect to the first accessible page instead of the "Select a view" empty state.
|
||||
|
||||
### Removed
|
||||
- The Web Applications list no longer shows a "Version" column or an "Update" button, because Stalwart does not expose the installed version of each web application and `/latest/` GitHub URLs hide it.
|
||||
|
||||
## [1.0.7] - 2026-07-29
|
||||
|
||||
### Added
|
||||
- Ctrl+K / Cmd+K command palette to search pages, form sections and fields across the admin panel, with an ESC hint badge instead of the close cross.
|
||||
- WebUI version and update action with a confirmation modal in the Web Applications list.
|
||||
- Calendar date picker with time input replacing native datetime inputs in all schema-driven forms.
|
||||
- Shared styled ScrollArea used app-wide, including the sidebar and the command palette.
|
||||
- Development proxy forwarding API and JMAP requests to a local Stalwart server.
|
||||
|
||||
### Changed
|
||||
- Renamed the package to `stalwart-webui-fork` to identify the community fork.
|
||||
- Sidebar behaves as an animated accordion: expanding a section collapses the others at the same level, with a softer hover.
|
||||
- Main content is horizontally centered.
|
||||
- Form fields now use a background color distinct from card surfaces.
|
||||
- Feature pages and the admin shell are code-split with React.lazy to reduce the initial bundle size.
|
||||
- Switches show green when enabled and red when disabled.
|
||||
|
||||
### Fixed
|
||||
- Table header background is clipped inside the rounded border, removing the square corner visible behind the radius.
|
||||
- Sidebar section stays synced with the URL on programmatic navigation and full page loads.
|
||||
|
||||
## [1.0.6] - 2026-07-28
|
||||
|
||||
### Added
|
||||
- WebUI version is now displayed when hovering over the logo.
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
- Properly serialize `date` filters when applying them to the list filter.
|
||||
|
||||
## [1.0.5] - 2026-06-21
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
- Redirect to `/login` when there is no refresh token.
|
||||
- Include required JMAP capabilities in `using`.
|
||||
- Default scopes omit `offline_access`.
|
||||
|
||||
## [1.0.4] - 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
- Align `base32` alphabet with the server.
|
||||
|
||||
## [1.0.3] - 2026-05-05
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
- Broken "Delivery History" link on OSS/Community editions.
|
||||
- Resolve object ids in map keys.
|
||||
|
||||
## [1.0.2] - 2026-04-30
|
||||
|
||||
### Added
|
||||
- OIDC:
|
||||
- Include `email` and `profile` scopes in OIDC authentication requests.
|
||||
- TOTP:
|
||||
- Add "Copy Secret" button to TOTP setup flow.
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
- Display validation errors returned by the server.
|
||||
|
||||
## [1.0.1] - 2026-04-25
|
||||
|
||||
### Added
|
||||
- OIDC:
|
||||
- Logout users from IdP when logging out of the app.
|
||||
- Include `openid` scope in OIDC authentication requests.
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
- Mobile display issues.
|
||||
- Editing a secret clears its masked value.
|
||||
- Array label properties crashes app.
|
||||
|
||||
## [1.0.0] - 2026-04-20
|
||||
|
||||
### Added
|
||||
- Initial release.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Read [AGENTS.md](AGENTS.md) first — it is the source of truth for this repo's rules (tech stack, commands, and the schema-fidelity rule in `.agents/rules/`). It applies to every AI agent working here, Claude Code included.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Development
|
||||
|
||||
How to run a local Stalwart test server and develop this WebUI against it.
|
||||
This file is meant to be read by humans and AI coding agents alike — see
|
||||
[AGENTS.md](AGENTS.md) for the rules that also apply while doing this.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- Docker (with the `docker compose` CLI plugin)
|
||||
|
||||
## 1. Start a local test server
|
||||
|
||||
```bash
|
||||
npm run dev:server
|
||||
```
|
||||
|
||||
This runs `docker compose up -d`, which starts a disposable Stalwart
|
||||
instance ([`docker-compose.yml`](docker-compose.yml)) with:
|
||||
|
||||
- The management/JMAP HTTP API on `http://localhost:8080` (the only port
|
||||
the WebUI dev proxy needs — see `server.proxy` in
|
||||
[`vite.config.ts`](vite.config.ts)).
|
||||
- Mail protocol ports (SMTP/IMAP/POP3/ManageSieve) exposed too, only
|
||||
needed if you're testing actual mail flows, not just admin UI screens.
|
||||
- A fixed admin account baked in via `STALWART_RECOVERY_ADMIN`:
|
||||
`admin@example.org` / `c8321iEscHDy0GWV`. **Disposable dev credentials
|
||||
only — never reuse them for anything real.**
|
||||
- Named Docker volumes (`stalwart-etc`, `stalwart-data`) so data survives
|
||||
a restart. Data persists until you tear the volumes down.
|
||||
|
||||
Useful companions:
|
||||
|
||||
```bash
|
||||
npm run dev:server:logs # tail the container's logs
|
||||
npm run dev:server:down # stop it (add `-v` via `docker compose down -v` to also wipe data)
|
||||
```
|
||||
|
||||
The server takes a couple of seconds to come up; `docker compose logs stalwart`
|
||||
will show `Network listener started ... localPort = 8080` once it's ready.
|
||||
|
||||
## 2. Initialize the server (first time only)
|
||||
|
||||
The container starts in Stalwart's bootstrap mode, which only allows
|
||||
signing in as the break-glass `STALWART_RECOVERY_ADMIN` account — real
|
||||
accounts and most settings aren't usable yet. Run once per fresh volume:
|
||||
|
||||
```bash
|
||||
# Windows / PowerShell
|
||||
pwsh ./scripts/dev-server-init.ps1
|
||||
|
||||
# Linux / macOS / any POSIX shell (including most AI agent sandboxes)
|
||||
bash ./scripts/dev-server-init.sh
|
||||
```
|
||||
|
||||
This completes the bootstrap wizard (default domain `example.org`, no TLS
|
||||
certificate request — safe for local/offline use), creates a real
|
||||
`devadmin@example.org` admin account, sets the server's default OAuth
|
||||
access token lifetime to 3 hours, and points the `x:Application` record
|
||||
serving `/admin`/`/account` at this fork's description and release URL
|
||||
(cosmetic — otherwise Settings > Web Applications' "Active WebUI" card
|
||||
shows Stalwart's default "Stalwart Web Interface" / upstream release URL,
|
||||
even though what's actually running is this fork served by Vite). It's
|
||||
idempotent — safe to re-run, it no-ops once the server is already
|
||||
bootstrapped. You only need to re-run it after `docker compose down -v`
|
||||
(which wipes the volumes).
|
||||
|
||||
## 3. Seed sample data (optional)
|
||||
|
||||
```bash
|
||||
# Windows / PowerShell
|
||||
pwsh ./scripts/dev-seed.ps1
|
||||
|
||||
# Linux / macOS / any POSIX shell (including most AI agent sandboxes)
|
||||
bash ./scripts/dev-seed.sh
|
||||
```
|
||||
|
||||
Populates Users, Groups, Mailing Lists, and Roles with sample data so
|
||||
every admin panel screen has something to test against:
|
||||
|
||||
- **Users**: `alice@example.org` / `AlicePass123!` (User role, in
|
||||
engineering, 1 alias), `bob@example.org` / `BobPass123!` (User role, in
|
||||
engineering + marketing), `carol@example.org` / `CarolPass123!` (Admin
|
||||
role, in marketing) — alongside the `devadmin`/`admin` accounts from
|
||||
step 2.
|
||||
- **Groups**: `engineering`, `marketing`.
|
||||
- **Mailing lists**: `newsletter@example.org`, `support@example.org`.
|
||||
- **Roles**: `Support Agent`, `Read-only Auditor` (custom, alongside the
|
||||
built-in System Administrator/Tenant Administrator/Group/User roles).
|
||||
|
||||
Idempotent — does nothing if `alice` already exists. Only needed once per
|
||||
fresh volume, same as step 2.
|
||||
|
||||
## 4. Get an access token
|
||||
|
||||
For local development it's simpler to skip interactive login and use a
|
||||
bearer token directly via `VITE_ACCESS_TOKEN` (see `.env.development`).
|
||||
|
||||
```bash
|
||||
# Windows / PowerShell
|
||||
pwsh ./scripts/dev-token.ps1 # 3 hour token (server default)
|
||||
pwsh ./scripts/dev-token.ps1 -DurationSeconds 1800 # custom duration (30 min)
|
||||
|
||||
# Linux / macOS / any POSIX shell (including most AI agent sandboxes)
|
||||
bash ./scripts/dev-token.sh # 3 hour token
|
||||
bash ./scripts/dev-token.sh 1800 # custom duration (30 min)
|
||||
```
|
||||
|
||||
Both scripts authenticate as the `devadmin` account created in step 2 and
|
||||
create a Stalwart API key with the requested expiry (default 3 hours,
|
||||
overridable per invocation — this is a genuine per-request duration, not
|
||||
a global setting), then write its secret to `.env.development.local`
|
||||
(gitignored, never committed) as `VITE_ACCESS_TOKEN`. Re-run the script
|
||||
and restart `npm run dev` once the token expires (the UI starts returning
|
||||
401s).
|
||||
|
||||
The `STALWART_RECOVERY_ADMIN` account is intentionally not used here: it's
|
||||
a break-glass credential and its tokens always expire in a fixed 1 hour
|
||||
regardless of server configuration, so it can't honor a custom duration.
|
||||
|
||||
## 5. Run the WebUI
|
||||
|
||||
```bash
|
||||
npm install # first time only
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:5173`. You should land directly in the admin panel
|
||||
(no login screen) since `VITE_ACCESS_TOKEN` is set.
|
||||
|
||||
## 6. Verify your change
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
For UI changes, actually look at the running app (browser or a browser
|
||||
automation tool) — passing typecheck/lint/tests proves the code compiles
|
||||
and existing behavior didn't regress, it doesn't prove the new UI works.
|
||||
|
||||
## Resetting the test server
|
||||
|
||||
To start from a completely clean server (e.g. to re-test first-run
|
||||
behavior):
|
||||
|
||||
```bash
|
||||
npm run dev:server:down
|
||||
docker compose down -v # also removes the stalwart-etc/stalwart-data volumes
|
||||
npm run dev:server
|
||||
bash ./scripts/dev-server-init.sh # re-run: fresh volume needs bootstrapping again
|
||||
bash ./scripts/dev-seed.sh # optional: re-seed sample data too
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **UI shows a login screen instead of the admin panel, or the page fails
|
||||
to load data**: `npm run dev` only reads `.env.development.local` at
|
||||
startup. If you regenerate a token, or restart the Docker container,
|
||||
while `npm run dev` is already running, stop it (Ctrl+C) and start it
|
||||
again — it won't pick up the new token or reconnect on its own.
|
||||
- **`Failed to fetch` / connection refused in the browser console**: the
|
||||
test server isn't running or isn't ready yet. Check with
|
||||
`docker ps --filter name=stalwart-webui-dev` and
|
||||
`npm run dev:server:logs`; wait for `Network listener started ...
|
||||
localPort = 8080` before retrying.
|
||||
- **401s after everything was working**: your token expired. Re-run
|
||||
`scripts/dev-token.sh` (or `.ps1`) and restart `npm run dev`.
|
||||
- **`scripts/dev-server-init.sh` fails with connection errors**: the
|
||||
container needs a few seconds after `npm run dev:server` before it
|
||||
accepts requests — the script retries for ~30s, but if your machine is
|
||||
slow, just re-run it.
|
||||
|
||||
## Notes for AI agents
|
||||
|
||||
- This whole workflow (steps 1–5) is scriptable end-to-end without a
|
||||
browser: `npm run dev:server`, then `bash scripts/dev-server-init.sh`
|
||||
and `bash scripts/dev-seed.sh` (first time only), then
|
||||
`bash scripts/dev-token.sh`, then the app is reachable at
|
||||
`http://localhost:5173` with `VITE_ACCESS_TOKEN` already set. Verify
|
||||
backend connectivity directly with `curl`, e.g.
|
||||
`curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/jmap/session`.
|
||||
- Read [AGENTS.md](AGENTS.md) before touching anything under `src/` —
|
||||
the schema-fidelity rule applies to all development, local test server
|
||||
or not.
|
||||
- Don't commit `.env.development.local` (it holds a live token) or leave
|
||||
the dev container running unexpectedly — `npm run dev:server:down` when
|
||||
you're done.
|
||||
@@ -0,0 +1,235 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see <http://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,213 @@
|
||||
# Stalwart Enterprise License 2.0 (SELv2) Agreement
|
||||
|
||||
*Last Update: March 29, 2026*
|
||||
|
||||
PLEASE CAREFULLY READ THIS STALWART ENTERPRISE LICENSE AGREEMENT ("AGREEMENT"). THIS AGREEMENT CONSTITUTES A LEGALLY BINDING AGREEMENT BETWEEN YOU AND STALWART LABS LLC AND GOVERNS YOUR USE OF THE SOFTWARE (DEFINED BELOW). IF YOU DO NOT AGREE WITH THIS AGREEMENT, YOU MAY NOT USE THE SOFTWARE. IF YOU ARE USING THE SOFTWARE ON BEHALF OF A LEGAL ENTITY, YOU REPRESENT AND WARRANT THAT YOU HAVE AUTHORITY TO AGREE TO THIS AGREEMENT ON BEHALF OF SUCH ENTITY. IF YOU DO NOT HAVE SUCH AUTHORITY, DO NOT USE THE SOFTWARE IN ANY MANNER.
|
||||
|
||||
This Agreement is entered into by and between Stalwart Labs LLC and you, or the legal entity on behalf of whom you are acting.
|
||||
|
||||
---
|
||||
|
||||
## 1. DEFINITIONS
|
||||
|
||||
1.1. "Software" refers to the Stalwart Server Enterprise Edition software, including all its versions, updates, modifications, accompanying documentation, and related materials. The Software is self-hosted by Licensee on its own infrastructure.
|
||||
|
||||
1.2. "Subscription" refers to the paid access to the Software provided by Licensor to Licensee, billed on a monthly or annual basis.
|
||||
|
||||
1.3. "Licensor" refers to Stalwart Labs LLC, the entity providing the Software.
|
||||
|
||||
1.4. "Licensee" refers to the individual or entity installing, accessing, or using the Software with a valid Subscription.
|
||||
|
||||
1.5. "License Key" refers to the unique code provided by Licensor upon purchasing a Subscription which activates the full features of the Software. Each License Key is bound to the domain name (including all subdomains) designated by Licensee at the time of purchase.
|
||||
|
||||
1.6. "Source Code" refers to the human-readable version of the Software's code, as opposed to the compiled machine-readable version.
|
||||
|
||||
1.7. "Mailbox" refers to each individual user account or group account provisioned within the Software. The total number of Mailboxes across all domains and tenants hosted by Licensee determines the applicable Subscription tier.
|
||||
|
||||
1.8. "Confidential Information" refers to any non-public information disclosed by either party to the other in connection with this Agreement, whether in written, oral, electronic, or other form, that is designated as confidential or that a reasonable person would understand to be confidential given the nature of the information and circumstances of disclosure.
|
||||
|
||||
## 2. GRANT OF LICENSE
|
||||
|
||||
2.1. Licensor grants Licensee a non-exclusive, non-transferable, non-sublicensable, limited license to download, install, and use the Software during the Subscription term, subject to the terms and conditions of this Agreement.
|
||||
|
||||
2.2. The use of the Software is conditioned upon Licensee maintaining an active and valid paid Subscription with Licensor. The paid Subscription covers all versions of the Software and all updates and modifications released during the Subscription term.
|
||||
|
||||
2.3. This license grants Licensee the right to use the Software for both personal and commercial purposes. Licensee may install and operate the Software on an unlimited number of servers within its organization, host an unlimited number of domains, and host data for an unlimited number of external organizations (tenants) using the Software's multi-tenancy features. The Subscription tier is determined solely by the total number of Mailboxes provisioned. However, Licensee is expressly prohibited from reselling, leasing, sublicensing, or otherwise redistributing the Software itself.
|
||||
|
||||
2.4. This license is further governed by the terms and conditions set forth in any licensing agreements separately executed between Licensor and Licensee. In the event of any conflict between the terms of this Agreement and the terms of a signed licensing agreement, the terms of the signed licensing agreement shall control.
|
||||
|
||||
2.5. You are not granted any other rights beyond what is expressly stated herein.
|
||||
|
||||
## 3. LICENSE KEYS
|
||||
|
||||
3.1. The Software shall not be used without a valid License Key issued by Licensor.
|
||||
|
||||
3.2. Licensee is required to use valid License Keys issued by Licensor to run the Software, including any modified versions. Any attempts to bypass the License Key requirement is a violation of this Agreement.
|
||||
|
||||
3.3. Distribution or sharing of License Keys to third parties, not associated with Licensee, is strictly prohibited.
|
||||
|
||||
3.4. License Keys are bound to the Subscription period. Should your Subscription expire or be cancelled, all License Keys will become invalid after fifteen (15) days from the Subscription expiration or cancellation date.
|
||||
|
||||
3.5. Any instance of the Software using such an expired key will revert to the Community Edition functionality after the aforementioned fifteen (15) day period.
|
||||
|
||||
## 4. SOURCE CODE USAGE
|
||||
|
||||
4.1. Licensee is permitted to view, copy, and modify the Software's Source Code, as made available by Licensor, solely for Licensee's internal business use and in compliance with this Agreement's terms.
|
||||
|
||||
4.2. Any modifications to the Source Code do not grant Licensee any ownership rights to the original Software or any modifications. All rights, title, and interest to the Software and its Source Code remain exclusively with Licensor.
|
||||
|
||||
4.3. Licensee is strictly prohibited from altering, removing, or in any way tampering with the License Key validation system within the Software. Any such unauthorized modifications will be considered a material breach of this Agreement and may result in legal action.
|
||||
|
||||
4.4. Notwithstanding the availability of the Software's Source Code for review and limited modification, the Software and its Source Code are not open source and remain proprietary to Licensor. The provision of access to the Source Code does not confer any rights typically associated with open source software, including but not limited to the right to freely sublicense, or create derivative works for public distribution. All rights not expressly granted herein are reserved by Licensor.
|
||||
|
||||
4.5. Notwithstanding the foregoing, you may copy the Source Code for development and testing purposes, without requiring a Subscription.
|
||||
|
||||
## 5. INTELLECTUAL PROPERTY RIGHTS
|
||||
|
||||
5.1. The Licensor retains all rights, title, and interest in and to the Software, including all intellectual property rights therein. This Agreement does not transfer any ownership rights to the Licensee.
|
||||
|
||||
5.2. The Licensee must not remove, alter, or obscure any proprietary notices (including copyright and trademark notices) on the Software.
|
||||
|
||||
## 6. SUBSCRIPTION TERMS, RENEWAL, AND CANCELLATION
|
||||
|
||||
6.1. Subscriptions are available on a monthly or annual basis. The applicable fees, Mailbox tier, and billing cycle will be as set forth at the time of purchase or as subsequently agreed in writing between the parties.
|
||||
|
||||
6.2. Where Licensee has provided a valid payment method (such as a credit card) on file, the Subscription will automatically renew at the end of each billing cycle at the then-current rate, unless Licensee removes the payment method or cancels the Subscription prior to the renewal date. No advance cancellation notice period is required; Licensee may cancel at any time by removing the payment method on file or by notifying Licensor.
|
||||
|
||||
6.3. Where Licensee pays by invoice (bank transfer), the Subscription will not automatically renew. Licensor will issue an invoice notification prior to the end of the billing cycle, and the Subscription will renew only upon receipt of payment.
|
||||
|
||||
6.4. Upon cancellation of a Subscription by Licensee prior to the end of a paid billing cycle, Licensee is entitled to a prorated refund for the unused portion of the then-current billing period. Refunds will be calculated from the effective date of cancellation through the end of the billing cycle and will be issued within thirty (30) days of the cancellation date.
|
||||
|
||||
6.5. Licensor reserves the right to modify Subscription fees upon renewal. Any fee changes will be communicated to Licensee at least thirty (30) days prior to the start of the next billing cycle.
|
||||
|
||||
## 7. SUPPORT AND SERVICE LEVELS
|
||||
|
||||
7.1. All Licensees with an active Subscription have access to standard community support resources, including documentation and community forums, as made available by Licensor.
|
||||
|
||||
7.2. Priority email support is available exclusively to Licensees whose Subscription covers one hundred fifty (150) or more Mailboxes. Priority email support inquiries will receive an initial response within forty-eight (48) hours of receipt during Licensor's standard business hours.
|
||||
|
||||
7.3. The forty-eight (48) hour response time set forth in Section 7.2 constitutes a service level commitment. In the event Licensor consistently fails to meet this commitment over a period of thirty (30) consecutive days, the affected Licensee's sole remedy shall be the right to terminate the Subscription and receive a prorated refund for the unused portion of the billing cycle.
|
||||
|
||||
7.4. The Software is self-hosted by Licensee on Licensee's own infrastructure. Licensor does not provide hosting services and makes no guarantees regarding uptime, availability, or performance of Licensee's self-hosted deployment.
|
||||
|
||||
## 8. TERMINATION
|
||||
|
||||
8.1. Licensor may terminate this Agreement immediately upon written notice if Licensee commits a material breach of any term of this Agreement and fails to cure such breach within thirty (30) days of receiving written notice specifying the breach.
|
||||
|
||||
8.2. Licensor may terminate this Agreement for convenience upon thirty (30) days' written notice to Licensee. In such event, Licensee shall receive a prorated refund for the unused portion of any prepaid Subscription fees.
|
||||
|
||||
8.3. In the event of a termination, Licensee will be provided with written notice, sent to the email address used during Subscription registration, outlining the reasons for the termination.
|
||||
|
||||
8.4. Upon termination, all rights granted to Licensee under this Agreement will cease, and Licensee must promptly cease all use of the Software and destroy or delete all copies in its possession, except that Licensee may retain copies of the Source Code obtained prior to termination solely for archival purposes, subject to the continuing obligations of confidentiality and intellectual property protection set forth herein.
|
||||
|
||||
## 9. CONFIDENTIALITY
|
||||
|
||||
9.1. Each party agrees to hold the other party's Confidential Information in strict confidence and not to disclose such information to any third party, except to employees, contractors, or agents who have a need to know and are bound by confidentiality obligations no less protective than those contained herein.
|
||||
|
||||
9.2. Confidential Information does not include information that: (a) is or becomes publicly available through no fault of the receiving party; (b) was rightfully in the receiving party's possession prior to disclosure; (c) is independently developed by the receiving party without use of the disclosing party's Confidential Information; or (d) is rightfully obtained from a third party without restriction on disclosure.
|
||||
|
||||
9.3. A receiving party may disclose Confidential Information to the extent required by applicable law, regulation, or court order, provided that the receiving party gives the disclosing party prompt written notice (where legally permissible) and cooperates with the disclosing party's efforts to seek protective treatment of such information.
|
||||
|
||||
9.4. The obligations of confidentiality set forth in this Section shall survive the termination or expiration of this Agreement for a period of three (3) years.
|
||||
|
||||
## 10. LIMITATION OF LIABILITY
|
||||
|
||||
10.1. In no event will the Licensor be liable for any indirect, incidental, special, consequential, or punitive damages, or any loss of profits or revenues, whether incurred directly or indirectly, or any loss of data, use, goodwill, or other intangible losses, resulting from (i) your use or inability to use the Software; (ii) any unauthorized access to or use of your servers and/or any personal information stored therein.
|
||||
|
||||
10.2. Except for liability arising from death or personal injury caused by negligence, fraud, willful misconduct, or a party's indemnification obligations under this Agreement, Licensor's total aggregate liability for any and all claims under this Agreement shall be limited to the total Subscription fees paid by Licensee to Licensor in the twelve (12) months immediately preceding the event giving rise to the claim.
|
||||
|
||||
## 11. INDEMNIFICATION
|
||||
|
||||
11.1. Licensee agrees to indemnify, defend, and hold harmless Licensor, its officers, directors, employees, agents, licensors, suppliers, and any third-party information providers from and against all claims, losses, expenses, damages, and costs, including reasonable attorneys' fees, resulting from any violation of this Agreement or any activity related to Licensee's use or misuse of the Software (including negligent or wrongful conduct).
|
||||
|
||||
11.2. Licensor agrees to indemnify, defend, and hold harmless Licensee from and against any third-party claim that the Software, as provided by Licensor, infringes or misappropriates any patent, copyright, trademark, or trade secret of a third party, provided that Licensee: (a) gives Licensor prompt written notice of such claim; (b) grants Licensor sole control of the defense and settlement of such claim; and (c) provides reasonable cooperation at Licensor's expense.
|
||||
|
||||
11.3. If the Software becomes, or in Licensor's opinion is likely to become, the subject of an infringement claim, Licensor may at its option and expense: (a) procure for Licensee the right to continue using the Software; (b) modify or replace the Software to make it non-infringing while maintaining substantially equivalent functionality; or (c) if neither (a) nor (b) is commercially practicable, terminate this Agreement and provide Licensee with a prorated refund of any prepaid Subscription fees.
|
||||
|
||||
11.4. Licensor shall have no obligation under this Section for any claim arising from: (a) modifications to the Software made by Licensee; (b) use of the Software in combination with products, services, or technologies not provided by Licensor, where the infringement would not have occurred but for such combination; or (c) Licensee's continued use of a version of the Software after being notified of the availability of a non-infringing update.
|
||||
|
||||
## 12. DATA PROTECTION AND PRIVACY
|
||||
|
||||
12.1. The Software is self-hosted by Licensee, and Licensee retains sole responsibility for all data stored and processed within its deployment of the Software, including any personal data of its users or tenants.
|
||||
|
||||
12.2. To the extent that Licensor processes any personal data on behalf of Licensee (for example, in connection with support services or license management), such processing shall be conducted in accordance with applicable data protection laws, including but not limited to the General Data Protection Regulation (GDPR) where applicable, the California Consumer Privacy Act (CCPA) where applicable, and any other relevant data protection legislation.
|
||||
|
||||
12.3. Where required by applicable data protection law, the parties shall enter into a separate Data Processing Agreement ("DPA") that sets forth the terms and conditions governing Licensor's processing of personal data on behalf of Licensee.
|
||||
|
||||
12.4. In the event of a data breach affecting personal data processed by Licensor in connection with this Agreement, Licensor shall notify Licensee without undue delay and in any event within seventy-two (72) hours of becoming aware of the breach, and shall cooperate with Licensee in investigating and remediating the breach.
|
||||
|
||||
12.5. Additional details regarding Licensor's data handling practices are outlined in Licensor's Privacy Policy, which can be accessed on Licensor's website.
|
||||
|
||||
## 13. EXPORT COMPLIANCE
|
||||
|
||||
13.1. The Software may be subject to export control and sanctions laws of the United States and other jurisdictions. Licensee agrees to comply with all applicable export control laws, including without limitation the U.S. Export Administration Regulations (EAR) and the regulations administered by the U.S. Department of the Treasury's Office of Foreign Assets Control (OFAC).
|
||||
|
||||
13.2. Licensee represents and warrants that: (a) Licensee is not located in, organized under the laws of, or a resident of any country or territory subject to comprehensive U.S. sanctions (currently including Cuba, Iran, North Korea, Syria, and the Crimea, Donetsk, and Luhansk regions of Ukraine); (b) Licensee is not listed on any U.S. government restricted party list; and (c) Licensee will not export, re-export, or transfer the Software to any prohibited destination, entity, or individual without the required governmental authorizations.
|
||||
|
||||
## 14. ANTI-CORRUPTION
|
||||
|
||||
14.1. Each party represents and warrants that it has not and will not, in connection with this Agreement, directly or indirectly offer, pay, promise to pay, or authorize the payment of any money or anything of value to any government official, political party, or candidate for political office for the purpose of influencing any act or decision, or securing any improper advantage.
|
||||
|
||||
14.2. Each party shall comply with all applicable anti-corruption and anti-bribery laws, including without limitation the U.S. Foreign Corrupt Practices Act (FCPA) and the UK Bribery Act 2010.
|
||||
|
||||
## 15. GOVERNING LAW AND DISPUTE RESOLUTION
|
||||
|
||||
15.1. This Agreement shall be governed by and construed under the laws of the State of Wyoming, United States of America, without regard to its conflict of laws principles.
|
||||
|
||||
15.2. Any dispute, controversy, or claim arising out of or relating to this Agreement, or the breach, termination, or invalidity thereof, shall first be attempted to be resolved through good faith negotiation between the parties for a period of thirty (30) days following written notice of the dispute.
|
||||
|
||||
15.3. If the dispute is not resolved through negotiation within the thirty (30) day period, it shall be finally resolved by binding arbitration administered by the American Arbitration Association ("AAA") in accordance with its Commercial Arbitration Rules. The arbitration shall be conducted in Sheridan, Wyoming, before a single arbitrator. The language of the arbitration shall be English.
|
||||
|
||||
15.4. The arbitrator's award shall be final and binding and may be entered as a judgment in any court of competent jurisdiction. Each party shall bear its own costs and attorneys' fees in connection with the arbitration, unless the arbitrator determines otherwise.
|
||||
|
||||
15.5. Notwithstanding the foregoing, either party may seek injunctive or other equitable relief in any court of competent jurisdiction to protect its intellectual property rights or Confidential Information without first submitting to arbitration.
|
||||
|
||||
## 16. NOTICES
|
||||
|
||||
16.1. All notices required or permitted under this Agreement shall be in writing and shall be deemed effectively given: (a) upon personal delivery; (b) upon confirmed transmission by email; or (c) one (1) business day after deposit with a nationally recognized overnight courier service.
|
||||
|
||||
16.2. Notices to Licensor shall be sent to the address and email set forth in Section 21 (Contact Information) of this Agreement. Notices to Licensee shall be sent to the email address provided during Subscription registration or as subsequently updated by Licensee in writing.
|
||||
|
||||
## 17. ASSIGNMENT
|
||||
|
||||
17.1. Licensee may not transfer or assign this Agreement or any rights or obligations hereunder without the prior written consent of Licensor, except that Licensee may assign this Agreement without consent in connection with a merger, acquisition, corporate reorganization, or sale of all or substantially all of its assets, provided that the assignee agrees in writing to be bound by the terms of this Agreement.
|
||||
|
||||
17.2. Licensor may assign this Agreement without restriction. Any assignment in violation of this Section shall be null and void.
|
||||
|
||||
## 18. DISCLAIMERS AND WARRANTIES
|
||||
|
||||
18.1. The Software is provided "AS IS" and "AS AVAILABLE", without warranty of any kind, either express or implied, including, without limitation, warranties of merchantability, fitness for a particular purpose, and non-infringement.
|
||||
|
||||
18.2. Licensor does not warrant that the Software will be error-free, that access thereto will be uninterrupted, or that defects will be corrected.
|
||||
|
||||
18.3. Licensor warrants that, as of the date of delivery, the Software will perform substantially in accordance with the accompanying documentation for a period of ninety (90) days. Licensee's sole remedy for breach of this warranty shall be, at Licensor's option, repair or replacement of the non-conforming Software, or a refund of the Subscription fees paid for the period during which the Software was non-conforming.
|
||||
|
||||
## 19. FORCE MAJEURE
|
||||
|
||||
Neither party shall be in default or otherwise liable for any delay in or failure of its performance under this Agreement if such delay or failure arises by any reason of any event beyond the reasonable control of a party, including acts of God, the elements, earthquakes, floods, fires, epidemics, riots, failures or delays in transportation or communications, or any act or failure to act by the other party or such other party's officers, employees, agents, or contractors. The affected party shall give prompt notice to the other party and shall use commercially reasonable efforts to mitigate the effects of the force majeure event. If a force majeure event continues for more than ninety (90) days, either party may terminate this Agreement upon written notice, and Licensee shall receive a prorated refund of any prepaid Subscription fees.
|
||||
|
||||
## 20. SURVIVAL
|
||||
|
||||
The following Sections shall survive the termination or expiration of this Agreement: Section 1 (Definitions), Section 4.2 (Ownership of Modifications), Section 4.4 (Proprietary Nature of Software), Section 5 (Intellectual Property Rights), Section 9 (Confidentiality), Section 10 (Limitation of Liability), Section 11 (Indemnification), Section 12 (Data Protection and Privacy), Section 13 (Export Compliance), Section 15 (Governing Law and Dispute Resolution), and Section 20 (Survival).
|
||||
|
||||
## 21. SEVERABILITY
|
||||
|
||||
If any provision of this Agreement is held to be unenforceable or invalid for any reason, that provision shall be reformed to the extent necessary to make it enforceable and consistent with the intent of the parties, and the remaining provisions shall remain in full force and effect.
|
||||
|
||||
## 22. ENTIRE AGREEMENT
|
||||
|
||||
This Agreement constitutes the entire agreement between the Licensor and the Licensee with respect to the subject matter hereof and supersedes all prior or contemporaneous understandings regarding such subject matter. No amendment to or modification of this Agreement will be binding unless in writing and signed by the Licensor.
|
||||
|
||||
## 23. ACCEPTANCE
|
||||
|
||||
By downloading, installing, or using the Software, even without explicitly clicking on an "I Agree" button or a similar mechanism, you acknowledge that you have read, understood, and agreed to be bound by the terms and conditions of this Agreement.
|
||||
|
||||
## 24. CONTACT INFORMATION
|
||||
|
||||
If you have any questions about this Agreement, please contact Stalwart Labs LLC at:
|
||||
|
||||
Stalwart Labs LLC
|
||||
1309 Coffeen Avenue STE 1200
|
||||
Sheridan, Wyoming 82801
|
||||
USA
|
||||
hello@stalw.art
|
||||
@@ -8,6 +8,10 @@
|
||||
Web-based User Interface for Stalwart 🛡️
|
||||
</h3>
|
||||
|
||||
<p align="center">
|
||||
Community fork of <a href="https://github.com/stalwartlabs/webui">stalwartlabs/webui</a> with UI improvements and fixes.
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
@@ -28,36 +32,93 @@
|
||||
<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>
|
||||
|
||||
## About this fork
|
||||
|
||||
This is a community fork of [stalwartlabs/webui](https://github.com/stalwartlabs/webui) maintained by [LinkPhoenix](https://github.com/LinkPhoenix), focused on UI/UX improvements: mobile-friendly layouts, dark mode polish, additional color themes, a command palette, a calendar date/time picker, and several list/form refinements. Several of these have already been contributed back and shipped in official Stalwart WebUI releases.
|
||||
|
||||
**Stalwart WebUI** is a 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 — the schema is the single source of truth, not the UI code.
|
||||
|
||||
This fork tries to stay aligned with that philosophy: any AI agent or contributor working on it follows the rules in [AGENTS.md](AGENTS.md), and the small number of deliberate exceptions where the UI does something the official schema doesn't (yet) support are tracked, with the ideal server-side fix for each, in [SCHEMA_DEVIATIONS.md](SCHEMA_DEVIATIONS.md).
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for the full list of changes in this fork.
|
||||
|
||||
Official Stalwart repositories:
|
||||
|
||||
- [stalwartlabs/stalwart](https://github.com/stalwartlabs/stalwart) — the mail server itself.
|
||||
- [stalwartlabs/webui](https://github.com/stalwartlabs/webui) — the official admin WebUI this project forks.
|
||||
- [stalwartlabs/cli](https://github.com/stalwartlabs/cli) — `stalwart-cli`, used below to point a server at a WebUI build.
|
||||
|
||||
## 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:
|
||||
Key features (shared with upstream):
|
||||
|
||||
- **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.
|
||||
|
||||
Additions in this fork:
|
||||
|
||||
- **Usable on mobile**: admin lists, forms, and the sidebar work on narrow viewports instead of assuming desktop.
|
||||
- **Selectable color themes** (Stalwart, Ocean, Forest, Violet, Rose, Amber, Teal) with a light/dark toggle and a square/rounded corners option.
|
||||
- **`Ctrl+K` / `Cmd+K` command palette** to search pages, form sections, and fields across the admin panel.
|
||||
- **Calendar date/time picker** replacing native date inputs, themed for dark mode.
|
||||
- **Accounts list**: Role and Usage/Quota columns, with a highlight and recalculate hint for stale negative disk-usage values.
|
||||
- **Mailboxes list**: shown as an indented hierarchy instead of a flat list.
|
||||
- **Log Entries**: client-side Level/Event filters and a rate-limited manual refresh button.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<img src="./img/screencast-setup.gif">
|
||||
<img src="./img/demo.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:
|
||||
Stalwart WebUI ships as part of Stalwart Mail Server. To install Stalwart Mail Server on your server, follow 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).
|
||||
All documentation is available at [stalw.art/docs/get-started](https://stalw.art/docs/get-started). Note that a standard Stalwart install ships the **official** WebUI; see [Switching your server to this fork's UI](#switching-your-server-to-this-forks-ui) below to point your server at this fork instead.
|
||||
|
||||
## Switching your server to this fork's UI
|
||||
|
||||
Stalwart serves its admin UI as a managed `WEBAPP` application, downloaded from a URL you control — switching to this fork (or back to upstream) is a server-side config change, no rebuild or redeploy of Stalwart itself required. This is done with [`stalwart-cli`](https://github.com/stalwartlabs/cli).
|
||||
|
||||
On your server:
|
||||
|
||||
```
|
||||
export STALWART_URL=https://subdomain.domain.com
|
||||
export STALWART_USER='user@domain.com'
|
||||
export STALWART_PASSWORD='Password'
|
||||
```
|
||||
|
||||
Find the id of your `WEBAPP` application:
|
||||
|
||||
```
|
||||
stalwart-cli query Application
|
||||
```
|
||||
|
||||
Point it at this fork's latest release instead of upstream's:
|
||||
|
||||
```
|
||||
stalwart-cli update Application ID WEBAPP \
|
||||
--field https://github.com/LinkPhoenix/stalwart-webui-fork/releases/latest/download/webui.zip
|
||||
```
|
||||
|
||||
Then trigger the update:
|
||||
|
||||
```
|
||||
stalwart-cli create Action/UpdateApps
|
||||
```
|
||||
|
||||
Every tagged release of this fork publishes a `webui.zip` build via CI (see [`.github/workflows/build.yml`](.github/workflows/build.yml)), so pointing at `releases/latest/download/webui.zip` always fetches the newest tested build. To go back to the official UI, repeat the `update` step with `https://github.com/stalwartlabs/webui/releases/latest/download/webui.zip`.
|
||||
|
||||
## Getting started
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Node.js 18 or later
|
||||
- A running Stalwart instance (for JMAP API calls)
|
||||
- A running Stalwart instance (for JMAP API calls) — see [DEVELOPMENT.md](DEVELOPMENT.md) for how to spin up a disposable local test server with Docker, no manual Stalwart setup required.
|
||||
|
||||
Install dependencies:
|
||||
|
||||
@@ -85,12 +146,14 @@ VITE_OAUTH_SCOPES=
|
||||
|
||||
### 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:
|
||||
Set `VITE_ACCESS_TOKEN` to a valid bearer token to skip the login page and go straight to the admin panel:
|
||||
|
||||
```
|
||||
VITE_ACCESS_TOKEN=your-bearer-token-here
|
||||
```
|
||||
|
||||
Against the local test server from [DEVELOPMENT.md](DEVELOPMENT.md), `scripts/dev-token.ps1` / `scripts/dev-token.sh` fetch one for you automatically.
|
||||
|
||||
### Running the dev server
|
||||
|
||||
```
|
||||
@@ -130,10 +193,11 @@ 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),
|
||||
For bugs or questions about **this fork's UI changes**, please open an issue on [this repository](https://github.com/LinkPhoenix/stalwart-webui-fork).
|
||||
|
||||
For anything related to Stalwart Mail Server itself, do not hesitate to reach the upstream team 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
|
||||
Additionally you may purchase a subscription to obtain priority support from Stalwart Labs LLC.
|
||||
|
||||
## License
|
||||
|
||||
@@ -144,6 +208,8 @@ This project is dual-licensed under the **GNU Affero General Public License v3.0
|
||||
|
||||
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.
|
||||
|
||||
As a fork, all changes made here — including new files added by this fork — remain under the same dual license as the upstream project; this is reflected in the SPDX license notice at the top of every source file.
|
||||
|
||||
## Copyright
|
||||
|
||||
Copyright (C) 2024, Stalwart Labs LLC
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Schema deviations
|
||||
|
||||
Stalwart WebUI is schema-driven: the server's JSON schema is the single
|
||||
source of truth for what forms, fields, filters, and columns exist. This
|
||||
fork tries to stay aligned with that philosophy (see
|
||||
[stalwartlabs/webui#discussion](https://github.com/stalwartlabs/webui) and
|
||||
the maintainer's note on why exceptions belong server-side, not in the UI).
|
||||
|
||||
Everything in this file is a deliberate exception: a place where the UI
|
||||
does something the schema doesn't (yet) describe, because the equivalent
|
||||
server-side capability doesn't exist in [stalwartlabs/stalwart](https://github.com/stalwartlabs/stalwart)
|
||||
or [stalwartlabs/webui](https://github.com/stalwartlabs/webui). Each entry
|
||||
is tagged in code with `// SCHEMA-DEVIATION: <id>` so they're greppable
|
||||
(`grep -rn "SCHEMA-DEVIATION" src/`).
|
||||
|
||||
The goal is **not** to remove these — they're real functionality this fork
|
||||
wants to keep — but to track them separately from schema-driven code, so
|
||||
it's always clear which is which, and so each one can be dropped the day
|
||||
the server (or upstream webui) grows the equivalent native capability.
|
||||
|
||||
## `src/types/schema.ts` is never touched for a deviation
|
||||
|
||||
`src/types/schema.ts` mirrors the server's schema contract exactly and
|
||||
must stay aligned with the official webui/server types — it is **never**
|
||||
edited to accommodate a deviation, even to add an extra optional field.
|
||||
|
||||
If a deviation needs to carry extra data on an otherwise-official schema
|
||||
shape (e.g. a flag consumed only by the deviation's own code), the
|
||||
augmented type lives in the deviation's own module or in
|
||||
[`src/lib/schemaDeviationTypes.ts`](src/lib/schemaDeviationTypes.ts), as
|
||||
an intersection with the official type (`OfficialType & { extra?: ... }`),
|
||||
and is imported only where the deviation is actually used. `schema.ts`
|
||||
itself stays byte-for-byte alignable with upstream's version of the file.
|
||||
|
||||
## Status legend
|
||||
|
||||
- 🟡 **Workaround** — client-only, would be removed if the server supported it natively.
|
||||
- 🔵 **Upstream tracked** — an issue has been filed upstream; link included.
|
||||
|
||||
## Deviations
|
||||
|
||||
### `log-client-filters` 🟡
|
||||
|
||||
- **Where**: [`src/lib/logFilters.ts`](src/lib/logFilters.ts), type augmentation in [`src/lib/schemaDeviationTypes.ts`](src/lib/schemaDeviationTypes.ts)
|
||||
- **What**: injects `level` and `event` as filterable columns on the `x:Log` list, applied entirely client-side (`clientOnly` flag consumed by `DynamicList`). The `clientOnly` flag is declared as `ClientOnlyFilterEnum` (an intersection type), not on the official `FilterEnum` in `schema.ts`.
|
||||
- **Why**: Stalwart's JMAP `x:Log/query` returns `unsupportedFilter` for both properties today, even though they're returned per row.
|
||||
- **Ideal fix**: `stalwartlabs/stalwart` accepts `level`/`event` as real query filters; the schema then advertises them normally and `logFilters.ts` + the `ClientOnlyFilterEnum` augmentation are deleted.
|
||||
|
||||
### `account-quota-usage-column` 🟡
|
||||
|
||||
- **Where**: [`src/lib/accountColumns.ts`](src/lib/accountColumns.ts)
|
||||
- **What**: adds a synthetic `quotaUsage` column to the `x:Account/User` and `x:Account/Group` lists — not a real schema property, `DynamicList` resolves it from the `usedDiskQuota` + `quotas.maxDiskQuota` pair and formats it specially. Also re-adds `roles` on the Users list only (a real property, just not in the list's default columns).
|
||||
- **Why**: neither the Accounts nor the Groups list schema exposes usage/role as list columns, only as detail-view fields, even though both object types have real `usedDiskQuota`/`quotas` fields.
|
||||
- **Ideal fix**: the server's `x:Account/User` and `x:Account/Group` list schemas include `roles` (Users) and a computed usage/quota column natively; this file is deleted.
|
||||
|
||||
### `mailbox-client-hierarchy-sort` 🟡
|
||||
|
||||
- **Where**: [`src/components/lists/DynamicList.tsx`](src/components/lists/DynamicList.tsx) — `sortMailboxesByHierarchy` and the `isMailboxList` branch
|
||||
- **What**: for the `Mailbox` list, fetches the *entire* result set (bypassing normal server pagination) and sorts it client-side so each parent mailbox is immediately followed by its children, with indentation depth tracked in React state.
|
||||
- **Why**: the server returns mailboxes in whatever order the query produces, not grouped by parent/child, and a mailbox's parent can land on a different page than the mailbox itself, so hierarchy can't be reconstructed one page at a time.
|
||||
- **Ideal fix**: the server offers a native tree/hierarchical ordering (or a `sort` that groups by ancestry) for `Mailbox/query`; the client-side full-fetch-and-sort is deleted in favor of normal paginated queries.
|
||||
|
||||
### `webapp-enabled-column-fallback` 🟡
|
||||
|
||||
- **Where**: [`src/components/lists/DynamicList.tsx`](src/components/lists/DynamicList.tsx) — `displayColumns` in the `isWebApplications` branch
|
||||
- **What**: reordering the `x:Application` list's real schema columns (Description first, Enabled second) is not a deviation, but if the schema's `list.columns` doesn't include an `enabled` column at all, a fallback column definition with a hardcoded label is fabricated client-side so the toggle still renders.
|
||||
- **Why**: the `x:Application` list schema is not guaranteed to expose `enabled` as a list column, even though it's a real object property (fetched separately via `properties.push('enabled')`).
|
||||
- **Ideal fix**: the server's `x:Application` list schema always includes `enabled` as a real column; the fallback branch is deleted (only the reordering logic remains, which is not a deviation).
|
||||
|
||||
### `account-alias-count-column` 🟡
|
||||
|
||||
- **Where**: [`src/lib/accountColumns.ts`](src/lib/accountColumns.ts), [`src/lib/mailingListColumns.ts`](src/lib/mailingListColumns.ts), [`src/lib/domainColumns.ts`](src/lib/domainColumns.ts), resolved generically via `COUNT_COLUMN_SOURCES` in [`src/components/lists/DynamicList.tsx`](src/components/lists/DynamicList.tsx)
|
||||
- **What**: adds a synthetic `aliasCount` column to the `x:Account/User`, `x:Account/Group`, `x:MailingList`, and `x:Domain` lists — not a real schema property; `DynamicList` resolves it from the real `aliases` property (an objectList on Accounts/Groups/Mailing Lists, a `set` of domain names on Domains — same id-keyed wire format either way) and renders its entry count.
|
||||
- **Why**: none of these lists' schemas expose alias count as a column, only the full `aliases` list on the detail view.
|
||||
- **Ideal fix**: the server's list schemas include a computed alias-count column natively; these column definitions are deleted.
|
||||
|
||||
### `account-client-sort` 🟡
|
||||
|
||||
- **Where**: table-level mechanism in [`src/components/lists/DynamicList.tsx`](src/components/lists/DynamicList.tsx) (`clientSortableColumns`, `getClientSortValue`, the `fetchData` branch triggered by `clientSortField`) reading a `clientSortable` flag set per-column via the shared `clientSortable()` helper in [`src/lib/schemaDeviationTypes.ts`](src/lib/schemaDeviationTypes.ts) (type: `ClientSortableColumn`), used by [`accountColumns.ts`](src/lib/accountColumns.ts), [`mailingListColumns.ts`](src/lib/mailingListColumns.ts), [`roleColumns.ts`](src/lib/roleColumns.ts), and [`domainColumns.ts`](src/lib/domainColumns.ts)
|
||||
- **What**: any column tagged `clientSortable` in the schema gets fetch-all-then-sort-in-memory on click (bypassing server pagination, same mechanism as `mailbox-client-hierarchy-sort`), instead of sending a JMAP `sort` to the server. The mechanism itself is generic and not tied to any specific list. Currently tagged: Email Address/Full Name/Usage/Aliases on `x:Account/User` and `x:Account/Group`; Email Address/Description/Aliases on `x:MailingList`; Description/Enabled Permissions/Disabled Permissions on `x:Role`; Domain Name/Enabled/Aliases on `x:Domain`.
|
||||
- **Why**: none of these lists' schemas declare any sortable property at all (`list.sort` is absent on all four) — confirmed against a live server by trying `sort` on every displayed real column: all return `unsupportedSort`, **except** `x:Domain/query` with `sort: [{"property":"name",...}]`, which the server actually accepts despite the schema not declaring it. Rather than add a second "trust an undeclared sort" pathway for that one case, Domain Name is routed through the same client-sort mechanism as everything else, for consistency; it's marginally less efficient (fetch-all instead of a paginated server sort) but domain lists are typically small.
|
||||
- **Ideal fix**: the server's query methods accept `sort` on these properties and the schema declares them in each list's `list.sort`; each `with*Columns` helper stops tagging its columns `clientSortable` and they fall through to the normal server-paginated `sortableFields` path already used elsewhere. The generic mechanism itself only goes away once nothing tags any column `clientSortable` anymore.
|
||||
|
||||
### `role-permission-count-columns` 🟡
|
||||
|
||||
- **Where**: [`src/lib/roleColumns.ts`](src/lib/roleColumns.ts); resolved generically by the same `COUNT_COLUMN_SOURCES` table in [`src/components/lists/DynamicList.tsx`](src/components/lists/DynamicList.tsx) used by `account-alias-count-column`
|
||||
- **What**: adds synthetic `enabledPermissionCount`/`disabledPermissionCount` columns to the `x:Role` list — not real schema properties; resolved from the real `enabledPermissions`/`disabledPermissions` set properties and rendered as entry counts.
|
||||
- **Why**: the Roles list schema only exposes Description as a column; seeing how broad or restrictive a role is requires opening it and counting permissions by hand.
|
||||
- **Ideal fix**: the server's `x:Role` list schema includes computed enabled/disabled permission count columns natively; this column definition is deleted.
|
||||
|
||||
## Not a deviation (for reference)
|
||||
|
||||
A few other `viewName === '...'` / `objectName === '...'` checks exist in
|
||||
`DynamicList.tsx`, `MainContent.tsx`, `Sidebar.tsx`, `layout.ts`, and
|
||||
`FieldWidget.tsx` (e.g. `x:OtpAuth`, `x:Expression`, `x:Rate`, `x:Action`,
|
||||
`x:Trace`, `CustomComponent/Dashboard` and other `CustomComponent/*`
|
||||
pages, the `x:Application` column reordering itself, the active-WebApp
|
||||
info card). These are **not** tracked here: they render real schema data
|
||||
with a custom widget or extra display, the same pattern already used
|
||||
upstream for special object types — they don't fabricate data or bypass
|
||||
the server's filtering/pagination. Verified against `upstream/main` for
|
||||
each: the object/view names above already drive special-cased rendering
|
||||
there too, except `x:Application`/`Mailbox`/`x:Log`/`x:Account/User`
|
||||
which are fork-only and covered by the entries above (or explicitly
|
||||
noted as presentation-only, e.g. the Web Applications column reorder).
|
||||
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
stalwart:
|
||||
image: stalwartlabs/stalwart:latest
|
||||
container_name: stalwart-webui-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# HTTP management/JMAP API — the only port the WebUI dev proxy needs
|
||||
# (see server.proxy in vite.config.ts).
|
||||
- "8080:8080"
|
||||
# Mail protocols, only needed if you're testing actual mail flows
|
||||
# (sending/receiving, not just admin UI screens).
|
||||
- "25:25"
|
||||
- "587:587"
|
||||
- "465:465"
|
||||
- "143:143"
|
||||
- "993:993"
|
||||
- "110:110"
|
||||
- "995:995"
|
||||
- "4190:4190"
|
||||
volumes:
|
||||
- stalwart-etc:/etc/stalwart
|
||||
- stalwart-data:/var/lib/stalwart
|
||||
environment:
|
||||
# Disposable local dev credentials — do not reuse for anything real.
|
||||
# Break-glass admin, used only by scripts/dev-server-init.sh (its
|
||||
# tokens always expire in 1h regardless of server config, so it's
|
||||
# not used for day-to-day dev tokens — see scripts/dev-token.sh).
|
||||
STALWART_RECOVERY_ADMIN: "admin@example.org:c8321iEscHDy0GWV"
|
||||
|
||||
volumes:
|
||||
stalwart-etc:
|
||||
stalwart-data:
|
||||
|
After Width: | Height: | Size: 446 KiB |
|
Before Width: | Height: | Size: 260 KiB |
@@ -4,9 +4,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<base href="/" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Portal</title>
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#09090b" media="(prefers-color-scheme: dark)" />
|
||||
<title>Stalwart WebUI</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "stalwart-webui",
|
||||
"name": "stalwart-webui-fork",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Stalwart WebUI",
|
||||
"version": "1.1.3",
|
||||
"description": "Stalwart WebUI Fork",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:server": "docker compose up -d",
|
||||
"dev:server:down": "docker compose down",
|
||||
"dev:server:logs": "docker compose logs -f stalwart",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -p tsconfig.app.json --noEmit",
|
||||
"lint": "eslint .",
|
||||
@@ -16,55 +19,56 @@
|
||||
"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",
|
||||
"@daypicker/react": "^10.0.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.20",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.23",
|
||||
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-separator": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.3.3",
|
||||
"@radix-ui/react-switch": "^1.3.7",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@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",
|
||||
"i18next": "^26.3.6",
|
||||
"lucide-react": "1.28.0",
|
||||
"otpauth": "^9.5.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zustand": "^5.0.12"
|
||||
"react-router-dom": "^7.18.2",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"@vitejs/plugin-react": "6.0.5",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"happy-dom": "^20.9.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"
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.8.0",
|
||||
"happy-dom": "^20.11.1",
|
||||
"prettier": "^3.9.6",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "8.2.0",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M307 346c-20.6 1-18.5-9.8-6.4-26.3 28.2-37.7 81.5-90.4 83.3-127.6 3.2-48.7-45.7-64.5-96-64-35.1.4-71.3 10.5-96 19.3C106.7 177.5 53.2 225.7 19.5 279c-35.7 52.9-24 105.8 51.5 105.1 58-.6 95.5-18.4 134.8-38.4.2 0-108.5 31-148.4 8.1-.1 0-.1 0-.2-.1-4.1-2.3-9.1-5.6-9.8-14.6-1.4-18.9 31-38.5 48.6-44.8v-32.7c12.9 5 27 7.8 41.7 7.8 28.2 0 54-10.2 74.1-27 .8 2.8 1.2 5.9 1 9.5h7.9c1-8.4-3.7-15.1-3.7-15.1-7.1-11.3-19.5-11.1-19.5-11.1s6.7 2.9 11.3 9.9c-18.4 15.4-42 24.7-67.9 24.7-11 0-21.6-1.7-31.6-4.8L135 230l-7.1-18.6c51.9-18.2 95.6-32.1 166.6-44.4l-15.9-13.3 8.3-5.1c42.9 12.1 70.9 21 69.4 43.7-.7 3.7-2 8.1-4.3 13-12.5 24.7-49.6 65.9-64.6 83.1-9.9 11.4-19.6 22.6-26.6 33.3-7.1 10.7-11.5 20.7-11.8 30.3.9 74.8 220.4-34.9 262.9-64-62.7 27-130.3 53-204.9 58" style="fill:#f60"/></svg>
|
||||
|
After Width: | Height: | Size: 871 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0.02 102.6 511.9 306.4"><style>.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#f90}</style><path d="M144.3 214.1c0 6.3.7 11.4 1.9 15.2 1.4 3.7 3.1 7.8 5.4 12.3.9 1.4 1.2 2.7 1.2 3.9 0 1.7-1 3.4-3.2 5.1l-10.7 7.2c-1.5 1-3.1 1.5-4.4 1.5-1.7 0-3.4-.9-5.1-2.4-2.4-2.6-4.4-5.3-6.1-8-1.7-2.9-3.4-6.1-5.3-10-13.3 15.7-30 23.5-50.1 23.5-14.3 0-25.7-4.1-34.1-12.3-8.3-8.2-12.6-19.1-12.6-32.7 0-14.5 5.1-26.2 15.5-35.1S60.8 169 78.4 169c5.8 0 11.7.5 18.1 1.4s12.8 2.2 19.6 3.7v-12.4c0-12.9-2.7-22-8-27.2-5.4-5.3-14.6-7.8-27.8-7.8-6 0-12.1.7-18.4 2.2s-12.4 3.4-18.4 5.8c-2.7 1.2-4.8 1.9-6 2.2s-2 .5-2.7.5c-2.4 0-3.6-1.7-3.6-5.3v-8.3c0-2.7.3-4.8 1.2-6s2.4-2.4 4.8-3.6c6-3.1 13.1-5.6 21.5-7.7 8.3-2.2 17.2-3.2 26.6-3.2 20.3 0 35.1 4.6 44.6 13.8 9.4 9.2 14.1 23.2 14.1 41.9v55.2h.3zM75.2 240c5.6 0 11.4-1 17.5-3.1 6.1-2 11.6-5.8 16.2-10.9 2.7-3.2 4.8-6.8 5.8-10.9s1.7-9 1.7-14.8v-7.2c-4.9-1.2-10.2-2.2-15.7-2.9-5.4-.7-10.7-1-16-1-11.4 0-19.8 2.2-25.4 6.8S51 207.1 51 215.6c0 8 2 14 6.3 18.1 4.1 4.2 10 6.3 17.9 6.3m136.7 18.4c-3.1 0-5.1-.5-6.5-1.7-1.4-1-2.6-3.4-3.6-6.6l-40-131.6c-1-3.4-1.5-5.6-1.5-6.8 0-2.7 1.4-4.3 4.1-4.3h16.7c3.2 0 5.4.5 6.6 1.7 1.4 1 2.4 3.4 3.4 6.6l28.6 112.7 26.6-112.7c.9-3.4 1.9-5.6 3.2-6.6 1.4-1 3.7-1.7 6.8-1.7H270c3.2 0 5.4.5 6.8 1.7 1.4 1 2.6 3.4 3.2 6.6l26.9 114.1 29.5-114.1c1-3.4 2.2-5.6 3.4-6.6 1.4-1 3.6-1.7 6.6-1.7h15.8c2.7 0 4.3 1.4 4.3 4.3 0 .9-.2 1.7-.3 2.7-.2 1-.5 2.4-1.2 4.3l-41 131.6q-1.5 5.1-3.6 6.6c-1.4 1-3.6 1.7-6.5 1.7h-14.6c-3.2 0-5.4-.5-6.8-1.7s-2.6-3.4-3.2-6.8l-26.4-109.8L236.7 250c-.9 3.4-1.9 5.6-3.2 6.8-1.4 1.2-3.7 1.7-6.8 1.7h-14.8zm218.8 4.6c-8.9 0-17.7-1-26.2-3.1-8.5-2-15.2-4.3-19.6-6.8-2.7-1.5-4.6-3.2-5.3-4.8s-1-3.2-1-4.8v-8.7c0-3.6 1.4-5.3 3.9-5.3 1 0 2 .2 3.1.5 1 .3 2.6 1 4.3 1.7 5.8 2.6 12.1 4.6 18.7 6 6.8 1.4 13.5 2 20.3 2 10.7 0 19.1-1.9 24.9-5.6s8.9-9.2 8.9-16.2c0-4.8-1.5-8.7-4.6-11.9s-8.9-6.1-17.2-8.9l-24.7-7.7c-12.4-3.9-21.6-9.7-27.2-17.4-5.6-7.5-8.5-15.8-8.5-24.7 0-7.2 1.5-13.5 4.6-18.9s7.2-10.2 12.3-14c5.1-3.9 10.9-6.8 17.7-8.9 6.8-2 14-2.9 21.5-2.9 3.7 0 7.7.2 11.4.7 3.9.5 7.5 1.2 11.1 1.9 3.4.9 6.6 1.7 9.7 2.7s5.4 2 7.2 3.1c2.4 1.4 4.1 2.7 5.1 4.3 1 1.4 1.5 3.2 1.5 5.6v8c0 3.6-1.4 5.4-3.9 5.4-1.4 0-3.6-.7-6.5-2q-14.55-6.6-32.7-6.6c-9.7 0-17.4 1.5-22.6 4.8s-8 8.2-8 15.2c0 4.8 1.7 8.9 5.1 12.1s9.7 6.5 18.7 9.4l24.2 7.7c12.3 3.9 21.1 9.4 26.4 16.3s7.8 15 7.8 23.8c0 7.3-1.5 14-4.4 19.8-3.1 5.8-7.2 10.9-12.4 15-5.3 4.3-11.6 7.3-18.9 9.5-8 2.5-16 3.7-24.7 3.7" style="fill:#fff"/><path fill="#fff" d="M462.9 345.7c-56 41.4-137.4 63.3-207.4 63.3-98.1 0-186.5-36.3-253.2-96.6-5.3-4.8-.5-11.2 5.8-7.5 72.2 41.9 161.3 67.3 253.4 67.3 62.2 0 130.4-12.9 193.3-39.5 9.3-4.2 17.3 6.2 8.1 13" class="st1"/><path fill="#fff" d="M486.2 319.2c-7.2-9.2-47.3-4.4-65.6-2.2-5.4.7-6.3-4.1-1.4-7.7 32-22.5 84.6-16 90.8-8.5 6.1 7.7-1.7 60.3-31.7 85.5-4.6 3.9-9 1.9-7-3.2 6.9-16.9 22.1-54.9 14.9-63.9" class="st1"/></svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>BaiduCloud</title><path d="M21.715 5.61l-3.983 2.31a.903.903 0 01-.896 0L12.44 5.384a.903.903 0 00-.897 0L7.156 7.92a.903.903 0 01-.896 0L2.276 5.617 12.002 0l9.713 5.61z" fill="#5BCA87"></path><path d="M18.641 9.467a.89.89 0 00-.438.77v5.072a.896.896 0 01-.445.77l-4.428 2.51a.884.884 0 00-.445.777v4.607l4.429-2.536 5.31-3.047V7.157l-3.983 2.31z" fill="#EC5D3E"></path><path d="M10.98 18.941a.936.936 0 00-.305-.352l-4.429-2.516a.903.903 0 01-.431-.764v-5.078a.89.89 0 00-.452-.757l-.451-.26L1.38 7.158V18.39l5.311 3.047L11.126 24v-4.608a.881.881 0 00-.146-.45z" fill="#2464F5"></path></svg>
|
||||
|
After Width: | Height: | Size: 717 B |
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 39 43" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g id="Layer_2">
|
||||
<g id="Layer_1-2">
|
||||
<path d="M21,6.85L30.87,12.21L21.75,0C20.278,1.976 19.99,4.603 21,6.85Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||
<path d="M16.54,26.73C17.774,26.73 18.79,27.746 18.79,28.98C18.79,30.214 17.774,31.23 16.54,31.23C15.313,31.23 14.301,30.227 14.29,29C14.29,28.993 14.29,28.987 14.29,28.98C14.29,27.746 15.306,26.73 16.54,26.73C16.54,26.73 16.54,26.73 16.54,26.73Z" style="fill:url(#_Linear2);fill-rule:nonzero;"/>
|
||||
<path d="M9.67,1.79L37.31,16.79C37.77,17.014 38.064,17.483 38.064,17.995C38.064,18.507 37.77,18.976 37.31,19.2C35.22,20.458 32.93,21.351 30.54,21.84L24.79,33.64C24.79,33.64 22.97,37.78 17.96,36.19C20.06,34.09 22.6,32.19 22.6,28.96C22.6,25.63 19.86,22.89 16.53,22.89C13.2,22.89 10.46,25.63 10.46,28.96C10.46,33.18 14.62,34.96 16.93,37.89C17.966,39.357 17.828,41.369 16.6,42.68C13.73,39.84 8.18,35.05 5.9,31.91C4.654,30.327 3.968,28.374 3.95,26.36C4.174,21.978 7.134,18.183 11.33,16.9C12.589,16.533 13.9,16.374 15.21,16.43C17.037,16.568 18.812,17.104 20.41,18C22.86,19.44 24.05,19.06 25.74,17.64C26.74,16.82 27.83,14.15 26.14,13.53C25.587,13.35 25.023,13.209 24.45,13.11C21.31,12.5 15.82,11.92 13.8,10.77C10.59,9 8.43,5.35 9.67,1.79Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
|
||||
<path d="M22.55,28.99C23.83,22.26 17,15.84 11.76,16.8L12.11,16.72C11.83,16.78 11.56,16.85 11.3,16.93C7.104,18.213 4.144,22.008 3.92,26.39C3.952,28.41 4.656,30.364 5.92,31.94C8.2,35.08 13.75,39.87 16.62,42.71C17.848,41.399 17.986,39.387 16.95,37.92C14.59,35 10.43,33.21 10.43,29C10.43,25.67 13.17,22.93 16.5,22.93C19.83,22.93 22.57,25.67 22.57,29L22.55,28.99Z" style="fill:url(#_Linear4);fill-rule:nonzero;"/>
|
||||
<path d="M9.67,1.79L30.67,13.23L31.27,13.56C31.77,13.95 32.27,14.73 31.62,16.17C30.62,18.32 26.62,20.4 22.01,18.77C23.45,19.19 24.43,18.71 25.69,17.65C26.69,16.83 27.78,14.16 26.09,13.54C25.537,13.36 24.973,13.219 24.4,13.12C21.26,12.51 15.77,11.93 13.75,10.78C10.59,9 8.43,5.35 9.67,1.79Z" style="fill:url(#_Linear5);fill-rule:nonzero;"/>
|
||||
<path d="M9.67,1.79C11.84,9.79 25.05,10.45 31.67,13.79L9.67,1.79Z" style="fill:url(#_Linear6);fill-rule:nonzero;"/>
|
||||
<path d="M16.9,37.92C14.59,35 10.43,33.21 10.43,29C10.442,25.961 12.734,23.375 15.75,23C10.956,23.016 7.016,26.956 7,31.75C6.999,32.341 7.059,32.931 7.18,33.51C9.09,35.67 11.85,38.22 14.18,40.38C15.09,41.23 15.93,42.03 16.62,42.71C17.194,42.045 17.544,41.215 17.62,40.34C17.675,39.474 17.419,38.616 16.9,37.92Z" style="fill:url(#_Linear7);fill-rule:nonzero;"/>
|
||||
<path d="M22.52,29.71C22.552,29.471 22.568,29.231 22.57,28.99C23.83,22.26 17,15.84 11.76,16.8C12.879,16.53 14.03,16.415 15.18,16.46C22.05,16.74 23.97,24.08 22.52,29.71Z" style="fill:url(#_Linear8);fill-rule:nonzero;"/>
|
||||
<path d="M2.26,14.84C3.502,14.845 4.52,15.868 4.52,17.11L4.52,19.37L2.26,19.37C1.02,19.37 0,18.35 0,17.11C-0,15.868 1.018,14.845 2.26,14.84Z" style="fill:url(#_Linear9);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(19.64,0,0,19.64,16.85,6.11)"><stop offset="0" style="stop-color:rgb(251,170,25);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(239,62,35);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.54,-4.21,4.21,1.54,15.77,31.08)"><stop offset="0" style="stop-color:rgb(247,141,30);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(243,113,33);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(23.93,-25.65,25.65,23.93,3.56,32.53)"><stop offset="0" style="stop-color:rgb(254,190,45);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(240,78,35);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear4" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-13.24,20.38,-20.38,-13.24,20.47,17.54)"><stop offset="0" style="stop-color:rgb(234,68,37);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(253,187,39);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear5" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(41.8,0,0,41.8,0.24,10.57)"><stop offset="0" style="stop-color:rgb(244,121,32);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(233,56,37);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear6" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(85.05,0,0,85.05,-21.84,7.78)"><stop offset="0" style="stop-color:rgb(253,202,11);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(245,132,31);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear7" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(8.85,30.87,-30.87,8.85,8.54,18.07)"><stop offset="0" style="stop-color:rgb(231,60,37);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(250,162,27);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear8" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(126.25,0,0,126.25,-51.37,23.08)"><stop offset="0" style="stop-color:rgb(253,186,18);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(247,146,30);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear9" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(21.3796,3.8052,-3.8052,21.3796,0.3616,90.5816)"><stop offset="0" style="stop-color:rgb(254,190,45);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(240,78,35);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="#F38020" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Cloudflare</title><path d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="72.79 132 358.38 240.01"><path d="M162.5 191.1h67.8l-10.5 40.2a25.38 25.38 0 0 1-9 13.5 24.32 24.32 0 0 1-15.3 5.1H164a30.53 30.53 0 0 0-19 6.3 33 33 0 0 0-11.55 17.1 31.9 31.9 0 0 0-.45 15.3 33.1 33.1 0 0 0 5.85 12.75 30.3 30.3 0 0 0 10.8 8.85 31.74 31.74 0 0 0 14.4 3.3h19.2a10.8 10.8 0 0 1 8.85 4.35 10.4 10.4 0 0 1 1.9 9.75L182 372h-21a84.8 84.8 0 0 1-39.75-9.45A89.8 89.8 0 0 1 91.1 337.5 88.4 88.4 0 0 1 74.75 302a87.5 87.5 0 0 1 1-40.95l1.2-4.5a88.7 88.7 0 0 1 31.65-47.25 89.9 89.9 0 0 1 25-13.35 87 87 0 0 1 28.9-4.85M196.7 372l59.1-221.4a25.38 25.38 0 0 1 9-13.5 24.32 24.32 0 0 1 15.3-5.1h62.7a84.8 84.8 0 0 1 39.75 9.45 89.21 89.21 0 0 1 46.65 60.6A83.8 83.8 0 0 1 428 243l-1.2 4.5a89.9 89.9 0 0 1-12 26.55 87.65 87.65 0 0 1-73.2 39.15h-54.3l10.8-40.5a25.38 25.38 0 0 1 9-13.2 24.32 24.32 0 0 1 15.3-5.1h17.4a31.56 31.56 0 0 0 30.6-23.7 29.4 29.4 0 0 0 .45-14.7 33.1 33.1 0 0 0-5.85-12.75 31.76 31.76 0 0 0-10.8-9 30.6 30.6 0 0 0-14.4-3.45h-33.6l-43.8 162.9a25.38 25.38 0 0 1-9 13.2 23.88 23.88 0 0 1-15 5.1Z" style="fill:#ff6c2c"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 354 354"><style>.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#0080ff}</style><g id="XMLID_690_"><g id="XMLID_691_"><g id="XMLID_44_"><g id="XMLID_48_"><path id="XMLID_49_" d="M177 354v-68.6c72.7 0 129.1-72.1 101.2-148.5-10.2-28.1-32.9-50.8-61.2-61C140.5 48.1 68.4 104.3 68.4 177H0C0 61.2 112-29.2 233.4 8.8c53 16.7 95.3 58.8 111.8 111.8C383.2 242 292.8 354 177 354" style="fill:#0080ff"/></g><path id="XMLID_47_" d="M177.2 285.8h-68.4v-68.5h68.4z" class="st1"/><path id="XMLID_46_" d="M108.8 338.1H56.4v-52.3h52.4z" class="st1"/><path id="XMLID_45_" d="M56.4 285.8h-44v-44h44z" class="st1"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 723 B |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 480 480" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414"><path fill="none" d="M0 0h480v480H0z"/><clipPath id="a"><path d="M0 0h480v480H0z"/></clipPath><g clip-path="url(#a)"><circle cx="240" cy="240" r="240" fill="#1a5ec6"/><path d="M295.77 96.601c-7.908 0-15.815.416-23.306 2.08v77.408c-10.82-4.162-23.305-6.243-39.952-6.243-59.096 0-107.372 40.369-107.372 105.291 0 66.587 44.114 103.21 109.037 103.21 32.045 0 64.922-8.74 85.314-19.56V98.681c-7.907-1.664-16.23-2.08-23.721-2.08zm-62.426 240.545c-35.79 0-60.344-24.138-60.344-62.841 0-38.288 26.218-62.842 61.177-62.842 14.149 0 27.051 1.665 38.287 8.74v110.701c-12.485 4.577-25.386 6.658-39.12 6.242z" fill="#fff" fill-rule="nonzero"/></g></svg>
|
||||
|
After Width: | Height: | Size: 793 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-auto w-[194px] false" alt="The DreamHost Logo" enable-background="new 0 0 471.3 89.3" viewBox="17.99 14.6 60.81 60.2"><g fill="#071c26"><path d="m77.3 60c-4.9 2.7-10.5 4.2-16.5 4.1-17.8-.4-31.8-14.7-31.5-32.1.1-5.9 1.9-11.4 4.9-16-9.5 5.3-16 15.2-16.2 26.7-.4 17.3 13.7 31.7 31.4 32.1 11.8.2 22.1-5.7 27.9-14.8"/></g><path d="m51.3 14.6c-4.8 0-9.3 1.2-13.3 3.4-2.5 4.1-3.9 8.8-4.1 13.6-.3 15.1 12 27.6 27.3 27.9 4.6 0 9.8-1.1 13.8-3.3 2.4-4.1 3.8-8.9 3.8-14 .2-15.2-12.2-27.6-27.5-27.6z" fill="#0073ec"/></svg>
|
||||
|
After Width: | Height: | Size: 559 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="-0.01 27.45 512.09 457.21"><style>.st0{fill:#040502}</style><path d="M226.8 213.8c-15.5 0-51 7.5-63.8-.1-9.4-5.6-15.6-18.7-23.2-26.5-14.1-14.4-30.6-26.3-48.7-35.1-8.1-4-19.6-10.8-28.9-7.7-17.5 5.8-30.6 34.2-38.6 49.4-31.6 59.6-29.4 136.1-5.7 198 17.1 44.7 47.5 59.6 91.1 74.1 64.5 21.5 143.6 25.7 209.1 6.4 40.2-11.9 69.3-19.2 90.5-58.3 20.3-37.5 30.3-84.3 24.2-126.8-2.6-18.1-19.6-50-16.5-66.6 2.5-13.2 29.8-8.9 39.7-12.6 29.2-11.1 73.4-64.3 49-98-12.6-17.4-53.4 3.4-70.3-7.4-9.9-6.3-15.3-24.1-22.7-33.3-16.5-20.7-39.3-33.4-64.9-39.1C276.2 14.3 211 69.5 207 140.4c-1.5 27 9.5 49.4 19.8 73.4" class="st0"/><path d="M304.7 54.7c-56.8 10.7-83.6 74.7-64.8 125.7 5 13.5 40.5 44.4 26.3 56.5-6.8 5.8-19.9 3.6-28.2 3.6-20.1-.2-39.9-1.8-60.1.6-11.1 1.3-23.6 5.8-32.5-3.4-18.3-18.7-28.6-40.4-52-54.9-6.8-4.2-17.9-12.5-26-7.5-14.3 8.9-23.7 34.5-29.4 49.6-17 44.8-14.2 97.2-.8 142.4 4.7 15.7 11 36.7 23.3 48.1 15.4 14.2 42 22.2 61.8 27.9 55.9 16.1 116.4 19.8 173.5 8 21.5-4.5 55.7-10.2 72.7-24.9 36.2-31.5 43.3-97.1 37.9-141.2-2.7-22.1-19-43.5-20.4-64.5-1.1-17.3 22-38.6 24.8-57.8 9-62.9-40.6-120.6-106.1-108.2" style="fill:#fcfc01"/><path d="M340.3 95.3c-29.3 11.2-10 54.3 17.7 42.8 28.9-11.8 10.1-53.4-17.7-42.8" class="st0"/><path d="M435.9 122.6c4.9 26.7-2.9 47.6-6.7 73.4 41-3.6 62.2-34.1 62.3-73.4z" style="fill:#fd0101"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,124 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 64 64"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="foundationdb-icon.svg"
|
||||
width="64"
|
||||
height="64"
|
||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"><metadata
|
||||
id="metadata47"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs45">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</defs><sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1001"
|
||||
id="namedview43"
|
||||
showgrid="false"
|
||||
inkscape:zoom="4.52"
|
||||
inkscape:cx="92.484101"
|
||||
inkscape:cy="34.852159"
|
||||
inkscape:window-x="-9"
|
||||
inkscape:window-y="-9"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="Layer_1" />
|
||||
<style
|
||||
type="text/css"
|
||||
id="style2">
|
||||
.st0{fill:#3F9AFB;}
|
||||
.st1{fill:#0B70E0;}
|
||||
.st2{fill:#9ECCFD;}
|
||||
.st3{fill:#047BFE;}
|
||||
.st4{fill:#087EFE;}
|
||||
</style>
|
||||
|
||||
<style
|
||||
id="style854"
|
||||
type="text/css">
|
||||
.st0{fill:#3F9AFB;}
|
||||
.st1{fill:#0B70E0;}
|
||||
.st2{fill:#9ECCFD;}
|
||||
.st3{fill:#047BFE;}
|
||||
.st4{fill:#087EFE;}
|
||||
</style><g
|
||||
id="g1134"
|
||||
transform="matrix(1.518929,0,0,1.518929,-59.143687,0.45053731)"><g
|
||||
transform="matrix(0.08541251,0,0,0.08541251,8.7615159,9.5962543)"
|
||||
id="g10">
|
||||
<polygon
|
||||
style="fill:#3f9afb"
|
||||
class="st0"
|
||||
points="845.8,143 846.4,189.9 667.4,165.8 560.6,177.3 457.1,165.4 354.2,177.6 354.1,165.7 457.2,150.5 457.2,98.6 561.4,124 561.6,164.8 666.6,150.9 666.3,98.7 "
|
||||
id="polygon4" />
|
||||
<path
|
||||
style="fill:#0b70e0"
|
||||
inkscape:connector-curvature="0"
|
||||
class="st1"
|
||||
d="m 666.6,183.2 179.6,18.6 v 46 H 353.8 l -0.5,-12.2 h 103.5 c 0,0 0,-34.2 0,-52.3 34.8,3.4 103.8,10.2 103.8,10.2 v 40.9 h 106 z"
|
||||
id="path6" />
|
||||
<path
|
||||
style="fill:#9eccfd"
|
||||
inkscape:connector-curvature="0"
|
||||
class="st2"
|
||||
d="m 561.4,109.1 -0.3,-12.6 c 0,0 68.1,-20.4 103.3,-30.8 0,-16.9 0,-33.2 0,-52.9 61.8,24.8 121.2,48.8 181.2,72.9 0,15 0,29.4 0,45.4 -61.5,-16.9 -121.7,-33.5 -180.2,-49.6 -35.6,9.5 -104,27.6 -104,27.6 z"
|
||||
id="path8" />
|
||||
</g><polygon
|
||||
transform="matrix(0.08541251,0,0,0.08541251,8.7795597,9.6869671)"
|
||||
style="fill:#3f9afb"
|
||||
class="st0"
|
||||
points="457.1,165.4 354.2,177.6 354.1,165.7 457.2,150.5 457.2,98.6 561.4,124 561.6,164.8 666.6,150.9 666.3,98.7 845.8,143 846.4,189.9 667.4,165.8 560.6,177.3 "
|
||||
id="polygon856" /><path
|
||||
style="fill:#0b70e0;stroke-width:0.08541251"
|
||||
inkscape:connector-curvature="0"
|
||||
class="st1"
|
||||
d="m 65.715539,25.334539 15.340087,1.588673 v 3.928975 h -42.05712 l -0.04271,-1.042033 h 8.840195 c 0,0 0,-2.921107 0,-4.467074 2.972356,0.290403 8.865819,0.871208 8.865819,0.871208 v 3.493371 h 9.053726 z"
|
||||
id="path858" /><path
|
||||
style="fill:#9eccfd;stroke-width:0.08541251"
|
||||
inkscape:connector-curvature="0"
|
||||
class="st2"
|
||||
d="m 56.730143,19.005472 -0.02562,-1.076198 c 0,0 5.816592,-1.742415 8.823112,-2.630705 0,-1.443471 0,-2.835695 0,-4.518322 5.278493,2.11823 10.351997,4.168131 15.476747,6.226572 0,1.281188 0,2.511128 0,3.877728 -5.252869,-1.443471 -10.394702,-2.861319 -15.391334,-4.23646 -3.040686,0.811419 -8.882901,2.357385 -8.882901,2.357385 z"
|
||||
id="path860" /></g></svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M401.2 118.4c-14.4-10.2-34.3-6.8-44.5 7.6-12.5 17.7-26.3 30.8-42.1 39.8-1.5.8-2.9 1.6-4.4 2.4-1.4.7-2.8 1.4-4.2 2-14.7 6.8-31.1 10.4-50 11-18.9-.6-35.3-4.1-50-11-19.4-9-35.9-23.5-50.7-44.3-10.2-14.4-30.1-17.8-44.5-7.6s-17.8 30.1-7.6 44.5c21 29.7 46.6 51.7 75.9 65.3 5.8 2.7 11.6 5 17.7 7.1-24.9 18-51.9 43.2-64.8 77.8-13.4 35.9-13.2 74.8.5 109.4 13.1 33.3 37.5 60.3 68.5 76.2 34.6 17.7 78.6 17.7 117.8.1 41.1-18.5 67.9-52.3 71.7-90.6 4-40.2-15.3-76.1-49.2-91.3-35.1-15.8-74.7-4.5-100.8 28.6-10.9 13.8-8.6 33.9 5.3 44.8 13.8 10.9 33.9 8.6 44.8-5.3 7.8-9.8 16.7-13.5 24.6-10 6.7 3 13.3 11.6 11.8 26.8-1.5 15-14.7 29.8-34.4 38.7-21.3 9.6-45.3 10.1-62.5 1.3s-30.8-24-38.2-42.8c-5.6-14.1-10.1-36.6 0-63.7 7.6-20.3 26.6-39.4 60-60.2 14.2-8.8 29.4-17 44.1-25 16.9-9.2 33-17.9 47.1-27.2 25.1-13.7 47.2-33.9 65.7-60.2 10.2-14.1 6.8-34-7.6-44.2m-162.3-60c4.1-4.1 9.5-6.3 15.3-6.3s11.2 2.3 15.3 6.3c4.1 4.1 6.3 9.5 6.3 15.3s-2.3 11.2-6.3 15.3c-4.1 4.1-9.5 6.3-15.3 6.3S243 93 238.9 89c-4.1-4.1-6.3-9.5-6.3-15.3s2.2-11.3 6.3-15.3m15.3 88.9c19.7 0 38.2-7.7 52.1-21.6s21.6-32.4 21.6-52.1-7.7-38.2-21.6-52.1C292.4 7.7 273.9 0 254.2 0S216 7.7 202.1 21.6 180.5 54 180.5 73.7s7.7 38.2 21.6 52.1c14 13.8 32.5 21.5 52.1 21.5" style="fill:#16f597"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="92" height="82"><defs><path id="a" d="M0 0h92v82H0z"/></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><path fill="#1BDBDB" d="M82.435 40.18c-1.23 4.686-3.225 9.257-5.927 13.585a51 51 0 0 1-5.8 7.613c2.434-9.933.786-22.102-5.186-33.513a1.504 1.504 0 0 0-2.158-.588L44.766 38.91a1.524 1.524 0 0 0-.484 2.098l2.727 4.368a1.52 1.52 0 0 0 2.095.485l12.054-7.54a47 47 0 0 1 1.075 3.5c1.16 4.415 1.592 8.723 1.284 12.806-.573 7.6-3.664 13.522-8.704 16.674-2.517 1.575-5.44 2.409-8.619 2.5h-.387c-3.178-.091-6.103-.925-8.62-2.5-5.04-3.152-8.131-9.074-8.704-16.674-.307-4.083.125-8.391 1.284-12.807 1.231-4.686 3.225-9.256 5.927-13.585 2.702-4.328 5.931-8.126 9.6-11.287 3.455-2.98 7.134-5.257 10.935-6.77 7.075-2.816 13.746-2.63 18.786.522s8.13 9.074 8.704 16.674c.308 4.083-.124 8.391-1.284 12.806M15.492 53.765c-2.702-4.328-4.696-8.899-5.927-13.585-1.16-4.415-1.591-8.723-1.284-12.806.573-7.6 3.664-13.522 8.704-16.674 5.04-3.153 11.711-3.338 18.786-.522 1.065.424 2.12.917 3.163 1.462-3.771 3.42-7.236 7.531-10.183 12.253-7.805 12.503-10.178 26.39-7.457 37.487a51 51 0 0 1-5.802-7.615M79.353 3.75C69.792-2.231 57.205-.81 45.997 6.255 34.79-.808 22.207-2.23 12.647 3.75c-15.105 9.448-16.94 33.786-4.098 54.358 9.47 15.17 24.273 24.055 37.452 23.89 13.178.164 27.981-8.721 37.45-23.89 12.842-20.572 11.008-44.91-4.098-54.358" mask="url(#b)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="#4285F4" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google Cloud</title><path d="M12.19 2.38a9.344 9.344 0 0 0-9.234 6.893c.053-.02-.055.013 0 0-3.875 2.551-3.922 8.11-.247 10.941l.006-.007-.007.03a6.717 6.717 0 0 0 4.077 1.356h5.173l.03.03h5.192c6.687.053 9.376-8.605 3.835-12.35a9.365 9.365 0 0 0-2.821-4.552l-.043.043.006-.05A9.344 9.344 0 0 0 12.19 2.38zm-.358 4.146c1.244-.04 2.518.368 3.486 1.15a5.186 5.186 0 0 1 1.862 4.078v.518c3.53-.07 3.53 5.262 0 5.193h-5.193l-.008.009v-.04H6.785a2.59 2.59 0 0 1-1.067-.23h.001a2.597 2.597 0 1 1 3.437-3.437l3.013-3.012A6.747 6.747 0 0 0 8.11 8.24c.018-.01.04-.026.054-.023a5.186 5.186 0 0 1 3.67-1.69z"/></svg>
|
||||
|
After Width: | Height: | Size: 698 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M501.8 261.8c0-18.2-1.6-35.6-4.7-52.4H256v99.1h137.8c-6.1 31.9-24.2 58.9-51.4 77V450h83.1c48.3-44.6 76.3-110.2 76.3-188.2" style="fill:#4285f4"/><path d="M256 512c69.1 0 127.1-22.8 169.4-61.9l-83.1-64.5c-22.8 15.4-51.9 24.7-86.3 24.7-66.6 0-123.1-44.9-143.4-105.4H27.5V371C69.6 454.5 155.9 512 256 512" style="fill:#34a853"/><path d="M112.6 304.6c-5.1-15.4-8.1-31.7-8.1-48.6s3-33.3 8.1-48.6v-66.1H27.5C10 175.7 0 214.6 0 256s10 80.3 27.5 114.7L93.8 319c0 .1 18.8-14.4 18.8-14.4" style="fill:#fbbc05"/><path d="M256 101.9c37.7 0 71.2 13 98 38.2l73.3-73.3C382.8 25.4 325.1 0 256 0 155.9 0 69.6 57.5 27.5 141.3l85.2 66.1c20.2-60.5 76.7-105.5 143.3-105.5" style="fill:#ea4335"/><path d="M0 0h512v512H0z" style="fill:none"/></svg>
|
||||
|
After Width: | Height: | Size: 817 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><circle cx="256" cy="256" r="256" style="fill:#d50c2d"/><path d="M395.6 105.3h-42.1c-9.5 0-13.5 3.9-13.5 13.5v106.6H172.2V118.9c0-9.5-3.9-13.5-13.5-13.5h-42.4c-9.6 0-13.5 3.9-13.5 13.5v274.2c0 9.6 3.9 13.5 13.5 13.5h42.4c9.5 0 13.5-3.8 13.5-13.5V284.8h167.9v108.3c0 9.5 3.9 13.5 13.5 13.5h42.1c9.5 0 13.5-3.9 13.5-13.5V118.9c-.2-9.2-4.1-13.6-13.6-13.6" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 461 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2112" height="2500" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="7.002 8.287 148.203 175.426"><path fill="#6747c7" d="m7.002 8.287 39.319 21.172v39.32h57.467l36.295 21.172H7.002zm148.203 75.615V29.459L112.861 8.287v51.418zm0 99.811-39.319-21.172v-39.32H58.419l-36.295-21.172h133.081zM7.002 108.098v54.443l42.343 21.172v-51.418z"/></svg>
|
||||
|
After Width: | Height: | Size: 433 B |
@@ -0,0 +1 @@
|
||||
<svg class="c4d--footer-logo__logo" xmlns="http://www.w3.org/2000/svg" aria-labelledby="footer-logo" width="157" height="65" viewBox="0 0 157 65" fill="#fff"><path d="M30.444 60.208v4.03H0v-4.03zm78.291-.001v4.03H86.983v-4.03zm47.858 0v4.03H134.84v-4.03zm-33.416 0-1.398 4.03-1.38-4.03zm-88.384 0h42.775c-2.797 2.426-6.39 3.925-10.327 4.025l-.423.006H34.793zh42.775Zm-4.35-8.46v4.03H0v-4.03zm52.402 0c-.332 1.248-.8 2.44-1.389 3.555l-.259.474H34.793v-4.029zm73.748-.005v4.031H134.84v-4.03zm-47.858 0v4.031H86.983v-4.03zm17.375 0-1.398 4.031h-5.85l-1.395-4.03zM21.745 43.285v4.03H8.698v-4.03zm61.195 0a17.3 17.3 0 0 1 .476 3.51l.008.52H68.796v-4.03zm-26.401 0v4.03H43.491v-4.03zm72.502-.007-1.396 4.03H115.93l-1.397-4.03zm18.85 0v4.03h-13.05v-4.03zm-39.156 0v4.03H95.684v-4.03zm-86.99-8.454v4.03H8.698v-4.03zm56.117 0a17 17 0 0 1 2.926 3.582l.264.447h-37.56v-4.03zm30.873-.01v4.03H95.684v-4.03zm39.157 0v4.03H134.84v-4.03zm-15.919 0-1.396 4.03h-17.579l-1.396-4.03zm-50.778-8.452a17 17 0 0 1-2.82 3.674l-.37.355H43.49v-4.029zm-59.45 0v4.03H8.698v-4.03zm126.147-.013v4.031H134.84v-3.839l-1.33 3.839h-11.456l1.373-4.03zm-27.743 0 1.372 4.031h-11.456l-1.33-3.839v3.84H95.684v-4.032zm-98.404-8.448v4.03H8.698V17.9zm61.68 0c0 1.215-.134 2.399-.375 3.542l-.11.487H68.796V17.9zM56.538 17.9v4.03H43.491V17.9zm91.352-.015v4.03h-22.954l1.37-4.03zm-30.624 0 1.372 4.03H95.684v-4.03zM30.444 9.437v4.03H0v-4.03zm50.753 0a17 17 0 0 1 1.498 3.499l.15.531H34.794v-4.03zm75.396-.018v4.03h-28.776l1.373-4.03zm-42.207 0 1.372 4.031H86.982V9.42zM30.444.978v4.03H0V.977zm36.374 0c3.96 0 7.594 1.415 10.448 3.772l.303.257H34.794V.977zm89.775-.022v4.031h-25.894l1.372-4.03zm-45.098 0 1.372 4.03H86.982V.955z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="background:#003c8f;fill:#fff" viewBox="0 0 40 40"><path d="M20 35.9a2.6 2.6 0 0 1-2.6-2.6V6.7c0-1.4 1.2-2.6 2.6-2.6 1.2 0 2.2.8 2.5 1.9v27.1c.2 1.6-1 2.8-2.5 2.8"/></svg>
|
||||
|
After Width: | Height: | Size: 217 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2085" height="2500" preserveAspectRatio="xMidYMid" viewBox="0 0 256 307"><path fill="#004711" d="m66.766 141.595-12.64-83.896L2.919 26.102l15.254 78.884zM78.75 231.81l-8.063-59.49-45.543-37.698 11.767 58.836zm-36.173-13.292 8.063 44.67 38.788 40.315-5.448-45.544zm115.71-13.73-19.938-14.817s-.284 5.306-.817 5.666l-14.546 10.242 15.907 13.074.927 14.873 18.903 15.417zm60.253-43.472-3.596 41.893-13.401-8.825s1.477-21.181 1.47-21.192c-.077-.142-25.822-17.433-25.822-17.433l.817-19.285z"/><path fill="#00B159" d="m133.665 106.947-3.704-78.012-75.833 28.764 12.638 83.896zm3.487 88.472-3.051-59.49-63.412 36.39 9.37 61.016zm1.089 24.188-54.26 38.352 6.756 46.85 49.465-39.441zm20.92-15.036.87 45.326 39.225-31.815 1.96-43.147zm93.429-65.646-5.121 39.769-30.671 24.624 3.65-41.784 32.142-22.61z"/><path d="M140.278 218.847c-.002-.038-.016-.073-.02-.11a1.8 1.8 0 0 0-.107-.46 2 2 0 0 0-.125-.26q-.04-.076-.088-.146a2 2 0 0 0-.202-.234c-.035-.035-.066-.074-.104-.107l-.028-.03-14.511-11.843 13.343-8.99c.533-.36.839-.966.81-1.603l-.135-3.165 17.692 13.504.344 41.008-16.276-13.702zm-71.23-45.953 8.205 56.072-38.886-36.633-11.26-54.382 41.94 34.943zm66.478 21.296-54.29 36.575-8.467-57.86 60.384-34.37zm-6.982-163.451 3.229 75.494-63.888 33.245L56.037 58.6zm-76.24 27.78 11.649 79.516L20.026 103.8 4.5 28.795 52.305 58.52zm.678 204.375-8.3-40.105 36.998 36.387 5.926 40.468zm85.558 1.459-46.91 37.37-6.238-42.582 51.354-36.742zm58.888-46.95-36.6 29.192-.342-41.166 39.383-28.17zm5.109-23.378 1.24-20.395c0-.023-.006-.044-.006-.067 0-.027.009-.052.008-.08-.001-.08-.022-.158-.034-.237-.01-.057-.01-.114-.024-.17a2 2 0 0 0-.156-.41l-.014-.034a2 2 0 0 0-.291-.388c-.023-.024-.051-.042-.075-.064-.074-.07-.146-.141-.232-.2l-24.655-16.282.359-17.868 38.8 24.178-3.46 39.993zm15.22 7.196 3.39-39.175 30.623-21.916-5.147 38.082zM256 136.045c-.002-.035-.015-.067-.02-.102a2 2 0 0 0-.036-.194 2 2 0 0 0-.17-.463 2 2 0 0 0-.181-.27c-.036-.045-.07-.091-.11-.133a2 2 0 0 0-.283-.233c-.032-.022-.055-.053-.09-.073-.006-.005-.016-.006-.024-.011l-.031-.022-41.91-23.223a1.84 1.84 0 0 0-1.85.043l-35.374 21.625c-.057.034-.102.083-.154.123-.063.048-.128.092-.184.148-.06.059-.106.127-.156.194-.044.058-.094.112-.13.175-.046.077-.075.162-.11.245-.024.063-.057.12-.075.187-.025.09-.032.184-.043.278-.007.06-.026.118-.028.18l-.376 18.779-14.39-9.503a1.84 1.84 0 0 0-1.976-.037l-20.702 12.656-.897-21.047c-.002-.044-.018-.084-.022-.128a2 2 0 0 0-.048-.273c-.018-.066-.045-.128-.07-.192a2 2 0 0 0-.104-.228c-.037-.065-.081-.123-.125-.183a2 2 0 0 0-.148-.183 2 2 0 0 0-.18-.157c-.04-.032-.072-.072-.115-.102l-21.545-14.471 20.167-10.494a1.85 1.85 0 0 0 .987-1.712l-3.387-79.222c-.004-.097-.027-.19-.046-.284-.008-.04-.01-.08-.02-.118a1.8 1.8 0 0 0-.188-.45c-.015-.026-.036-.047-.052-.071a1.8 1.8 0 0 0-.253-.311q-.053-.05-.111-.094c-.053-.044-.1-.094-.157-.132-.045-.03-.096-.046-.143-.072-.024-.013-.042-.033-.067-.045L76.736.225a1.83 1.83 0 0 0-1.347-.1L1.279 23.209l-.015.008c-.03.009-.056.03-.086.04a1.8 1.8 0 0 0-.391.196l-.011.005c-.041.029-.07.07-.107.102a2 2 0 0 0-.265.249c-.055.068-.092.146-.137.22s-.095.144-.13.224c-.04.096-.06.198-.082.3-.015.062-.042.118-.051.18-.001.01.002.019 0 .028-.01.093 0 .186.005.28.003.087-.001.173.014.257.003.014 0 .027.003.04l16.536 79.869c.09.427.323.808.67 1.077l22.444 17.493-16.226 7.726c-.03.014-.052.036-.08.05-.025.015-.052.018-.076.032-.036.022-.06.054-.093.078a2 2 0 0 0-.258.216q-.069.066-.13.138a2 2 0 0 0-.2.315c-.02.04-.047.078-.065.12a1.8 1.8 0 0 0-.133.482v.02c-.003.026.002.052 0 .078q-.017.226.022.447c.003.02-.001.039.003.058l12.44 60.109c.077.372.265.708.54.967l15.622 14.714-10.352 6.332a2 2 0 0 0-.36.293c-.03.029-.05.062-.076.093q-.134.158-.23.342a1.9 1.9 0 0 0-.204.628c-.02.177-.005.352.025.524.004.019-.001.038.003.058l9.638 46.554c.069.33.228.637.463.886l38.982 41.385c.059.062.127.11.192.164.024.019.044.04.069.059q.182.132.388.216.007.006.018.01c.01.005.024.003.036.007.205.076.42.122.639.122a1.8 1.8 0 0 0 .659-.134c.046-.018.095-.027.14-.05.076-.036.132-.094.199-.14.048-.03.103-.045.148-.081l50.145-39.95c.46-.368.716-.931.692-1.516l-1.172-27.43 16.753 14.1c.029.025.067.034.098.057.095.07.186.144.294.196.036.016.073.023.109.038q.068.026.136.046c.18.057.362.096.547.096.18 0 .358-.037.535-.091q.06-.019.122-.042c.042-.016.085-.024.126-.043.093-.044.168-.108.25-.165.036-.025.078-.035.112-.062l40.197-32.056c.409-.33.657-.81.689-1.332l1.223-20.115 12.23 8.513c.026.017.055.023.08.039.036.022.063.054.1.074.072.038.149.057.223.085.05.019.099.042.152.057.162.045.328.074.494.074.18 0 .36-.036.537-.09q.06-.018.12-.04c.04-.016.084-.024.125-.042.1-.048.186-.115.274-.178.029-.02.064-.028.092-.05l32.79-26.134a1.85 1.85 0 0 0 .678-1.195l5.817-43.041c.006-.046-.005-.09-.002-.136.004-.073.019-.143.015-.217"/></svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M471.6 393.4c-27.9-.7-49.4 2.1-67.6 9.8-5.2 2.1-13.5 2.1-14.3 8.7 2.8 2.8 3.2 7.3 5.6 11.2 4.2 7 11.5 16.3 18.1 21.2 7.3 5.6 14.6 11.1 22.3 16 13.5 8.4 28.9 13.3 42.1 21.6 7.7 4.9 15.3 11.1 23 16.4 3.8 2.8 6.2 7.3 11.1 9.1v-1.1c-2.5-3.1-3.2-7.7-5.6-11.1L496 485c-10.1-13.6-22.7-25.4-36.2-35.2-11.1-7.7-35.5-18.1-40.1-31l-.7-.7c7.7-.7 16.8-3.5 24-5.6 11.8-3.1 22.7-2.4 34.8-5.5 5.6-1.4 11.1-3.2 16.8-4.9V399c-6.3-6.3-10.8-14.6-17.4-20.5-17.7-15.3-37.3-30.3-57.5-42.8-10.8-7-24.7-11.5-36.2-17.4-4.1-2.1-11.1-3.1-13.6-6.6-6.2-7.7-9.8-17.7-14.3-26.8-10.1-19.1-19.8-40.4-28.5-60.6-6.3-13.6-10.1-27.1-17.8-39.7-35.9-59.2-74.9-95-134.8-130.2-12.8-7.5-28.1-10.6-44.5-14.4l-26.1-1.4c-5.6-2.4-11.2-9.1-16-12.2C68 13.9 16.8-13.3 2.2 22.6-7.2 45.2 16.1 67.5 24.1 79c5.9 8 13.6 17.1 17.7 26.1 2.5 5.9 3.1 12.2 5.6 18.5 5.6 15.3 10.8 32.4 18.1 46.7 3.8 7.3 8 15 12.9 21.6 2.8 3.9 7.7 5.6 8.7 11.9-4.9 6.9-5.2 17.4-8 26.1-12.5 39.3-7.6 88.1 10.1 117 5.6 8.7 18.8 27.9 36.5 20.5 15.7-6.3 12.2-26.1 16.7-43.5 1-4.2.4-7 2.4-9.7v.7c4.9 9.7 9.8 19.1 14.3 28.9 10.8 17 29.6 34.8 45.3 46.6 8.3 6.3 15 17.1 25.4 20.9v-1h-.7c-2.1-3.1-5.2-4.5-8-6.9-6.3-6.3-13.2-13.9-18.1-20.9-14.6-19.5-27.5-41.1-39-63.4-5.6-10.8-10.4-22.6-15-33.4-2.1-4.2-2.1-10.4-5.6-12.5-5.2 7.7-12.9 14.3-16.7 23.7-6.6 15-7.3 33.4-9.8 52.6l-1.4.7c-11.1-2.8-15-14.3-19.2-24-10.4-24.7-12.2-64.4-3.1-93 2.4-7.3 12.9-30.3 8.7-37.2-2.1-6.7-9.1-10.5-12.9-15.7-4.5-6.6-9.4-15-12.5-22.3-8.4-19.5-12.6-41.1-21.6-60.6C51 88 43.7 78.6 37.8 70.3c-6.6-9.4-13.9-16-19.2-27.2-1.7-3.8-4.2-10.1-1.4-14.3.7-2.8 2.1-3.8 4.9-4.5 4.5-3.8 17.4 1 21.9 3.1 12.9 5.2 23.7 10.1 34.5 17.4 4.9 3.5 10.1 10.1 16.4 11.9h7.3c11.1 2.4 23.7.7 34.1 3.8 18.4 5.9 35.2 14.6 50.1 24 45.6 28.9 83.2 70 108.6 119.1 4.2 8 5.9 15.3 9.7 23.7 7.3 17.1 16.4 34.5 23.7 51.2 7.3 16.4 14.3 33.1 24.7 46.7 5.2 7.3 26.1 11.1 35.5 15 6.9 3.1 17.8 5.9 24 9.7 11.8 7.3 23.6 15.7 34.8 23.7 5.7 4.1 23.1 12.9 24.2 19.8M116.4 90.8c-4.8 0-9.6.5-14.3 1.8v.7h.7c2.8 5.6 7.7 9.4 11.2 14.3 2.8 5.6 5.2 11.1 8 16.7l.7-.7c4.9-3.5 7.4-9.1 7.4-17.4-2.1-2.5-2.4-4.9-4.2-7.3-2.2-3.6-6.7-5.3-9.5-8.1" style="fill:#5d87a1"/></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,31 @@
|
||||
<svg xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" version="1.1" id="svg22" sodipodi:docname="namecheap-resized.svg" inkscape:version="1.4 (e7c3feb1, 2024-10-09)" viewBox="-0.02 0 84.05 46.59">
|
||||
<sodipodi:namedview id="namedview22" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="4.0930233" inkscape:cx="128.87784" inkscape:cy="23.332386" inkscape:window-width="2224" inkscape:window-height="1290" inkscape:window-x="554" inkscape:window-y="25" inkscape:window-maximized="0" inkscape:current-layer="svg22"/>
|
||||
<defs id="defs9">
|
||||
<linearGradient id="a" x1="59.990002" y1="44.16" x2="80.010002" y2="1.23" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#d4202c" id="stop1"/>
|
||||
<stop offset="0.1" stop-color="#dc3d29" stop-opacity="0.79" id="stop2"/>
|
||||
<stop offset="0.2" stop-color="#e45926" stop-opacity="0.58" id="stop3"/>
|
||||
<stop offset="0.32" stop-color="#ea7123" stop-opacity="0.4" id="stop4"/>
|
||||
<stop offset="0.43" stop-color="#f08521" stop-opacity="0.25" id="stop5"/>
|
||||
<stop offset="0.55" stop-color="#f4941f" stop-opacity="0.14" id="stop6"/>
|
||||
<stop offset="0.68" stop-color="#f79f1e" stop-opacity="0.06" id="stop7"/>
|
||||
<stop offset="0.82" stop-color="#f8a51d" stop-opacity="0.02" id="stop8"/>
|
||||
<stop offset="1" stop-color="#f9a71d" stop-opacity="0" id="stop9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="b" x1="1777.48" y1="1048.76" x2="1797.5" y2="1005.83" gradientTransform="rotate(180,901.25,525.945)" xlink:href="#a"/>
|
||||
<linearGradient inkscape:collect="always" xlink:href="#a" id="linearGradient22" gradientUnits="userSpaceOnUse" x1="59.990002" y1="44.16" x2="80.010002" y2="1.23" gradientTransform="translate(-1,-0.71)"/>
|
||||
</defs>
|
||||
<title id="title9">cn-logo</title>
|
||||
<path d="M 76.17,0 A 7.87,7.87 0 0 0 69.29,4.05 L 69.13,4.38 63,16.54 l -7.8,15.37 5.11,10.07 0.28,0.55 A 8,8 0 0 0 64,45.76 8.05,8.05 0 0 0 67.41,42.53 L 67.69,41.98 83,11.77 83.37,11.04 a 7.86,7.86 0 0 0 -7.19,-11 z" fill="#ff5100" id="path9"/>
|
||||
<path d="m 28.85,14.63 -5.1,-10 -0.28,-0.55 a 7.89,7.89 0 0 0 -3.4,-3.22 7.92,7.92 0 0 0 -3.4,3.21 L 16.38,4.63 1.05,34.81 0.68,35.53 a 7.86,7.86 0 0 0 14.06,7 l 0.17,-0.32 6.17,-12.16 7.79,-15.36 z" fill="#ff5100" id="path10"/>
|
||||
<path d="m 76.15,0 a 7.86,7.86 0 0 0 -6.87,4.05 l -0.17,0.33 -6.17,12.16 -7.81,15.37 5.12,10.07 0.28,0.55 a 7.94,7.94 0 0 0 3.41,3.23 7.94,7.94 0 0 0 3.41,-3.23 L 67.64,41.98 83,11.77 83.36,11.04 a 7.86,7.86 0 0 0 -7.19,-11 z" fill="url(#a)" id="path11" style="fill:url(#linearGradient22)"/>
|
||||
<path d="m 7.86,46.58 a 7.86,7.86 0 0 0 6.87,-4 L 14.9,42.25 21.08,30.09 28.88,14.72 23.77,4.6 23.49,4.05 A 8,8 0 0 0 20.07,0.82 8,8 0 0 0 16.66,4.05 L 16.38,4.6 1,34.81 0.63,35.54 a 7.86,7.86 0 0 0 7.19,11 z" fill="url(#b)" id="path12" style="fill:url(#b)"/>
|
||||
<path d="m 28.85,14.63 -5.1,-10 -0.28,-0.55 a 7.94,7.94 0 0 0 -3.41,-3.23 8.31,8.31 0 0 1 1.49,-0.56 8.16,8.16 0 0 1 2,-0.25 h 10.68 a 7.92,7.92 0 0 1 6.86,4 l 0.28,0.55 13.81,27.36 5.09,10 0.28,0.55 A 8,8 0 0 0 64,45.76 8.05,8.05 0 0 1 60.53,46.57 H 49.79 a 7.91,7.91 0 0 1 -6.85,-4 l -0.29,-0.55 z" fill="#ff8c44" id="path13"/>
|
||||
<metadata id="metadata22">
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<dc:title>cn-logo</dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><radialGradient id="a" cx="-45.399" cy="659.487" r="13.368" gradientTransform="matrix(0 38.301 44.1228 0 -28842.379 1738.555)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#20c6b7"/><stop offset="1" style="stop-color:#4d9abf"/></radialGradient><path d="m370.9 177.7-.2-.1c-.1 0-.2-.1-.3-.2-.3-.3-.4-.8-.4-1.2l10.3-63.1 48.4 48.4-50.3 21.4c-.1.1-.3.1-.4.1h-.2c-.1 0-.1-.1-.3-.2-1.8-2.1-4.1-3.8-6.6-5.1m70.2-3.9 51.8 51.8c10.7 10.8 16.1 16.1 18.1 22.4.3.9.5 1.8.7 2.8L388 198.3c-.1 0-.1-.1-.2-.1-.5-.2-1.1-.4-1.1-.9s.6-.7 1.1-.9l.2-.1zm68.5 93.5c-2.7 5-7.9 10.2-16.7 19.1l-58.4 58.3-75.4-15.7-.4-.1c-.7-.1-1.4-.2-1.4-.8-.6-6.3-3.7-12.1-8.7-15.9-.3-.3-.2-.8-.1-1.2v-.2l14.2-87.1.1-.3c.1-.7.2-1.4.8-1.4 6.1-.8 11.7-4 15.5-8.9.1-.1.2-.3.4-.4.4-.2.9 0 1.4.2zm-88.5 90.9-96 96 16.4-100.9v-.1c0-.1 0-.3.1-.4.1-.3.5-.5.8-.6l.2-.1c3.6-1.5 6.8-3.9 9.3-6.9.3-.4.7-.7 1.2-.8h.4zM304.9 474.4l-10.8 10.8-119.6-172.8c0-.1-.1-.1-.1-.2-.2-.3-.4-.5-.3-.8q0-.3.3-.6l.1-.2c.4-.5.7-1.1 1-1.6l.3-.5c.2-.3.4-.6.7-.8.3-.1.7-.1 1 0L310 335c.4.1.7.2 1 .4.2.2.2.4.3.6 1.9 7.1 7 12.9 13.7 15.7.4.2.2.6 0 1-.1.2-.2.4-.2.6-1.8 10.3-16.1 97.6-19.9 121.1M282.3 497c-8 7.9-12.7 12.1-18 13.8-5.2 1.7-10.9 1.7-16.1 0-6.2-2-11.6-7.3-22.4-18.1l-120-120.1 31.4-48.6c.1-.2.3-.5.5-.6.3-.2.8-.1 1.2 0 7.2 2.2 14.9 1.8 21.9-1.1.4-.1.7-.2 1 0l.4.4zM94.2 361l-27.5-27.5 54.4-23.2c.1-.1.3-.1.4-.1.5 0 .7.5 1 .9.5.8 1.1 1.7 1.7 2.5l.2.2c.2.2.1.5-.1.7zm-39.8-39.8-34.9-34.9c-5.9-5.9-10.2-10.2-13.2-13.9l106 22c.1 0 .3 0 .4.1.7.1 1.4.2 1.4.8 0 .7-.8 1-1.5 1.2l-.3.1zM.3 254.6c.1-2.2.5-4.5 1.2-6.6 2-6.2 7.3-11.6 18.1-22.4L64.2 181c20.5 29.8 41.1 59.6 61.8 89.3.4.5.8 1 .3 1.4-1.9 2.1-3.9 4.5-5.3 7.1-.1.3-.4.6-.7.8q-.3.15-.6 0zm75.8-85.5 59.9-60c5.6 2.5 26.2 11.1 44.5 18.9 13.9 5.9 26.5 11.2 30.5 13 .4.2.8.3.9.7.1.2.1.5 0 .8-1.9 8.8.7 18 7 24.4.4.4 0 1-.3 1.5l-.2.3-60.9 94.3c-.2.3-.3.5-.6.7s-.8.1-1.1 0c-2.4-.6-4.8-1-7.3-1-2.2 0-4.6.4-7 .8-.3 0-.5.1-.7-.1s-.4-.4-.6-.7zM148.2 97l77.6-77.6C236.6 8.6 242 3.2 248.2 1.2c5.2-1.7 10.9-1.7 16.1 0 6.2 2 11.6 7.3 22.4 18.1l16.8 16.8-55.2 85.5c-.1.2-.3.5-.5.6-.3.2-.8.1-1.2 0-8.8-2.7-18.4-.8-25.6 4.9-.4.4-.9.2-1.3 0-7.4-3-63.5-26.7-71.5-30.1m167-49.1 51 51-12.3 76.1v.2c0 .2 0 .3-.1.5-.1.3-.4.3-.7.4-2.6.8-5.1 2-7.3 3.6-.1.1-.2.1-.3.2-.1.2-.3.3-.5.3s-.4 0-.6-.1l-77.7-33-.1-.1c-.5-.2-1.1-.4-1.1-.9-.5-4.3-1.9-8.5-4.1-12.2-.4-.6-.8-1.3-.5-1.9zm-52.5 114.9 72.8 30.8c.4.2.8.4 1 .8.1.2.1.5 0 .8-.2 1.1-.4 2.3-.4 3.5v2c0 .5-.5.7-1 .9l-.1.1c-11.5 4.9-162 69.1-162.2 69.1s-.5 0-.7-.2c-.4-.4 0-1 .4-1.5.1-.1.1-.2.2-.3l59.8-92.7.1-.2c.3-.6.7-1.2 1.4-1.2l.6.1c1.4.2 2.6.4 3.8.4 9.1 0 17.5-4.4 22.6-12 .1-.2.3-.4.5-.5.2-.2.8-.1 1.2.1m-83.4 122.7 164-69.9s.2 0 .5.2c.9.9 1.7 1.5 2.4 2.1l.4.2c.3.2.7.4.7.7v.3l-14 86.3-.1.3c-.1.7-.2 1.4-.8 1.4-7.6.5-14.5 4.7-18.3 11.3l-.1.1c-.2.3-.4.6-.7.8q-.45.15-.9 0l-130.8-27c-.3.1-2.2-6.8-2.3-6.8" style="fill:url(#a)"/></svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="20"><path fill="none" stroke="#C74634" stroke-width="4" d="M10 2a8 8 0 1 0 0 16h12a8 8 0 1 0 0-16z"/></svg>
|
||||
|
After Width: | Height: | Size: 166 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1505" height="909" baseProfile="tiny-ps" version="1.2"><path fill-rule="evenodd" d="m1407.08 52.27-160.24 283.35h-168.16L880.85 684.34h168.16l-126.62 223.9h413.47c193.88-243.71 223.55-582.53 71.22-855.97M592.01 908.24 1116.27.76H673.13L372.42 523.85 99.41 50.29C-54.9 323.73-27.2 664.53 172.61 908.24z" style="fill:#000e9c"/></svg>
|
||||
|
After Width: | Height: | Size: 378 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="40.6 36.4 418.8 173.8"><path d="M53.3 210.2H40.6V77.1c10.6-3.3 21.1-4.9 31.3-4.9 7.5 0 14.2 1.2 20.3 3.5s11.3 5.6 15.7 9.7c4.3 4.2 7.7 9.1 10 14.8s3.5 12 3.5 18.7c0 8.2-1.4 15.2-4.2 21.1s-6.5 10.7-11.1 14.5-9.8 6.6-15.7 8.3q-8.85 2.7-18 2.7c-3.3 0-6.5-.2-9.7-.7s-6.4-1.2-9.5-2v47.4zm53.7-91.5c0-10.6-3-19.2-8.9-25.7s-14.3-9.8-25-9.8c-3.4 0-6.8.2-10.1.6s-6.5 1.2-9.7 2.3V151c2.9 1 6 1.8 9.1 2.5s6.1 1 9.1 1c10.9 0 19.5-3.1 25.9-9.3s9.6-15 9.6-26.5m40.1 46V36.4h12.8v128.3zm117.7-13.4c-4.3 4.6-9.2 8.2-14.8 10.5-5.6 2.4-12.4 3.6-20.3 3.6-7.3 0-13.7-1.3-19.2-3.8s-10.1-5.9-13.7-10.2c-3.7-4.3-6.4-9.2-8.2-14.7q-2.7-8.25-2.7-17.4 0-12.15 3.3-21c2.2-5.9 5.2-10.8 9-14.8q5.7-5.85 13.2-8.7c5-1.9 10.3-2.8 15.9-2.8 13.4 0 23.6 4.1 30.4 12.4q10.2 12.3 9.9 36h-68.7c.4 10.4 3.4 18.6 9 24.7s13.3 9.2 22.9 9.2q8.1 0 14.7-2.7c4.4-1.8 8.4-4.6 11.9-8.5zm-10.3-41.6c0-3.8-.6-7.3-1.7-10.6-1.2-3.3-2.8-6.1-4.9-8.4s-4.9-4.2-8.3-5.6-7.4-2.1-11.9-2.1q-6.9 0-12 2.1c-3.4 1.4-6 3.2-8 5.4s-3.8 5-5.4 8.3c-1.7 3.4-2.7 7-3.2 10.9zm96.8-15c-3.2-4-6.7-7-10.4-8.9s-8.1-2.8-13.2-2.8c-6.5 0-11.1 1.2-13.7 3.5s-3.9 5.3-3.9 8.8c0 2.4.6 4.5 1.8 6.2s2.8 3.2 4.8 4.4 4.3 2.3 6.9 3.2 5.3 1.8 8.2 2.7c3.5 1.1 6.9 2.3 10.4 3.6s6.6 3 9.5 5c2.9 2.1 5.2 4.6 7 7.6s2.7 6.8 2.7 11.3c0 4.2-.9 7.9-2.7 11.2s-4.2 6.1-7.3 8.3q-4.65 3.45-10.8 5.1c-4.2 1.2-8.6 1.7-13.3 1.7-7.3 0-13.8-1.2-19.4-3.6s-10.9-6.3-15.9-11.8l10-8.8c6.7 8.6 15.3 12.8 25.9 12.8 6.9 0 12-1.3 15.4-3.9 3.3-2.6 5-5.7 5-9.3 0-2.8-.6-5.2-1.8-7.1s-2.9-3.5-5-4.9c-2.1-1.3-4.5-2.5-7.3-3.4q-4.2-1.35-8.7-2.7c-3.5-1-6.9-2.1-10.3-3.3s-6.4-2.8-9.1-4.7q-4.05-2.85-6.6-7.2c-1.7-2.9-2.6-6.7-2.6-11.2 0-8.1 2.9-14.3 8.8-18.7s13.5-6.6 22.9-6.6c6.6 0 12.6 1.1 17.8 3.3s10.1 6 14.9 11.5l-9.4 8.6zm34.1 70V36.6h12.8v128.1zm12.8-47.3 44-44.4h17.2l-45.1 44 44.9 47.6h-17.8z" style="fill:#fff"/><path fill="#53bce6" d="M191.7 210.2h-77.2v-12.1h77.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512"><style>.st2{fill:none;stroke:#fff;stroke-width:12.4651;stroke-linecap:round;stroke-linejoin:round}</style><g id="Layer_x0020_3"><path d="M378.5 372.5c3.2-26.9 2.3-30.8 22.3-26.5l5.1.4c15.4.7 35.5-2.5 47.4-8 25.5-11.8 40.6-31.5 15.5-26.4-57.3 11.8-61.2-7.6-61.2-7.6 60.5-89.7 85.8-203.6 63.9-231.5C411.9-3 308.8 33 307.1 33.9l-.5.1c-11.3-2.3-24-3.8-38.2-4-25.9-.4-45.6 6.8-60.5 18.1 0 0-183.8-75.7-175.2 95.2 1.8 36.4 52.1 275.2 112.1 203 21.9-26.4 43.1-48.7 43.1-48.7 10.5 7 23.1 10.6 36.3 9.3l1-.9c-.3 3.3-.2 6.5.4 10.3-15.5 17.3-10.9 20.3-41.8 26.7-31.3 6.4-12.9 17.9-.9 20.9 14.5 3.6 48.2 8.8 70.9-23l-.9 3.6c6.1 4.9 5.7 34.9 6.5 56.3.9 21.4 2.3 41.5 6.7 53.3s9.5 42.2 50.1 33.5c34-7.3 59.9-17.7 62.3-115.1" style="stroke:#000;stroke-width:37.3953"/><path d="M468.7 312.1c-57.3 11.8-61.2-7.6-61.2-7.6C468 214.8 493.3 100.9 471.4 73 411.9-3 308.8 33 307.1 33.9l-.6.1c-11.3-2.3-24-3.7-38.2-4-25.9-.4-45.6 6.8-60.5 18.1 0 0-183.8-75.7-175.2 95.2 1.8 36.4 52.1 275.2 112.1 203 21.9-26.4 43.1-48.7 43.1-48.7 10.5 7 23.1 10.6 36.3 9.3l1-.9c-.3 3.3-.2 6.5.4 10.3-15.5 17.3-10.9 20.3-41.8 26.7-31.3 6.4-12.9 17.9-.9 20.9 14.5 3.6 48.2 8.8 70.9-23l-.9 3.6c6.1 4.9 10.3 31.6 9.6 55.8s-1.2 40.8 3.6 53.8 9.5 42.2 50.1 33.5c33.9-7.3 51.5-26.1 54-57.6 1.7-22.4 5.7-19 5.9-39l3.2-9.5c3.6-30.3.6-40.1 21.5-35.5l5.1.4c15.4.7 35.5-2.5 47.4-8 25.5-11.7 40.5-31.4 15.5-26.3" style="fill:#336791"/><path d="M256.3 329.5c-1.6 56.4.4 113.2 5.9 126.9 5.5 13.8 17.3 40.6 58 31.9 33.9-7.3 46.3-21.4 51.6-52.4 3.9-22.9 11.6-86.4 12.5-99.4M207.6 46.9S23.7-28.3 32.2 142.7c1.8 36.4 52.1 275.2 112.1 203 21.9-26.4 41.8-47.1 41.8-47.1M306.9 33.2c-6.4 2 102.3-39.7 164.1 39.2 21.8 27.9-3.5 141.8-63.9 231.5" class="st2"/><path d="M407 303.9s3.9 19.4 61.2 7.6c25.1-5.2 10 14.5-15.5 26.4-20.9 9.7-67.7 12.2-68.5-1.2-1.9-34.7 24.8-24.2 22.8-32.8-1.7-7.8-13.6-15.5-21.5-34.5-6.9-16.7-94.3-144.4 24.2-125.5 4.3-.9-30.9-112.7-141.8-114.5S160.7 165.8 160.7 165.8" style="fill:none;stroke:#fff;stroke-width:12.4651;stroke-linecap:round;stroke-linejoin:bevel"/><path d="M225.2 315.7c-15.5 17.3-10.9 20.3-41.8 26.7-31.3 6.4-12.9 17.9-.9 20.9 14.5 3.6 48.2 8.8 70.9-23 6.9-9.7 0-25.1-9.5-29.1-4.6-2-10.8-4.4-18.7 4.5" class="st2"/><path d="M224.2 315.4c-1.6-10.2 3.3-22.2 8.6-36.4 7.9-21.2 26.1-42.4 11.5-109.7-10.8-50.1-83.6-10.4-83.6-3.6s3.3 34.5-1.2 66.7c-5.9 42 26.7 77.6 64.3 73.9" class="st2"/><path d="M206.9 164.7c-.3 2.3 4.3 8.5 10.2 9.3 6 .8 11.1-4 11.4-6.3s-4.2-4.9-10.2-5.7c-6-.9-11.1.3-11.4 2.7z" style="fill:#fff;stroke:#fff;stroke-width:4.155"/><path d="M388.4 159.9c.3 2.3-4.2 8.5-10.2 9.3s-11.1-4-11.4-6.3 4.3-4.9 10.2-5.7 11.1.4 11.4 2.7z" style="fill:#fff;stroke:#fff;stroke-width:2.0775"/><path d="M409.8 143.9c1 18.2-3.9 30.6-4.5 50-.9 28.2 13.4 60.4-8.2 92.7" class="st2"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="#DC205E" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Quad9</title><path d="M6.822 24h5.608l6.331-9.48c1.463-2.185 2.288-4.197 2.288-6.4C21.05 3.458 17.144 0 12 0 6.822 0 2.95 3.493 2.95 8.207c0 4.507 3.459 8 8.345 8 .413 0 .757-.018 1.083-.07zM12 12.129c-2.426 0-4.215-1.634-4.215-3.957 0-2.34 1.79-3.957 4.215-3.957 2.409 0 4.215 1.617 4.215 3.957 0 2.323-1.806 3.957-4.215 3.957z"/></svg>
|
||||
|
After Width: | Height: | Size: 430 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet" viewBox="0 0.58 256 218.59"><path fill="#912626" d="M245.97 168.943c-13.662 7.121-84.434 36.22-99.501 44.075s-23.437 7.78-35.34 2.09c-11.902-5.69-87.216-36.112-100.783-42.597C3.566 169.271 0 166.535 0 163.951v-25.876s98.05-21.345 113.879-27.024c15.828-5.679 21.32-5.884 34.79-.95 13.472 4.936 94.018 19.468 107.331 24.344l-.006 25.51c.002 2.558-3.07 5.364-10.024 8.988"/><path fill="#C6302B" d="M245.965 143.22c-13.661 7.118-84.431 36.218-99.498 44.072-15.066 7.857-23.436 7.78-35.338 2.09-11.903-5.686-87.214-36.113-100.78-42.594-13.566-6.485-13.85-10.948-.524-16.166 13.326-5.22 88.224-34.605 104.055-40.284 15.828-5.677 21.319-5.884 34.789-.948 13.471 4.934 83.819 32.935 97.13 37.81 13.316 4.881 13.827 8.9.166 16.02"/><path fill="#912626" d="M245.97 127.074c-13.662 7.122-84.434 36.22-99.501 44.078-15.067 7.853-23.437 7.777-35.34 2.087-11.903-5.687-87.216-36.112-100.783-42.597C3.566 127.402 0 124.67 0 122.085V96.206s98.05-21.344 113.879-27.023c15.828-5.679 21.32-5.885 34.79-.95C162.142 73.168 242.688 87.697 256 92.574l-.006 25.513c.002 2.557-3.07 5.363-10.024 8.987"/><path fill="#C6302B" d="M245.965 101.351c-13.661 7.12-84.431 36.218-99.498 44.075-15.066 7.854-23.436 7.777-35.338 2.087-11.903-5.686-87.214-36.112-100.78-42.594-13.566-6.483-13.85-10.947-.524-16.167C23.151 83.535 98.05 54.148 113.88 48.47c15.828-5.678 21.319-5.884 34.789-.949 13.471 4.934 83.819 32.933 97.13 37.81 13.316 4.88 13.827 8.9.166 16.02"/><path fill="#912626" d="M245.97 83.653c-13.662 7.12-84.434 36.22-99.501 44.078-15.067 7.854-23.437 7.777-35.34 2.087-11.903-5.687-87.216-36.113-100.783-42.595C3.566 83.98 0 81.247 0 78.665v-25.88s98.05-21.343 113.879-27.021c15.828-5.68 21.32-5.884 34.79-.95C162.142 29.749 242.688 44.278 256 49.155l-.006 25.512c.002 2.555-3.07 5.361-10.024 8.986"/><path fill="#C6302B" d="M245.965 57.93c-13.661 7.12-84.431 36.22-99.498 44.074-15.066 7.854-23.436 7.777-35.338 2.09C99.227 98.404 23.915 67.98 10.35 61.497-3.217 55.015-3.5 50.55 9.825 45.331 23.151 40.113 98.05 10.73 113.88 5.05c15.828-5.679 21.319-5.883 34.789-.948s83.819 32.934 97.13 37.811c13.316 4.876 13.827 8.897.166 16.017"/><path fill="#FFF" d="m159.283 32.757-22.01 2.285-4.927 11.856-7.958-13.23-25.415-2.284 18.964-6.839-5.69-10.498 17.755 6.944 16.738-5.48-4.524 10.855zm-28.251 57.518L89.955 73.238l58.86-9.035zm-56.95-50.928c17.375 0 31.46 5.46 31.46 12.194 0 6.736-14.085 12.195-31.46 12.195s-31.46-5.46-31.46-12.195c0-6.734 14.085-12.194 31.46-12.194"/><path fill="#621B1C" d="m185.295 35.998 34.836 13.766-34.806 13.753z"/><path fill="#9A2928" d="m146.755 51.243 38.54-15.245.03 27.519-3.779 1.478z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M357.8 237.3V359c-1.1 19.5-16 35.4-35.4 37.8h-84.9v.1c-17.2.2-31.3-13.7-31.5-30.9-.1-17.2 13.8-31.2 31.1-31.3h38.4c11.2 0 20.3-9 20.3-20.1v-77.3c0-17 13.9-30.9 31.1-30.9 17 0 30.9 13.9 30.9 30.9m-140.3 37.8v-77.3c0-11.1 9.1-20.2 20.3-20.2h38.5c8.3 0 16.3-3.4 22.1-9.2 5.8-5.9 9-13.8 9-22.1-.3-17.1-14.3-30.8-31.5-30.8h-85c-19.4 2.4-34.3 18.4-35.3 37.9v121.7c0 17.1 13.9 30.9 31.1 30.9 17 0 30.8-13.9 30.8-30.9m256.4-92.5v243.5c-5 46.1-42.2 82.1-88.7 85.9h-163c-48.8.1-95.6-19.1-130.3-53.5C57.4 424.3 38 377.7 38 329.1V97.2C38 43.6 81.7.2 135.6 0h154.2c48.7-.1 95.5 19.1 130.1 53.4 34.5 34.1 54 80.7 54 129.2m-62.2 1.8c.1-32.4-12.7-63.5-35.7-86.3-23-22.9-54.2-35.8-86.7-35.7L138 62.1c-20.9.2-37.7 17.2-37.7 38.1v227.7c0 32.3 12.9 63.3 35.8 86.1 23 22.9 54.1 35.7 86.6 35.6h156.5c16.4-2.2 29.5-14.8 32.4-31.1V184.4z" style="fill:#55139a"/></svg>
|
||||
|
After Width: | Height: | Size: 935 B |
@@ -0,0 +1 @@
|
||||
<svg fill="#003B57" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>SQLite</title><path d="M21.678.521c-1.032-.92-2.28-.55-3.513.544a8.71 8.71 0 0 0-.547.535c-2.109 2.237-4.066 6.38-4.674 9.544.237.48.422 1.093.544 1.561a13.044 13.044 0 0 1 .164.703s-.019-.071-.096-.296l-.05-.146a1.689 1.689 0 0 0-.033-.08c-.138-.32-.518-.995-.686-1.289-.143.423-.27.818-.376 1.176.484.884.778 2.4.778 2.4s-.025-.099-.147-.442c-.107-.303-.644-1.244-.772-1.464-.217.804-.304 1.346-.226 1.478.152.256.296.698.422 1.186.286 1.1.485 2.44.485 2.44l.017.224a22.41 22.41 0 0 0 .056 2.748c.095 1.146.273 2.13.5 2.657l.155-.084c-.334-1.038-.47-2.399-.41-3.967.09-2.398.642-5.29 1.661-8.304 1.723-4.55 4.113-8.201 6.3-9.945-1.993 1.8-4.692 7.63-5.5 9.788-.904 2.416-1.545 4.684-1.931 6.857.666-2.037 2.821-2.912 2.821-2.912s1.057-1.304 2.292-3.166c-.74.169-1.955.458-2.362.629-.6.251-.762.337-.762.337s1.945-1.184 3.613-1.72C21.695 7.9 24.195 2.767 21.678.521m-18.573.543A1.842 1.842 0 0 0 1.27 2.9v16.608a1.84 1.84 0 0 0 1.835 1.834h9.418a22.953 22.953 0 0 1-.052-2.707c-.006-.062-.011-.141-.016-.2a27.01 27.01 0 0 0-.473-2.378c-.121-.47-.275-.898-.369-1.057-.116-.197-.098-.31-.097-.432 0-.12.015-.245.037-.386a9.98 9.98 0 0 1 .234-1.045l.217-.028c-.017-.035-.014-.065-.031-.097l-.041-.381a32.8 32.8 0 0 1 .382-1.194l.2-.019c-.008-.016-.01-.038-.018-.053l-.043-.316c.63-3.28 2.587-7.443 4.8-9.791.066-.069.133-.128.198-.194Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>TencentCloud</title><path d="M20.0483 17.1416C19.6945 17.4914 18.987 18.0161 17.7488 18.0161C17.2182 18.0161 16.5991 18.0161 16.3338 18.0161C15.98 18.0161 13.3268 18.0161 10.143 18.0161C12.4424 15.8298 14.3881 13.9932 14.565 13.8183C14.7419 13.6434 15.1841 13.2061 15.6263 12.8563C16.5107 12.0692 17.2182 11.9817 17.8373 11.9817C18.7217 11.9817 19.4292 12.3316 20.0483 12.8563C21.2864 13.9932 21.2864 16.0047 20.0483 17.1416ZM21.5518 11.457C20.6674 10.495 19.3408 9.88281 17.9257 9.88281C16.6875 9.88281 15.6263 10.3201 14.6534 11.0197C14.2997 11.3695 13.769 11.7194 13.3268 12.2441C12.9731 12.5939 5.36719 19.9401 5.36719 19.9401C5.80939 20.0276 6.34003 20.0276 6.78223 20.0276C7.22443 20.0276 16.0685 20.0276 16.4222 20.0276C17.1298 20.0276 17.6604 20.0276 18.191 19.9401C19.3408 19.8527 20.4905 19.4154 21.4633 18.5409C23.4975 16.6168 23.4975 13.381 21.5518 11.457Z" fill="#00A3FF"></path><path d="M9.1701 10.9323C8.19726 10.2326 7.22442 9.88281 6.07469 9.88281C4.65965 9.88281 3.33304 10.495 2.44864 11.457C0.502952 13.4685 0.502952 16.6168 2.53708 18.6283C3.42148 19.4154 4.30589 19.8527 5.36717 19.9401L7.4013 18.0161C7.04754 18.0161 6.60533 18.0161 6.25157 18.0161C5.10185 17.9287 4.39433 17.5789 3.95212 17.1416C2.71396 15.9172 2.71396 13.9932 3.86368 12.7688C4.48277 12.1566 5.19029 11.8943 6.07469 11.8943C6.60533 11.8943 7.4013 11.9817 8.19726 12.7688C8.55102 13.1186 9.52386 13.8183 9.87763 14.1681H9.96607L11.2927 12.8563V12.7688C10.6736 12.1566 9.70075 11.3695 9.1701 10.9323Z" fill="#00C8DC"></path><path d="M18.4564 8.74536C17.4836 6.12171 14.9188 4.28516 12.0003 4.28516C8.5511 4.28516 5.80945 6.82135 5.27881 9.96973C5.54413 9.96973 5.80945 9.88228 6.16321 9.88228C6.51697 9.88228 6.95917 9.96973 7.31294 9.96973C7.75514 7.78336 9.70082 6.20917 12.0003 6.20917C13.946 6.20917 15.6263 7.34608 16.4223 9.00773C16.4223 9.00773 16.5107 9.09518 16.5107 9.00773C17.1298 8.92027 17.8373 8.74536 18.4564 8.74536C18.4564 8.83282 18.4564 8.83282 18.4564 8.74536Z" fill="#006EFF"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="m126.1 431.2-91.7-57.4V128.6L258.7 0l218.9 128.8v258L255.2 512 178 463.7V346.2L136.2 320V187l121.2-69.5 118.4 69.7v139.4L282 379.4v-56.1c28.1-10.8 48.3-38.6 48.3-71.5 0-42.3-33.5-76.3-74.3-76.3s-74.3 34-74.3 76.3c0 32.8 20.2 60.6 48.3 71.5v106.3l26.8 16.8 164.4-92.6V161.1L258.3 65.3l-167.5 96v181.3l35.3 22.1zM256 216.7c18.5 0 33.1 15.9 33.1 35.1 0 19.1-14.6 35.1-33.1 35.1s-33.1-15.9-33.1-35.1 14.6-35.1 33.1-35.1" style="fill-rule:evenodd;clip-rule:evenodd;fill:#123678"/></svg>
|
||||
|
After Width: | Height: | Size: 573 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1155" height="1000" fill="none"><path fill="#000" d="m577.344 0 577.346 1000H0z"/></svg>
|
||||
|
After Width: | Height: | Size: 135 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="14.4 37.7 267 229.53"><path fill="#c9f4ff" d="M107.4 44.1c-2.4-3.9-6.7-6.3-11.3-6.4H27.8c-7.4 0-13.4 6-13.4 13.4 0 2.5.7 5 2 7.1L34 84.1 122.1 65z"/><path fill="#51b9ff" d="M122.5 65.5c-2.4-3.9-6.7-6.3-11.3-6.4H43c-7.3-.3-13.5 5.3-13.9 12.7-.1 2.8.6 5.6 2.2 7.9L51 110.9l91-14.2z"/><path d="M50.1 109.6c-2.2-3.6-2.7-8-1.2-12 2-5.2 7.1-8.6 12.7-8.5h68.2c4.6 0 8.9 2.4 11.3 6.4l61.2 97.1c1.3 2.1 2 4.6 2 7.1s-.7 5-2 7.1L168.1 261c-4 6.3-12.3 8.1-18.5 4.1-1.7-1.1-3.1-2.5-4.1-4.1zm172.5 31.1c4 6.3 12.3 8.1 18.5 4.1 1.7-1.1 3.1-2.5 4.1-4.1L257 122l22.4-35.6c1.3-2.1 2-4.6 2-7.1s-.7-5-2-7.1l-17.8-28.1c-2.4-3.9-6.7-6.3-11.3-6.4h-68.4c-7.4 0-13.4 6-13.4 13.4 0 2.5.7 5 2.1 7.1z" style="fill:#007bfc"/></svg>
|
||||
|
After Width: | Height: | Size: 772 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1309" height="1581" version="1.2"><path d="M792.7 1312.1v268.3H518.1V1128L0 .7h286.5l403.7 881.9c77.8 168.5 102.5 227.1 102.5 429.5M1308.9.7 972.1 764.5H693L1029.8.7z" style="fill:#fc3f1d"/></svg>
|
||||
|
After Width: | Height: | Size: 243 B |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,139 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Populates the disposable Stalwart test server with sample Users, Groups,
|
||||
Mailing Lists, and Roles, so each admin panel screen has something to
|
||||
click through and test against.
|
||||
|
||||
.DESCRIPTION
|
||||
Local development only. Run after scripts/dev-server-init.ps1.
|
||||
Idempotent: does nothing if the seed users already exist. Only a fresh
|
||||
volume (`docker compose down -v`) clears this data.
|
||||
#>
|
||||
param(
|
||||
[string]$ApiBaseUrl = "http://localhost:8080"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$DevAdminAccount = "devadmin@example.org"
|
||||
$DevAdminSecret = "DevAdminPass123!"
|
||||
|
||||
$creds = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$($DevAdminAccount):$($DevAdminSecret)"))
|
||||
$authHeader = @{ Authorization = "Basic $creds" }
|
||||
|
||||
function Invoke-Jmap($body) {
|
||||
Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/" -Method Post -ContentType "application/json" -Headers $authHeader -Body ($body | ConvertTo-Json -Depth 10 -Compress) -TimeoutSec 15
|
||||
}
|
||||
|
||||
try {
|
||||
$session = Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 15
|
||||
} catch {
|
||||
throw "Could not reach $ApiBaseUrl as $DevAdminAccount. Run scripts/dev-server-init.ps1 first."
|
||||
}
|
||||
$accountId = $session.primaryAccounts.'urn:stalwart:jmap'
|
||||
|
||||
$existing = Invoke-Jmap @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:Account/query", @{ accountId = $accountId; filter = @{ text = "alice" } }, "0"))
|
||||
}
|
||||
if ($existing.methodResponses[0][1].ids.Count -gt 0) {
|
||||
Write-Host "Seed data already present (found 'alice'), skipping. Use 'docker compose down -v' + dev-server-init.ps1 to start fresh."
|
||||
return
|
||||
}
|
||||
|
||||
$domainResult = Invoke-Jmap @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:Domain/query", @{ accountId = $accountId }, "0"))
|
||||
}
|
||||
$domainId = $domainResult.methodResponses[0][1].ids[0]
|
||||
|
||||
Write-Host "Creating groups..."
|
||||
$groupsResult = Invoke-Jmap @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:Account/set", @{
|
||||
accountId = $accountId
|
||||
create = @{
|
||||
g_eng = @{ "@type" = "Group"; name = "engineering"; domainId = $domainId; description = "Engineering team" }
|
||||
g_mkt = @{ "@type" = "Group"; name = "marketing"; domainId = $domainId; description = "Marketing team" }
|
||||
}
|
||||
}, "0"))
|
||||
}
|
||||
$groupEngId = $groupsResult.methodResponses[0][1].created.g_eng.id
|
||||
$groupMktId = $groupsResult.methodResponses[0][1].created.g_mkt.id
|
||||
|
||||
Write-Host "Creating users..."
|
||||
$usersResult = Invoke-Jmap @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:Account/set", @{
|
||||
accountId = $accountId
|
||||
create = @{
|
||||
u_alice = @{
|
||||
"@type" = "User"; name = "alice"; domainId = $domainId; description = "Alice Smith"
|
||||
roles = @{ "@type" = "User" }
|
||||
credentials = @{ "0" = @{ "@type" = "Password"; secret = "AlicePass123!" } }
|
||||
memberGroupIds = @{ $groupEngId = $true }
|
||||
}
|
||||
u_bob = @{
|
||||
"@type" = "User"; name = "bob"; domainId = $domainId; description = "Bob Jones"
|
||||
roles = @{ "@type" = "User" }
|
||||
credentials = @{ "0" = @{ "@type" = "Password"; secret = "BobPass123!" } }
|
||||
memberGroupIds = @{ $groupEngId = $true; $groupMktId = $true }
|
||||
}
|
||||
u_carol = @{
|
||||
"@type" = "User"; name = "carol"; domainId = $domainId; description = "Carol Diaz"
|
||||
roles = @{ "@type" = "Admin" }
|
||||
credentials = @{ "0" = @{ "@type" = "Password"; secret = "CarolPass123!" } }
|
||||
memberGroupIds = @{ $groupMktId = $true }
|
||||
}
|
||||
}
|
||||
}, "0"))
|
||||
}
|
||||
$userAliceId = $usersResult.methodResponses[0][1].created.u_alice.id
|
||||
|
||||
Write-Host "Adding an alias to alice (for the Aliases column)..."
|
||||
Invoke-Jmap @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:Account/set", @{
|
||||
accountId = $accountId
|
||||
update = @{ $userAliceId = @{ aliases = @{ "0" = @{ name = "a.smith"; domainId = $domainId } } } }
|
||||
}, "0"))
|
||||
} | Out-Null
|
||||
|
||||
Write-Host "Creating mailing lists..."
|
||||
Invoke-Jmap @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:MailingList/set", @{
|
||||
accountId = $accountId
|
||||
create = @{
|
||||
m_news = @{
|
||||
name = "newsletter"; domainId = $domainId; description = "Company newsletter"
|
||||
recipients = @{ "alice@example.org" = $true; "bob@example.org" = $true; "carol@example.org" = $true }
|
||||
}
|
||||
m_support = @{
|
||||
name = "support"; domainId = $domainId; description = "Support queue"
|
||||
recipients = @{ "bob@example.org" = $true }
|
||||
}
|
||||
}
|
||||
}, "0"))
|
||||
} | Out-Null
|
||||
|
||||
Write-Host "Creating roles..."
|
||||
Invoke-Jmap @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:Role/set", @{
|
||||
accountId = $accountId
|
||||
create = @{
|
||||
r_support = @{ description = "Support Agent"; enabledPermissions = @{ authenticate = $true; emailSend = $true; emailReceive = $true } }
|
||||
r_audit = @{ description = "Read-only Auditor"; enabledPermissions = @{ authenticate = $true } }
|
||||
}
|
||||
}, "0"))
|
||||
} | Out-Null
|
||||
|
||||
Write-Host "Done. Seeded:"
|
||||
Write-Host " Users: alice@example.org / AlicePass123! (User role, in engineering, 1 alias)"
|
||||
Write-Host " bob@example.org / BobPass123! (User role, in engineering + marketing)"
|
||||
Write-Host " carol@example.org / CarolPass123! (Admin role, in marketing)"
|
||||
Write-Host " Groups: engineering, marketing"
|
||||
Write-Host " Mailing lists: newsletter@example.org, support@example.org"
|
||||
Write-Host " Roles: Support Agent, Read-only Auditor"
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local development only. Populates the disposable Stalwart test server
|
||||
# (docker-compose.yml) with sample data across Users, Groups, Mailing Lists,
|
||||
# and Roles, so each admin panel screen has something to click through and
|
||||
# test against. Run after scripts/dev-server-init.sh.
|
||||
#
|
||||
# Idempotent: does nothing if the seed users already exist (re-running
|
||||
# scripts/dev-server-init.sh does not wipe this data; only a fresh volume
|
||||
# via `docker compose down -v` does).
|
||||
set -euo pipefail
|
||||
|
||||
API_BASE_URL="${1:-http://localhost:8080}"
|
||||
DEVADMIN_ACCOUNT="devadmin@example.org"
|
||||
DEVADMIN_SECRET="DevAdminPass123!"
|
||||
|
||||
jmap() {
|
||||
curl -sf --compressed -u "$DEVADMIN_ACCOUNT:$DEVADMIN_SECRET" \
|
||||
-X POST -H "Content-Type: application/json" -d "$1" "$API_BASE_URL/jmap/"
|
||||
}
|
||||
|
||||
extract_id() {
|
||||
# $1 = JSON response, $2 = create-id key (e.g. "u1")
|
||||
printf '%s' "$1" | grep -o "\"$2\":{\"id\":\"[^\"]*\"" | head -1 | grep -o '"id":"[^"]*"' | cut -d'"' -f4
|
||||
}
|
||||
|
||||
SESSION=$(curl -sf --compressed -u "$DEVADMIN_ACCOUNT:$DEVADMIN_SECRET" "$API_BASE_URL/jmap/session") || {
|
||||
echo "Could not reach $API_BASE_URL as $DEVADMIN_ACCOUNT. Run scripts/dev-server-init.sh first." >&2
|
||||
exit 1
|
||||
}
|
||||
ACCOUNT_ID=$(printf '%s' "$SESSION" | grep -o '"urn:stalwart:jmap":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
EXISTING=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/query\",{\"accountId\":\"$ACCOUNT_ID\",\"filter\":{\"text\":\"alice\"}},\"0\"]]}")
|
||||
if printf '%s' "$EXISTING" | grep -q '"ids":\["'; then
|
||||
echo "Seed data already present (found 'alice'), skipping. Use 'docker compose down -v' + dev-server-init.sh to start fresh."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DOMAIN_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Domain/query\",{\"accountId\":\"$ACCOUNT_ID\"},\"0\"]]}")
|
||||
DOMAIN_ID=$(printf '%s' "$DOMAIN_RESULT" | grep -o '"ids":\["[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
echo "Creating groups..."
|
||||
GROUPS_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{
|
||||
\"g_eng\":{\"@type\":\"Group\",\"name\":\"engineering\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Engineering team\"},
|
||||
\"g_mkt\":{\"@type\":\"Group\",\"name\":\"marketing\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Marketing team\"}
|
||||
}},\"0\"]]}")
|
||||
GROUP_ENG_ID=$(extract_id "$GROUPS_RESULT" g_eng)
|
||||
GROUP_MKT_ID=$(extract_id "$GROUPS_RESULT" g_mkt)
|
||||
|
||||
echo "Creating users..."
|
||||
USERS_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{
|
||||
\"u_alice\":{\"@type\":\"User\",\"name\":\"alice\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Alice Smith\",\"roles\":{\"@type\":\"User\"},\"credentials\":{\"0\":{\"@type\":\"Password\",\"secret\":\"AlicePass123!\"}},\"memberGroupIds\":{\"$GROUP_ENG_ID\":true}},
|
||||
\"u_bob\":{\"@type\":\"User\",\"name\":\"bob\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Bob Jones\",\"roles\":{\"@type\":\"User\"},\"credentials\":{\"0\":{\"@type\":\"Password\",\"secret\":\"BobPass123!\"}},\"memberGroupIds\":{\"$GROUP_ENG_ID\":true,\"$GROUP_MKT_ID\":true}},
|
||||
\"u_carol\":{\"@type\":\"User\",\"name\":\"carol\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Carol Diaz\",\"roles\":{\"@type\":\"Admin\"},\"credentials\":{\"0\":{\"@type\":\"Password\",\"secret\":\"CarolPass123!\"}},\"memberGroupIds\":{\"$GROUP_MKT_ID\":true}}
|
||||
}},\"0\"]]}")
|
||||
USER_ALICE_ID=$(extract_id "$USERS_RESULT" u_alice)
|
||||
|
||||
echo "Adding an alias to alice (for the Aliases column)..."
|
||||
jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/set\",{\"accountId\":\"$ACCOUNT_ID\",\"update\":{\"$USER_ALICE_ID\":{\"aliases\":{\"0\":{\"name\":\"a.smith\",\"domainId\":\"$DOMAIN_ID\"}}}}},\"0\"]]}" > /dev/null
|
||||
|
||||
echo "Creating mailing lists..."
|
||||
jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:MailingList/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{
|
||||
\"m_news\":{\"name\":\"newsletter\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Company newsletter\",\"recipients\":{\"alice@example.org\":true,\"bob@example.org\":true,\"carol@example.org\":true}},
|
||||
\"m_support\":{\"name\":\"support\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Support queue\",\"recipients\":{\"bob@example.org\":true}}
|
||||
}},\"0\"]]}" > /dev/null
|
||||
|
||||
echo "Creating roles..."
|
||||
jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Role/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{
|
||||
\"r_support\":{\"description\":\"Support Agent\",\"enabledPermissions\":{\"authenticate\":true,\"emailSend\":true,\"emailReceive\":true}},
|
||||
\"r_audit\":{\"description\":\"Read-only Auditor\",\"enabledPermissions\":{\"authenticate\":true}}
|
||||
}},\"0\"]]}" > /dev/null
|
||||
|
||||
echo "Done. Seeded:"
|
||||
echo " Users: alice@example.org / AlicePass123! (User role, in engineering, 1 alias)"
|
||||
echo " bob@example.org / BobPass123! (User role, in engineering + marketing)"
|
||||
echo " carol@example.org / CarolPass123! (Admin role, in marketing)"
|
||||
echo " Groups: engineering, marketing"
|
||||
echo " Mailing lists: newsletter@example.org, support@example.org"
|
||||
echo " Roles: Support Agent, Read-only Auditor"
|
||||
@@ -0,0 +1,147 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
One-time setup for the disposable Stalwart test server from
|
||||
docker-compose.yml: completes the bootstrap wizard, creates a real
|
||||
"devadmin" account, and sets the default OAuth access token lifetime.
|
||||
|
||||
.DESCRIPTION
|
||||
Local development only. Requires the dev container to be running
|
||||
('npm run dev:server'). The STALWART_RECOVERY_ADMIN account is
|
||||
break-glass only and always issues fixed 1h OAuth tokens regardless of
|
||||
server config, so dev-token.ps1 authenticates as "devadmin" instead,
|
||||
created here. Idempotent: safe to re-run; does nothing if the server
|
||||
already left bootstrap mode.
|
||||
#>
|
||||
param(
|
||||
[string]$ApiBaseUrl = "http://localhost:8080"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$RecoveryAccount = "admin@example.org"
|
||||
$RecoverySecret = "c8321iEscHDy0GWV"
|
||||
$DevDomain = "example.org"
|
||||
$DevHostname = "mail.example.org"
|
||||
$DevAdminName = "devadmin"
|
||||
$DevAdminSecret = "DevAdminPass123!"
|
||||
$DefaultTokenExpiryMs = 10800000 # 3 hours
|
||||
|
||||
$recoveryCreds = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$($RecoveryAccount):$($RecoverySecret)"))
|
||||
$authHeader = @{ Authorization = "Basic $recoveryCreds" }
|
||||
|
||||
function Invoke-Jmap($body) {
|
||||
Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/" -Method Post -ContentType "application/json" -Headers $authHeader -Body ($body | ConvertTo-Json -Depth 10 -Compress) -TimeoutSec 15
|
||||
}
|
||||
|
||||
Write-Host "Waiting for $ApiBaseUrl to be reachable..."
|
||||
$ready = $false
|
||||
for ($i = 0; $i -lt 30; $i++) {
|
||||
try {
|
||||
Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 5 | Out-Null
|
||||
$ready = $true
|
||||
break
|
||||
} catch { Start-Sleep -Seconds 1 }
|
||||
}
|
||||
if (-not $ready) { throw "Server did not become reachable at $ApiBaseUrl" }
|
||||
|
||||
$session = Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 15
|
||||
$accountId = $session.primaryAccounts.'urn:stalwart:jmap'
|
||||
|
||||
$queryResult = Invoke-Jmap @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:Domain/query", @{ accountId = $accountId }, "0"))
|
||||
}
|
||||
$alreadyBootstrapped = -not ($queryResult.methodResponses[0][1].type -eq "forbidden")
|
||||
|
||||
if ($alreadyBootstrapped) {
|
||||
Write-Host "Server already bootstrapped, skipping setup. (Use 'docker compose down -v; npm run dev:server' to start fresh.)"
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Completing server bootstrap (domain: $DevDomain, no TLS certificate request)..."
|
||||
Invoke-Jmap @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:Bootstrap/set", @{
|
||||
accountId = $accountId
|
||||
update = @{ singleton = @{ defaultDomain = $DevDomain; serverHostname = $DevHostname; requestTlsCertificate = $false } }
|
||||
}, "0"))
|
||||
} | Out-Null
|
||||
|
||||
Write-Host "Restarting the container to apply bootstrap config (one-time only)..."
|
||||
docker compose restart stalwart | Out-Null
|
||||
$ready = $false
|
||||
for ($i = 0; $i -lt 30; $i++) {
|
||||
try {
|
||||
Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 5 | Out-Null
|
||||
$ready = $true
|
||||
break
|
||||
} catch { Start-Sleep -Seconds 1 }
|
||||
}
|
||||
if (-not $ready) { throw "Server did not come back up after restart" }
|
||||
|
||||
$domainResult = Invoke-Jmap @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:Domain/query", @{ accountId = $accountId }, "0"))
|
||||
}
|
||||
$domainId = $domainResult.methodResponses[0][1].ids[0]
|
||||
|
||||
Write-Host "Creating devadmin account ($DevAdminName@$DevDomain)..."
|
||||
$createResult = Invoke-Jmap @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:Account/set", @{
|
||||
accountId = $accountId
|
||||
create = @{ u1 = @{ "@type" = "User"; name = $DevAdminName; domainId = $domainId; roles = @{ "@type" = "Admin" } } }
|
||||
}, "0"))
|
||||
}
|
||||
$devAdminId = $createResult.methodResponses[0][1].created.u1.id
|
||||
|
||||
Invoke-Jmap @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:Account/set", @{
|
||||
accountId = $accountId
|
||||
update = @{ $devAdminId = @{ credentials = @{ "0" = @{ "@type" = "Password"; secret = $DevAdminSecret } } } }
|
||||
}, "0"))
|
||||
} | Out-Null
|
||||
|
||||
Write-Host "Setting default OAuth access token lifetime to 3 hours..."
|
||||
Invoke-Jmap @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:OidcProvider/set", @{
|
||||
accountId = $accountId
|
||||
update = @{ singleton = @{ accessTokenExpiry = $DefaultTokenExpiryMs } }
|
||||
}, "0"))
|
||||
} | Out-Null
|
||||
try {
|
||||
Invoke-Jmap @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:Action/set", @{ accountId = $accountId; create = @{ a1 = @{ "@type" = "ReloadSettings" } } }, "0"))
|
||||
} | Out-Null
|
||||
} catch {}
|
||||
|
||||
# Cosmetic only: Stalwart seeds a default "Stalwart Web Interface" /
|
||||
# stalwartlabs/webui entry for the x:Application record serving /admin and
|
||||
# /account (see Settings > Web Applications' "Active WebUI" card). Point it
|
||||
# at this fork so that card isn't misleading in local dev too.
|
||||
Write-Host "Pointing the active WebUI's description at this fork..."
|
||||
try {
|
||||
$appResult = Invoke-Jmap @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:Application/query", @{ accountId = $accountId }, "0"))
|
||||
}
|
||||
$appId = $appResult.methodResponses[0][1].ids[0]
|
||||
if ($appId) {
|
||||
Invoke-Jmap @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:Application/set", @{
|
||||
accountId = $accountId
|
||||
update = @{ $appId = @{
|
||||
description = "Stalwart WebUI Fork"
|
||||
resourceUrl = "https://github.com/LinkPhoenix/stalwart-webui-fork/releases/latest/download/webui.zip"
|
||||
} }
|
||||
}, "0"))
|
||||
} | Out-Null
|
||||
}
|
||||
} catch {}
|
||||
|
||||
Write-Host "Done. $DevAdminName@$DevDomain / $DevAdminSecret is ready - run scripts/dev-token.ps1 to get a token."
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local development only. One-time setup for the disposable Stalwart test
|
||||
# server from docker-compose.yml: completes the server's bootstrap wizard,
|
||||
# creates a real "devadmin" account (used by dev-token.sh/.ps1 — the
|
||||
# STALWART_RECOVERY_ADMIN account is break-glass only and always issues
|
||||
# fixed 1h OAuth tokens regardless of server config), and sets the default
|
||||
# OAuth access token lifetime to 3 hours.
|
||||
#
|
||||
# Idempotent: safe to re-run; does nothing if the server already left
|
||||
# bootstrap mode. Run this once after `npm run dev:server` on a fresh
|
||||
# volume (or after `docker compose down -v`).
|
||||
set -euo pipefail
|
||||
|
||||
API_BASE_URL="${1:-http://localhost:8080}"
|
||||
RECOVERY_ACCOUNT="admin@example.org"
|
||||
RECOVERY_SECRET="c8321iEscHDy0GWV"
|
||||
DEV_DOMAIN="example.org"
|
||||
DEV_HOSTNAME="mail.example.org"
|
||||
DEVADMIN_NAME="devadmin"
|
||||
DEVADMIN_SECRET="DevAdminPass123!"
|
||||
DEFAULT_TOKEN_EXPIRY_MS=10800000 # 3 hours
|
||||
|
||||
jmap() {
|
||||
curl -sf --compressed -u "$RECOVERY_ACCOUNT:$RECOVERY_SECRET" \
|
||||
-X POST -H "Content-Type: application/json" -d "$1" "$API_BASE_URL/jmap/"
|
||||
}
|
||||
|
||||
echo "Waiting for $API_BASE_URL to be reachable..."
|
||||
for _ in $(seq 1 30); do
|
||||
curl -sf -o /dev/null -u "$RECOVERY_ACCOUNT:$RECOVERY_SECRET" "$API_BASE_URL/jmap/session" && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
SESSION=$(curl -sf --compressed -u "$RECOVERY_ACCOUNT:$RECOVERY_SECRET" "$API_BASE_URL/jmap/session")
|
||||
ACCOUNT_ID=$(printf '%s' "$SESSION" | grep -o '"urn:stalwart:jmap":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
QUERY_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Domain/query\",{\"accountId\":\"$ACCOUNT_ID\"},\"0\"]]}")
|
||||
if ! printf '%s' "$QUERY_RESULT" | grep -q '"forbidden"'; then
|
||||
echo "Server already bootstrapped, skipping setup. (Use 'docker compose down -v && npm run dev:server' to start fresh.)"
|
||||
else
|
||||
echo "Completing server bootstrap (domain: $DEV_DOMAIN, no TLS certificate request)..."
|
||||
jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Bootstrap/set\",{\"accountId\":\"$ACCOUNT_ID\",\"update\":{\"singleton\":{\"defaultDomain\":\"$DEV_DOMAIN\",\"serverHostname\":\"$DEV_HOSTNAME\",\"requestTlsCertificate\":false}}},\"0\"]]}" > /dev/null
|
||||
|
||||
echo "Restarting the container to apply bootstrap config (one-time only)..."
|
||||
docker compose restart stalwart > /dev/null
|
||||
for _ in $(seq 1 30); do
|
||||
curl -sf -o /dev/null -u "$RECOVERY_ACCOUNT:$RECOVERY_SECRET" "$API_BASE_URL/jmap/session" && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
DOMAIN_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Domain/query\",{\"accountId\":\"$ACCOUNT_ID\"},\"0\"]]}")
|
||||
DOMAIN_ID=$(printf '%s' "$DOMAIN_RESULT" | grep -o '"ids":\["[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
echo "Creating devadmin account ($DEVADMIN_NAME@$DEV_DOMAIN)..."
|
||||
CREATE_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{\"u1\":{\"@type\":\"User\",\"name\":\"$DEVADMIN_NAME\",\"domainId\":\"$DOMAIN_ID\",\"roles\":{\"@type\":\"Admin\"}}}},\"0\"]]}")
|
||||
DEVADMIN_ID=$(printf '%s' "$CREATE_RESULT" | grep -o '"id":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/set\",{\"accountId\":\"$ACCOUNT_ID\",\"update\":{\"$DEVADMIN_ID\":{\"credentials\":{\"0\":{\"@type\":\"Password\",\"secret\":\"$DEVADMIN_SECRET\"}}}}},\"0\"]]}" > /dev/null
|
||||
|
||||
echo "Setting default OAuth access token lifetime to 3 hours..."
|
||||
jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:OidcProvider/set\",{\"accountId\":\"$ACCOUNT_ID\",\"update\":{\"singleton\":{\"accessTokenExpiry\":$DEFAULT_TOKEN_EXPIRY_MS}}},\"0\"]]}" > /dev/null
|
||||
jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Action/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{\"a1\":{\"@type\":\"ReloadSettings\"}}},\"0\"]]}" > /dev/null 2>&1 || true
|
||||
|
||||
# Cosmetic only: Stalwart seeds a default "Stalwart Web Interface" /
|
||||
# stalwartlabs/webui entry for the x:Application record serving /admin
|
||||
# and /account (see Settings > Web Applications' "Active WebUI" card).
|
||||
# Point it at this fork so that card isn't misleading in local dev too.
|
||||
echo "Pointing the active WebUI's description at this fork..."
|
||||
APP_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Application/query\",{\"accountId\":\"$ACCOUNT_ID\"},\"0\"]]}") || true
|
||||
APP_ID=$(printf '%s' "$APP_RESULT" | grep -o '"ids":\["[^"]*"' | cut -d'"' -f4)
|
||||
if [ -n "$APP_ID" ]; then
|
||||
jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Application/set\",{\"accountId\":\"$ACCOUNT_ID\",\"update\":{\"$APP_ID\":{\"description\":\"Stalwart WebUI Fork\",\"resourceUrl\":\"https://github.com/LinkPhoenix/stalwart-webui-fork/releases/latest/download/webui.zip\"}}},\"0\"]]}" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Done. devadmin@$DEV_DOMAIN / $DEVADMIN_SECRET is ready — run scripts/dev-token.sh to get a token."
|
||||
fi
|
||||
@@ -0,0 +1,71 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Generates a fresh access token from the local Stalwart dev container and
|
||||
writes it to .env.development.local (gitignored).
|
||||
|
||||
.DESCRIPTION
|
||||
Local development only. Requires scripts/dev-server-init.ps1 to have been
|
||||
run once first (creates the "devadmin" account this script authenticates
|
||||
as — the STALWART_RECOVERY_ADMIN account is break-glass only and always
|
||||
issues fixed 1h tokens regardless of server config, so it can't honor a
|
||||
custom duration). See DEVELOPMENT.md for the full workflow. Non-Windows
|
||||
shells (and AI agents without PowerShell) can use scripts/dev-token.sh
|
||||
instead.
|
||||
|
||||
.PARAMETER DurationSeconds
|
||||
How long the token should stay valid, in seconds. Defaults to 3 hours
|
||||
(10800), matching the server default set by dev-server-init.ps1.
|
||||
|
||||
.EXAMPLE
|
||||
./dev-token.ps1 # 3 hour token
|
||||
.EXAMPLE
|
||||
./dev-token.ps1 -DurationSeconds 1800 # 30 minute token
|
||||
#>
|
||||
param(
|
||||
[int]$DurationSeconds = 10800,
|
||||
[string]$ApiBaseUrl = "http://localhost:8080"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
$DevAdminAccount = "devadmin@example.org"
|
||||
$DevAdminSecret = "DevAdminPass123!"
|
||||
|
||||
$creds = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$($DevAdminAccount):$($DevAdminSecret)"))
|
||||
$authHeader = @{ Authorization = "Basic $creds" }
|
||||
|
||||
try {
|
||||
$session = Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 15
|
||||
} catch {
|
||||
throw "Could not reach $ApiBaseUrl as $DevAdminAccount. Is the server running ('npm run dev:server') and initialized ('scripts/dev-server-init.ps1')?"
|
||||
}
|
||||
$accountId = $session.primaryAccounts.'urn:stalwart:jmap'
|
||||
|
||||
$expiresAt = [DateTime]::UtcNow.AddSeconds($DurationSeconds).ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
|
||||
$request = @{
|
||||
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||
methodCalls = @(, @("x:ApiKey/set", @{
|
||||
accountId = $accountId
|
||||
create = @{ k1 = @{ description = "dev-token.ps1"; expiresAt = $expiresAt } }
|
||||
}, "0"))
|
||||
} | ConvertTo-Json -Depth 10 -Compress
|
||||
|
||||
$response = Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/" -Method Post -ContentType "application/json" -Headers $authHeader -Body $request -TimeoutSec 15
|
||||
$secret = $response.methodResponses[0][1].created.k1.secret
|
||||
|
||||
if (-not $secret) {
|
||||
throw "Unexpected x:ApiKey/set response: $($response | ConvertTo-Json -Depth 10 -Compress)"
|
||||
}
|
||||
|
||||
$envPath = Join-Path $root ".env.development.local"
|
||||
@"
|
||||
# Generated by scripts/dev-token.ps1 - gitignored, do not commit.
|
||||
# Empty base URL: API calls stay same-origin and go through the Vite proxy.
|
||||
VITE_API_BASE_URL=
|
||||
VITE_ACCESS_TOKEN=$secret
|
||||
"@ | Set-Content -Path $envPath -Encoding ascii
|
||||
|
||||
Write-Host "Token written to $envPath (expires $expiresAt, in ${DurationSeconds}s). Restart 'npm run dev' to pick it up."
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local development only. Generates a fresh access token from the local
|
||||
# Stalwart dev container (see docker-compose.yml) and writes it to
|
||||
# .env.development.local (gitignored). Bash equivalent of dev-token.ps1, for
|
||||
# non-Windows shells (and AI agents without PowerShell).
|
||||
#
|
||||
# Requires scripts/dev-server-init.sh to have been run once first (creates
|
||||
# the "devadmin" account this script authenticates as — the
|
||||
# STALWART_RECOVERY_ADMIN account is break-glass only and always issues
|
||||
# fixed 1h tokens regardless of server config, so it can't honor a custom
|
||||
# duration).
|
||||
#
|
||||
# Usage: dev-token.sh [duration_seconds] [api_base_url]
|
||||
# dev-token.sh # 3 hour token (server default, see dev-server-init.sh)
|
||||
# dev-token.sh 1800 # 30 minute token
|
||||
set -euo pipefail
|
||||
|
||||
DURATION_SECONDS="${1:-10800}"
|
||||
API_BASE_URL="${2:-http://localhost:8080}"
|
||||
DEVADMIN_ACCOUNT="devadmin@example.org"
|
||||
DEVADMIN_SECRET="DevAdminPass123!"
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
if ! date -u -d "+1 minute" +"%Y-%m-%dT%H:%M:%SZ" >/dev/null 2>&1; then
|
||||
EXPIRES_AT=$(date -u -v+"${DURATION_SECONDS}"S +"%Y-%m-%dT%H:%M:%SZ") # BSD/macOS date
|
||||
else
|
||||
EXPIRES_AT=$(date -u -d "+${DURATION_SECONDS} seconds" +"%Y-%m-%dT%H:%M:%SZ") # GNU date
|
||||
fi
|
||||
|
||||
SESSION=$(curl -sf --compressed -u "$DEVADMIN_ACCOUNT:$DEVADMIN_SECRET" "$API_BASE_URL/jmap/session") || {
|
||||
echo "Could not reach $API_BASE_URL as $DEVADMIN_ACCOUNT. Is the server running ('npm run dev:server') and initialized ('scripts/dev-server-init.sh')?" >&2
|
||||
exit 1
|
||||
}
|
||||
ACCOUNT_ID=$(printf '%s' "$SESSION" | grep -o '"urn:stalwart:jmap":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
REQ=$(cat <<JSON
|
||||
{"using":["urn:ietf:params:jmap:core","urn:stalwart:jmap"],"methodCalls":[["x:ApiKey/set",{"accountId":"$ACCOUNT_ID","create":{"k1":{"description":"dev-token.sh","expiresAt":"$EXPIRES_AT"}}},"0"]]}
|
||||
JSON
|
||||
)
|
||||
|
||||
RESPONSE=$(curl -sf --compressed -u "$DEVADMIN_ACCOUNT:$DEVADMIN_SECRET" -X POST -H "Content-Type: application/json" -d "$REQ" "$API_BASE_URL/jmap/")
|
||||
TOKEN=$(printf '%s' "$RESPONSE" | grep -o '"secret":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "Unexpected x:ApiKey/set response: $RESPONSE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ENV_PATH="$ROOT_DIR/.env.development.local"
|
||||
cat > "$ENV_PATH" <<EOF
|
||||
# Generated by scripts/dev-token.sh - gitignored, do not commit.
|
||||
# Empty base URL: API calls stay same-origin and go through the Vite proxy.
|
||||
VITE_API_BASE_URL=
|
||||
VITE_ACCESS_TOKEN=$TOKEN
|
||||
EOF
|
||||
|
||||
echo "Token written to $ENV_PATH (expires $EXPIRES_AT, in ${DURATION_SECONDS}s). Restart 'npm run dev' to pick it up."
|
||||
@@ -4,14 +4,18 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Outlet />
|
||||
<Suspense fallback={<LoadingFallback fullScreen />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
<Toaster />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { ArrowLeft, ArrowRight, Check, Copy, Loader2, Rocket } from 'lucide-react';
|
||||
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
@@ -19,7 +20,7 @@ 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 { friendlySetError, validationErrorMessage } from '@/lib/jmapErrors';
|
||||
|
||||
import type { Field, Fields, Form, FormField } from '@/types/schema';
|
||||
import type { JmapSetError, JmapSetResponse } from '@/types/jmap';
|
||||
@@ -88,9 +89,9 @@ export function BootstrapWizard() {
|
||||
const { obj, sch } = resolved;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
setLoading(true);
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const accountId = getAccountId(obj.objectName);
|
||||
const responses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties, ctrl.signal);
|
||||
@@ -181,19 +182,7 @@ export function BootstrapWizard() {
|
||||
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);
|
||||
record(top, validationErrorMessage(ve));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,13 +404,15 @@ export function BootstrapWizard() {
|
||||
|
||||
function WizardShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-content-background">
|
||||
<div className="flex 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>
|
||||
<ScrollArea role="main" className="min-w-0 flex-1">
|
||||
<div className="min-w-0 p-4 sm:p-6">
|
||||
<div className="mx-auto w-full min-w-0 max-w-3xl">{children}</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const BACKEND_ICONS: Record<string, { path: string; isIco?: boolean }> = {
|
||||
// Storage / database backends
|
||||
foundationdb: { path: '/icons/backends/foundationdb.svg' },
|
||||
mariadb: { path: '/icons/backends/mysql.svg' },
|
||||
mysql: { path: '/icons/backends/mysql.svg' },
|
||||
postgres: { path: '/icons/backends/postgresql.svg' },
|
||||
postgresql: { path: '/icons/backends/postgresql.svg' },
|
||||
quad9: { path: '/icons/backends/quad9.svg' },
|
||||
redis: { path: '/icons/backends/redis.svg' },
|
||||
rediscluster: { path: '/icons/backends/redis.svg' },
|
||||
redissentinel: { path: '/icons/backends/redis.svg' },
|
||||
redisvalkey: { path: '/icons/backends/redis.svg' },
|
||||
rocksdb: { path: '/icons/backends/rocksdb.svg' },
|
||||
sqlite: { path: '/icons/backends/sqlite.svg' },
|
||||
valkey: { path: '/icons/backends/valkey.svg' },
|
||||
|
||||
// Cloud / DNS providers with official icons
|
||||
alibaba: { path: '/icons/backends/alibaba.svg' },
|
||||
alibabacloud: { path: '/icons/backends/alibaba.svg' },
|
||||
alidns: { path: '/icons/backends/alibaba.svg' },
|
||||
amazonwebservices: { path: '/icons/backends/aws-light.svg' },
|
||||
aws: { path: '/icons/backends/aws-light.svg' },
|
||||
lightsail: { path: '/icons/backends/aws-light.svg' },
|
||||
route53: { path: '/icons/backends/aws-light.svg' },
|
||||
azure: { path: '/icons/backends/azure.ico', isIco: true },
|
||||
azuredns: { path: '/icons/backends/azure.ico', isIco: true },
|
||||
baidu: { path: '/icons/backends/baiducloud-color.svg' },
|
||||
baiducloud: { path: '/icons/backends/baiducloud-color.svg' },
|
||||
bunny: { path: '/icons/backends/bunny.svg' },
|
||||
bunnynet: { path: '/icons/backends/bunny.svg' },
|
||||
cloudflare: { path: '/icons/backends/cloudflare.svg' },
|
||||
cpanel: { path: '/icons/backends/cpanel.svg' },
|
||||
digitalocean: { path: '/icons/backends/digital-ocean.svg' },
|
||||
dnsimple: { path: '/icons/backends/dnsimple.svg' },
|
||||
dreamhost: { path: '/icons/backends/dream-host.svg' },
|
||||
duckdns: { path: '/icons/backends/duckdns.svg' },
|
||||
dynu: { path: '/icons/backends/dynu.png' },
|
||||
gandi: { path: '/icons/backends/gandi.svg' },
|
||||
gandiv5: { path: '/icons/backends/gandi.svg' },
|
||||
godaddy: { path: '/icons/backends/godaddy.svg' },
|
||||
google: { path: '/icons/backends/google.svg' },
|
||||
googleclouddns: { path: '/icons/backends/google-cloud.svg' },
|
||||
googlecloud: { path: '/icons/backends/google-cloud.svg' },
|
||||
hetzner: { path: '/icons/backends/hetzner.svg' },
|
||||
hostinger: { path: '/icons/backends/hostinger.svg' },
|
||||
ibm: { path: '/icons/backends/ibm.svg' },
|
||||
ibmcloud: { path: '/icons/backends/ibm.svg' },
|
||||
ionos: { path: '/icons/backends/ionos.svg' },
|
||||
linode: { path: '/icons/backends/linode.svg' },
|
||||
namecheap: { path: '/icons/backends/namecheap.svg' },
|
||||
netlify: { path: '/icons/backends/netlify.svg' },
|
||||
oracle: { path: '/icons/backends/oracle-cloud.svg' },
|
||||
oraclecloud: { path: '/icons/backends/oracle-cloud.svg' },
|
||||
ovh: { path: '/icons/backends/ovh.svg' },
|
||||
plesk: { path: '/icons/backends/plesk.svg' },
|
||||
porkbun: { path: '/icons/backends/porkbun.png' },
|
||||
scaleway: { path: '/icons/backends/scaleway.svg' },
|
||||
tencent: { path: '/icons/backends/tencentcloud-color.svg' },
|
||||
tencentcloud: { path: '/icons/backends/tencentcloud-color.svg' },
|
||||
vercel: { path: '/icons/backends/vercel.svg' },
|
||||
vultr: { path: '/icons/backends/vultr.svg' },
|
||||
yandex: { path: '/icons/backends/yandex.svg' },
|
||||
yandexcloud: { path: '/icons/backends/yandex.svg' },
|
||||
};
|
||||
|
||||
interface BackendIconProps {
|
||||
backend: string | null | undefined;
|
||||
className?: string;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function BackendIcon({ backend, className, fallback = null }: BackendIconProps): React.ReactElement | null {
|
||||
if (!backend) return null;
|
||||
|
||||
const key = backend.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const icon = BACKEND_ICONS[key] ?? BACKEND_ICONS[backend.toLowerCase()];
|
||||
if (!icon) return fallback as React.ReactElement | null;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={icon.path}
|
||||
alt={`${backend} icon`}
|
||||
className={className ?? 'h-4 w-4 object-contain'}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface BackendVariantIconProps {
|
||||
variant: { name: string; label: string };
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BackendVariantIcon({ variant, className }: BackendVariantIconProps): React.ReactElement | null {
|
||||
const nameLower = variant.name.toLowerCase();
|
||||
const labelLower = variant.label?.toLowerCase() ?? '';
|
||||
const isRedisValkey = nameLower === 'redis' && labelLower.includes('valkey');
|
||||
if (isRedisValkey) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<BackendIcon backend="redis" className={className} />
|
||||
<BackendIcon backend="valkey" className={className} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <BackendIcon backend={variant.name} className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { friendlyName, getActionInfo, getObjectKind, useGlobalSearch } from '@/hooks/useGlobalSearch';
|
||||
import type { SearchIndexEntry } from '@/stores/schemaStore';
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
const closePalette = useCallback(() => onOpenChange(false), [onOpenChange]);
|
||||
const { query, setQuery, results, groups, selectEntry, reset, schema } = useGlobalSearch(closePalette);
|
||||
|
||||
const GROUP_LABELS: Record<SearchIndexEntry['type'], string> = {
|
||||
link: t('globalSearch.pages', 'Pages'),
|
||||
form: t('globalSearch.formSections', 'Form Sections'),
|
||||
field: t('globalSearch.fields', 'Fields'),
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) reset();
|
||||
}, [open, reset]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="top-[15%] translate-y-0 overflow-hidden p-0" showCloseButton={false}>
|
||||
<DialogTitle className="sr-only">{t('globalSearch.title', 'Search')}</DialogTitle>
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
loop
|
||||
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"
|
||||
>
|
||||
<CommandInput
|
||||
placeholder={t('globalSearch.placeholder', 'Search pages, fields, settings...')}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{query.trim()
|
||||
? t('globalSearch.noResults', 'No results found.')
|
||||
: t('globalSearch.typeToSearch', 'Type to search the admin panel.')}
|
||||
</CommandEmpty>
|
||||
{Array.from(groups.entries()).map(([type, entries]) => (
|
||||
<CommandGroup key={type} heading={GROUP_LABELS[type]}>
|
||||
{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 (
|
||||
<CommandItem
|
||||
key={`${type}-${entry.viewName}-${flatIdx}`}
|
||||
value={`${type}-${entry.viewName}-${flatIdx}`}
|
||||
onSelect={() => selectEntry(entry)}
|
||||
>
|
||||
<ActionIcon className="mr-2 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="ml-auto shrink-0 pl-2 text-xs text-muted-foreground">{actionLabel}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
/*
|
||||
* 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,24 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LoadingFallbackProps {
|
||||
fullScreen?: boolean;
|
||||
}
|
||||
|
||||
export function LoadingFallback({ fullScreen }: LoadingFallbackProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${fullScreen ? 'min-h-screen' : 'p-8'}`}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,9 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getApiBaseUrl } from '@/services/api';
|
||||
import { ensureLogoLoaded, getLogoSnapshot, subscribeLogo } from '@/lib/logoCache';
|
||||
|
||||
export function DefaultLogo() {
|
||||
const { t } = useTranslation();
|
||||
@@ -31,50 +31,18 @@ export function DefaultLogo() {
|
||||
|
||||
export default function Logo() {
|
||||
const { t } = useTranslation();
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const logo = useSyncExternalStore(subscribeLogo, getLogoSnapshot, getLogoSnapshot);
|
||||
|
||||
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();
|
||||
};
|
||||
ensureLogoLoaded();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (logoUrl) {
|
||||
URL.revokeObjectURL(logoUrl);
|
||||
}
|
||||
};
|
||||
}, [logoUrl]);
|
||||
if (logo.status === 'custom') {
|
||||
return <img src={logo.url} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
|
||||
}
|
||||
|
||||
if (logoUrl && !failed) {
|
||||
return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
|
||||
if (logo.status === 'loading') {
|
||||
return <span className="h-7 w-[140px] block" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return <DefaultLogo />;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 { Moon, Sun } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
// Single-click light/dark toggle. Color themes and corner radius live on the
|
||||
// Appearance settings page instead of a dropdown.
|
||||
export function ModeToggle() {
|
||||
const { t } = useTranslation();
|
||||
const theme = useUIStore((s) => s.theme);
|
||||
const setTheme = useUIStore((s) => s.setTheme);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
||||
aria-label={
|
||||
theme === 'light'
|
||||
? t('appearance.switchToDark', 'Switch to dark mode')
|
||||
: t('appearance.switchToLight', 'Switch to light mode')
|
||||
}
|
||||
>
|
||||
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -48,8 +48,14 @@ export function ObjectPicker({ schema, objectName, value, onChange, onClear, pla
|
||||
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)}
|
||||
<Badge variant="secondary" className="gap-1 pr-1.5 text-sm max-w-xs">
|
||||
{labelLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<span className="truncate" title={display ?? value}>
|
||||
{display ?? value}
|
||||
</span>
|
||||
)}
|
||||
{onClear && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -121,7 +127,9 @@ export function ObjectPicker({ schema, objectName, value, onChange, onClear, pla
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
<span className="truncate" title={opt.label}>
|
||||
{opt.label}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { Info } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { formatSize } from '@/lib/durationFormat';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
interface SizeDisplayProps {
|
||||
bytes: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a byte size. Negative values (stale Stalwart quota counters) are
|
||||
* shown in red with an info tooltip pointing admins at recalculateQuota.
|
||||
*/
|
||||
export function SizeDisplay({ bytes, className }: SizeDisplayProps): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
const label = Number.isFinite(bytes) ? formatSize(bytes) : formatSize(0);
|
||||
|
||||
if (!Number.isFinite(bytes) || bytes >= 0) {
|
||||
return <span className={className}>{label}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 ${className ?? ''}`.trim()}>
|
||||
<span className="font-medium text-destructive">{label}</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 text-destructive hover:text-destructive/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
aria-label={t('list.negativeQuotaInfoAria', 'Why is disk usage negative?')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs text-left">
|
||||
{t(
|
||||
'list.negativeQuotaTooltip',
|
||||
'This disk-usage counter is out of sync (often after a migration or reset). Schedule a task: Perform account maintenance operations → Recalculate storage quota usage for the account. Or for all accounts: Perform store maintenance operations → Reset all user quotas.',
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBufferedValue } from '@/hooks/useBufferedValue';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -21,11 +22,7 @@ function BufferedExprInput({
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
'onChange' | 'value'
|
||||
>) {
|
||||
const [local, setLocal] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(value);
|
||||
}, [value]);
|
||||
const [local, setLocal] = useBufferedValue(value);
|
||||
|
||||
const commit = () => {
|
||||
if (local !== value) onCommit(local);
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useNavigate, useBlocker } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -46,10 +47,13 @@ import {
|
||||
} from '@/lib/schemaResolver';
|
||||
import { jmapGet, jmapSet, jmapRequest, getAccountId } from '@/services/jmap/client';
|
||||
import { calculateJmapPatch } from '@/lib/jmapPatch';
|
||||
import { friendlySetError } from '@/lib/jmapErrors';
|
||||
import { friendlySetError, validationErrorMessage } from '@/lib/jmapErrors';
|
||||
import { coerceLabel } from '@/lib/objectOptions';
|
||||
import { SECRET_MASK } from '@/lib/jmapUtils';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { logFormChange } from '@/lib/debug';
|
||||
import { FieldWidget } from '@/components/forms/FieldWidget';
|
||||
import { BackendVariantIcon } from '@/components/common/BackendIcon';
|
||||
|
||||
import type { Field, Fields, Form, FormField, Schema } from '@/types/schema';
|
||||
import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap';
|
||||
@@ -164,11 +168,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
return Array.from(set);
|
||||
}, [schema, resolved, viewName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!schema || !resolved) return;
|
||||
const { obj, sch } = resolved;
|
||||
|
||||
if (isCreate) {
|
||||
const [prevCreateInitKey, setPrevCreateInitKey] = useState<typeof resolved | undefined>(undefined);
|
||||
if (isCreate && schema && resolved) {
|
||||
if (resolved !== prevCreateInitKey) {
|
||||
setPrevCreateInitKey(resolved);
|
||||
const { obj, sch } = resolved;
|
||||
const staticFilters = resolved.list?.filtersStatic;
|
||||
if (sch.type === 'multiple') {
|
||||
const variantFromFilter = typeof staticFilters?.['@type'] === 'string' ? staticFilters['@type'] : undefined;
|
||||
@@ -182,13 +186,19 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
setFormData(defaults);
|
||||
setOriginalData(defaults);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else if (prevCreateInitKey !== undefined) {
|
||||
setPrevCreateInitKey(undefined);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!schema || !resolved || isCreate) return;
|
||||
const { obj, sch } = resolved;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
setLoading(true);
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const accountId = getAccountId(obj.objectName);
|
||||
const ids = isSingleton ? ['singleton'] : [objectId];
|
||||
@@ -249,13 +259,14 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
|
||||
const blocker = useBlocker(isDirty && !saving);
|
||||
|
||||
const [pendingNavAfterCreate, setPendingNavAfterCreate] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!pendingNavAfterCreate) return;
|
||||
setPendingNavAfterCreate(false);
|
||||
const navigateAfterCreate = useCallback(() => {
|
||||
flushSync(() => {
|
||||
setOriginalData({ ...formData });
|
||||
setServerCreatedProps(null);
|
||||
});
|
||||
const section = viewToSection[viewName] ?? '';
|
||||
navigate(`/${section}/${viewName}`);
|
||||
}, [pendingNavAfterCreate, viewName, viewToSection, navigate]);
|
||||
}, [formData, viewToSection, viewName, navigate]);
|
||||
|
||||
const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
@@ -345,21 +356,10 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
if (error.validationErrors && error.validationErrors.length > 0) {
|
||||
for (const ve of error.validationErrors) {
|
||||
if (ve.property && currentFields?.properties[ve.property]) {
|
||||
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.');
|
||||
newFieldErrors[ve.property] = msg;
|
||||
newFieldErrors[ve.property] = validationErrorMessage(ve);
|
||||
} else if (ve.property) {
|
||||
setGeneralError((prev) => (prev ? `${prev}\n${ve.property}: ${ve.type}` : `${ve.property}: ${ve.type}`));
|
||||
const detail = ve.value && ve.value.length > 0 ? ve.value : ve.type;
|
||||
setGeneralError((prev) => (prev ? `${prev}\n${ve.property}: ${detail}` : `${ve.property}: ${detail}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -467,7 +467,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
const isSecret =
|
||||
fieldDef.type.type === 'string' &&
|
||||
(fieldDef.type.format === 'secret' || fieldDef.type.format === 'secretText');
|
||||
if (isSecret && formData[fieldName] === '*****') continue;
|
||||
if (isSecret && formData[fieldName] === SECRET_MASK) continue;
|
||||
createPayload[fieldName] = formData[fieldName];
|
||||
}
|
||||
}
|
||||
@@ -530,7 +530,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
if (
|
||||
fieldDef?.type.type === 'string' &&
|
||||
(fieldDef.type.format === 'secret' || fieldDef.type.format === 'secretText') &&
|
||||
patchValue === '*****'
|
||||
patchValue === SECRET_MASK
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -695,9 +695,9 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
|
||||
const labelProp = list?.labelProperty;
|
||||
if (labelProp) {
|
||||
const raw = formData[labelProp];
|
||||
if (typeof raw === 'string' && raw.length > 0) {
|
||||
return t('form.editTitleWithValue', 'Edit {{name}}: {{value}}', { name, value: raw });
|
||||
const value = coerceLabel(formData[labelProp], '');
|
||||
if (value.length > 0) {
|
||||
return t('form.editTitleWithValue', 'Edit {{name}}: {{value}}', { name, value });
|
||||
}
|
||||
}
|
||||
return t('form.editTitle', 'Edit {{name}}', { name });
|
||||
@@ -743,7 +743,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div className="mx-auto w-full min-w-0 max-w-4xl space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
@@ -774,17 +774,26 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
if (formField.name === '@type' && sch.type === 'multiple') {
|
||||
const selectedVariantLabel = sch.variants.find((v) => v.name === selectedVariant)?.label;
|
||||
return (
|
||||
<div key="@type" className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">{formField.label}</Label>
|
||||
<Select value={selectedVariant} onValueChange={handleVariantChange} disabled={readOnly}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('form.selectType', 'Select type...')} />
|
||||
<div className="flex flex-1 items-center gap-2 overflow-hidden">
|
||||
<BackendVariantIcon variant={{ name: selectedVariant, label: selectedVariantLabel ?? selectedVariant }} />
|
||||
<span className="truncate">
|
||||
{selectedVariantLabel || t('form.selectType', 'Select type...')}
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sch.variants.map((v) => (
|
||||
<SelectItem key={v.name} value={v.name}>
|
||||
{v.label}
|
||||
<span className="flex items-center gap-2">
|
||||
<BackendVariantIcon variant={v} />
|
||||
{v.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -836,7 +845,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-between pt-2 pb-8">
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 pb-8 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
{canDelete && (
|
||||
<Button type="button" variant="destructive" disabled={saving} onClick={() => setDeleteConfirmOpen(true)}>
|
||||
@@ -845,7 +854,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
{isDirty && (
|
||||
<span className="text-xs text-muted-foreground">{t('form.unsavedChangesLabel', 'Unsaved changes')}</span>
|
||||
)}
|
||||
@@ -937,9 +946,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
open={serverCreatedProps !== null && createdObjectId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setOriginalData({ ...formData });
|
||||
setServerCreatedProps(null);
|
||||
setPendingNavAfterCreate(true);
|
||||
navigateAfterCreate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -972,15 +979,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
})()}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setOriginalData({ ...formData });
|
||||
setServerCreatedProps(null);
|
||||
setPendingNavAfterCreate(true);
|
||||
}}
|
||||
>
|
||||
{t('common.continue', 'Continue')}
|
||||
</Button>
|
||||
<Button onClick={navigateAfterCreate}>{t('common.continue', 'Continue')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -1100,8 +1099,6 @@ function buildRenderableField(
|
||||
return { formField, field, visible, enterpriseDisabled };
|
||||
}
|
||||
|
||||
const SECRET_MASK_PLACEHOLDER = '*****';
|
||||
|
||||
function isOtpAuthValid(data: unknown): boolean {
|
||||
if (data == null || typeof data !== 'object' || Array.isArray(data)) return true;
|
||||
const obj = data as Record<string, unknown>;
|
||||
@@ -1117,7 +1114,7 @@ function isOtpAuthValid(data: unknown): boolean {
|
||||
const otpUrl = obj.otpUrl;
|
||||
const otpCode = obj.otpCode;
|
||||
if (otpUrl == null || otpUrl === '') return true;
|
||||
if (otpCode == null || otpCode === '' || otpCode === SECRET_MASK_PLACEHOLDER) {
|
||||
if (otpCode == null || otpCode === '' || otpCode === SECRET_MASK) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useState, useEffect, type KeyboardEvent } from 'react';
|
||||
import { useState, useEffect, useMemo, type KeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -14,33 +15,38 @@ import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
|
||||
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight } from 'lucide-react';
|
||||
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon, Clock } from 'lucide-react';
|
||||
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
|
||||
import { ExpressionEditor } from '@/components/expression/ExpressionEditor';
|
||||
import { OtpAuthField } from '@/components/forms/OtpAuthField';
|
||||
import { SizeDisplay } from '@/components/common/SizeDisplay';
|
||||
|
||||
import {
|
||||
bytesToHuman,
|
||||
humanToBytes,
|
||||
msToHuman,
|
||||
humanToMs,
|
||||
formatSize,
|
||||
formatDuration,
|
||||
SIZE_UNITS,
|
||||
DURATION_UNITS,
|
||||
} from '@/lib/durationFormat';
|
||||
import { resolveSchema, resolveVariantForm, resolveObject, buildEmbeddedDefaults } from '@/lib/schemaResolver';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { useEffectiveEdition } from '@/components/forms/FormEditionContext';
|
||||
import { useObjectList, useObjectLabel, useNoPermissionMessage, type ObjectOption } from '@/lib/objectOptions';
|
||||
import { BackendVariantIcon } from '@/components/common/BackendIcon';
|
||||
import { SECRET_MASK } from '@/lib/jmapUtils';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { jmapGet, getAccountId } from '@/services/jmap/client';
|
||||
|
||||
@@ -242,11 +248,7 @@ interface BufferedInputProps extends Omit<React.InputHTMLAttributes<HTMLInputEle
|
||||
}
|
||||
|
||||
function BufferedInput({ value, onCommit, onBlur, onKeyDown, ...rest }: BufferedInputProps) {
|
||||
const [local, setLocal] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(value);
|
||||
}, [value]);
|
||||
const [local, setLocal] = useBufferedValue(value);
|
||||
|
||||
const commit = () => {
|
||||
if (local !== value) onCommit(local);
|
||||
@@ -277,11 +279,7 @@ interface BufferedTextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTe
|
||||
}
|
||||
|
||||
function BufferedTextarea({ value, onCommit, onBlur, ...rest }: BufferedTextareaProps) {
|
||||
const [local, setLocal] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(value);
|
||||
}, [value]);
|
||||
const [local, setLocal] = useBufferedValue(value);
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
@@ -404,7 +402,7 @@ function StringField({
|
||||
value={strValue || '#000000'}
|
||||
onChange={(e) => handleCommit(e.target.value)}
|
||||
disabled={readOnly}
|
||||
className="h-9 w-14 cursor-pointer rounded-md border border-input bg-transparent p-1"
|
||||
className="h-9 w-14 cursor-pointer rounded-md border border-input bg-field p-1"
|
||||
/>
|
||||
<BufferedInput
|
||||
value={strValue}
|
||||
@@ -457,15 +455,12 @@ interface SecretInputProps {
|
||||
multiline: boolean;
|
||||
}
|
||||
|
||||
const SECRET_MASK = '****';
|
||||
|
||||
function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLength, multiline }: SecretInputProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [localValue, setLocalValue] = useState(() => (value === SECRET_MASK || value === '' ? '' : value));
|
||||
const [isMasked, setIsMasked] = useState(() => value === SECRET_MASK);
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
useResetOnChange(value, () => {
|
||||
if (value === SECRET_MASK || value === '') {
|
||||
setLocalValue('');
|
||||
setIsMasked(value === SECRET_MASK);
|
||||
@@ -473,8 +468,7 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
|
||||
setLocalValue(value);
|
||||
setIsMasked(false);
|
||||
}
|
||||
}, [value]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
});
|
||||
|
||||
const handleLocalChange = (v: string) => {
|
||||
setLocalValue(v);
|
||||
@@ -482,6 +476,7 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
if (isMasked && localValue === '') return;
|
||||
if (localValue !== value) onChange(localValue);
|
||||
};
|
||||
|
||||
@@ -513,6 +508,10 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
|
||||
minLength={minLength}
|
||||
maxLength={maxLength}
|
||||
rows={4}
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore
|
||||
className={visible ? '' : 'tracking-widest'}
|
||||
style={visible ? undefined : ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)}
|
||||
/>
|
||||
@@ -535,6 +534,10 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
|
||||
placeholder={displayPlaceholder}
|
||||
minLength={minLength}
|
||||
maxLength={maxLength}
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore
|
||||
className="flex-1"
|
||||
/>
|
||||
{toggleBtn}
|
||||
@@ -607,13 +610,7 @@ function BufferedNumberInput({
|
||||
disabled,
|
||||
onCommit,
|
||||
}: BufferedNumberInputProps) {
|
||||
const [local, setLocal] = useState<string>(value != null ? String(value) : '');
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
setLocal(value != null ? String(value) : '');
|
||||
}, [value]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
const [local, setLocal] = useBufferedValue(value, (v) => (v != null ? String(v) : ''));
|
||||
|
||||
const commit = () => {
|
||||
if (local === '') {
|
||||
@@ -654,7 +651,11 @@ function SizeInput({ value, onChange, readOnly, nullable }: SizeInputProps) {
|
||||
if (readOnly) {
|
||||
if (value == null)
|
||||
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
|
||||
return <span className="text-sm">{formatSize(value as number)}</span>;
|
||||
return (
|
||||
<span className="text-sm">
|
||||
<SizeDisplay bytes={value as number} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <SizeInputEditable value={value} onChange={onChange} nullable={nullable} />;
|
||||
}
|
||||
@@ -666,13 +667,11 @@ function SizeInputEditable({ value, onChange, nullable }: Omit<SizeInputProps, '
|
||||
const [unit, setUnit] = useState(initHuman.unit);
|
||||
const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value));
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
useResetOnChange(value, () => {
|
||||
const h = bytesToHuman(typeof value === 'number' ? value : 0);
|
||||
setUnit(h.unit);
|
||||
setLocalStr(value == null ? '' : String(h.value));
|
||||
}, [value]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
});
|
||||
|
||||
const commit = () => {
|
||||
if (localStr === '') {
|
||||
@@ -771,13 +770,11 @@ function DurationInputEditable({ value, onChange, nullable }: Omit<DurationInput
|
||||
const [unit, setUnit] = useState(initHuman.unit);
|
||||
const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value));
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
useResetOnChange(value, () => {
|
||||
const h = msToHuman(typeof value === 'number' ? value : 0);
|
||||
setUnit(h.unit);
|
||||
setLocalStr(value == null ? '' : String(h.value));
|
||||
}, [value]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
});
|
||||
|
||||
const commit = () => {
|
||||
if (localStr === '') {
|
||||
@@ -842,37 +839,102 @@ interface DateTimeFieldProps {
|
||||
nullable?: boolean;
|
||||
}
|
||||
|
||||
const TIME_HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||
const TIME_MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
|
||||
|
||||
/** Themed hour/minute selects — avoids the native time picker which ignores app dark/light tokens. */
|
||||
function DateTimeTimeSelect({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [hour = '00', minute = '00'] = value.split(':');
|
||||
|
||||
const update = (nextHour: string, nextMinute: string) => {
|
||||
onChange(`${nextHour}:${nextMinute}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center gap-1.5">
|
||||
<Select
|
||||
value={TIME_HOURS.includes(hour) ? hour : '00'}
|
||||
disabled={disabled}
|
||||
onValueChange={(nextHour) => update(nextHour, TIME_MINUTES.includes(minute) ? minute : '00')}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[4.75rem]" aria-label={t('field.hour', 'Hour')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60" position="popper">
|
||||
{TIME_HOURS.map((h) => (
|
||||
<SelectItem key={h} value={h}>
|
||||
{h}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground" aria-hidden="true">
|
||||
:
|
||||
</span>
|
||||
<Select
|
||||
value={TIME_MINUTES.includes(minute) ? minute : '00'}
|
||||
disabled={disabled}
|
||||
onValueChange={(nextMinute) => update(TIME_HOURS.includes(hour) ? hour : '00', nextMinute)}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[4.75rem]" aria-label={t('field.minute', 'Minute')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60" position="popper">
|
||||
{TIME_MINUTES.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const strValue = typeof value === 'string' ? value : '';
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const toLocal = (iso: string): string => {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
return d.toISOString().slice(0, 16);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const toIso = (local: string): string | null => {
|
||||
if (!local) return nullable ? null : '';
|
||||
return new Date(local).toISOString();
|
||||
};
|
||||
|
||||
const [local, setLocal] = useState(() => toLocal(strValue));
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
setLocal(toLocal(strValue));
|
||||
const parsed = useMemo(() => {
|
||||
if (!strValue) return null;
|
||||
const d = new Date(strValue);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}, [strValue]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
const commit = () => {
|
||||
const iso = toIso(local);
|
||||
if (iso !== strValue) onChange(iso);
|
||||
const timeValue = parsed
|
||||
? `${String(parsed.getHours()).padStart(2, '0')}:${String(parsed.getMinutes()).padStart(2, '0')}`
|
||||
: '';
|
||||
|
||||
// Date and time are committed together so the field never holds a
|
||||
// half-entered value the way a free-form datetime input does.
|
||||
const commit = (date: Date, time: string) => {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
const next = new Date(date);
|
||||
next.setHours(Number.isFinite(hours) ? hours : 0, Number.isFinite(minutes) ? minutes : 0, 0, 0);
|
||||
onChange(next.toISOString());
|
||||
};
|
||||
|
||||
const formatClock = (d: Date): string =>
|
||||
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
|
||||
/** Empty fields open on "now" so the calendar and time selects start useful. */
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
if (next && !parsed) {
|
||||
const now = new Date();
|
||||
now.setSeconds(0, 0);
|
||||
onChange(now.toISOString());
|
||||
}
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
@@ -895,13 +957,52 @@ function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldPro
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={local}
|
||||
onChange={(e) => setLocal(e.target.value)}
|
||||
onBlur={commit}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Popover open={open} onOpenChange={handleOpenChange} modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn('flex-1 justify-start text-left font-normal', !parsed && 'text-muted-foreground')}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{parsed
|
||||
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(parsed)
|
||||
: t('field.pickDate', 'Pick a date')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto p-0"
|
||||
align="start"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('[data-radix-select-content]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={parsed ?? undefined}
|
||||
defaultMonth={parsed ?? new Date()}
|
||||
onSelect={(day) => {
|
||||
if (!day) return;
|
||||
commit(day, timeValue || formatClock(new Date()));
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-2 border-t bg-background p-3">
|
||||
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<DateTimeTimeSelect
|
||||
value={timeValue || formatClock(new Date())}
|
||||
disabled={!parsed}
|
||||
onChange={(next) => {
|
||||
if (parsed) commit(parsed, next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{nullable && strValue && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1005,9 +1106,9 @@ function BlobField({ value, onChange, readOnly }: BlobFieldProps) {
|
||||
if (!blobId || loaded) return;
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const accountId = getAccountId('x:Blob');
|
||||
const responses = await jmapGet('Blob', accountId, [blobId], ['data:asText']);
|
||||
@@ -1253,12 +1354,8 @@ function RateField({ value, onChange, readOnly, nullable }: RateFieldProps) {
|
||||
const [localCount, setLocalCount] = useState(String(count));
|
||||
const [localPeriod, setLocalPeriod] = useState(String(human.value));
|
||||
|
||||
useEffect(() => {
|
||||
setLocalCount(String(count));
|
||||
}, [count]);
|
||||
useEffect(() => {
|
||||
setLocalPeriod(String(human.value));
|
||||
}, [human.value]);
|
||||
useResetOnChange(count, () => setLocalCount(String(count)));
|
||||
useResetOnChange(human.value, () => setLocalPeriod(String(human.value)));
|
||||
|
||||
const commitCount = () => {
|
||||
const n = parseInt(localCount, 10);
|
||||
@@ -1459,18 +1556,28 @@ function EmbeddedObjectField({
|
||||
onChange(newObj);
|
||||
};
|
||||
|
||||
const selectedVariantLabel = resolvedSchema.variants.find((v) => v.name === currentType)?.label;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">{formField.label ?? t('field.type', 'Type')}</Label>
|
||||
<Select value={currentType} onValueChange={handleVariantChange} disabled={readOnly}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('form.selectType', 'Select type...')} />
|
||||
<div className="flex flex-1 items-center gap-2 overflow-hidden">
|
||||
<BackendVariantIcon variant={{ name: currentType, label: selectedVariantLabel ?? currentType }} />
|
||||
<span className="truncate">
|
||||
{selectedVariantLabel || t('form.selectType', 'Select type...')}
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{resolvedSchema.variants.map((v) => (
|
||||
<SelectItem key={v.name} value={v.name}>
|
||||
{v.label}
|
||||
<span className="flex items-center gap-2">
|
||||
<BackendVariantIcon variant={v} />
|
||||
{v.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -1852,7 +1959,8 @@ function EnumMultiSelect({ enumName, items, onChange, readOnly, schema, minItems
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto p-2 space-y-1">
|
||||
<ScrollArea viewportClassName="max-h-60">
|
||||
<div className="p-2 space-y-1">
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">{t('field.noMatches', 'No matches')}</p>
|
||||
)}
|
||||
@@ -1880,7 +1988,8 @@ function EnumMultiSelect({ enumName, items, onChange, readOnly, schema, minItems
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -1979,6 +2088,29 @@ function ObjectIdMultiSelectPill({
|
||||
);
|
||||
}
|
||||
|
||||
function MapEntryKeyLabel({ keyClass, keyValue, schema }: { keyClass: ScalarType; keyValue: string; schema: Schema }) {
|
||||
if (keyClass.type === 'enum') {
|
||||
const variants = schema.enums[keyClass.enumName] ?? [];
|
||||
const variant = variants.find((v) => v.name === keyValue);
|
||||
return <>{variant?.label ?? keyValue}</>;
|
||||
}
|
||||
if (keyClass.type === 'objectId') {
|
||||
return <ObjectIdKeyLabel objectName={keyClass.objectName} keyValue={keyValue} schema={schema} />;
|
||||
}
|
||||
return <>{keyValue}</>;
|
||||
}
|
||||
|
||||
function ObjectIdKeyLabel({ objectName, keyValue, schema }: { objectName: string; keyValue: string; schema: Schema }) {
|
||||
const list = useObjectList(objectName, schema);
|
||||
const fromList = list.options.find((o) => o.id === keyValue)?.label;
|
||||
const { label: cheapLabel, loading } = useObjectLabel(objectName, fromList ? null : keyValue, schema);
|
||||
const display = fromList ?? cheapLabel;
|
||||
if (loading && !display) {
|
||||
return <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />;
|
||||
}
|
||||
return <>{display ?? keyValue}</>;
|
||||
}
|
||||
|
||||
interface MapFieldProps {
|
||||
keyClass: ScalarType;
|
||||
valueClass: MapValueType;
|
||||
@@ -2038,15 +2170,6 @@ function MapField({ keyClass, valueClass, value, onChange, readOnly, schema, min
|
||||
}
|
||||
};
|
||||
|
||||
const getKeyLabel = (key: string): string => {
|
||||
if (keyClass.type === 'enum') {
|
||||
const variants = schema.enums[keyClass.enumName] ?? [];
|
||||
const variant = variants.find((v) => v.name === key);
|
||||
if (variant) return variant.label;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const existingKeys = new Set(Object.keys(mapValue));
|
||||
|
||||
return (
|
||||
@@ -2063,7 +2186,7 @@ function MapField({ keyClass, valueClass, value, onChange, readOnly, schema, min
|
||||
className="flex flex-1 items-center gap-2 p-3 text-sm font-medium hover:bg-accent/50 rounded-t-md transition-colors [&[data-state=closed]>svg]:rotate-0 [&[data-state=open]>svg]:rotate-90"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
{getKeyLabel(key)}
|
||||
<MapEntryKeyLabel keyClass={keyClass} keyValue={key} schema={schema} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
{!readOnly && (
|
||||
|
||||
@@ -8,11 +8,13 @@ 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 { Check, Copy, Loader2, ShieldCheck, ShieldOff } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { SECRET_MASK } from '@/lib/jmapUtils';
|
||||
|
||||
interface OtpAuthValue {
|
||||
otpUrl?: string | null;
|
||||
@@ -25,8 +27,6 @@ interface OtpAuthFieldProps {
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
const SECRET_MASK = '*****';
|
||||
|
||||
const STALWART_IMAGE_URL = 'https://stalw.art/img/favicon-32x32.png';
|
||||
|
||||
function buildOtpAuthUrl(totp: OTPAuth.TOTP): string {
|
||||
@@ -57,6 +57,27 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) {
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [setupCode, setSetupCode] = useState('');
|
||||
const [setupError, setSetupError] = useState<string | null>(null);
|
||||
const [secretCopied, setSecretCopied] = useState(false);
|
||||
|
||||
const setupSecret = useMemo(() => {
|
||||
if (!setupTotp) return null;
|
||||
return setupTotp.secret.base32.replace(/(.{4})/g, '$1 ').trim();
|
||||
}, [setupTotp]);
|
||||
|
||||
const copySecret = async () => {
|
||||
if (!setupTotp) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(setupTotp.secret.base32);
|
||||
setSecretCopied(true);
|
||||
setTimeout(() => setSecretCopied(false), 1500);
|
||||
} catch {
|
||||
toast({
|
||||
title: t('otp.copyFailed', 'Copy failed'),
|
||||
description: t('otp.clipboardBlocked', 'Your browser blocked clipboard access.'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!setupUrl) return;
|
||||
@@ -97,6 +118,7 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) {
|
||||
setSetupTotp(null);
|
||||
setSetupUrl(null);
|
||||
setSetupCode('');
|
||||
setSecretCopied(false);
|
||||
};
|
||||
|
||||
const cancelSetup = () => {
|
||||
@@ -104,6 +126,7 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) {
|
||||
setSetupUrl(null);
|
||||
setSetupCode('');
|
||||
setSetupError(null);
|
||||
setSecretCopied(false);
|
||||
};
|
||||
|
||||
const otpCodeValue = useMemo(
|
||||
@@ -156,6 +179,29 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{setupSecret && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">{t('otp.manualEntryLabel', 'Or enter this code manually')}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'otp.manualEntryDescription',
|
||||
'If you cannot scan the QR code, enter this secret into your authenticator app instead.',
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<code className="flex-1 rounded bg-muted p-2 text-sm font-mono break-all select-all">{setupSecret}</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copySecret}
|
||||
aria-label={t('otp.copySecret', 'Copy secret')}
|
||||
>
|
||||
{secretCopied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">{t('otp.confirmationCodeLabel', 'Confirmation code')}</Label>
|
||||
<Input
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { useCacheStore } from '@/stores/cacheStore';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
@@ -12,11 +12,23 @@ 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';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
|
||||
// Heavy or rarely used feature pages are code-split so the initial bundle
|
||||
// stays small (the dashboard pulls in recharts, ~150 kB gzipped on its own).
|
||||
const DashboardView = lazy(() =>
|
||||
import('@/features/dashboard/components/DashboardView').then((m) => ({ default: m.DashboardView })),
|
||||
);
|
||||
const DeliveryTracePage = lazy(() =>
|
||||
import('@/features/troubleshoot/DeliveryTracePage').then((m) => ({ default: m.DeliveryTracePage })),
|
||||
);
|
||||
const LiveTracingPage = lazy(() =>
|
||||
import('@/features/tracing/components/LiveTracingPage').then((m) => ({ default: m.LiveTracingPage })),
|
||||
);
|
||||
const TraceDetailView = lazy(() =>
|
||||
import('@/features/tracing/components/TraceDetailView').then((m) => ({ default: m.TraceDetailView })),
|
||||
);
|
||||
const ActionPage = lazy(() => import('@/features/actions/ActionPage').then((m) => ({ default: m.ActionPage })));
|
||||
|
||||
interface MainContentProps {
|
||||
viewName?: string;
|
||||
@@ -25,6 +37,14 @@ interface MainContentProps {
|
||||
}
|
||||
|
||||
export function MainContent({ viewName, id, section }: MainContentProps) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<MainContentView viewName={viewName} id={id} section={section} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function MainContentView({ viewName, id, section }: MainContentProps) {
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const invalidateAllObjectLists = useCacheStore((s) => s.invalidateAllObjectLists);
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ 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 authenticated = useAuthStore((s) => s.isAuthenticated());
|
||||
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
|
||||
const location = useLocation();
|
||||
if (!accessToken && !bypassToken) {
|
||||
if (!authenticated && !bypassToken) {
|
||||
const originalPath = location.pathname + location.search;
|
||||
return <Navigate to="/login" replace state={{ from: originalPath }} />;
|
||||
}
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, 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 { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
isLinkEnterprise,
|
||||
isLinkVisible,
|
||||
} from '@/lib/layout';
|
||||
import { findLastVisitedLinkInLayout, setLastVisitedSection } from '@/lib/lastVisited';
|
||||
import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema';
|
||||
|
||||
function LucideIcon({ name, className }: { name: string; className?: string }) {
|
||||
@@ -75,6 +76,57 @@ function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean
|
||||
return false;
|
||||
}
|
||||
|
||||
interface AccordionLevelContextValue {
|
||||
openId: string | null;
|
||||
setOpenId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const AccordionLevelContext = createContext<AccordionLevelContextValue | null>(null);
|
||||
|
||||
// Sibling collapsibles share a single open id, so expanding one collapses the
|
||||
// others at the same level (accordion behavior).
|
||||
function AccordionLevel({ children }: { children: React.ReactNode }) {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const value = useMemo(() => ({ openId, setOpenId }), [openId]);
|
||||
return <AccordionLevelContext.Provider value={value}>{children}</AccordionLevelContext.Provider>;
|
||||
}
|
||||
|
||||
// A collapsible wired to its accordion level. It opens itself whenever the
|
||||
// active page lands inside it, while still allowing manual toggling.
|
||||
function AccordionCollapsible({
|
||||
id,
|
||||
containsActive,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
containsActive: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const level = useContext(AccordionLevelContext);
|
||||
if (!level) throw new Error('AccordionCollapsible must be used within AccordionLevel');
|
||||
const { openId, setOpenId } = level;
|
||||
// Layout effect so the branch containing the active page is already open on
|
||||
// the first paint after a navigation.
|
||||
useLayoutEffect(() => {
|
||||
if (containsActive) setOpenId(id);
|
||||
}, [containsActive, id, setOpenId]);
|
||||
return (
|
||||
<Collapsible open={openId === id} onOpenChange={(open) => setOpenId(open ? id : null)}>
|
||||
{children}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// Softer than the default ghost hover, closer to documentation sidebars:
|
||||
// muted text that brightens with a faint background instead of a strong fill.
|
||||
const sidebarItemClass =
|
||||
'w-full justify-start gap-2 font-normal text-muted-foreground hover:bg-accent/50 hover:text-foreground';
|
||||
|
||||
// In square mode the sidebar follows documentation conventions (better-auth):
|
||||
// full-bleed rows with a barely-there hover wash instead of inset pills.
|
||||
const sidebarItemSquareClass =
|
||||
"[[data-radius='square']_&]:hover:bg-foreground/[0.03] [[data-radius='square']_&]:hover:text-foreground/90";
|
||||
|
||||
function checkLinkVisible(viewName: string): boolean {
|
||||
const schema = useSchemaStore.getState().schema;
|
||||
if (!schema) return true;
|
||||
@@ -107,6 +159,11 @@ interface SidebarSubItemProps {
|
||||
}
|
||||
|
||||
function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: SidebarSubItemProps) {
|
||||
// Picking a plain link closes any sibling group left open at this same
|
||||
// accordion level — it's not part of a collapsible, so nothing should
|
||||
// stay expanded on its account once it's the one that's active.
|
||||
const level = useContext(AccordionLevelContext);
|
||||
|
||||
if (item.type === 'link') {
|
||||
if (!checkLinkVisible(item.viewName)) return null;
|
||||
|
||||
@@ -121,9 +178,11 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-sidebar-active={isActive || undefined}
|
||||
className={cn(
|
||||
'w-full justify-start gap-2 font-normal',
|
||||
isActive && 'bg-accent text-accent-foreground',
|
||||
sidebarItemClass,
|
||||
sidebarItemSquareClass,
|
||||
isActive && 'bg-accent text-accent-foreground hover:bg-accent',
|
||||
depth > 0 && 'text-sm',
|
||||
)}
|
||||
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
||||
@@ -131,6 +190,8 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
||||
if (isLocked) {
|
||||
onUpsell();
|
||||
} else {
|
||||
level?.setOpenId(null);
|
||||
setLastVisitedSection(sectionName, item.viewName);
|
||||
navigate(path);
|
||||
}
|
||||
}}
|
||||
@@ -146,11 +207,11 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
||||
|
||||
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
|
||||
return (
|
||||
<Collapsible defaultOpen={containsActive}>
|
||||
<AccordionCollapsible id={item.name} containsActive={containsActive}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 font-normal text-sm"
|
||||
className={cn(sidebarItemClass, sidebarItemSquareClass, '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]" />
|
||||
@@ -158,20 +219,22 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
||||
</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}
|
||||
/>
|
||||
))}
|
||||
<AccordionLevel>
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
</AccordionLevel>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</AccordionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -188,6 +251,11 @@ interface SidebarTopItemProps {
|
||||
}
|
||||
|
||||
function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: SidebarTopItemProps) {
|
||||
// Picking a plain link closes any sibling group left open at this same
|
||||
// accordion level — it's not part of a collapsible, so nothing should
|
||||
// stay expanded on its account once it's the one that's active.
|
||||
const level = useContext(AccordionLevelContext);
|
||||
|
||||
if ('link' in item) {
|
||||
const { name, icon, viewName } = item.link;
|
||||
|
||||
@@ -204,11 +272,18 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
|
||||
data-sidebar-active={isActive || undefined}
|
||||
className={cn(
|
||||
sidebarItemClass,
|
||||
sidebarItemSquareClass,
|
||||
isActive && 'bg-accent text-accent-foreground hover:bg-accent',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isLocked) {
|
||||
onUpsell();
|
||||
} else {
|
||||
level?.setOpenId(null);
|
||||
setLastVisitedSection(sectionName, viewName);
|
||||
navigate(path);
|
||||
}
|
||||
}}
|
||||
@@ -227,29 +302,31 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
|
||||
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
||||
|
||||
return (
|
||||
<Collapsible defaultOpen={containsActive}>
|
||||
<AccordionCollapsible id={name} containsActive={containsActive}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
|
||||
<Button variant="ghost" className={cn(sidebarItemClass, sidebarItemSquareClass)}>
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
<AccordionLevel>
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
</AccordionLevel>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</AccordionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -262,25 +339,45 @@ export function Sidebar() {
|
||||
const activeSection = useUIStore((s) => s.activeSection);
|
||||
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const edition = useAccountStore((s) => s.edition);
|
||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||
const permissions = useAccountStore((s) => s.permissions);
|
||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
// Set by handleSectionClick right before navigating: switching sections
|
||||
// from the footer should keep the sidebar open so the new section's pages
|
||||
// are still browsable, instead of closing right back up like a leaf-page
|
||||
// navigation would.
|
||||
const skipCloseOnNavigateRef = useRef(false);
|
||||
|
||||
const layouts = useMemo(
|
||||
() =>
|
||||
schema ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) : [],
|
||||
[schema, edition, hasObjectPermission, hasPermission],
|
||||
);
|
||||
// Build the permission checks from the permissions array itself: the store
|
||||
// accessors are stable refs, so depending on them alone would keep a stale
|
||||
// layout list after access data finishes loading.
|
||||
const layouts = useMemo(() => {
|
||||
if (!schema) return [];
|
||||
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
||||
return visibleLayouts(schema, edition, canGet, hasPermission);
|
||||
}, [schema, edition, permissions, hasPermission]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!schema) return;
|
||||
if (layouts.length === 0) return;
|
||||
if (!layouts.find((l) => l.name === activeSection)) {
|
||||
setActiveSection(layouts[0].name);
|
||||
if (typeof window === 'undefined') return;
|
||||
if (skipCloseOnNavigateRef.current) {
|
||||
skipCloseOnNavigateRef.current = false;
|
||||
return;
|
||||
}
|
||||
}, [schema, layouts, activeSection, setActiveSection]);
|
||||
if (window.matchMedia('(max-width: 767px)').matches) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}, [location.pathname, setSidebarOpen]);
|
||||
|
||||
// Keep the active page visible in the sidebar after any navigation
|
||||
// (e.g. from the command palette or an external link).
|
||||
useEffect(() => {
|
||||
const active = navRef.current?.querySelector('[data-sidebar-active="true"]');
|
||||
active?.scrollIntoView({ block: 'nearest' });
|
||||
}, [location.pathname, activeSection]);
|
||||
|
||||
if (!sidebarOpen || !schema) return null;
|
||||
|
||||
@@ -289,66 +386,87 @@ export function Sidebar() {
|
||||
|
||||
const handleSectionClick = (target: Layout) => {
|
||||
setActiveSection(target.name);
|
||||
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
||||
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
||||
const last = findLastVisitedLinkInLayout(schema, target, edition, canGet, hasPermission);
|
||||
const first =
|
||||
last ??
|
||||
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
|
||||
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
|
||||
if (first) navigate(`/${target.name}/${first}`);
|
||||
if (first) {
|
||||
skipCloseOnNavigateRef.current = true;
|
||||
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>
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="fixed inset-0 top-14 z-20 bg-black/40 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
<aside className="fixed top-14 left-0 bottom-0 z-30 flex w-64 flex-col border-r bg-background">
|
||||
<ScrollArea className="flex-1">
|
||||
<nav
|
||||
ref={navRef}
|
||||
className="flex flex-col gap-0.5 px-2 py-2 [[data-radius='square']_&]:gap-0 [[data-radius='square']_&]:px-0"
|
||||
>
|
||||
<AccordionLevel>
|
||||
{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)}
|
||||
/>
|
||||
))}
|
||||
</AccordionLevel>
|
||||
</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>
|
||||
)}
|
||||
{layouts.length > 1 && (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-around border-t bg-background px-2 py-2 [[data-radius='square']_&]:px-0 [[data-radius='square']_&]:py-0">
|
||||
{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',
|
||||
"[[data-radius='square']_&]:h-12 [[data-radius='square']_&]:flex-1 [[data-radius='square']_&]:border-r [[data-radius='square']_&]:border-border [[data-radius='square']_&]:last:border-r-0 [[data-radius='square']_&]:hover:bg-foreground/[0.03]",
|
||||
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>
|
||||
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
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;
|
||||
const { User, LogOut, Check, Menu, Sparkles, Search, Palette, ScrollText } = LucideIcons;
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { GlobalSearch } from '@/components/common/GlobalSearch';
|
||||
import { CommandPalette } from '@/components/common/CommandPalette';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -20,12 +20,17 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import Logo from '@/components/common/Logo';
|
||||
import { ModeToggle } from '@/components/common/ModeToggle';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
|
||||
import { findLastVisitedLinkInLayout } from '@/lib/lastVisited';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { useState } from 'react';
|
||||
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { useCurrentAccountDetails } from '@/hooks/useCurrentAccount';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
|
||||
function getIcon(name: string): LucideIcons.LucideIcon {
|
||||
@@ -39,19 +44,35 @@ function getIcon(name: string): LucideIcons.LucideIcon {
|
||||
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 activeAccount = activeAccountId ? accounts[activeAccountId] : null;
|
||||
const currentAccount = useCurrentAccountDetails();
|
||||
const displayName = currentAccount.username?.trim() || activeAccount?.name?.trim() || activeAccountId?.split('@')[0] || activeAccountId;
|
||||
const displayEmail = currentAccount.email || activeAccountId;
|
||||
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 [paletteOpen, setPaletteOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function handleGlobalKeyDown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setPaletteOpen((open) => !open);
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleGlobalKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
|
||||
}, []);
|
||||
|
||||
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
|
||||
const navigableLayouts = schema
|
||||
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
|
||||
@@ -63,23 +84,60 @@ export function TopBar() {
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Link to="/" className="flex shrink-0 items-center">
|
||||
<Logo />
|
||||
</Link>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link to="/" className="flex shrink-0 items-center">
|
||||
<Logo />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t('version.label', 'Stalwart WebUI Fork v{{version}}', { version: __APP_VERSION__ })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<GlobalSearch />
|
||||
<div className="hidden min-w-0 flex-1 items-center justify-center px-4 md:flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaletteOpen(true)}
|
||||
className="flex h-9 w-full max-w-md items-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="flex-1 text-left">
|
||||
{t('globalSearch.placeholder', 'Search pages, fields, settings...')}
|
||||
</span>
|
||||
<kbd className="pointer-events-none flex h-5 select-none items-center rounded border bg-muted px-1.5 font-mono text-[10px] font-medium">
|
||||
{isMac ? '⌘K' : 'Ctrl K'}
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="ml-auto flex items-center gap-2 md:ml-0">
|
||||
{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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
onClick={() => setPaletteOpen(true)}
|
||||
aria-label={t('search', 'Search')}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
||||
|
||||
<ModeToggle />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label={t('userMenu', 'User menu')}>
|
||||
<Button variant="ghost" className="h-auto gap-2 px-2" aria-label={t('userMenu', 'User menu')}>
|
||||
<User className="h-4 w-4" />
|
||||
<div className="hidden flex-col items-start text-left md:flex">
|
||||
<span className="text-sm font-medium leading-none">{displayName}</span>
|
||||
<span className="text-xs text-muted-foreground leading-none">{displayEmail}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
@@ -95,7 +153,9 @@ export function TopBar() {
|
||||
onClick={() => {
|
||||
setActiveSection(layout.name);
|
||||
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
||||
const lastLink = findLastVisitedLinkInLayout(schema, layout, edition, canGet, hasPermission);
|
||||
const firstLink =
|
||||
lastLink ??
|
||||
findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPermission) ??
|
||||
findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPermission);
|
||||
if (firstLink) {
|
||||
@@ -138,10 +198,26 @@ export function TopBar() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={() => navigate('/Appearance')}>
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
{t('appearance.label', 'Appearance')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => navigate('/Changelog')}>
|
||||
<ScrollText className="mr-2 h-4 w-4" />
|
||||
{t('changelog.label', 'Changelog')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
const endSessionEndpoint = useAuthStore.getState().endSessionEndpoint;
|
||||
logout();
|
||||
navigate('/login');
|
||||
if (endSessionEndpoint) {
|
||||
window.location.href = buildEndSessionUrl(endSessionEndpoint, getPostLogoutRedirectUri());
|
||||
} else {
|
||||
navigate('/login');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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 { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { DayPicker, getDefaultClassNames, type DayButton } from '@daypicker/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = 'label',
|
||||
buttonVariant = 'ghost',
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) => date.toLocaleString('default', { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn('w-fit', defaultClassNames.root),
|
||||
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
|
||||
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
|
||||
nav: cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', defaultClassNames.nav),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown),
|
||||
caption_label: cn(
|
||||
'font-medium select-none',
|
||||
captionLayout === 'label'
|
||||
? 'text-sm'
|
||||
: 'flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
month_grid: cn('w-full border-collapse', defaultClassNames.month_grid),
|
||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
'flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none',
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn('mt-2 flex w-full', defaultClassNames.week),
|
||||
week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
|
||||
week_number: cn('text-[0.8rem] text-muted-foreground select-none', defaultClassNames.week_number),
|
||||
day: cn(
|
||||
'group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md',
|
||||
props.showWeekNumber
|
||||
? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-md'
|
||||
: '[&:first-child[data-selected=true]_button]:rounded-l-md',
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn('rounded-l-md bg-accent', defaultClassNames.range_start),
|
||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
|
||||
today: cn('rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none', defaultClassNames.today),
|
||||
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
|
||||
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
|
||||
hidden: cn('invisible', defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === 'left') {
|
||||
return <ChevronLeftIcon className={cn('size-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
if (orientation === 'right') {
|
||||
return <ChevronRightIcon className={cn('size-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
return <ChevronDownIcon className={cn('size-4', className)} {...props} />;
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70',
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
@@ -5,11 +5,27 @@
|
||||
*/
|
||||
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
const CollapsibleContent = React.forwardRef<
|
||||
React.ComponentRef<typeof CollapsiblePrimitive.CollapsibleContent>,
|
||||
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CollapsibleContent.displayName = CollapsiblePrimitive.CollapsibleContent.displayName;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Search } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive>,
|
||||
@@ -30,7 +31,7 @@ Command.displayName = CommandPrimitive.displayName;
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<DialogContent className="overflow-hidden p-0" showCloseButton={false}>
|
||||
<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>
|
||||
@@ -53,6 +54,9 @@ const CommandInput = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<kbd className="pointer-events-none ml-2 inline-flex h-5 shrink-0 select-none items-center rounded-md border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
));
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
@@ -61,11 +65,9 @@ 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}
|
||||
/>
|
||||
<ScrollArea viewportClassName="max-h-[300px]">
|
||||
<CommandPrimitive.List ref={ref} className={cn('overflow-x-hidden', className)} {...props} />
|
||||
</ScrollArea>
|
||||
));
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
|
||||
@@ -35,8 +35,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
@@ -48,10 +50,12 @@ const DialogContent = React.forwardRef<
|
||||
{...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>
|
||||
{showCloseButton && (
|
||||
<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>
|
||||
));
|
||||
|
||||
@@ -81,12 +81,15 @@ const DropdownMenuItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
>(({ className, inset, variant = 'default', ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
data-variant={variant}
|
||||
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',
|
||||
'data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
||||
<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',
|
||||
'flex h-9 w-full rounded-md border border-input bg-field 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}
|
||||
|
||||
@@ -11,10 +11,25 @@ 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>
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
|
||||
// Height caps (max-h-*) must be applied to the Viewport — the actual
|
||||
// scroller — because a percentage height resolves to auto when the
|
||||
// Root's height is content-driven, which would break scrolling.
|
||||
viewportClassName?: string;
|
||||
}
|
||||
>(({ className, viewportClassName, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative min-w-0 overflow-hidden', className)} {...props}>
|
||||
{/*
|
||||
Radix wraps children in a `display: table` div with an intrinsic min-width.
|
||||
That lets wide tables expand the whole shell and clip under overflow-x:hidden,
|
||||
so horizontal scroll never reaches the table's own overflow-x-auto wrapper.
|
||||
Force the inner wrapper to shrink to the viewport width instead.
|
||||
*/}
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
className={cn('h-full w-full rounded-[inherit] [&>div]:!block [&>div]:!min-w-0', viewportClassName)}
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
|
||||
@@ -23,7 +23,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<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',
|
||||
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-field 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}
|
||||
|
||||
@@ -15,7 +15,7 @@ const Switch = React.forwardRef<
|
||||
>(({ 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',
|
||||
'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-green-600 data-[state=unchecked]:bg-red-600 disabled:data-[state=checked]:bg-primary disabled:data-[state=unchecked]:bg-input',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -13,7 +13,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttribu
|
||||
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',
|
||||
'flex min-h-[60px] w-full rounded-md border border-input bg-field 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}
|
||||
|
||||