115 Commits
Author SHA1 Message Date
gawells 8804c944ce change upstream
Build and Release / build (push) Successful in 10m19s
Build and Release / Cleanup failed release (push) Skipped
Build and Release / Publish release (push) Failing after 4s
2026-08-03 14:08:20 -04:00
Steven RYDELL eda20cbe2f chore: release v1.1.3
Fix column sort headers so a third click clears the sort and restores
the list's default order.
2026-08-01 23:31:27 +02:00
Steven RYDELL 41b3824894 chore: release v1.1.2
Add a Changelog page that renders CHANGELOG.md from the header menu,
switch the default color theme to the brand-accurate Stalwart theme, and
document all changes since v1.1.1.
2026-08-01 23:25:02 +02:00
Steven RYDELL f5524eb45a fix(ui): close sidebar group when picking a link outside it
Plain links (not wrapped in a collapsible) are siblings of collapsible
groups within the same AccordionLevel, but their onClick never touched
that level's openId — so a group left expanded stayed expanded even
after navigating to an unrelated top-level page, or to a sibling link
outside any group.

Both SidebarTopItem and SidebarSubItem's link branches now read the
AccordionLevelContext they're rendered in and call setOpenId(null)
before navigating. For a link inside its own group, this is
immediately overridden by AccordionCollapsible's existing
containsActive effect (which reopens the branch that now contains the
active page), so nested navigation is unaffected — only a truly
unrelated group gets collapsed.

Verified: opening Directory (showing Groups), then clicking Cluster (a
plain top-level link) now collapses Directory instead of leaving it
expanded.
2026-08-01 23:07:48 +02:00
Steven RYDELL 637d8bb286 feat(appearance): rename default theme, add real brand-accurate Stalwart theme
The color theme previously named "Stalwart" was actually just the
neutral black/white default — it never used Stalwart's own brand
color. Renamed it to "Default" (ColorTheme 'stalwart' -> 'default'
value; label/i18n key updated) and added a genuinely new "Stalwart"
theme in its place.

Colors sourced directly from the official site's own design tokens
(D:\project\stalwart_website/src/styles/tokens.css): brand red/pink
#db2d54 (light primary/ring) and #ff4570 (--brand-hi, dark
primary/ring), converted to oklch via the sRGB->OKLab->OKLCH matrix
transform to match this project's existing token format. Neutrals use
near-true-gray values (also converted from the site's actual light/
dark backgrounds and text colors) rather than being re-hued toward the
brand color like the other theme presets — matching how the real site
treats color (saturated only on the accent, neutral everywhere else).

Verified in the running dev server: Default reverts to the original
neutral look, Stalwart renders the brand red across primary buttons,
active nav state, checkboxes, and badges in both light and dark mode.
No regression (243 tests, typecheck, lint all pass).
2026-08-01 23:02:32 +02:00
Steven RYDELL 410a01700b feat(lists): make the most relevant columns sortable on the new tables
Mailing Lists, Roles, and Domains got new/existing columns in prior
commits but no way to sort them — the account-client-sort mechanism
was only wired up in accountColumns.ts. Extracted its one-line
clientSortable() column tagger into schemaDeviationTypes.ts (shared,
same intersection-type pattern) and used it in all four column-patch
files:

- Mailing Lists: Email Address, Description, Aliases
- Roles: Description, Enabled Permissions, Disabled Permissions
- Domains: Domain Name, Enabled, Aliases

Re-verified server sort support live, per list this time rather than
just Accounts: every property tried returns unsupportedSort except one
surprise — x:Domain/query actually accepts sort by "name", despite the
schema not declaring it. Documented in SCHEMA_DEVIATIONS.md and
deliberately routed through the same client-sort path as everything
else instead of adding a one-off "trust an undeclared sort" mechanism
for that single case.

Verified in the running dev server: sort indicators appear on the
intended columns for all three lists, clicking reorders rows correctly
(alphabetical on Mailing Lists' Email Address, numeric on Roles'
Enabled Permissions: 1 -> 3 -> 50 -> 229 -> 244 -> 452), Domains sorts
without error. No regression on Accounts/Groups.
2026-08-01 22:31:25 +02:00
Steven RYDELL 26a3878faa feat(domains): add Aliases count column
Same account-alias-count-column deviation already used on Accounts,
Groups, and Mailing Lists, extended to x:Domain: adds a synthetic
aliasCount column resolved from the real aliases property (a `set` of
alias domain names here, vs. an objectList elsewhere — same id-keyed
wire format, so the existing generic COUNT_COLUMN_SOURCES handling
needed no changes).

Verified in the running dev server: the column renders correctly
alongside the domain's existing Enabled/Certificate/DNS columns.
2026-08-01 22:25:26 +02:00
Steven RYDELL 503cae465f feat(roles): add Enabled/Disabled Permissions count columns
New SCHEMA-DEVIATION: role-permission-count-columns — the Roles list
only shows Description, so seeing how broad or restrictive a role is
required opening it and counting permissions by hand.

Generalized DynamicList's existing alias-count handling (previously
hardcoded to the single `aliasCount` -> `aliases` mapping) into
COUNT_COLUMN_SOURCES, a table of synthetic "*Count" column names to the
real set/objectList property they count. account-alias-count-column
now runs through the same generic path with no behavior change;
role-permission-count-columns (enabledPermissionCount/
disabledPermissionCount -> enabledPermissions/disabledPermissions) is
the second consumer, added with zero new DynamicList.tsx branching.

Verified against the live dev server: correct counts for both seeded
custom roles (Support Agent: 3/0, Read-only Auditor: 1/0) and the
built-in roles (System Administrator: 452/0, User: 244/0, etc.).
Confirmed no regression on Accounts' existing Aliases column/sort.
2026-08-01 22:23:48 +02:00
Steven RYDELL 9cd21a2d34 feat(mailing-lists): add Aliases count column
Same account-alias-count-column deviation already used on Accounts and
Groups, extended to x:MailingList: adds a synthetic aliasCount column
and lets DynamicList's existing generic column-name-driven handling
resolve/render it — no changes needed there, the mechanism was already
list-agnostic.

Verified in the running dev server: the column renders 0 with no
aliases set on either seeded mailing list.
2026-08-01 22:19:35 +02:00
Steven RYDELL d9b073ad6c feat(dev): add scripts/dev-seed to populate sample test data
New scripts/dev-seed.sh (+ .ps1), run after dev-server-init: seeds the
test server with Users (alice, bob, carol — spanning User/Admin roles,
group membership, an alias), Groups (engineering, marketing), Mailing
Lists (newsletter, support), and custom Roles (Support Agent,
Read-only Auditor), so each admin panel screen has real data to test
against instead of an empty list.

Idempotent (skips if 'alice' already exists), documented as workflow
step 3 in DEVELOPMENT.md (renumbering the steps after it) and listed
in AGENTS.md. Verified end-to-end with both scripts against a fresh
server, including idempotency and the resulting UI (Accounts, Groups,
Mailing Lists, Roles all populated as expected).
2026-08-01 22:10:43 +02:00
Steven RYDELL face5b4161 fix(dev): respect $PORT env var in vite.config.ts
Preview/dev tooling that assigns a free port via $PORT (because the
project's usual 5173 is taken) had no way to make Vite actually bind
there — Vite ignores PORT by default and falls back to its own
auto-increment, so the dev server would come up on a different port
than the one the tooling proxied to.
2026-08-01 21:58:34 +02:00
Steven RYDELL 93b10c3ed1 refactor(sort): make client-side column sorting generic, not view-hardcoded
Previous commit hardcoded which lists (viewName === 'x:Account/User' /
'x:Account/Group') and columns (a fixed accessor map) got client-side
sort. Moved the "which columns" decision into the schema itself instead:

- New ClientSortableColumn type in schemaDeviationTypes.ts (intersection
  with the official Column type, same pattern as ClientOnlyFilterEnum —
  src/types/schema.ts stays untouched).
- withAccountListColumns tags Email Address, Full Name, quotaUsage, and
  aliasCount with clientSortable: true when it builds the Accounts/
  Groups column lists — the deviation-specific knowledge lives where the
  columns themselves are defined.
- DynamicList reads that flag generically (clientSortableColumns, a
  useMemo over resolved.list.columns) with no viewName check at all.
  getClientSortValue() replaces the old per-list accessor map: real
  columns compare their own property directly, only the two synthetic
  columns need a value override.

Net effect for Accounts/Groups is unchanged (verified: sort indicators
still only on Email/Full Name/Usage/Aliases, ascending/descending still
works). Any other list can now opt into the same client-sort mechanism
by tagging a column clientSortable, without touching DynamicList.tsx.
2026-08-01 20:32:04 +02:00
Steven RYDELL 518ec4059a feat(accounts): add client-side column sorting to Accounts and Groups
Verified against a live server: x:Account/query rejects sort on every
property tried, including real ones like emailAddress, with
unsupportedSort — the schema's empty list.sort is accurate, this is a
systemic server gap, not something specific to this fork's synthetic
columns.

Added client-side sorting (SCHEMA-DEVIATION: account-client-sort) for
Email Address, Full Name, Usage/Quota, and Aliases on both lists,
reusing the fetch-all-then-sort-locally mechanism already established
for mailbox-client-hierarchy-sort: clicking a sortable header switches
that one query to an unpaginated fetch, sorts the results in memory by
the appropriate accessor (numeric for Usage/Aliases, string compare
otherwise), then paginates client-side. Role isn't included (not a
sortable scalar). Normal server-paginated lists are unaffected — this
only activates when a client-sortable column is actually clicked.

Verified end-to-end: sort indicators appear only on the intended
columns, clicking Email Address/Aliases correctly reorders rows and
toggles ascending/descending, Groups behaves the same as Accounts.
2026-08-01 20:23:42 +02:00
Steven RYDELL 3241dea5e4 feat(accounts): add Aliases count column to Accounts and Groups
Same pattern as the existing quotaUsage deviation: neither list's
schema exposes an alias count as a column, only the full `aliases`
objectList on the detail view. Added a synthetic `aliasCount` column
(SCHEMA-DEVIATION: account-alias-count-column, documented in
SCHEMA_DEVIATIONS.md) that DynamicList resolves by fetching the real
`aliases` property and counting its entries.

Verified against a live test server: adding a real alias to an
account correctly bumps its Aliases count from 0 to 1 in the list.
2026-08-01 20:18:01 +02:00
Steven RYDELL 0aa92c394c feat(accounts): add Usage/Quota column to Groups, show unlimited as ∞
Groups have real usedDiskQuota/quotas fields, same as Users, but the
Groups list schema doesn't expose usage as a column any more than the
Accounts list did — extend the existing account-quota-usage-column
deviation to x:Account/Group too (SCHEMA_DEVIATIONS.md updated).

The isAccountsList gate that resolved/rendered quotaUsage was hardcoded
to viewName === 'x:Account/User', so it silently no-oped for Groups.
Replaced with hasQuotaUsageColumn, derived from whether the resolved
list's own columns include the synthetic quotaUsage column — works for
any list withAccountListColumns patches, not just a hardcoded pair of
view names.

Also: "Unlimited" (no quota configured) now renders as "∞" instead of
the word, matching the size formatting style used elsewhere in the
column.
2026-08-01 20:02:10 +02:00
Steven RYDELL 09910a7bf9 fix(lint): remove unused isBackendIconKnown, unblocking CI
Pre-existing dead code (defined, never imported anywhere) that also
tripped react-refresh/only-export-components since it mixed a
non-component export into a component-only file — eslint treats that
as an error, which was silently failing every CI run's lint step
(including this release build) well before this session's changes.
2026-08-01 19:31:57 +02:00
Steven RYDELL 95053d7dc8 chore: release v1.1.1 2026-08-01 19:24:08 +02:00
Steven RYDELL 52123bce29 fix(assets): use upstream's favicon.ico instead of a mis-cropped fork export
This fork's favicon.ico had drifted from upstream's: same Stalwart logo
and color (#DB2D54) in both, but the fork's version had visible padding
around the logo (trimmed bounding box 120x104 within the 128x128 icon)
while upstream's fills the full canvas edge-to-edge. Also encoded at a
lower bit depth (8-bit indexed vs upstream's 32-bit true color).

Replaced with the exact file from stalwartlabs/webui (md5 e9f5eaa1...,
matches byte-for-byte) rather than re-exporting, so it can't drift
again the same way.
2026-08-01 19:21:19 +02:00
Steven RYDELL 40164a6652 feat(webapps): show and correctly set the active WebUI's resource URL
The "Active WebUI" card (Settings > Web Applications) only showed
description and __APP_VERSION__, never the resourceUrl it's actually
built from — add it as a linked "Source" row.

That card also turned out to be misleading in local dev: it reads the
x:Application record whose urlPrefix matches /admin or /account, and a
freshly bootstrapped server keeps Stalwart's seeded default there
("Stalwart Web Interface", pointing at stalwartlabs/webui's release),
regardless of what's actually being served — in dev that's this fork,
served directly by Vite, which never touches that record at all.

dev-server-init.sh/.ps1 now point that record's description and
resourceUrl at this fork's own release, so the card (now including the
visible Source URL) reflects what's actually running instead of
Stalwart's factory default.

Verified end-to-end against a fresh container with both scripts.
2026-08-01 19:17:44 +02:00
Steven RYDELL 7fab3540ea fix(ui): keep mobile sidebar open when switching sections
Tapping Management/Settings/Account in the sidebar footer navigated to
that section's default page, which triggered the same effect that
closes the sidebar after picking a leaf page on mobile — so the
sidebar snapped shut right after switching sections, forcing users to
reopen it just to browse the new section's pages.

Have handleSectionClick flag the navigation as section-only via a ref;
the mobile auto-close effect consumes that flag and skips closing for
that one navigation, leaving normal leaf-page navigation unaffected.

Verified on a mobile viewport: switching sections now keeps the
sidebar open, while picking a specific page still closes it as before.
2026-08-01 19:07:22 +02:00
Steven RYDELL 18264a5b76 docs(dev): add troubleshooting section, fix stale "one command" claim
Add a Troubleshooting section to DEVELOPMENT.md covering the case that
just happened: npm run dev only reads .env.development.local at
startup, so regenerating a token or restarting the Docker container
while it's already running silently leaves the UI unable to reach the
backend until it's restarted.

README.md's "one command" claim for spinning up the test server was
stale since dev-server-init.sh became a separate one-time step.
2026-08-01 18:39:33 +02:00
Steven RYDELL adca6d8730 feat(dev): switch dev tokens to a real admin account with configurable duration
Requested: dev-token.sh/.ps1 tokens should last 3h by default, with an
argument to override the duration.

Server-verified finding: STALWART_RECOVERY_ADMIN (the break-glass
account docker-compose.yml and the scripts used) always issues OAuth
tokens with a fixed 1h expiry, regardless of the server's configured
accessTokenExpiry — confirmed against the live container, including
after changing the setting and restarting. Confirmed x:ApiKey objects,
by contrast, support an arbitrary expiresAt set per request, and their
secret works directly as a bearer token.

Add scripts/dev-server-init.sh (+ .ps1): a one-time, idempotent setup
step that completes the server's bootstrap wizard (default domain, no
TLS certificate request), creates a real "devadmin" admin account, and
sets the server's default OAuth token lifetime to 3h.

Rework dev-token.sh/.ps1 to authenticate as devadmin and create an
x:ApiKey with a caller-supplied expiry (`dev-token.sh 1800` for 30
minutes, defaults to 10800s/3h) instead of running the OAuth PKCE flow
against the recovery account. Verified end-to-end against a fresh
container, including a real browser session against the running WebUI.

Also includes an incidental package-lock.json sync (was still pinned to
v1.0.8 / stale dependency ranges from before the upstream merge).
2026-08-01 18:32:13 +02:00
Steven RYDELL f4c8f8f21c feat(dev): add one-command local Stalwart test server + workflow docs
Add docker-compose.yml: a disposable Stalwart instance with a fixed dev
admin account (STALWART_RECOVERY_ADMIN), matching the credentials
scripts/dev-token.ps1 already expected. Wired up via new npm scripts
(dev:server, dev:server:down, dev:server:logs).

scripts/dev-token.ps1 was previously untracked (the whole scripts/
directory was gitignored) even though it's part of the documented dev
workflow — un-ignored it, and added scripts/dev-token.sh, a POSIX
equivalent for non-Windows shells and AI agents without PowerShell.

New DEVELOPMENT.md documents the full loop end-to-end (start server,
get a token, run the dev server, verify), written so it's actionable by
both humans and AI coding agents without needing a browser. Linked from
AGENTS.md (Commands) and README.md (Getting started).

Verified manually: docker compose up brings the server to a healthy
state, /api/auth + /auth/token issue a working bearer token, and
/jmap/session returns 200 with it end-to-end.
2026-08-01 18:05:43 +02:00
Steven RYDELL 38abba39f8 docs(readme): document fork identity, deviations policy, and UI switching
Rewrite everything below the project header to actually describe this
fork instead of a generic copy of upstream's README:

- "About this fork" + list of official Stalwart repositories.
- Corrected the "Nothing is hardcoded" overclaim; Features now lists
  what's shared with upstream vs. this fork's own additions, and links
  to SCHEMA_DEVIATIONS.md for the tracked exceptions.
- New "Switching your server to this fork's UI" section: how to point
  a Stalwart server's WEBAPP application at this fork's release build
  via stalwart-cli, and how to switch back.
- Support section now distinguishes fork-specific issues (this repo)
  from Stalwart Mail Server support (upstream channels).
- License/Copyright text left untouched; added one line noting fork
  changes stay under the same dual license per each file's SPDX header.

The project header block (logo, title, badges) is unchanged.
2026-08-01 17:54:24 +02:00
Steven RYDELL 101b24b361 fix(schema): track client-side schema deviations, restore schema.ts fidelity
src/types/schema.ts had drifted from the official schema contract: a
`clientOnly` field had been added to `FilterEnum` to support client-side
log filtering. Restored it to match upstream exactly and moved the
deviation-only type into a new intersection type in
src/lib/schemaDeviationTypes.ts instead.

Audited the codebase for every place the UI does something the official
schema doesn't support and tagged each with `// SCHEMA-DEVIATION: <id>`,
documented in the new SCHEMA_DEVIATIONS.md registry (what, why, and the
ideal server-side fix):

- log-client-filters: Level/Event filters on Log Entries, applied
  client-side because x:Log/query rejects them as JMAP filters.
- account-quota-usage-column: synthetic quotaUsage column on the
  Accounts list.
- mailbox-client-hierarchy-sort: full-fetch + client-side sort to
  reconstruct mailbox parent/child hierarchy.
- webapp-enabled-column-fallback: synthetic "Enabled" column label for
  x:Application when the schema list doesn't define one.

Other viewName/objectName special cases (x:OtpAuth, x:Expression,
x:Rate, x:Action, x:Trace, CustomComponent/*) were checked against
upstream and are pre-existing architecture, not fork deviations.
2026-08-01 17:54:08 +02:00
Steven RYDELL 4a6340e83f chore(agents): add AGENTS.md/CLAUDE.md rules for AI coding agents
Any AI agent working in this repo (Claude Code, Codex, Kimi, or any
other) must stay schema-driven and never hardcode object types, field
names, filters, or columns as a shortcut. AGENTS.md is the index (tech
stack, commands, universal rules); CLAUDE.md just points Claude Code at
it so the same rules apply without duplicating content. The detailed
schema-fidelity rule lives in .agents/rules/.

.gitignore previously excluded *.md except README/CHANGELOG, which
would have hidden these files (and SCHEMA_DEVIATIONS.md) from git.
2026-08-01 17:53:04 +02:00
Steven RYDELL 137e6c6ed9 fix(merge): remove upstream loadLogoOnce leftover, restore schema.ts formatting
main.tsx no longer needs an eager logo fetch call: Logo.tsx already
triggers ensureLogoLoaded() from logoCache.ts (the fork's own
implementation, kept over upstream's during the sync merge).
2026-08-01 17:00:12 +02:00
Steven RYDELL 4c953e599d Merge remote-tracking branch 'upstream/main' into sync-upstream
# Conflicts:
#	CHANGELOG.md
#	package-lock.json
#	package.json
#	src/components/common/CommandPalette.tsx
#	src/components/common/LoadingFallback.tsx
#	src/components/common/Logo.tsx
#	src/components/forms/DynamicForm.tsx
#	src/components/forms/FieldWidget.tsx
#	src/components/layout/MainContent.tsx
#	src/components/layout/Sidebar.tsx
#	src/components/layout/TopBar.tsx
#	src/components/lists/DynamicList.tsx
#	src/components/ui/calendar.tsx
#	src/components/ui/command.tsx
#	src/components/ui/dialog.tsx
#	src/hooks/useDocumentTitle.ts
#	src/hooks/useGlobalSearch.ts
#	src/i18n/en.json
#	src/lib/lastVisited.ts
#	src/lib/logoCache.ts
#	src/pages/AdminPanel.lazy.tsx
#	src/pages/AdminPanel.tsx
#	src/pages/LoginPage.tsx
#	src/pages/NotFound.tsx
#	src/pages/OAuthCallback.tsx
#	src/stores/authStore.test.ts
#	src/stores/authStore.ts
#	src/stores/cacheStore.ts
#	vite.config.ts
2026-08-01 16:59:31 +02:00
Maurus Decimus 8cab61a9c5 v1.0.8 2026-07-31 15:52:36 +02:00
gawells d84784c9d9 fix merge error
Build and Release / build (push) Canceled after 0s
Build and Release / Publish release (push) Canceled after 0s
Build and Release / Cleanup failed release (push) Canceled after 0s
2026-07-30 22:53:01 -07:00
gawells c40d1c69d8 Merge remote-tracking branch 'upstream/main' 2026-07-30 22:49:10 -07:00
Steven RYDELL a4b324bb8b fix(ui): prefill empty date/time picker with now 2026-07-30 20:15:07 +02:00
Steven RYDELL 08382472f4 fix(ui): theme date/time picker for dark mode 2026-07-30 20:08:10 +02:00
Steven RYDELL 75823ebb21 chore: release v1.1.0 2026-07-30 19:55:14 +02:00
Steven RYDELL 0dec14c3f8 feat(accounts): highlight negative disk usage with recalculate hint 2026-07-30 19:54:46 +02:00
Steven RYDELL 1ab574a42a fix(ui): make admin lists usable on mobile viewports 2026-07-30 19:54:34 +02:00
Steven RYDELL d1284de415 feat: add iOS home-screen icon and Stalwart title without service worker 2026-07-30 18:56:29 +02:00
Steven RYDELL 3189be2de0 refactor(logo): encapsulate shared logo cache with AbortController cleanup 2026-07-30 18:49:35 +02:00
Steven RYDELL eda9c9c8bf fix: remove PWA service worker that breaks Stalwart /admin and /account mounts 2026-07-30 18:30:36 +02:00
Steven RYDELL 4e089d9b6b fix(appearance): keep Rounded corners preview curved in square mode 2026-07-30 18:09:51 +02:00
Steven RYDELLandClaude Sonnet 5 8b5b9c8f8e docs: add v1.0.9 CHANGELOG entry
Documents everything since the v1.0.8 entry: icon batches, the Web
Applications active-app info card, PWA support, Log Entries filters
and refresh button, Accounts list columns, the account-switch fixes
(reviewed against and aligned with upstream's own fix for the same
issue), the WebUI Fork rename, mailbox hierarchy, new color themes,
and the confirmed-backend-only limitations (Log filter properties,
exact-match-only text search across the admin API).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:51:04 +02:00
Steven RYDELLandClaude Sonnet 5 bd00b4e922 fix: persist active account across reload, remount views on switch
Aligns with upstream stalwartlabs/webui@189e270 (v1.0.7, "fixes #17"),
reviewed after they independently landed a fix for the same issue:

- authStore now persists activeAccountId (sessionStorage) and
  setSession preserves it across a session refresh instead of always
  resetting to primaryAccountId, so a hard reload keeps you on the
  group account you had selected instead of bouncing back to your own.
- switchAccount now clears cacheStore (displayNames/objectLists) when
  actually changing account, since those were resolved against the
  previous account and would otherwise show stale labels.
- The ErrorBoundary wrapping MainContent is now keyed on
  activeAccountId, forcing a full remount of every view on switch.
  This is more robust than gating individual components' fetch
  effects on activeAccountId (our earlier fix in DynamicList.tsx,
  kept as-is — harmless now, but no longer load-bearing on its own)
  since it covers every current and future view type, not just lists.

Verified against a live instance: switching to a group account updates
the JMAP accountId immediately (no tab switch needed), and a full page
reload keeps the group account active instead of resetting to admin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:48:19 +02:00
Steven RYDELLandClaude Sonnet 5 8c1e826d63 feat: add Rose/Amber/Teal color themes, default to Ocean + Square
Adds three new color themes alongside the existing Stalwart/Ocean/
Forest/Violet set so more people can find one they like, and switches
the out-of-the-box defaults to Ocean + square corners instead of the
neutral Stalwart theme + rounded corners (existing users' saved
preferences are untouched, this only changes what a fresh install
starts with).

Verified in the browser: all three new themes render with legible
primary-foreground contrast in both light and dark mode, and the
pre-hydration flash-prevention script in main.tsx (which reads
localStorage before React mounts) now recognizes the new theme names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:38:40 +02:00
Steven RYDELLandClaude Sonnet 5 7a96fa6786 feat: show mailbox hierarchy in the Mailboxes list
Fixes stalwartlabs/webui#16. Mailboxes had no visual indication of
parent/child relationships. Since a mailbox's parent can land on a
different server page than the mailbox itself, the Mailbox list now
always fetches the full set (like the existing client-filter path)
and orders it depth-first by parentId, then indents each row's name
with a corner connector proportional to its depth.

Verified against a live instance with a 3-level nested hierarchy
(Projects > Alpha > Docs) alongside the default flat mailboxes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:27:48 +02:00
Maurus Decimus 189e270785 v1.0.7 (fixes #17) 2026-07-30 17:16:05 +02:00
Steven RYDELLandClaude Sonnet 5 3e08dcaff5 chore: rename WebUI label to "Stalwart WebUI Fork"
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:07:44 +02:00
Steven RYDELLandClaude Sonnet 5 2ac3526da9 fix: refresh account-scoped lists when switching accounts
Switching accounts via the profile dropdown (TopBar) only updates
authStore.activeAccountId with no navigation, but DynamicList's fetch
effect depended on [viewName, sort, resolved?.list, appliedFilters] —
missing activeAccountId, and account resolution only happened through
a non-reactive getState() snapshot inside getAccountId(). So views
like Mailboxes, Calendars, or Sieve Scripts kept showing the previous
account's data until an unrelated viewName change (switching tabs)
incidentally re-ran the effect.

Reproduced and verified against a live Stalwart instance: a mailbox
list stayed on accountId "b" after switching to a group account "d"
in the dropdown, and only picked up "d" after navigating away and
back. Subscribing to activeAccountId reactively and adding it to the
effect's dependencies fixes the switch to apply immediately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:05:12 +02:00
Steven RYDELLandClaude Sonnet 5 3dda6d1527 feat: show Role and Usage/Quota columns on the Accounts list
Created At is replaced with two columns that previously required
opening each account individually: Role (badge, resolved against
x:UserRoles specifically since the list's merged User/Group field
definitions otherwise resolve `roles` against the wrong object) and
Usage / Quota (usedDiskQuota vs quotas.maxDiskQuota, "Unlimited" when
no limit is set).

Also makes renderCellValue's generic 'object' case resolve variant
labels via schema.schemas, instead of only ever printing the raw
@type string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 16:47:26 +02:00
Steven RYDELLandClaude Sonnet 5 3113e6309d feat: add rate-limited manual refresh button to Log Entries
Adds a Refresh button next to Filters, right-aligned, specific to the
Logs list. Guarded to one click per 5 seconds (button disabled and
re-enabled via a timer) to avoid hammering the server if left clicked
repeatedly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 16:36:25 +02:00
Steven RYDELLandClaude Sonnet 5 21b183327c feat: add client-side Level/Event filters to Log Entries
The backend rejects `level`/`event` as x:Log/query filter conditions
(unsupportedFilter), even though both are already returned per row.
Since a real fix requires backend changes outside this repo, the two
filters are injected into the schema client-side (clientOnly flag) and
applied entirely in the browser: excluded from the JMAP filter sent to
the server, and used to narrow an eagerly-fetched, locally-paginated
result set instead.

Also makes large enum filters (Event has 634 values) render as a
searchable Combobox instead of a plain Select, generically for any
list with more than 15 enum options.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 16:31:36 +02:00
Steven RYDELLandClaude Sonnet 5 da2c99990f feat: add PWA support (manifest, service worker, icons)
Adds vite-plugin-pwa with a generated manifest, icon set derived from
the Stalwart mark (192/512/maskable/apple-touch), and theme-color meta
tags. /api and /jmap are excluded from the service worker's navigate
fallback and are never precached or runtime-cached, so authenticated
mail data can't end up in Cache Storage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 16:06:59 +02:00
Steven RYDELL 25a38cd560 fix: prevent custom logo blink with global loading state 2026-07-30 15:42:27 +02:00
Steven RYDELL fe24a9bec0 chore: upgrade Vite to 8.2.0 and @vitejs/plugin-react to 6.0.5 2026-07-30 15:42:16 +02:00
Steven RYDELL b9489a795b feat: show Redis and Valkey icons for Redis/Valkey backend variant 2026-07-30 15:31:16 +02:00
Steven RYDELL 49b3d60378 chore(icons): add final batch of backend/provider icons 2026-07-30 15:13:29 +02:00
Steven RYDELL 532e560106 fix(ui): align icon and label in backend select trigger 2026-07-30 14:47:04 +02:00
Steven RYDELL 0a417c5123 chore(icons): rescan backend icons and add DNS provider mappings 2026-07-30 14:43:45 +02:00
Steven RYDELL 0e41f4d18e feat(ui): add backend icons to variant selectors 2026-07-30 14:28:43 +02:00
Steven RYDELL a7e69eefa6 refactor(ui): move Appearance from sidebar to header user dropdown 2026-07-30 13:50:28 +02:00
Steven RYDELL bfdf8f4ba2 fix(web-applications): show Enabled column and active WebUI info card 2026-07-30 13:43:23 +02:00
Steven RYDELL 87cabf3886 Align x:Application list layout with Domains: Description first, Enabled second 2026-07-30 13:06:33 +02:00
Steven RYDELL aa48b90323 Show active WebUI column in x:Application list and active app info card 2026-07-30 13:03:05 +02:00
Steven RYDELL 231f2ec3ee Remember last visited page per section in localStorage 2026-07-30 12:48:25 +02:00
Steven RYDELL 5127a3bf8a Show current user username and email in the TopBar user menu trigger 2026-07-30 12:37:36 +02:00
Steven RYDELL e4223d8cb4 Prefix dynamic page titles with Stalwart | 2026-07-30 12:14:17 +02:00
Steven RYDELL b1849230dc Update lucide-react to 1.28.0 2026-07-30 12:10:02 +02:00
Steven RYDELL 40c04555b4 Remove the Web Applications version column and update action 2026-07-30 12:00:01 +02:00
Steven RYDELL 5dac3490ee Update react-router-dom and migrate to @daypicker/react 10 2026-07-29 11:40:29 +02:00
Steven RYDELL 868dd5e877 v1.0.8 2026-07-29 11:28:56 +02:00
Steven RYDELL eec7f2ada5 Redirect to a default page when the URL has no view 2026-07-29 11:23:50 +02:00
Steven RYDELL 2ac9a6b90e Add an Appearance settings page with color theme and corner options 2026-07-29 11:21:44 +02:00
Steven RYDELL dca6082039 Restore the direct light/dark toggle instead of a dropdown 2026-07-29 11:16:42 +02:00
Steven RYDELL 544ee36a1e Set dynamic document titles per page 2026-07-29 09:43:30 +02:00
Steven RYDELL 5152b07a9a Make the theme switcher available on the login pages 2026-07-29 09:41:28 +02:00
Steven RYDELL 2a872d2ccf Add selectable color themes with a global appearance switcher 2026-07-29 09:40:40 +02:00
Steven RYDELL 0a8dfb44be Mark the logout menu item as destructive 2026-07-29 09:06:20 +02:00
Steven RYDELL 09d43f1e9b Stretch sidebar hover full-width with a separated footer in square mode 2026-07-29 08:55:57 +02:00
Steven RYDELL 272502e0a3 Add a square corners toggle for a border-radius-free interface 2026-07-29 08:52:37 +02:00
Steven RYDELL 13c21fc6d5 v1.0.7 2026-07-29 08:05:20 +02:00
Steven RYDELL 23bef766ca Hide the close cross in the global search palette 2026-07-29 07:55:24 +02:00
Steven RYDELL 0fb734a6c7 Replace native datetime inputs with a calendar date picker 2026-07-29 07:50:55 +02:00
Steven RYDELL 3f31a115c9 Show an ESC hint instead of the close button in the command palette 2026-07-29 07:34:48 +02:00
Steven RYDELL 149524376b Make the sidebar an animated accordion with a softer hover 2026-07-29 07:32:36 +02:00
Steven RYDELL 956212031a Clip table header background inside the rounded border 2026-07-29 07:24:58 +02:00
Steven RYDELL 6d3ea6c6b1 Keep the sidebar section synced with the URL on full page loads 2026-07-29 07:21:27 +02:00
Steven RYDELL c15cb22be4 Adopt the shared ScrollArea for app-wide scrolling 2026-07-29 07:20:43 +02:00
Steven RYDELL bd16e64e51 Show WebUI version and update action in the Web Applications list 2026-07-29 06:48:41 +02:00
Steven RYDELL 8fde771ff2 Show switch state with green when on and red when off 2026-07-29 06:48:34 +02:00
Steven RYDELL da5eaf0558 Center main content horizontally
Wrap the main area in a centered max-w-7xl container and center the max-w-4xl form column inside it, so pages no longer hug the left edge on wide screens. Lists and views center up to 7xl, forms stay at a readable 4xl.
2026-07-29 06:20:14 +02:00
Steven RYDELL eb57250409 Distinguish form field backgrounds from card surfaces
Introduce a --field design token (page background tone) used by inputs, textareas and select triggers, and raise the dark-mode card lightness so cards read as elevated surfaces with inset fields, following the shadcn convention.
2026-07-29 06:15:49 +02:00
Steven RYDELL a657ad1b93 Sync sidebar with programmatic navigation
Collapsible groups now auto-open when the active page lands inside them (while remaining manually toggleable), and the sidebar scrolls the active item into view. Previously, navigating via the command palette left the sidebar collapsed on the wrong spot.
2026-07-29 06:11:06 +02:00
Steven RYDELL 7c97297ce6 Add Ctrl+K/Cmd+K command palette search
Replace the TopBar dropdown search with a cmdk-based command palette opened via a visible trigger button (with platform-aware shortcut badge) or the Ctrl+K/Cmd+K global shortcut. Shared search logic moves to a useGlobalSearch hook; the palette reuses the existing ui/command components.
2026-07-29 06:02:18 +02:00
Steven RYDELL 4739f9ed92 Proxy API and JMAP requests to a local Stalwart server in dev
Same-origin proxying to localhost:8080 avoids CORS without weakening the server's CORS policy; pair with VITE_API_BASE_URL= (empty) in .env.development.local.
2026-07-29 05:48:07 +02:00
Steven RYDELL a83e75a79f Code-split feature pages and admin shell with React.lazy
The dashboard (recharts, ~120 kB gzip), tracing, troubleshoot, actions and bootstrap wizard now load on demand, and the admin panel is split out of the entry chunk so anonymous visitors only download the login page (~134 kB gzip instead of ~557 kB).
2026-07-29 05:47:58 +02:00
Maurus Decimus f18f3012aa v1.0.6 2026-07-28 10:57:49 +02:00
Maurus Decimus 5eea77346a v1.0.6 2026-07-28 10:57:30 +02:00
gawells ef99135cae remove flash of stalwart logo 2026-07-19 07:47:12 -04:00
gawells 5ce2a5acf7 change favicon 2026-07-18 18:55:42 -04:00
Maurus Decimus e080a6e061 Properly serialize date filters when applying them to the list filter 2026-06-23 17:26:22 +02:00
Maurus Decimus 574de03e42 Include required JMAP capabilities in using 2026-06-21 15:33:51 +02:00
Maurus Decimus a7fda8bd6b Default scopes omit offline_access (fixes #10) 2026-05-25 13:58:38 +02:00
Maurus Decimus 001a1f3a15 v1.0.4 2026-05-11 16:35:11 +02:00
Maurus Decimus 782dde0573 Align base32 alphabet with the server 2026-05-07 15:08:54 +02:00
Maurus Decimus cbe77f9e4a v1.0.3 2026-05-05 11:36:51 +02:00
Maurus Decimus e9b9efa084 Fix: Resolve object ids in map keys 2026-05-04 19:16:37 +02:00
Maurus Decimus dee0f7fbe3 Fix broken "Delivery History" link on OSS/Community editions 2026-05-01 09:03:02 +02:00
Maurus Decimus ba6b4472d2 Display validation errors returned by the server 2026-04-30 18:12:30 +02:00
Maurus Decimus 612fd796f3 Add "Copy Secret" button to TOTP setup flow 2026-04-30 13:29:05 +02:00
Maurus Decimus a3376b0a7d Include email and profile scopes in OIDC authentication requests 2026-04-30 12:11:40 +02:00
Maurus Decimus 2344c96651 Include openid scope in OIDC authentication requests 2026-04-25 08:14:47 +02:00
Maurus Decimus 68f0ca3629 Fix mobile display issues (fixes #4) 2026-04-23 15:21:55 +02:00
Maurus Decimus 47fcf1fe08 Logout users from OIDC provider when logging out of the app 2026-04-23 13:57:27 +02:00
Maurus Decimus 27ba55fbf6 Fix: Editing a secret clears its masked value 2026-04-22 20:09:53 +02:00
Maurus Decimus 35e0ef74b9 Fix array label properties display 2026-04-21 15:44:00 +02:00
Maurus Decimus cfb3a308cd Updated screencast 2026-04-20 18:42:46 +02:00
145 changed files with 8264 additions and 2246 deletions
+30
View File
@@ -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 -1
View File
@@ -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_OAUTH_CLIENT_ID=stalwart-webui
#VITE_ACCESS_TOKEN=OPEN_SESAME #VITE_ACCESS_TOKEN=OPEN_SESAME
VITE_OAUTH_SCOPES= VITE_OAUTH_SCOPES=
+20 -1
View File
@@ -23,8 +23,27 @@ dist-ssr
*.sln *.sln
*.sw? *.sw?
.ignore .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 *.md
!README.md !README.md
!CHANGELOG.md !CHANGELOG.md
!AGENTS.md
!CLAUDE.md
!SCHEMA_DEVIATIONS.md
!DEVELOPMENT.md
!.agents/rules/*.md
/SPEC-* /SPEC-*
# Tool-generated artifacts during agent sessions
.playwright-mcp/
outputs/
.vite_*.log
.tmp_*
*.py
webui.zip
release_body.md
+34
View File
@@ -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.
+187 -1
View File
@@ -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/). 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 ### Added
- Initial release. - Initial release.
+1
View File
@@ -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.
+190
View File
@@ -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 15) 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.
+235
View File
@@ -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/>.
+213
View File
@@ -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
+78 -12
View File
@@ -8,6 +8,10 @@
Web-based User Interface for Stalwart 🛡️ Web-based User Interface for Stalwart 🛡️
</h3> </h3>
<p align="center">
Community fork of <a href="https://github.com/stalwartlabs/webui">stalwartlabs/webui</a> with UI improvements and fixes.
</p>
<br> <br>
<p align="center"> <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> <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> </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 ## 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 (shared with upstream):
Key features:
- **Schema-driven UI**: All forms, lists, and navigation are generated from a JSON schema fetched from `/api/schema` after login. No object types, field names, or layouts are hardcoded. - **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. - **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. - **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 ## Screenshots
<img src="./img/screencast-setup.gif"> <img src="./img/demo.gif">
## Get Started ## 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) - [Linux / MacOS](https://stalw.art/docs/install/linux)
- [Windows](https://stalw.art/docs/install/windows) - [Windows](https://stalw.art/docs/install/windows)
- [Docker](https://stalw.art/docs/install/docker) - [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 ## Getting started
Prerequisites: Prerequisites:
- Node.js 18 or later - 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: Install dependencies:
@@ -85,12 +146,14 @@ VITE_OAUTH_SCOPES=
### Bypassing OAuth for development ### 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 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 ### Running the dev server
``` ```
@@ -130,10 +193,11 @@ npm run preview
## Support ## Support
If you are having problems running Stalwart Mail Server, you found a bug or just have a question, For bugs or questions about **this fork's UI changes**, please open an issue on [this repository](https://github.com/LinkPhoenix/stalwart-webui-fork).
do not hesitate to reach us on [Github Discussions](https://github.com/stalwartlabs/mail-server/discussions),
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). [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 ## License
@@ -143,7 +207,9 @@ This project is dual-licensed under the **GNU Affero General Public License v3.0
- The [Stalwart Enterprise License v1 (SELv1)](./LICENSES/LicenseRef-SEL.txt) is a proprietary license designed for commercial use. It offers additional features and greater flexibility for businesses that do not wish to comply with the AGPL-3.0 license requirements. - The [Stalwart Enterprise License v1 (SELv1)](./LICENSES/LicenseRef-SEL.txt) is a proprietary license designed for commercial use. It offers additional features and greater flexibility for businesses that do not wish to comply with the AGPL-3.0 license requirements.
Each file in this project contains a license notice at the top, indicating the applicable license(s). The license notice follows the [REUSE guidelines](https://reuse.software/) to ensure clarity and consistency. The full text of each license is available in the [LICENSES](./LICENSES/) directory. 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
Copyright (C) 2024, Stalwart Labs LLC Copyright (C) 2024, Stalwart Labs LLC
+105
View File
@@ -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).
+1077
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

+5 -3
View File
@@ -4,9 +4,11 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<base href="/" /> <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" /> <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> </head>
<body> <body>
@@ -14,4 +16,4 @@
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+1358 -1388
View File
File diff suppressed because it is too large Load Diff
+47 -43
View File
@@ -1,11 +1,14 @@
{ {
"name": "stalwart-webui", "name": "stalwart-webui-fork",
"private": true, "private": true,
"version": "1.0.0", "version": "1.1.3",
"description": "Stalwart WebUI", "description": "Stalwart WebUI Fork",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "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", "build": "tsc -b && vite build",
"typecheck": "tsc -p tsconfig.app.json --noEmit", "typecheck": "tsc -p tsconfig.app.json --noEmit",
"lint": "eslint .", "lint": "eslint .",
@@ -16,55 +19,56 @@
"format:check": "prettier --check src/" "format:check": "prettier --check src/"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15", "@daypicker/react": "^10.0.1",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-scroll-area": "^1.2.18",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-tooltip": "^1.2.16",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"i18next": "^26.0.4", "i18next": "^26.3.6",
"lucide-react": "^1.8.0", "lucide-react": "1.28.0",
"otpauth": "^9.5.0", "otpauth": "^9.5.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.4", "react": "^19.2.8",
"react-dom": "^19.2.4", "react-dom": "^19.2.8",
"react-i18next": "^17.0.2", "react-i18next": "^17.0.11",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.14.0", "react-router-dom": "^7.18.2",
"recharts": "^3.8.1", "recharts": "^3.10.1",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.6.0",
"zustand": "^5.0.12" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.12.2", "@types/node": "^26.1.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "6.0.5",
"eslint": "^9.39.4", "eslint": "^10.8.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.4.0", "globals": "^17.8.0",
"happy-dom": "^20.9.0", "happy-dom": "^20.11.1",
"prettier": "^3.8.2", "prettier": "^3.9.6",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.3.3",
"typescript": "~6.0.2", "typescript": "~6.0.3",
"typescript-eslint": "^8.58.0", "typescript-eslint": "^8.65.0",
"vite": "^8.0.4", "vite": "8.2.0",
"vitest": "^4.1.4" "vitest": "^4.1.10"
} }
} }

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+1
View File
@@ -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

+1
View File
@@ -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

Binary file not shown.

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

+28
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+124
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+31
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.9 KiB

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+139
View File
@@ -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"
+78
View File
@@ -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"
+147
View File
@@ -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."
+76
View File
@@ -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
+71
View File
@@ -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."
+57
View File
@@ -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."
+5 -1
View File
@@ -4,14 +4,18 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
import { Suspense } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
import { LoadingFallback } from '@/components/common/LoadingFallback';
import { Toaster } from '@/components/ui/toaster'; import { Toaster } from '@/components/ui/toaster';
export default function App() { export default function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<Outlet /> <Suspense fallback={<LoadingFallback fullScreen />}>
<Outlet />
</Suspense>
<Toaster /> <Toaster />
</ErrorBoundary> </ErrorBoundary>
); );
+10 -19
View File
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 { ArrowLeft, ArrowRight, Check, Copy, Loader2, Rocket } from 'lucide-react';
import { useSchemaStore } from '@/stores/schemaStore'; 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 { resolveObject, resolveSchema, resolveForm, buildCreateDefaults, deepMerge } from '@/lib/schemaResolver';
import { calculateJmapPatch } from '@/lib/jmapPatch'; import { calculateJmapPatch } from '@/lib/jmapPatch';
import { jmapGet, jmapSet, getAccountId } from '@/services/jmap/client'; 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 { Field, Fields, Form, FormField } from '@/types/schema';
import type { JmapSetError, JmapSetResponse } from '@/types/jmap'; import type { JmapSetError, JmapSetResponse } from '@/types/jmap';
@@ -88,9 +89,9 @@ export function BootstrapWizard() {
const { obj, sch } = resolved; const { obj, sch } = resolved;
const ctrl = new AbortController(); const ctrl = new AbortController();
setLoading(true);
(async () => { (async () => {
setLoading(true);
try { try {
const accountId = getAccountId(obj.objectName); const accountId = getAccountId(obj.objectName);
const responses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties, ctrl.signal); const responses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties, ctrl.signal);
@@ -181,19 +182,7 @@ export function BootstrapWizard() {
for (const ve of error.validationErrors) { for (const ve of error.validationErrors) {
const top = ve.property?.split('/')[0] ?? ''; const top = ve.property?.split('/')[0] ?? '';
if (!top) continue; if (!top) continue;
const msg = record(top, validationErrorMessage(ve));
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);
} }
} }
@@ -415,13 +404,15 @@ export function BootstrapWizard() {
function WizardShell({ children }: { children: React.ReactNode }) { function WizardShell({ children }: { children: React.ReactNode }) {
return ( 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"> <header className="flex items-center px-6 py-4 border-b bg-background">
<DefaultLogo /> <DefaultLogo />
</header> </header>
<main className="flex-1 overflow-auto p-6"> <ScrollArea role="main" className="min-w-0 flex-1">
<div className="mx-auto max-w-3xl">{children}</div> <div className="min-w-0 p-4 sm:p-6">
</main> <div className="mx-auto w-full min-w-0 max-w-3xl">{children}</div>
</div>
</ScrollArea>
</div> </div>
); );
} }
+115
View File
@@ -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} />;
}
+90
View File
@@ -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>
);
}
-240
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+9 -41
View File
@@ -4,9 +4,9 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * 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 { useTranslation } from 'react-i18next';
import { getApiBaseUrl } from '@/services/api'; import { ensureLogoLoaded, getLogoSnapshot, subscribeLogo } from '@/lib/logoCache';
export function DefaultLogo() { export function DefaultLogo() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -31,50 +31,18 @@ export function DefaultLogo() {
export default function Logo() { export default function Logo() {
const { t } = useTranslation(); const { t } = useTranslation();
const [logoUrl, setLogoUrl] = useState<string | null>(null); const logo = useSyncExternalStore(subscribeLogo, getLogoSnapshot, getLogoSnapshot);
const [failed, setFailed] = useState(false);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); ensureLogoLoaded();
async function fetchLogo() {
try {
const response = await fetch(`${getApiBaseUrl()}/logo`, {
signal: controller.signal,
});
const contentType = response.headers.get('content-type') ?? '';
if (response.ok && contentType.startsWith('image/')) {
const blob = await response.blob();
if (!controller.signal.aborted) {
const url = URL.createObjectURL(blob);
setLogoUrl(url);
}
} else {
if (!controller.signal.aborted) setFailed(true);
}
} catch {
if (!controller.signal.aborted) setFailed(true);
}
}
fetchLogo();
return () => {
controller.abort();
};
}, []); }, []);
useEffect(() => { if (logo.status === 'custom') {
return () => { return <img src={logo.url} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
if (logoUrl) { }
URL.revokeObjectURL(logoUrl);
}
};
}, [logoUrl]);
if (logoUrl && !failed) { if (logo.status === 'loading') {
return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />; return <span className="h-7 w-[140px] block" aria-hidden="true" />;
} }
return <DefaultLogo />; return <DefaultLogo />;
+34
View File
@@ -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>
);
}
+11 -3
View File
@@ -48,8 +48,14 @@ export function ObjectPicker({ schema, objectName, value, onChange, onClear, pla
return ( return (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{value && ( {value && (
<Badge variant="secondary" className="gap-1 pr-1.5 text-sm"> <Badge variant="secondary" className="gap-1 pr-1.5 text-sm max-w-xs">
{labelLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : (display ?? value)} {labelLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<span className="truncate" title={display ?? value}>
{display ?? value}
</span>
)}
{onClear && ( {onClear && (
<button <button
type="button" type="button"
@@ -121,7 +127,9 @@ export function ObjectPicker({ schema, objectName, value, onChange, onClear, pla
setOpen(false); setOpen(false);
}} }}
> >
{opt.label} <span className="truncate" title={opt.label}>
{opt.label}
</span>
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
+56
View File
@@ -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 * 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 { useTranslation } from 'react-i18next';
import { useBufferedValue } from '@/hooks/useBufferedValue';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -21,11 +22,7 @@ function BufferedExprInput({
React.InputHTMLAttributes<HTMLInputElement>, React.InputHTMLAttributes<HTMLInputElement>,
'onChange' | 'value' 'onChange' | 'value'
>) { >) {
const [local, setLocal] = useState(value); const [local, setLocal] = useBufferedValue(value);
useEffect(() => {
setLocal(value);
}, [value]);
const commit = () => { const commit = () => {
if (local !== value) onCommit(local); if (local !== value) onCommit(local);
+50 -53
View File
@@ -5,12 +5,13 @@
*/ */
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { flushSync } from 'react-dom';
import { useNavigate, useBlocker } from 'react-router-dom'; import { useNavigate, useBlocker } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label'; 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 { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -46,10 +47,13 @@ import {
} from '@/lib/schemaResolver'; } from '@/lib/schemaResolver';
import { jmapGet, jmapSet, jmapRequest, getAccountId } from '@/services/jmap/client'; import { jmapGet, jmapSet, jmapRequest, getAccountId } from '@/services/jmap/client';
import { calculateJmapPatch } from '@/lib/jmapPatch'; 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 { toast } from '@/hooks/use-toast';
import { logFormChange } from '@/lib/debug'; import { logFormChange } from '@/lib/debug';
import { FieldWidget } from '@/components/forms/FieldWidget'; import { FieldWidget } from '@/components/forms/FieldWidget';
import { BackendVariantIcon } from '@/components/common/BackendIcon';
import type { Field, Fields, Form, FormField, Schema } from '@/types/schema'; import type { Field, Fields, Form, FormField, Schema } from '@/types/schema';
import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap'; import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap';
@@ -164,11 +168,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
return Array.from(set); return Array.from(set);
}, [schema, resolved, viewName]); }, [schema, resolved, viewName]);
useEffect(() => { const [prevCreateInitKey, setPrevCreateInitKey] = useState<typeof resolved | undefined>(undefined);
if (!schema || !resolved) return; if (isCreate && schema && resolved) {
const { obj, sch } = resolved; if (resolved !== prevCreateInitKey) {
setPrevCreateInitKey(resolved);
if (isCreate) { const { obj, sch } = resolved;
const staticFilters = resolved.list?.filtersStatic; const staticFilters = resolved.list?.filtersStatic;
if (sch.type === 'multiple') { if (sch.type === 'multiple') {
const variantFromFilter = typeof staticFilters?.['@type'] === 'string' ? staticFilters['@type'] : undefined; const variantFromFilter = typeof staticFilters?.['@type'] === 'string' ? staticFilters['@type'] : undefined;
@@ -182,13 +186,19 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
setFormData(defaults); setFormData(defaults);
setOriginalData(defaults); setOriginalData(defaults);
} }
return;
} }
} else if (prevCreateInitKey !== undefined) {
setPrevCreateInitKey(undefined);
}
useEffect(() => {
if (!schema || !resolved || isCreate) return;
const { obj, sch } = resolved;
const ctrl = new AbortController(); const ctrl = new AbortController();
setLoading(true);
(async () => { (async () => {
setLoading(true);
try { try {
const accountId = getAccountId(obj.objectName); const accountId = getAccountId(obj.objectName);
const ids = isSingleton ? ['singleton'] : [objectId]; const ids = isSingleton ? ['singleton'] : [objectId];
@@ -249,13 +259,14 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
const blocker = useBlocker(isDirty && !saving); const blocker = useBlocker(isDirty && !saving);
const [pendingNavAfterCreate, setPendingNavAfterCreate] = useState(false); const navigateAfterCreate = useCallback(() => {
useEffect(() => { flushSync(() => {
if (!pendingNavAfterCreate) return; setOriginalData({ ...formData });
setPendingNavAfterCreate(false); setServerCreatedProps(null);
});
const section = viewToSection[viewName] ?? ''; const section = viewToSection[viewName] ?? '';
navigate(`/${section}/${viewName}`); navigate(`/${section}/${viewName}`);
}, [pendingNavAfterCreate, viewName, viewToSection, navigate]); }, [formData, viewToSection, viewName, navigate]);
const handleFieldChange = useCallback((fieldName: string, value: unknown) => { const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [fieldName]: value })); setFormData((prev) => ({ ...prev, [fieldName]: value }));
@@ -345,21 +356,10 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
if (error.validationErrors && error.validationErrors.length > 0) { if (error.validationErrors && error.validationErrors.length > 0) {
for (const ve of error.validationErrors) { for (const ve of error.validationErrors) {
if (ve.property && currentFields?.properties[ve.property]) { if (ve.property && currentFields?.properties[ve.property]) {
const msg = newFieldErrors[ve.property] = validationErrorMessage(ve);
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;
} else if (ve.property) { } 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 = const isSecret =
fieldDef.type.type === 'string' && fieldDef.type.type === 'string' &&
(fieldDef.type.format === 'secret' || fieldDef.type.format === 'secretText'); (fieldDef.type.format === 'secret' || fieldDef.type.format === 'secretText');
if (isSecret && formData[fieldName] === '*****') continue; if (isSecret && formData[fieldName] === SECRET_MASK) continue;
createPayload[fieldName] = formData[fieldName]; createPayload[fieldName] = formData[fieldName];
} }
} }
@@ -530,7 +530,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
if ( if (
fieldDef?.type.type === 'string' && fieldDef?.type.type === 'string' &&
(fieldDef.type.format === 'secret' || fieldDef.type.format === 'secretText') && (fieldDef.type.format === 'secret' || fieldDef.type.format === 'secretText') &&
patchValue === '*****' patchValue === SECRET_MASK
) { ) {
continue; continue;
} }
@@ -695,9 +695,9 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
const labelProp = list?.labelProperty; const labelProp = list?.labelProperty;
if (labelProp) { if (labelProp) {
const raw = formData[labelProp]; const value = coerceLabel(formData[labelProp], '');
if (typeof raw === 'string' && raw.length > 0) { if (value.length > 0) {
return t('form.editTitleWithValue', 'Edit {{name}}: {{value}}', { name, value: raw }); return t('form.editTitleWithValue', 'Edit {{name}}: {{value}}', { name, value });
} }
} }
return t('form.editTitle', 'Edit {{name}}', { name }); return t('form.editTitle', 'Edit {{name}}', { name });
@@ -743,7 +743,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition); const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition);
return ( 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"> <div className="flex items-center gap-4">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}> <Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
@@ -774,17 +774,26 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
if (!visible) return null; if (!visible) return null;
if (formField.name === '@type' && sch.type === 'multiple') { if (formField.name === '@type' && sch.type === 'multiple') {
const selectedVariantLabel = sch.variants.find((v) => v.name === selectedVariant)?.label;
return ( return (
<div key="@type" className="space-y-1.5"> <div key="@type" className="space-y-1.5">
<Label className="text-sm font-medium">{formField.label}</Label> <Label className="text-sm font-medium">{formField.label}</Label>
<Select value={selectedVariant} onValueChange={handleVariantChange} disabled={readOnly}> <Select value={selectedVariant} onValueChange={handleVariantChange} disabled={readOnly}>
<SelectTrigger> <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> </SelectTrigger>
<SelectContent> <SelectContent>
{sch.variants.map((v) => ( {sch.variants.map((v) => (
<SelectItem key={v.name} value={v.name}> <SelectItem key={v.name} value={v.name}>
{v.label} <span className="flex items-center gap-2">
<BackendVariantIcon variant={v} />
{v.label}
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -836,7 +845,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
</Card> </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> <div>
{canDelete && ( {canDelete && (
<Button type="button" variant="destructive" disabled={saving} onClick={() => setDeleteConfirmOpen(true)}> <Button type="button" variant="destructive" disabled={saving} onClick={() => setDeleteConfirmOpen(true)}>
@@ -845,7 +854,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
</Button> </Button>
)} )}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center justify-end gap-3">
{isDirty && ( {isDirty && (
<span className="text-xs text-muted-foreground">{t('form.unsavedChangesLabel', 'Unsaved changes')}</span> <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} open={serverCreatedProps !== null && createdObjectId !== null}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setOriginalData({ ...formData }); navigateAfterCreate();
setServerCreatedProps(null);
setPendingNavAfterCreate(true);
} }
}} }}
> >
@@ -972,15 +979,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
})()} })()}
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button onClick={navigateAfterCreate}>{t('common.continue', 'Continue')}</Button>
onClick={() => {
setOriginalData({ ...formData });
setServerCreatedProps(null);
setPendingNavAfterCreate(true);
}}
>
{t('common.continue', 'Continue')}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -1100,8 +1099,6 @@ function buildRenderableField(
return { formField, field, visible, enterpriseDisabled }; return { formField, field, visible, enterpriseDisabled };
} }
const SECRET_MASK_PLACEHOLDER = '*****';
function isOtpAuthValid(data: unknown): boolean { function isOtpAuthValid(data: unknown): boolean {
if (data == null || typeof data !== 'object' || Array.isArray(data)) return true; if (data == null || typeof data !== 'object' || Array.isArray(data)) return true;
const obj = data as Record<string, unknown>; const obj = data as Record<string, unknown>;
@@ -1117,7 +1114,7 @@ function isOtpAuthValid(data: unknown): boolean {
const otpUrl = obj.otpUrl; const otpUrl = obj.otpUrl;
const otpCode = obj.otpCode; const otpCode = obj.otpCode;
if (otpUrl == null || otpUrl === '') return true; if (otpUrl == null || otpUrl === '') return true;
if (otpCode == null || otpCode === '' || otpCode === SECRET_MASK_PLACEHOLDER) { if (otpCode == null || otpCode === '' || otpCode === SECRET_MASK) {
return false; return false;
} }
return true; return true;
+212 -89
View File
@@ -4,8 +4,9 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * 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 { useTranslation } from 'react-i18next';
import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -14,33 +15,38 @@ import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'; 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ExpressionEditor } from '@/components/expression/ExpressionEditor'; import { ExpressionEditor } from '@/components/expression/ExpressionEditor';
import { OtpAuthField } from '@/components/forms/OtpAuthField'; import { OtpAuthField } from '@/components/forms/OtpAuthField';
import { SizeDisplay } from '@/components/common/SizeDisplay';
import { import {
bytesToHuman, bytesToHuman,
humanToBytes, humanToBytes,
msToHuman, msToHuman,
humanToMs, humanToMs,
formatSize,
formatDuration, formatDuration,
SIZE_UNITS, SIZE_UNITS,
DURATION_UNITS, DURATION_UNITS,
} from '@/lib/durationFormat'; } from '@/lib/durationFormat';
import { resolveSchema, resolveVariantForm, resolveObject, buildEmbeddedDefaults } from '@/lib/schemaResolver'; import { resolveSchema, resolveVariantForm, resolveObject, buildEmbeddedDefaults } from '@/lib/schemaResolver';
import { cn } from '@/lib/utils';
import { useAccountStore } from '@/stores/accountStore'; import { useAccountStore } from '@/stores/accountStore';
import { useEffectiveEdition } from '@/components/forms/FormEditionContext'; import { useEffectiveEdition } from '@/components/forms/FormEditionContext';
import { useObjectList, useObjectLabel, useNoPermissionMessage, type ObjectOption } from '@/lib/objectOptions'; 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 { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { jmapGet, getAccountId } from '@/services/jmap/client'; 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) { function BufferedInput({ value, onCommit, onBlur, onKeyDown, ...rest }: BufferedInputProps) {
const [local, setLocal] = useState(value); const [local, setLocal] = useBufferedValue(value);
useEffect(() => {
setLocal(value);
}, [value]);
const commit = () => { const commit = () => {
if (local !== value) onCommit(local); if (local !== value) onCommit(local);
@@ -277,11 +279,7 @@ interface BufferedTextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTe
} }
function BufferedTextarea({ value, onCommit, onBlur, ...rest }: BufferedTextareaProps) { function BufferedTextarea({ value, onCommit, onBlur, ...rest }: BufferedTextareaProps) {
const [local, setLocal] = useState(value); const [local, setLocal] = useBufferedValue(value);
useEffect(() => {
setLocal(value);
}, [value]);
return ( return (
<Textarea <Textarea
@@ -404,7 +402,7 @@ function StringField({
value={strValue || '#000000'} value={strValue || '#000000'}
onChange={(e) => handleCommit(e.target.value)} onChange={(e) => handleCommit(e.target.value)}
disabled={readOnly} 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 <BufferedInput
value={strValue} value={strValue}
@@ -457,15 +455,12 @@ interface SecretInputProps {
multiline: boolean; multiline: boolean;
} }
const SECRET_MASK = '****';
function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLength, multiline }: SecretInputProps) { function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLength, multiline }: SecretInputProps) {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [localValue, setLocalValue] = useState(() => (value === SECRET_MASK || value === '' ? '' : value)); const [localValue, setLocalValue] = useState(() => (value === SECRET_MASK || value === '' ? '' : value));
const [isMasked, setIsMasked] = useState(() => value === SECRET_MASK); const [isMasked, setIsMasked] = useState(() => value === SECRET_MASK);
/* eslint-disable react-hooks/set-state-in-effect */ useResetOnChange(value, () => {
useEffect(() => {
if (value === SECRET_MASK || value === '') { if (value === SECRET_MASK || value === '') {
setLocalValue(''); setLocalValue('');
setIsMasked(value === SECRET_MASK); setIsMasked(value === SECRET_MASK);
@@ -473,8 +468,7 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
setLocalValue(value); setLocalValue(value);
setIsMasked(false); setIsMasked(false);
} }
}, [value]); });
/* eslint-enable react-hooks/set-state-in-effect */
const handleLocalChange = (v: string) => { const handleLocalChange = (v: string) => {
setLocalValue(v); setLocalValue(v);
@@ -482,6 +476,7 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
}; };
const commit = () => { const commit = () => {
if (isMasked && localValue === '') return;
if (localValue !== value) onChange(localValue); if (localValue !== value) onChange(localValue);
}; };
@@ -513,6 +508,10 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
minLength={minLength} minLength={minLength}
maxLength={maxLength} maxLength={maxLength}
rows={4} rows={4}
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-bwignore
className={visible ? '' : 'tracking-widest'} className={visible ? '' : 'tracking-widest'}
style={visible ? undefined : ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)} style={visible ? undefined : ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)}
/> />
@@ -535,6 +534,10 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
placeholder={displayPlaceholder} placeholder={displayPlaceholder}
minLength={minLength} minLength={minLength}
maxLength={maxLength} maxLength={maxLength}
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-bwignore
className="flex-1" className="flex-1"
/> />
{toggleBtn} {toggleBtn}
@@ -607,13 +610,7 @@ function BufferedNumberInput({
disabled, disabled,
onCommit, onCommit,
}: BufferedNumberInputProps) { }: BufferedNumberInputProps) {
const [local, setLocal] = useState<string>(value != null ? String(value) : ''); const [local, setLocal] = useBufferedValue(value, (v) => (v != null ? String(v) : ''));
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
setLocal(value != null ? String(value) : '');
}, [value]);
/* eslint-enable react-hooks/set-state-in-effect */
const commit = () => { const commit = () => {
if (local === '') { if (local === '') {
@@ -654,7 +651,11 @@ function SizeInput({ value, onChange, readOnly, nullable }: SizeInputProps) {
if (readOnly) { if (readOnly) {
if (value == null) if (value == null)
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>; 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} />; 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 [unit, setUnit] = useState(initHuman.unit);
const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value)); const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value));
/* eslint-disable react-hooks/set-state-in-effect */ useResetOnChange(value, () => {
useEffect(() => {
const h = bytesToHuman(typeof value === 'number' ? value : 0); const h = bytesToHuman(typeof value === 'number' ? value : 0);
setUnit(h.unit); setUnit(h.unit);
setLocalStr(value == null ? '' : String(h.value)); setLocalStr(value == null ? '' : String(h.value));
}, [value]); });
/* eslint-enable react-hooks/set-state-in-effect */
const commit = () => { const commit = () => {
if (localStr === '') { if (localStr === '') {
@@ -771,13 +770,11 @@ function DurationInputEditable({ value, onChange, nullable }: Omit<DurationInput
const [unit, setUnit] = useState(initHuman.unit); const [unit, setUnit] = useState(initHuman.unit);
const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value)); const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value));
/* eslint-disable react-hooks/set-state-in-effect */ useResetOnChange(value, () => {
useEffect(() => {
const h = msToHuman(typeof value === 'number' ? value : 0); const h = msToHuman(typeof value === 'number' ? value : 0);
setUnit(h.unit); setUnit(h.unit);
setLocalStr(value == null ? '' : String(h.value)); setLocalStr(value == null ? '' : String(h.value));
}, [value]); });
/* eslint-enable react-hooks/set-state-in-effect */
const commit = () => { const commit = () => {
if (localStr === '') { if (localStr === '') {
@@ -842,37 +839,102 @@ interface DateTimeFieldProps {
nullable?: boolean; 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) { function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const strValue = typeof value === 'string' ? value : ''; const strValue = typeof value === 'string' ? value : '';
const [open, setOpen] = useState(false);
const toLocal = (iso: string): string => { const parsed = useMemo(() => {
if (!iso) return ''; if (!strValue) return null;
try { const d = new Date(strValue);
const d = new Date(iso); return isNaN(d.getTime()) ? null : d;
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));
}, [strValue]); }, [strValue]);
/* eslint-enable react-hooks/set-state-in-effect */
const commit = () => { const timeValue = parsed
const iso = toIso(local); ? `${String(parsed.getHours()).padStart(2, '0')}:${String(parsed.getMinutes()).padStart(2, '0')}`
if (iso !== strValue) onChange(iso); : '';
// 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) { if (readOnly) {
@@ -895,13 +957,52 @@ function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldPro
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Popover open={open} onOpenChange={handleOpenChange} modal={false}>
type="datetime-local" <PopoverTrigger asChild>
value={local} <Button
onChange={(e) => setLocal(e.target.value)} type="button"
onBlur={commit} variant="outline"
className="flex-1" 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 && ( {nullable && strValue && (
<Button <Button
type="button" type="button"
@@ -1005,9 +1106,9 @@ function BlobField({ value, onChange, readOnly }: BlobFieldProps) {
if (!blobId || loaded) return; if (!blobId || loaded) return;
let cancelled = false; let cancelled = false;
setLoading(true);
(async () => { (async () => {
setLoading(true);
try { try {
const accountId = getAccountId('x:Blob'); const accountId = getAccountId('x:Blob');
const responses = await jmapGet('Blob', accountId, [blobId], ['data:asText']); 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 [localCount, setLocalCount] = useState(String(count));
const [localPeriod, setLocalPeriod] = useState(String(human.value)); const [localPeriod, setLocalPeriod] = useState(String(human.value));
useEffect(() => { useResetOnChange(count, () => setLocalCount(String(count)));
setLocalCount(String(count)); useResetOnChange(human.value, () => setLocalPeriod(String(human.value)));
}, [count]);
useEffect(() => {
setLocalPeriod(String(human.value));
}, [human.value]);
const commitCount = () => { const commitCount = () => {
const n = parseInt(localCount, 10); const n = parseInt(localCount, 10);
@@ -1459,18 +1556,28 @@ function EmbeddedObjectField({
onChange(newObj); onChange(newObj);
}; };
const selectedVariantLabel = resolvedSchema.variants.find((v) => v.name === currentType)?.label;
return ( return (
<div className="rounded-md border p-4 space-y-4"> <div className="rounded-md border p-4 space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-sm font-medium">{formField.label ?? t('field.type', 'Type')}</Label> <Label className="text-sm font-medium">{formField.label ?? t('field.type', 'Type')}</Label>
<Select value={currentType} onValueChange={handleVariantChange} disabled={readOnly}> <Select value={currentType} onValueChange={handleVariantChange} disabled={readOnly}>
<SelectTrigger> <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> </SelectTrigger>
<SelectContent> <SelectContent>
{resolvedSchema.variants.map((v) => ( {resolvedSchema.variants.map((v) => (
<SelectItem key={v.name} value={v.name}> <SelectItem key={v.name} value={v.name}>
{v.label} <span className="flex items-center gap-2">
<BackendVariantIcon variant={v} />
{v.label}
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -1852,7 +1959,8 @@ function EnumMultiSelect({ enumName, items, onChange, readOnly, schema, minItems
className="h-8" className="h-8"
/> />
</div> </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 && ( {filtered.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-2">{t('field.noMatches', 'No matches')}</p> <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> </Label>
</div> </div>
))} ))}
</div> </div>
</ScrollArea>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </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 { interface MapFieldProps {
keyClass: ScalarType; keyClass: ScalarType;
valueClass: MapValueType; 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)); const existingKeys = new Set(Object.keys(mapValue));
return ( 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" 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" /> <ChevronRight className="h-4 w-4 shrink-0 transition-transform duration-200" />
{getKeyLabel(key)} <MapEntryKeyLabel keyClass={keyClass} keyValue={key} schema={schema} />
</button> </button>
</CollapsibleTrigger> </CollapsibleTrigger>
{!readOnly && ( {!readOnly && (
+49 -3
View File
@@ -8,11 +8,13 @@ import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import * as OTPAuth from 'otpauth'; import * as OTPAuth from 'otpauth';
import QRCode from 'qrcode'; 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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { toast } from '@/hooks/use-toast';
import { SECRET_MASK } from '@/lib/jmapUtils';
interface OtpAuthValue { interface OtpAuthValue {
otpUrl?: string | null; otpUrl?: string | null;
@@ -25,8 +27,6 @@ interface OtpAuthFieldProps {
readOnly: boolean; readOnly: boolean;
} }
const SECRET_MASK = '*****';
const STALWART_IMAGE_URL = 'https://stalw.art/img/favicon-32x32.png'; const STALWART_IMAGE_URL = 'https://stalw.art/img/favicon-32x32.png';
function buildOtpAuthUrl(totp: OTPAuth.TOTP): string { 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 [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [setupCode, setSetupCode] = useState(''); const [setupCode, setSetupCode] = useState('');
const [setupError, setSetupError] = useState<string | null>(null); 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(() => { useEffect(() => {
if (!setupUrl) return; if (!setupUrl) return;
@@ -97,6 +118,7 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) {
setSetupTotp(null); setSetupTotp(null);
setSetupUrl(null); setSetupUrl(null);
setSetupCode(''); setSetupCode('');
setSecretCopied(false);
}; };
const cancelSetup = () => { const cancelSetup = () => {
@@ -104,6 +126,7 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) {
setSetupUrl(null); setSetupUrl(null);
setSetupCode(''); setSetupCode('');
setSetupError(null); setSetupError(null);
setSecretCopied(false);
}; };
const otpCodeValue = useMemo( const otpCodeValue = useMemo(
@@ -156,6 +179,29 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) {
</div> </div>
)} )}
</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"> <div className="space-y-1.5">
<Label className="text-sm font-medium">{t('otp.confirmationCodeLabel', 'Confirmation code')}</Label> <Label className="text-sm font-medium">{t('otp.confirmationCodeLabel', 'Confirmation code')}</Label>
<Input <Input
+26 -6
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * 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 { useSchemaStore } from '@/stores/schemaStore';
import { useCacheStore } from '@/stores/cacheStore'; import { useCacheStore } from '@/stores/cacheStore';
import { useAccountStore } from '@/stores/accountStore'; import { useAccountStore } from '@/stores/accountStore';
@@ -12,11 +12,23 @@ import { resolveObject } from '@/lib/schemaResolver';
import { DynamicList } from '@/components/lists/DynamicList'; import { DynamicList } from '@/components/lists/DynamicList';
import { DynamicForm } from '@/components/forms/DynamicForm'; import { DynamicForm } from '@/components/forms/DynamicForm';
import { DynamicViewPage } from '@/components/views/DynamicViewPage'; import { DynamicViewPage } from '@/components/views/DynamicViewPage';
import { DashboardView } from '@/features/dashboard/components/DashboardView'; import { LoadingFallback } from '@/components/common/LoadingFallback';
import { DeliveryTracePage } from '@/features/troubleshoot/DeliveryTracePage';
import { LiveTracingPage } from '@/features/tracing/components/LiveTracingPage'; // Heavy or rarely used feature pages are code-split so the initial bundle
import { TraceDetailView } from '@/features/tracing/components/TraceDetailView'; // stays small (the dashboard pulls in recharts, ~150 kB gzipped on its own).
import { ActionPage } from '@/features/actions/ActionPage'; 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 { interface MainContentProps {
viewName?: string; viewName?: string;
@@ -25,6 +37,14 @@ interface MainContentProps {
} }
export function MainContent({ viewName, id, section }: 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 schema = useSchemaStore((s) => s.schema);
const invalidateAllObjectLists = useCacheStore((s) => s.invalidateAllObjectLists); const invalidateAllObjectLists = useCacheStore((s) => s.invalidateAllObjectLists);
+2 -2
View File
@@ -8,10 +8,10 @@ import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '@/stores/authStore'; import { useAuthStore } from '@/stores/authStore';
export function ProtectedRoute({ children }: { children: React.ReactNode }) { 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 bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
const location = useLocation(); const location = useLocation();
if (!accessToken && !bypassToken) { if (!authenticated && !bypassToken) {
const originalPath = location.pathname + location.search; const originalPath = location.pathname + location.search;
return <Navigate to="/login" replace state={{ from: originalPath }} />; return <Navigate to="/login" replace state={{ from: originalPath }} />;
} }
+217 -99
View File
@@ -4,15 +4,15 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * 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 { useLocation, useNavigate } from 'react-router-dom';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
const { ChevronDown, Lock } = LucideIcons; const { ChevronDown, Lock } = LucideIcons;
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell'; import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { useAccountStore } from '@/stores/accountStore'; import { useAccountStore } from '@/stores/accountStore';
@@ -24,6 +24,7 @@ import {
isLinkEnterprise, isLinkEnterprise,
isLinkVisible, isLinkVisible,
} from '@/lib/layout'; } from '@/lib/layout';
import { findLastVisitedLinkInLayout, setLastVisitedSection } from '@/lib/lastVisited';
import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema'; import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema';
function LucideIcon({ name, className }: { name: string; className?: string }) { function LucideIcon({ name, className }: { name: string; className?: string }) {
@@ -75,6 +76,57 @@ function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean
return false; 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 { function checkLinkVisible(viewName: string): boolean {
const schema = useSchemaStore.getState().schema; const schema = useSchemaStore.getState().schema;
if (!schema) return true; if (!schema) return true;
@@ -107,6 +159,11 @@ interface SidebarSubItemProps {
} }
function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: 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 (item.type === 'link') {
if (!checkLinkVisible(item.viewName)) return null; if (!checkLinkVisible(item.viewName)) return null;
@@ -121,9 +178,11 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
return ( return (
<Button <Button
variant="ghost" variant="ghost"
data-sidebar-active={isActive || undefined}
className={cn( className={cn(
'w-full justify-start gap-2 font-normal', sidebarItemClass,
isActive && 'bg-accent text-accent-foreground', sidebarItemSquareClass,
isActive && 'bg-accent text-accent-foreground hover:bg-accent',
depth > 0 && 'text-sm', depth > 0 && 'text-sm',
)} )}
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }} style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
@@ -131,6 +190,8 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
if (isLocked) { if (isLocked) {
onUpsell(); onUpsell();
} else { } else {
level?.setOpenId(null);
setLastVisitedSection(sectionName, item.viewName);
navigate(path); navigate(path);
} }
}} }}
@@ -146,11 +207,11 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName); const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
return ( return (
<Collapsible defaultOpen={containsActive}> <AccordionCollapsible id={item.name} containsActive={containsActive}>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button <Button
variant="ghost" 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` }} style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
> >
<ChevronDown className="h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" /> <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> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
{item.items.map((sub) => ( <AccordionLevel>
<SidebarSubItem {item.items.map((sub) => (
key={sub.type === 'link' ? sub.viewName : sub.name} <SidebarSubItem
item={sub} key={sub.type === 'link' ? sub.viewName : sub.name}
depth={depth + 1} item={sub}
sectionName={sectionName} depth={depth + 1}
currentPath={currentPath} sectionName={sectionName}
navigate={navigate} currentPath={currentPath}
edition={edition} navigate={navigate}
onUpsell={onUpsell} edition={edition}
/> onUpsell={onUpsell}
))} />
))}
</AccordionLevel>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </AccordionCollapsible>
); );
} }
@@ -188,6 +251,11 @@ interface SidebarTopItemProps {
} }
function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: 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) { if ('link' in item) {
const { name, icon, viewName } = item.link; const { name, icon, viewName } = item.link;
@@ -204,11 +272,18 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
return ( return (
<Button <Button
variant="ghost" 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={() => { onClick={() => {
if (isLocked) { if (isLocked) {
onUpsell(); onUpsell();
} else { } else {
level?.setOpenId(null);
setLastVisitedSection(sectionName, viewName);
navigate(path); navigate(path);
} }
}} }}
@@ -227,29 +302,31 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
const containsActive = subtreeContainsActive(items, currentPath, sectionName); const containsActive = subtreeContainsActive(items, currentPath, sectionName);
return ( return (
<Collapsible defaultOpen={containsActive}> <AccordionCollapsible id={name} containsActive={containsActive}>
<CollapsibleTrigger asChild> <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" /> <LucideIcon name={icon} className="h-4 w-4 shrink-0" />
<span className="truncate">{name}</span> <span className="truncate">{name}</span>
<ChevronDown className="ml-auto h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" /> <ChevronDown className="ml-auto h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
{items.map((sub) => ( <AccordionLevel>
<SidebarSubItem {items.map((sub) => (
key={sub.type === 'link' ? sub.viewName : sub.name} <SidebarSubItem
item={sub} key={sub.type === 'link' ? sub.viewName : sub.name}
depth={1} item={sub}
sectionName={sectionName} depth={1}
currentPath={currentPath} sectionName={sectionName}
navigate={navigate} currentPath={currentPath}
edition={edition} navigate={navigate}
onUpsell={onUpsell} edition={edition}
/> onUpsell={onUpsell}
))} />
))}
</AccordionLevel>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </AccordionCollapsible>
); );
} }
@@ -262,25 +339,45 @@ export function Sidebar() {
const activeSection = useUIStore((s) => s.activeSection); const activeSection = useUIStore((s) => s.activeSection);
const setActiveSection = useUIStore((s) => s.setActiveSection); const setActiveSection = useUIStore((s) => s.setActiveSection);
const sidebarOpen = useUIStore((s) => s.sidebarOpen); const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
const schema = useSchemaStore((s) => s.schema); const schema = useSchemaStore((s) => s.schema);
const edition = useAccountStore((s) => s.edition); 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 hasPermission = useAccountStore((s) => s.hasPermission);
const [upsellOpen, setUpsellOpen] = useState(false); 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( // Build the permission checks from the permissions array itself: the store
() => // accessors are stable refs, so depending on them alone would keep a stale
schema ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) : [], // layout list after access data finishes loading.
[schema, edition, hasObjectPermission, hasPermission], 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(() => { useEffect(() => {
if (!schema) return; if (typeof window === 'undefined') return;
if (layouts.length === 0) return; if (skipCloseOnNavigateRef.current) {
if (!layouts.find((l) => l.name === activeSection)) { skipCloseOnNavigateRef.current = false;
setActiveSection(layouts[0].name); 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; if (!sidebarOpen || !schema) return null;
@@ -289,66 +386,87 @@ export function Sidebar() {
const handleSectionClick = (target: Layout) => { const handleSectionClick = (target: Layout) => {
setActiveSection(target.name); 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 = const first =
last ??
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ?? findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
findFirstVisibleLinkInLayout(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 ( 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"> <div
<nav className="flex flex-col gap-0.5 px-2"> aria-hidden="true"
{layout.items.map((item) => ( className="fixed inset-0 top-14 z-20 bg-black/40 md:hidden"
<SidebarTopItem onClick={() => setSidebarOpen(false)}
key={'link' in item ? item.link.viewName : item.container.name} />
item={item} <aside className="fixed top-14 left-0 bottom-0 z-30 flex w-64 flex-col border-r bg-background">
sectionName={layout.name} <ScrollArea className="flex-1">
currentPath={location.pathname} <nav
navigate={navigate} ref={navRef}
edition={edition} className="flex flex-col gap-0.5 px-2 py-2 [[data-radius='square']_&]:gap-0 [[data-radius='square']_&]:px-0"
onUpsell={() => setUpsellOpen(true)} >
/> <AccordionLevel>
))} {layout.items.map((item) => (
</nav> <SidebarTopItem
</ScrollArea> 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 && ( {layouts.length > 1 && (
<TooltipProvider> <TooltipProvider>
<div className="flex items-center justify-around border-t bg-background px-2 py-2"> <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) => { {layouts.map((target) => {
const Icon = (LucideIcons as Record<string, unknown>)[ const Icon = (LucideIcons as Record<string, unknown>)[
target.icon target.icon
.split('-') .split('-')
.map((s) => s[0].toUpperCase() + s.slice(1)) .map((s) => s[0].toUpperCase() + s.slice(1))
.join('') .join('')
] as LucideIcons.LucideIcon | undefined; ] as LucideIcons.LucideIcon | undefined;
const isActive = target.name === activeSection; const isActive = target.name === activeSection;
return ( return (
<Tooltip key={target.name}> <Tooltip key={target.name}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label={target.name} aria-label={target.name}
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? 'page' : undefined}
onClick={() => handleSectionClick(target)} onClick={() => handleSectionClick(target)}
className={cn('h-9 w-9', isActive && 'bg-accent text-accent-foreground')} className={cn(
> 'h-9 w-9',
{Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.Circle className="h-4 w-4" />} "[[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]",
</Button> isActive && 'bg-accent text-accent-foreground',
</TooltipTrigger> )}
<TooltipContent side="top">{target.name}</TooltipContent> >
</Tooltip> {Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.Circle className="h-4 w-4" />}
); </Button>
})} </TooltipTrigger>
</div> <TooltipContent side="top">{target.name}</TooltipContent>
</TooltipProvider> </Tooltip>
)} );
})}
</div>
</TooltipProvider>
)}
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} /> <EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
</aside> </aside>
</>
); );
} }
+90 -14
View File
@@ -7,9 +7,9 @@
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import * as LucideIcons from 'lucide-react'; 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 { Button } from '@/components/ui/button';
import { GlobalSearch } from '@/components/common/GlobalSearch'; import { CommandPalette } from '@/components/common/CommandPalette';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -20,12 +20,17 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import Logo from '@/components/common/Logo'; 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 { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout'; import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
import { findLastVisitedLinkInLayout } from '@/lib/lastVisited';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { useAuthStore } from '@/stores/authStore'; 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 { useAccountStore } from '@/stores/accountStore';
import { useCurrentAccountDetails } from '@/hooks/useCurrentAccount';
import { useSchemaStore } from '@/stores/schemaStore'; import { useSchemaStore } from '@/stores/schemaStore';
function getIcon(name: string): LucideIcons.LucideIcon { function getIcon(name: string): LucideIcons.LucideIcon {
@@ -39,19 +44,35 @@ function getIcon(name: string): LucideIcons.LucideIcon {
export function TopBar() { export function TopBar() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useUIStore((s) => s.theme);
const toggleTheme = useUIStore((s) => s.toggleTheme);
const toggleSidebar = useUIStore((s) => s.toggleSidebar); const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const setActiveSection = useUIStore((s) => s.setActiveSection); const setActiveSection = useUIStore((s) => s.setActiveSection);
const accounts = useAuthStore((s) => s.accounts); const accounts = useAuthStore((s) => s.accounts);
const activeAccountId = useAuthStore((s) => s.activeAccountId); const activeAccountId = useAuthStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount); const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout); 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 edition = useAccountStore((s) => s.edition);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission); const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const hasPermission = useAccountStore((s) => s.hasPermission); const hasPermission = useAccountStore((s) => s.hasPermission);
const schema = useSchemaStore((s) => s.schema); const schema = useSchemaStore((s) => s.schema);
const [upsellOpen, setUpsellOpen] = useState(false); 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 const navigableLayouts = schema
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
@@ -63,23 +84,60 @@ export function TopBar() {
<Menu className="h-4 w-4" /> <Menu className="h-4 w-4" />
</Button> </Button>
<Link to="/" className="flex shrink-0 items-center"> <TooltipProvider>
<Logo /> <Tooltip>
</Link> <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)} />} {edition !== 'enterprise' && <EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />}
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}> <Button
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />} variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setPaletteOpen(true)}
aria-label={t('search', 'Search')}
>
<Search className="h-4 w-4" />
</Button> </Button>
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
<ModeToggle />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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" /> <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> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent align="end" className="w-56">
@@ -95,7 +153,9 @@ export function TopBar() {
onClick={() => { onClick={() => {
setActiveSection(layout.name); setActiveSection(layout.name);
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get'); const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
const lastLink = findLastVisitedLinkInLayout(schema, layout, edition, canGet, hasPermission);
const firstLink = const firstLink =
lastLink ??
findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPermission) ?? findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPermission) ??
findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPermission); findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPermission);
if (firstLink) { 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 <DropdownMenuItem
variant="destructive"
onClick={() => { onClick={() => {
const endSessionEndpoint = useAuthStore.getState().endSessionEndpoint;
logout(); logout();
navigate('/login'); if (endSessionEndpoint) {
window.location.href = buildEndSessionUrl(endSessionEndpoint, getPostLogoutRedirectUri());
} else {
navigate('/login');
}
}} }}
> >
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
File diff suppressed because it is too large Load Diff
+167
View File
@@ -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 };
+17 -1
View File
@@ -5,11 +5,27 @@
*/ */
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'; import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Collapsible = CollapsiblePrimitive.Root; const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; 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 }; export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+8 -6
View File
@@ -11,6 +11,7 @@ import { Search } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Dialog, DialogContent } from '@/components/ui/dialog'; import { Dialog, DialogContent } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
const Command = React.forwardRef< const Command = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive>, React.ComponentRef<typeof CommandPrimitive>,
@@ -30,7 +31,7 @@ Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => { const CommandDialog = ({ children, ...props }: DialogProps) => {
return ( return (
<Dialog {...props}> <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"> <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} {children}
</Command> </Command>
@@ -53,6 +54,9 @@ const CommandInput = React.forwardRef<
)} )}
{...props} {...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> </div>
)); ));
CommandInput.displayName = CommandPrimitive.Input.displayName; CommandInput.displayName = CommandPrimitive.Input.displayName;
@@ -61,11 +65,9 @@ const CommandList = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.List>, React.ComponentRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CommandPrimitive.List <ScrollArea viewportClassName="max-h-[300px]">
ref={ref} <CommandPrimitive.List ref={ref} className={cn('overflow-x-hidden', className)} {...props} />
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)} </ScrollArea>
{...props}
/>
)); ));
CommandList.displayName = CommandPrimitive.List.displayName; CommandList.displayName = CommandPrimitive.List.displayName;
+10 -6
View File
@@ -35,8 +35,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>, React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
>(({ className, children, ...props }, ref) => ( showCloseButton?: boolean;
}
>(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
@@ -48,10 +50,12 @@ const DialogContent = React.forwardRef<
{...props} {...props}
> >
{children} {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"> {showCloseButton && (
<X className="h-4 w-4" /> <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">
<span className="sr-only">Close</span> <X className="h-4 w-4" />
</DialogPrimitive.Close> <span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)); ));
+4 -1
View File
@@ -81,12 +81,15 @@ const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>, React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean; inset?: boolean;
variant?: 'default' | 'destructive';
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, variant = 'default', ...props }, ref) => (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
data-variant={variant}
className={cn( 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', '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', inset && 'pl-8',
className, className,
)} )}
+1 -1
View File
@@ -14,7 +14,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
<input <input
type={type} type={type}
className={cn( 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, className,
)} )}
ref={ref} ref={ref}
+19 -4
View File
@@ -11,10 +11,25 @@ import { cn } from '@/lib/utils';
const ScrollArea = React.forwardRef< const ScrollArea = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Root>, React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
>(({ className, children, ...props }, ref) => ( // Height caps (max-h-*) must be applied to the Viewport — the actual
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}> // scroller — because a percentage height resolves to auto when the
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport> // 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 /> <ScrollBar />
<ScrollAreaPrimitive.Corner /> <ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root> </ScrollAreaPrimitive.Root>
+1 -1
View File
@@ -23,7 +23,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -15,7 +15,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SwitchPrimitives.Root <SwitchPrimitives.Root
className={cn( 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, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -13,7 +13,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttribu
return ( return (
<textarea <textarea
className={cn( 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, className,
)} )}
ref={ref} ref={ref}

Some files were not shown because too many files have changed in this diff Show More