Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d5f2273ae | ||
|
|
de58de765a | ||
|
|
1be7b5c60b | ||
|
|
2cd73c6705 | ||
|
|
7a455e7b6a | ||
|
|
ff38c7a5e8 | ||
|
|
0454528e8a | ||
|
|
9967f9cd7f | ||
|
|
2611815a7e | ||
|
|
4859607bab | ||
|
|
11d85cd759 | ||
|
|
e020f9f02c | ||
|
|
2aff8c8a10 | ||
|
|
8001a6e71b | ||
|
|
25e33767af | ||
|
|
baa8181c19 | ||
|
|
b6b54d2ac0 | ||
|
|
8a95814eee | ||
|
|
0ff997d111 | ||
|
|
a157fed8f2 | ||
|
|
4720470a3d | ||
|
|
fa42a74697 | ||
|
|
9c44a28197 | ||
|
|
3a33f3d514 | ||
|
|
93a76098cc | ||
|
|
8ae4156aee | ||
|
|
e04975915e | ||
|
|
3d3dcb0227 | ||
|
|
824427ef46 | ||
|
|
7ab0b8099c | ||
|
|
5bf09ceed5 | ||
|
|
5641560a91 | ||
|
|
2e02532279 | ||
|
|
91531b4784 | ||
|
|
96e7b9842f | ||
|
|
3fec79545c | ||
|
|
2d2b551d46 | ||
|
|
e4ad5e2c1e | ||
|
|
92bcb58f76 | ||
|
|
14d708ecf8 | ||
|
|
2399f5ce97 | ||
|
|
e387e7976f | ||
|
|
1d72ef3aa1 |
@@ -0,0 +1,127 @@
|
||||
# CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off
|
||||
# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once
|
||||
# this directory exists; .github/workflows stays as it was for GitHub.
|
||||
#
|
||||
# Every job runs in an image pinned by digest (tag in the trailing comment),
|
||||
# and the only action used is coffey-labs/actions/checkout pinned by SHA. The
|
||||
# instance resolves short `uses:` against itself, never GitHub, so nothing
|
||||
# unreviewed can be pulled in. Read the comment for the version; the digest is
|
||||
# what runs.
|
||||
#
|
||||
# Jobs run on the runner's `ci-net` network and clone from Gitea's internal
|
||||
# address, never through the Cloudflare-proxied public name, which caps
|
||||
# request bodies at 100 MB. Images go to the registry's own DNS-only name
|
||||
# (vars.REGISTRY, an org variable).
|
||||
#
|
||||
# The weekly release is its own workflow, weekly-release.yml.
|
||||
#
|
||||
# Not ported:
|
||||
# * cleanup.yml pruned GHCR with dataaxiom/ghcr-cleanup-action; on Gitea
|
||||
# that belongs in the package cleanup rules (owner settings -> Packages),
|
||||
# not in a workflow.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Only date tags publish (v2026.9.21, v2026.9.21.2). The repository still
|
||||
# carries the inherited v1.0.x tags, and a tag of any other shape pushed
|
||||
# by hand is not a release.
|
||||
tags:
|
||||
- 'v[0-9][0-9][0-9][0-9].[0-9]+.[0-9]+'
|
||||
- 'v[0-9][0-9][0-9][0-9].[0-9]+.[0-9]+.[0-9]+'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# A release tag is built and tested again before its image is published.
|
||||
build:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 # 22-bookworm-slim
|
||||
env:
|
||||
NPM_CONFIG_CACHE: ${{ github.workspace }}/.npm
|
||||
steps:
|
||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||
- run: npm ci --ignore-scripts
|
||||
- run: npm run typecheck
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
|
||||
# ----------------------------------------------------------- publish ------
|
||||
# Port of publish.yml, to the owner's own registry now that GHCR went with
|
||||
# the GitHub account: <REGISTRY>/inbuxa/inbuxa-admin, the same path the
|
||||
# GitLab registry used.
|
||||
#
|
||||
# Tag-driven. A release cut with the job's own token raises no event on
|
||||
# Gitea (as on GitHub), so weekly-release.yml creates its release with
|
||||
# RELEASE_TOKEN; the tag that makes is an ordinary push, and starts this.
|
||||
#
|
||||
# The tag must agree with inbuxa-version.json at the commit it names -- the
|
||||
# property release.yml was built around: the tree a tag points at reports
|
||||
# the version the tag claims. A tag placed beside an unbumped file fails
|
||||
# here rather than publishing an image that reports the wrong version.
|
||||
#
|
||||
# Both architectures build under QEMU on this amd64 host, where publish.yml
|
||||
# had a native arm64 runner. That is slow -- tens of minutes for npm ci and
|
||||
# the Vite build through instruction translation -- and tolerable for a
|
||||
# weekly tag, which is why this is tag-only. If arm64 starts timing out, the
|
||||
# fix is an arm64 runner, not dropping the platform.
|
||||
#
|
||||
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): Gitea's
|
||||
# per-job token is refused by the container registry. The registry hands out
|
||||
# its push tokens from its own name, so unlike on GitLab nothing here has to
|
||||
# be pointed at a public address.
|
||||
publish:
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
needs: [build]
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
REGISTRY: ${{ vars.REGISTRY }}
|
||||
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
|
||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
steps:
|
||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||
- run: |
|
||||
set -eu
|
||||
apk add --no-cache -q jq curl
|
||||
VERSION="$(jq -er .version inbuxa-version.json)"
|
||||
if [ "$GITHUB_REF_NAME" != "v$VERSION" ]; then
|
||||
echo "Tag $GITHUB_REF_NAME names a commit whose inbuxa-version.json says $VERSION." >&2
|
||||
echo "Refusing to publish an image that would report the wrong version." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
- run: |
|
||||
test -n "$REGISTRY"
|
||||
test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; }
|
||||
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
|
||||
docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
|
||||
# Attestations are off, as they were in publish.yml: they add manifests
|
||||
# of their own to the index.
|
||||
- run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--provenance=false --sbom=false \
|
||||
--tag "$IMAGE:$VERSION" \
|
||||
--tag "$IMAGE:latest" \
|
||||
--push .
|
||||
docker buildx imagetools inspect "$IMAGE:$VERSION"
|
||||
# Gitea keeps a container package on its owner; linking it shows it on
|
||||
# the repository's Packages tab. Idempotent.
|
||||
- run: |
|
||||
curl -fsS -o /dev/null -X POST -H "Authorization: token $PACKAGE_TOKEN" \
|
||||
"$CI_SERVER_INTERNAL/api/v1/packages/${GITHUB_REPOSITORY%%/*}/container/${GITHUB_REPOSITORY#*/}/-/link/${GITHUB_REPOSITORY#*/}" \
|
||||
|| echo "package already linked (or link refused); not fatal"
|
||||
- if: always()
|
||||
run: docker logout "$REGISTRY" || true
|
||||
@@ -0,0 +1,135 @@
|
||||
# Weekly release, ported from the weekly-release job in .gitlab-ci.yml (itself
|
||||
# a port of release.yml): cut a release once a week, but only if there is
|
||||
# something in it. The decision is unchanged -- count the commits on main
|
||||
# since the newest published release, and skip the week if there are none. A
|
||||
# release with nothing in it moves :latest to an identical build, spends a
|
||||
# version number, and notifies everybody about nothing.
|
||||
#
|
||||
# The version is the date, YYYY.M.D unpadded, with a .N suffix from 2 for a
|
||||
# second release on one day. It is committed to main in inbuxa-version.json
|
||||
# and the tag names that commit, so the commit is the release.
|
||||
#
|
||||
# Mondays 09:37 UTC, as release.yml did. Run it by hand from the Actions tab
|
||||
# (workflow_dispatch); dry_run defaults to true, so a manual run shows the
|
||||
# decision and stops unless you untick it.
|
||||
#
|
||||
# SIDE-BY-SIDE PERIOD: until the GitLab cutover, GitLab's own schedule is
|
||||
# still live and still cuts the real release, and its bump commit and tag
|
||||
# reach this copy through the sync. Two releasers would race to write the same
|
||||
# version, so this workflow only ever dry-runs unless the variable
|
||||
# RELEASE_LIVE is '1'. Set RELEASE_LIVE=1 (repo or org Actions variable) at
|
||||
# cutover, when GitLab's schedule is switched off -- not before.
|
||||
#
|
||||
# Reads use the job's own token. Everything that writes uses RELEASE_TOKEN
|
||||
# (jcoffey-dev, write:repository), because a tag Gitea creates for the job
|
||||
# token raises no event (checked 2026-09-22) and the tag must start ci.yml's
|
||||
# publish job:
|
||||
# * the bump is committed through the contents API. Gitea's API has no
|
||||
# "only if the branch is still at X" guard like GitLab's last_commit_id,
|
||||
# so the job checks main's head immediately before writing and refuses if
|
||||
# it moved since the commit it counted from; run it again. Otherwise the
|
||||
# notes and the count would describe a different commit from the one
|
||||
# released. (The API does refuse if the file itself changed, via its blob
|
||||
# sha.)
|
||||
# * the release -- and with it the tag -- is created through the releases
|
||||
# API. A tag made that way is an ordinary push, so it starts ci.yml and
|
||||
# `publish` builds the image.
|
||||
# The token's owner must be allowed to push to main.
|
||||
name: weekly-release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '37 9 * * 1'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Show the decision and stop
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
# One at a time: two overlapping runs would race to write the same version and
|
||||
# create the same tag.
|
||||
concurrency:
|
||||
group: weekly-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
weekly-release:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 # 22-bookworm-slim
|
||||
env:
|
||||
READ_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
# Live only with RELEASE_LIVE=1 AND either the schedule or a manual run
|
||||
# with dry_run unticked.
|
||||
DRY_RUN: ${{ (vars.RELEASE_LIVE == '1' && (github.event_name == 'schedule' || inputs.dry_run == false || inputs.dry_run == 'false')) && '0' || '1' }}
|
||||
steps:
|
||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: apt-get update -qq && apt-get install -y -qq --no-install-recommends curl jq ca-certificates >/dev/null
|
||||
- shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
API="${CI_SERVER_INTERNAL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
sha="$(git rev-parse HEAD)"
|
||||
|
||||
# The newest published release, or empty on a project that has never
|
||||
# had one -- in which case everything counts as new.
|
||||
previous="$(curl -fsS -H "Authorization: token ${READ_TOKEN}" "${API}/releases?draft=false&pre-release=false&limit=1" | jq -r '.[0].tag_name // ""')"
|
||||
# A release can outlive its tag; falling back to the whole history
|
||||
# over-counts, which cuts a release that was due anyway. Tag lookups
|
||||
# use show-ref, which matches an exact ref: rev-parse --verify on this
|
||||
# git can read some tag names as describe output and "find" a tag
|
||||
# that isn't there (see ihasmail's port).
|
||||
if [ -n "$previous" ] && git show-ref --verify --quiet "refs/tags/${previous}"; then
|
||||
count="$(git rev-list --count "${previous}..HEAD")"; range="${previous}..HEAD"
|
||||
else
|
||||
count="$(git rev-list --count HEAD)"; range="HEAD"
|
||||
fi
|
||||
if [ "$count" -eq 0 ]; then
|
||||
echo "Nothing to release: no commits since ${previous}."; exit 0
|
||||
fi
|
||||
|
||||
today="$(date -u +%Y.%-m.%-d)"
|
||||
version="$today"; n=2
|
||||
while git show-ref --verify --quiet "refs/tags/v${version}"; do
|
||||
version="${today}.${n}"; n=$((n + 1))
|
||||
done
|
||||
tag="v${version}"
|
||||
echo "Releasing ${tag} -- ${count} commit(s) since ${previous:-the beginning}, from ${sha}."
|
||||
if [ "$DRY_RUN" = "1" ]; then echo "Dry run (RELEASE_LIVE='${{ vars.RELEASE_LIVE }}'): stopping here."; exit 0; fi
|
||||
|
||||
auth=(-H "Authorization: token ${RELEASE_TOKEN}")
|
||||
# The bump, written with a JSON parser rather than sed: a version put
|
||||
# into JSON by string substitution is one stray quote from a file
|
||||
# nothing can read.
|
||||
VERSION="$version" node -e '
|
||||
const fs = require("fs");
|
||||
const f = "inbuxa-version.json";
|
||||
const j = JSON.parse(fs.readFileSync(f, "utf8"));
|
||||
j.version = process.env.VERSION;
|
||||
fs.writeFileSync(f, JSON.stringify(j, null, 2) + "\n");
|
||||
'
|
||||
head="$(curl -fsS "${auth[@]}" "${API}/branches/main" | jq -er .commit.id)"
|
||||
if [ "$head" != "$sha" ]; then
|
||||
echo "main moved from ${sha} to ${head} since this run counted; run it again." >&2
|
||||
exit 1
|
||||
fi
|
||||
blob="$(curl -fsS "${auth[@]}" "${API}/contents/inbuxa-version.json?ref=${sha}" | jq -er .sha)"
|
||||
jq -n --arg msg "Version ${version}" --arg blob "$blob" \
|
||||
--arg content "$(base64 -w0 inbuxa-version.json)" \
|
||||
'{branch:"main", message:$msg, sha:$blob, content:$content}' > commit.json
|
||||
bump="$(curl -fsS "${auth[@]}" -X PUT -H "Content-Type: application/json" \
|
||||
--data @commit.json "${API}/contents/inbuxa-version.json" | jq -er .commit.sha)"
|
||||
echo "committed the bump as ${bump}"
|
||||
|
||||
# Notes bounded to what is new: one line per change on main's
|
||||
# first-parent history, which is what GitHub's generated notes listed.
|
||||
notes="$(git log --first-parent --format='- %s' "$range")"
|
||||
jq -n --arg tag "$tag" --arg ref "$bump" --arg name "INBUXA Admin ${version}" \
|
||||
--arg body "$(printf '%s commit(s) since %s.\n\n%s' "$count" "${previous:-the beginning}" "$notes")" \
|
||||
'{tag_name:$tag, target_commitish:$ref, name:$name, body:$body}' > release.json
|
||||
curl -fsS "${auth[@]}" -H "Content-Type: application/json" \
|
||||
--data @release.json "${API}/releases" | jq -r '"created release " + .tag_name'
|
||||
@@ -0,0 +1,4 @@
|
||||
# Funding platforms shown behind the repository's Sponsor button.
|
||||
# https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||
|
||||
github: jcoffey-dev
|
||||
@@ -0,0 +1,31 @@
|
||||
version: 2
|
||||
updates:
|
||||
# One npm entry at the root, where the single lockfile is.
|
||||
#
|
||||
# Minor and patch arrive as one pull request a week. Majors are left out of
|
||||
# the group on purpose: they are migrations rather than bumps, and each one
|
||||
# deserves its own pull request and its own CI run.
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: tuesday
|
||||
time: "09:00"
|
||||
timezone: Etc/UTC
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: tuesday
|
||||
time: "09:00"
|
||||
timezone: Etc/UTC
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -0,0 +1,31 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Pinned to full commit SHAs, with the release in the trailing comment.
|
||||
# A tag is a mutable pointer, so trusting `@v7` is trusting every future
|
||||
# version of that action. Dependabot updates both halves together on its
|
||||
# weekly run -- do not "simplify" a pin back to a tag.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
# --ignore-scripts: a postinstall script in any transitive dependency
|
||||
# would otherwise run with the runner's token in its environment.
|
||||
- run: npm ci --ignore-scripts
|
||||
- run: npm run typecheck
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
@@ -0,0 +1,69 @@
|
||||
# Prune old image versions from GHCR.
|
||||
#
|
||||
# Releases are kept forever -- they carry no assets and their generated notes
|
||||
# are this project's only changelog, so deleting one destroys history that
|
||||
# cannot be reconstructed for nothing saved. Images are the opposite: a
|
||||
# multi-arch build a week, and the by-digest push in publish.yml leaves two
|
||||
# untagged per-architecture manifests behind each time on top of the tagged
|
||||
# index. Those accumulate and nobody wants fifty of them.
|
||||
#
|
||||
# THE FOOTGUN: the obvious tool for this -- delete-package-versions with
|
||||
# `delete-only-untagged-versions` -- will happily delete the per-architecture
|
||||
# manifests that a multi-arch tag points *at*, because they are untagged by
|
||||
# design. Nothing appears to break: the tag still exists, and pulls simply
|
||||
# start failing for one architecture. This action understands manifest lists
|
||||
# and will not orphan a retained index, and `validate` re-checks every
|
||||
# multi-arch manifest against the registry afterwards.
|
||||
#
|
||||
# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show
|
||||
# exactly what would be deleted without rebuilding and re-pushing an image to
|
||||
# find out.
|
||||
name: Prune images
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
dry_run:
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "List what would be deleted, delete nothing"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
prune:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
# The only third-party action here that is not published by GitHub or
|
||||
# Docker, and the one with the most to lose: it is handed
|
||||
# `packages: write` and its whole job is deletion, so a ref repointed at
|
||||
# something else -- by a compromise or a mistake upstream -- is a bad
|
||||
# day. It was pinned to a commit long before the rest of them were.
|
||||
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
|
||||
with:
|
||||
owner: inbuxa
|
||||
package: inbuxa-admin
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Ten weekly releases is roughly a quarter of history, which is more
|
||||
# than enough to roll back to and far less than the year's worth that
|
||||
# would otherwise pile up. Older *releases* stay either way; this
|
||||
# only removes the images.
|
||||
keep-n-tagged: 10
|
||||
# Belt and braces on top of the action's own manifest awareness:
|
||||
# `latest` is never a candidate for deletion under any counting.
|
||||
exclude-tags: latest
|
||||
delete-untagged: true
|
||||
# Sweeps the wreckage of a half-failed run: an index whose platform
|
||||
# images did not all land, and referrers whose parent is gone.
|
||||
delete-partial-images: true
|
||||
delete-orphaned-images: true
|
||||
# Checks every remaining multi-architecture manifest still resolves
|
||||
# in the registry. This is the step that would catch the footgun
|
||||
# above rather than leaving a reader to discover it on `docker pull`.
|
||||
validate: true
|
||||
dry-run: ${{ inputs.dry_run }}
|
||||
@@ -0,0 +1,194 @@
|
||||
# Publish the container image to GHCR.
|
||||
#
|
||||
# The README and the docs site have told people to run
|
||||
# `ghcr.io/inbuxa/inbuxa-admin:latest` for a long time, and nothing ever
|
||||
# pushed it: `docker pull` answered `denied`, because the package did not
|
||||
# exist. This is the workflow that makes those instructions true. It is also
|
||||
# the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid
|
||||
# both install by pulling an image and neither builds from source.
|
||||
#
|
||||
# FIRST RUN: a package GHCR creates for the first time is **private**, even in
|
||||
# a public repository, and an anonymous `docker pull` will still answer
|
||||
# `denied`. Nothing in a workflow can change that -- the visibility is set once
|
||||
# by hand under the package's settings, and until it is, this looks like it
|
||||
# worked while the docs stay just as wrong as before. Check with a logged-out
|
||||
# pull, not with one from a machine that has credentials.
|
||||
#
|
||||
# Two architectures, each built on its own native runner rather than under
|
||||
# QEMU. Emulated arm64 has to run `npm ci` and the Vite build through
|
||||
# instruction translation, which takes tens of minutes and occasionally runs
|
||||
# out of memory; `ubuntu-24.04-arm` is free for public repositories and does
|
||||
# the same work at native speed. The cost is the by-digest dance below: each
|
||||
# runner pushes an untagged image, and a final job joins the two digests into
|
||||
# one multi-arch tag.
|
||||
name: Publish image
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
# Callable, so release.yml can build the release it just cut. This is not a
|
||||
# stylistic choice: a release created with GITHUB_TOKEN does **not** raise a
|
||||
# `release` event -- GitHub refuses to let a token trigger another workflow,
|
||||
# to stop a workflow looping on its own output. A scheduled job that cut a
|
||||
# release and expected this file to notice would silently never publish. The
|
||||
# alternatives are a personal access token kept as a secret, or calling the
|
||||
# workflow directly. This is the one that needs no credential.
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Tag, branch or SHA to build"
|
||||
required: true
|
||||
type: string
|
||||
tag_latest:
|
||||
description: "Also move :latest to this build"
|
||||
type: boolean
|
||||
default: false
|
||||
# Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then
|
||||
# orphans can be neither rerun nor canceled, and this workflow otherwise
|
||||
# only fires on a release -- which is not something to cut twice because a
|
||||
# runner died. `ref` also allows publishing an image for a tag that predates
|
||||
# this workflow, which is how the first one gets built.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Tag, branch or SHA to build"
|
||||
required: true
|
||||
default: main
|
||||
tag_latest:
|
||||
description: "Also move :latest to this build"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
env:
|
||||
# Hardcoded rather than derived from github.repository, which would have to
|
||||
# be lowercased to be a legal registry path. This is the string the docs name.
|
||||
IMAGE: ghcr.io/inbuxa/inbuxa-admin
|
||||
|
||||
jobs:
|
||||
# The version is read once and handed to both builds, so the two
|
||||
# architectures cannot disagree about what they are. It comes from the file
|
||||
# the interface itself reads, which the weekly release commits before this
|
||||
# runs -- so the image is tagged with the version it will report.
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
- id: v
|
||||
run: |
|
||||
set -euo pipefail
|
||||
V="$(jq -er .version inbuxa-version.json)"
|
||||
# A date version carries nothing a Docker tag objects to, so there is
|
||||
# no second, sanitized form of it here.
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
echo "version $V"
|
||||
|
||||
build:
|
||||
needs: version
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push by digest
|
||||
id: push
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
# Attestations are off deliberately: they add manifests of their own
|
||||
# to the index, and `imagetools create` below expects the two entries
|
||||
# it pushed rather than four.
|
||||
provenance: false
|
||||
sbom: false
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
- name: Save the digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
# The prefix is stripped here and put back in the merge job, so the
|
||||
# filename is the bare hash. Leaving it on produces
|
||||
# `image@sha256:sha256:...` when the reference is rebuilt.
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
# One artifact per platform; the merge job globs them back together.
|
||||
name: digest-${{ strategy.job-index }}
|
||||
path: /tmp/digests/*
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
# Joins the per-architecture digests into a single tagged manifest, so
|
||||
# `docker pull ghcr.io/inbuxa/inbuxa-admin:<tag>` resolves on both.
|
||||
publish:
|
||||
needs: [version, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Create the manifest
|
||||
run: |
|
||||
# Arrays rather than a string: the tags and the digest references
|
||||
# have to reach docker as separate arguments, and building them by
|
||||
# word-splitting an unquoted variable is the version of this that
|
||||
# breaks the day a value contains a space.
|
||||
tags=(-t "${IMAGE}:${{ needs.version.outputs.version }}")
|
||||
# :latest follows real releases only. A prerelease that moved it
|
||||
# would hand every `:latest` deployment an unfinished build, and a
|
||||
# dispatch run has to ask for it on purpose.
|
||||
if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then
|
||||
tags+=(-t "${IMAGE}:latest")
|
||||
elif [ "${{ inputs.tag_latest }}" = "true" ]; then
|
||||
tags+=(-t "${IMAGE}:latest")
|
||||
fi
|
||||
refs=()
|
||||
for f in /tmp/digests/*; do
|
||||
refs+=("${IMAGE}@sha256:$(basename "$f")")
|
||||
done
|
||||
echo "tags: ${tags[*]}"
|
||||
echo "refs: ${refs[*]}"
|
||||
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
|
||||
- name: Show what landed
|
||||
run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.version }}"
|
||||
|
||||
# Runs only after a successful publish, because that is the only moment the
|
||||
# package grows. See cleanup.yml for why this is not the obvious one-liner.
|
||||
prune:
|
||||
needs: publish
|
||||
permissions:
|
||||
packages: write
|
||||
uses: ./.github/workflows/cleanup.yml
|
||||
@@ -0,0 +1,182 @@
|
||||
# Cut a release once a week, but only if there is something in it.
|
||||
#
|
||||
# It does nothing on a quiet week. A release with no commits in it is worse
|
||||
# than no release: it moves `:latest` to an identical build, spends a version
|
||||
# number, and mails everybody watching the repository about nothing.
|
||||
#
|
||||
# Unlike ihasmail, whose version is derived from the commit it builds, INBUXA
|
||||
# Admin keeps its version in inbuxa-version.json. So this writes it: the bump
|
||||
# is committed to main, and the tag names that commit. The commit is the
|
||||
# release, which means the tree a tag points at always reports the version the
|
||||
# tag claims -- something a tag placed beside an unbumped file cannot promise.
|
||||
name: Weekly release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Mondays, 09:37 UTC -- twenty minutes behind ihasmail-inbuxa's, twenty
|
||||
# ahead of the server's. Staggered rather than simultaneous so three
|
||||
# releases do not compete for runners, and so a bad Monday names one
|
||||
# repository instead of three. GitHub runs scheduled jobs best-effort and
|
||||
# can delay a run considerably, so the exact minute is not a promise; the
|
||||
# odd minute keeps it off the crowded top of the hour.
|
||||
#
|
||||
# Note also that GitHub disables scheduled workflows in a repository with
|
||||
# no activity for 60 days, which is worth checking for before assuming
|
||||
# this file is broken.
|
||||
- cron: "37 9 * * 1"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Work out what would be released, then stop"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# One at a time. Two overlapping runs would race to write the same version and
|
||||
# create the same tag, and the loser fails noisily for a reason that has
|
||||
# nothing to do with the code.
|
||||
concurrency:
|
||||
group: weekly-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should_release: ${{ steps.decide.outputs.should_release }}
|
||||
version: ${{ steps.decide.outputs.version }}
|
||||
tag: ${{ steps.decide.outputs.tag }}
|
||||
previous: ${{ steps.decide.outputs.previous }}
|
||||
count: ${{ steps.decide.outputs.count }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- id: decide
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# The newest published release, or empty on a repository that has
|
||||
# never had one -- in which case everything counts as new. Drafts are
|
||||
# excluded: an unpublished draft is not a release anybody has, so
|
||||
# counting from it would hide commits that have never shipped.
|
||||
previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')"
|
||||
# A tag named by a release is normally present after a full checkout,
|
||||
# but a release can outlive its tag. Falling back to the whole
|
||||
# history is the safe direction to be wrong in: it over-counts, which
|
||||
# cuts a release that was due anyway, where under-counting would skip
|
||||
# one that was.
|
||||
if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then
|
||||
count="$(git rev-list --count "${previous}..HEAD")"
|
||||
else
|
||||
count="$(git rev-list --count HEAD)"
|
||||
fi
|
||||
|
||||
# INBUXA's version is the date, as the rest of the family does it:
|
||||
# YYYY.M.D, unpadded. A second release on one day takes a `.N`
|
||||
# suffix, counting from 2, which is why this asks the tags rather
|
||||
# than assuming today is free.
|
||||
today="$(date -u +%Y.%-m.%-d)"
|
||||
version="$today"
|
||||
n=2
|
||||
while git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; do
|
||||
version="${today}.${n}"
|
||||
n=$((n + 1))
|
||||
done
|
||||
|
||||
should_release=true
|
||||
reason=""
|
||||
if [ "$count" -eq 0 ]; then
|
||||
should_release=false
|
||||
reason="no commits since ${previous}"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "should_release=$should_release"
|
||||
echo "version=$version"
|
||||
echo "tag=v${version}"
|
||||
echo "previous=$previous"
|
||||
echo "count=$count"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Written to the run summary so a skipped week reads as a decision
|
||||
# rather than as a workflow that quietly did nothing.
|
||||
{
|
||||
echo "### Weekly release"
|
||||
echo
|
||||
if [ "$should_release" = "true" ]; then
|
||||
echo "Releasing **v${version}** — ${count} commit(s) since ${previous:-the beginning}."
|
||||
else
|
||||
echo "Nothing to release: ${reason}."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
cut:
|
||||
needs: check
|
||||
if: needs.check.outputs.should_release == 'true' && !inputs.dry_run
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
sha: ${{ steps.bump.outputs.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- id: bump
|
||||
env:
|
||||
VERSION: ${{ needs.check.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Rewritten with a JSON parser rather than sed: the file is small and
|
||||
# the shape is known, but a version written into JSON by string
|
||||
# substitution is one stray quote away from a file nothing can read.
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const f = "inbuxa-version.json";
|
||||
const j = JSON.parse(fs.readFileSync(f, "utf8"));
|
||||
j.version = process.env.VERSION;
|
||||
fs.writeFileSync(f, JSON.stringify(j, null, 2) + "\n");
|
||||
'
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add inbuxa-version.json
|
||||
git commit -m "Version ${VERSION}"
|
||||
git push origin HEAD:main
|
||||
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=(--target "${{ steps.bump.outputs.sha }}"
|
||||
--title "INBUXA Admin ${{ needs.check.outputs.version }}"
|
||||
--generate-notes)
|
||||
# Bound the notes to what is actually new. Without a start tag the
|
||||
# generator reaches back to whatever it decides is previous, which on
|
||||
# a repository carrying older tag shapes -- this one still has the
|
||||
# inherited v1.0.x tags -- is not always the last release.
|
||||
if [ -n "${{ needs.check.outputs.previous }}" ]; then
|
||||
args+=(--notes-start-tag "${{ needs.check.outputs.previous }}")
|
||||
fi
|
||||
gh release create "${{ needs.check.outputs.tag }}" "${args[@]}"
|
||||
|
||||
# Called rather than left to the `release` trigger on purpose: see the note
|
||||
# at the top of publish.yml. A release created with GITHUB_TOKEN raises no
|
||||
# event, so without this the tag would exist and no image would follow it.
|
||||
publish:
|
||||
needs: [check, cut]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
uses: ./.github/workflows/publish.yml
|
||||
with:
|
||||
ref: ${{ needs.cut.outputs.sha }}
|
||||
tag_latest: true
|
||||
@@ -20,6 +20,7 @@ scripts/
|
||||
!.prettierrc
|
||||
!.env.development
|
||||
!.github/
|
||||
!.gitlab-ci.yml
|
||||
!.vscode/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# CI on the self-hosted GitLab, ported from .github/workflows/ when the GitHub
|
||||
# account was suspended on 2026-09-20. The Actions files stay in the tree: they
|
||||
# are the reference this was written from and work unchanged if the appeal
|
||||
# succeeds.
|
||||
#
|
||||
# Every `image:` is pinned by digest, with its tag in the trailing comment --
|
||||
# the replacement for the workflows' SHA-pinned actions, since GitLab has no
|
||||
# action allowlist. Read the comment for the version; the digest is what runs.
|
||||
#
|
||||
# The runner is the inbuxa group runner on Web_Host, with the host docker
|
||||
# socket bound in, as ihasmail's is. Jobs reach GitLab and its registry on the
|
||||
# internal network, never through https://git.coffeylabs.org, which is
|
||||
# Cloudflare-proxied and caps request bodies at 100 MB.
|
||||
#
|
||||
# Not ported:
|
||||
# * cleanup.yml pruned GHCR with dataaxiom/ghcr-cleanup-action; on GitLab
|
||||
# that belongs in the project's container registry cleanup policy, not in
|
||||
# a pipeline.
|
||||
|
||||
stages: [build, publish, release]
|
||||
|
||||
default:
|
||||
interruptible: true
|
||||
|
||||
build:
|
||||
stage: build
|
||||
image: node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 # 22-bookworm-slim
|
||||
variables:
|
||||
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"
|
||||
cache:
|
||||
key:
|
||||
files: [package-lock.json]
|
||||
paths: [.npm/]
|
||||
script:
|
||||
- npm ci --ignore-scripts
|
||||
- npm run typecheck
|
||||
- npm run lint
|
||||
- npm test
|
||||
- npm run build
|
||||
rules:
|
||||
- if: $RELEASE_WEEKLY == "1"
|
||||
when: never
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
# A release tag is built and tested again before its image is published.
|
||||
- if: $CI_COMMIT_TAG
|
||||
|
||||
# ------------------------------------------------------------- publish ------
|
||||
# Port of publish.yml, to the project's own registry now that GHCR went with
|
||||
# the GitHub account: registry.coffeylabs.org/inbuxa/inbuxa-admin.
|
||||
#
|
||||
# Tag-driven. publish.yml was called from release.yml because a release cut
|
||||
# with GITHUB_TOKEN raises no event; GitLab has no such rule, so the tag the
|
||||
# weekly release creates starts a tag pipeline, and this builds from it.
|
||||
#
|
||||
# Only date tags publish (v2026.9.21, v2026.9.21.2). The repository still
|
||||
# carries the inherited v1.0.x tags, and a tag of any other shape pushed by
|
||||
# hand is not a release.
|
||||
#
|
||||
# The tag must agree with inbuxa-version.json at the commit it names -- the
|
||||
# property release.yml was built around: the tree a tag points at reports the
|
||||
# version the tag claims. A tag placed beside an unbumped file fails here
|
||||
# rather than publishing an image that reports the wrong version.
|
||||
#
|
||||
# Both architectures build under QEMU on this amd64 host, where publish.yml
|
||||
# had a native arm64 runner. That is slow -- tens of minutes for npm ci and
|
||||
# the Vite build through instruction translation -- and tolerable for a weekly
|
||||
# tag, which is why this is tag-only. If arm64 starts timing out, the fix is
|
||||
# an arm64 runner, not dropping the platform.
|
||||
publish:
|
||||
stage: publish
|
||||
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||
needs: [build]
|
||||
variables:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
IMAGE: $CI_REGISTRY_IMAGE
|
||||
before_script:
|
||||
- apk add --no-cache jq >/dev/null
|
||||
- |
|
||||
set -eu
|
||||
VERSION="$(jq -er .version inbuxa-version.json)"
|
||||
if [ "$CI_COMMIT_TAG" != "v$VERSION" ]; then
|
||||
echo "Tag $CI_COMMIT_TAG names a commit whose inbuxa-version.json says $VERSION." >&2
|
||||
echo "Refusing to publish an image that would report the wrong version." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "VERSION=$VERSION" > version.env
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||
- docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||
# The registry hands out push tokens from https://git.coffeylabs.org/jwt/auth,
|
||||
# and buildx fetches them here, in the job, not in its builder. On ci-net
|
||||
# that name is the gitlab container itself (172.30.0.2), which serves
|
||||
# plain HTTP to the runner and nothing on 443, so every push failed at the
|
||||
# last step with "connection refused". The login above works because the
|
||||
# host's daemon does it, and the host resolves the name publicly. So, for
|
||||
# this job only, point the name at its public address the same way. Only
|
||||
# the token request uses it; layers go to the registry's own DNS-only name.
|
||||
- |
|
||||
public="$(nslookup "$CI_SERVER_HOST" 1.1.1.1 2>/dev/null | awk '/^Address: / && $2 !~ /:/ { print $2; exit }')"
|
||||
if [ -z "$public" ]; then echo "Could not resolve $CI_SERVER_HOST publicly" >&2; exit 1; fi
|
||||
echo "$public $CI_SERVER_HOST" >> /etc/hosts
|
||||
echo "$CI_SERVER_HOST -> $public for the registry token"
|
||||
- docker buildx create --use --name ci-builder --driver docker-container || docker buildx use ci-builder
|
||||
script:
|
||||
- . ./version.env
|
||||
# Attestations are off, as they were in publish.yml: they add manifests of
|
||||
# their own to the index.
|
||||
- |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--provenance=false --sbom=false \
|
||||
--tag "$IMAGE:$VERSION" \
|
||||
--tag "$IMAGE:latest" \
|
||||
--push .
|
||||
- docker buildx imagetools inspect "$IMAGE:$VERSION"
|
||||
after_script:
|
||||
- docker logout "$CI_REGISTRY" || true
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v[0-9]{4}\.[0-9]+\.[0-9]+(\.[0-9]+)?$/
|
||||
|
||||
# ------------------------------------------------------- weekly release -----
|
||||
# Port of release.yml: cut a release once a week, but only if there is
|
||||
# something in it. The decision is the workflow's, unchanged -- count the
|
||||
# commits on main since the newest published release, and skip the week if
|
||||
# there are none. A release with nothing in it moves :latest to an identical
|
||||
# build, spends a version number, and notifies everybody about nothing.
|
||||
#
|
||||
# The version is the date, YYYY.M.D unpadded, with a .N suffix from 2 for a
|
||||
# second release on one day. It is committed to main in inbuxa-version.json
|
||||
# and the tag names that commit, so the commit is the release.
|
||||
#
|
||||
# It runs from a pipeline schedule (Mondays 09:37 UTC, as release.yml did)
|
||||
# that sets RELEASE_WEEKLY=1. GitLab keeps schedules on the project, not in
|
||||
# this file, so the schedule and this job only work as a pair. Run it by hand
|
||||
# with RELEASE_WEEKLY=1, adding DRY_RUN=1 to see the decision and stop.
|
||||
#
|
||||
# Everything that writes uses RELEASE_TOKEN, a project access token
|
||||
# (Maintainer, `api` scope; protected, masked), never CI_JOB_TOKEN, which can
|
||||
# neither commit nor raise a tag pipeline:
|
||||
# * the bump is committed through the commits API, with last_commit_id set
|
||||
# to the commit this job counted from. If main moved meanwhile the API
|
||||
# refuses and the job fails; run it again. Otherwise the notes and the
|
||||
# count would describe a different commit from the one released.
|
||||
# * the release -- and with it the tag -- is created through the releases
|
||||
# API. A tag made that way is an ordinary push, so it starts the tag
|
||||
# pipeline and `publish` builds the image.
|
||||
# The token's role must be allowed to push to main. The token expires; when
|
||||
# it does this fails loudly at the first API call, and a new one goes in the
|
||||
# same variable.
|
||||
weekly-release:
|
||||
stage: release
|
||||
image: node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 # 22-bookworm-slim
|
||||
# One at a time: two overlapping runs would race to write the same version
|
||||
# and create the same tag.
|
||||
resource_group: weekly-release
|
||||
variables:
|
||||
GIT_DEPTH: "0"
|
||||
before_script:
|
||||
- apt-get update -qq && apt-get install -y -qq --no-install-recommends git curl jq ca-certificates >/dev/null
|
||||
# The build directory is shared between jobs, and a checkout owned by
|
||||
# another user makes git refuse with "detected dubious ownership".
|
||||
- git config --global --add safe.directory "$CI_PROJECT_DIR"
|
||||
script:
|
||||
- |
|
||||
set -euo pipefail
|
||||
API="http://gitlab/api/v4/projects/${CI_PROJECT_ID}"
|
||||
auth=(--header "PRIVATE-TOKEN: ${RELEASE_TOKEN}")
|
||||
git fetch -q --tags origin
|
||||
sha="$(git rev-parse HEAD)"
|
||||
|
||||
# The newest published release, or empty on a project that has never
|
||||
# had one -- in which case everything counts as new.
|
||||
previous="$(curl -fsS "${auth[@]}" "${API}/releases?order_by=released_at&sort=desc&per_page=1" | jq -r '.[0].tag_name // ""')"
|
||||
# A release can outlive its tag; falling back to the whole history
|
||||
# over-counts, which cuts a release that was due anyway. Tag lookups use
|
||||
# show-ref, which matches an exact ref: rev-parse --verify on this git
|
||||
# can read some tag names as describe output and "find" a tag that
|
||||
# isn't there (see ihasmail's port).
|
||||
if [ -n "$previous" ] && git show-ref --verify --quiet "refs/tags/${previous}"; then
|
||||
count="$(git rev-list --count "${previous}..HEAD")"; range="${previous}..HEAD"
|
||||
else
|
||||
count="$(git rev-list --count HEAD)"; range="HEAD"
|
||||
fi
|
||||
if [ "$count" -eq 0 ]; then
|
||||
echo "Nothing to release: no commits since ${previous}."; exit 0
|
||||
fi
|
||||
|
||||
today="$(date -u +%Y.%-m.%-d)"
|
||||
version="$today"; n=2
|
||||
while git show-ref --verify --quiet "refs/tags/v${version}"; do
|
||||
version="${today}.${n}"; n=$((n + 1))
|
||||
done
|
||||
tag="v${version}"
|
||||
echo "Releasing ${tag} -- ${count} commit(s) since ${previous:-the beginning}, from ${sha}."
|
||||
if [ "${DRY_RUN:-0}" = "1" ]; then echo "DRY_RUN=1: stopping here."; exit 0; fi
|
||||
|
||||
# The bump, written with a JSON parser rather than sed: a version put
|
||||
# into JSON by string substitution is one stray quote from a file
|
||||
# nothing can read.
|
||||
VERSION="$version" node -e '
|
||||
const fs = require("fs");
|
||||
const f = "inbuxa-version.json";
|
||||
const j = JSON.parse(fs.readFileSync(f, "utf8"));
|
||||
j.version = process.env.VERSION;
|
||||
fs.writeFileSync(f, JSON.stringify(j, null, 2) + "\n");
|
||||
'
|
||||
jq -n --arg msg "Version ${version}" --arg sha "$sha" --rawfile content inbuxa-version.json \
|
||||
'{branch:"main", commit_message:$msg, last_commit_id:$sha,
|
||||
actions:[{action:"update", file_path:"inbuxa-version.json", content:$content}]}' > commit.json
|
||||
bump="$(curl -fsS "${auth[@]}" --header "Content-Type: application/json" \
|
||||
--data @commit.json "${API}/repository/commits" | jq -er .id)"
|
||||
echo "committed the bump as ${bump}"
|
||||
|
||||
# Notes bounded to what is new: one line per change on main's
|
||||
# first-parent history, which is what GitHub's generated notes listed.
|
||||
notes="$(git log --first-parent --format='- %s' "$range")"
|
||||
jq -n --arg tag "$tag" --arg ref "$bump" --arg name "INBUXA Admin ${version}" \
|
||||
--arg desc "$(printf '%s commit(s) since %s.\n\n%s' "$count" "${previous:-the beginning}" "$notes")" \
|
||||
'{tag_name:$tag, ref:$ref, name:$name, description:$desc}' > release.json
|
||||
curl -fsS "${auth[@]}" --header "Content-Type: application/json" \
|
||||
--data @release.json "${API}/releases" | jq -r '"created release " + .tag_name'
|
||||
rules:
|
||||
- if: $RELEASE_WEEKLY == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**johnellisATlinuxDOTcom**.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Contributing
|
||||
|
||||
Patches, bug reports and questions are welcome. Open an issue first for
|
||||
anything substantial; small fixes need no ceremony.
|
||||
|
||||
## What this is
|
||||
|
||||
A fork of Stalwart's web interface, taken under the AGPL-3.0-only half of its
|
||||
dual licence, talking to INBUXA over JMAP and OAuth. Upstream's copyright
|
||||
headers stay where they are, and a file this fork has changed says so beneath
|
||||
them. New files carry Coffey Labs' own header and `AGPL-3.0-only`.
|
||||
|
||||
Changes to files that came from upstream are kept small, so the next import
|
||||
merges cleanly and a reader can tell fork from base.
|
||||
|
||||
## Before you push
|
||||
|
||||
```
|
||||
npm ci
|
||||
npm run typecheck && npm run lint && npm test && npm run build
|
||||
```
|
||||
|
||||
CI runs exactly that. Nothing here talks to a live server, so a failing test
|
||||
is a real failure rather than a missing container.
|
||||
|
||||
## Commit messages
|
||||
|
||||
Say what changed and why, in prose. The why is the part that is hard to
|
||||
recover later. No tool trailers.
|
||||
@@ -0,0 +1,31 @@
|
||||
# INBUXA Admin as an image: the built interface and a static server for it.
|
||||
#
|
||||
# The interface is static files and nothing else -- it talks to the mail server
|
||||
# from the browser, never from here -- so this is nginx with a SPA fallback and
|
||||
# no back end of its own.
|
||||
#
|
||||
# It is built here rather than copied from `dist/`, which is committed for the
|
||||
# convenience of people serving the tree directly. An image built from a stale
|
||||
# `dist/` would be a build nobody can reproduce from the commit it claims.
|
||||
FROM docker.io/node:26-alpine AS build
|
||||
WORKDIR /build
|
||||
# The lockfile alone first, so a commit that changes no dependency reuses this
|
||||
# layer instead of resolving the tree again.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
# The version comes from inbuxa-version.json, which the release commits before
|
||||
# this builds, so there is nothing to pass in here.
|
||||
RUN npm run build
|
||||
|
||||
FROM docker.io/nginxinc/nginx-unprivileged:1.29-alpine
|
||||
# Unprivileged nginx, which runs as uid 101 and cannot bind 80. 8080 is the
|
||||
# port it listens on and the one to publish.
|
||||
EXPOSE 8080
|
||||
# Owned by the nginx user (uid 101 in this image), not root: the entrypoint
|
||||
# below rewrites index.html, and cannot if the file is root's. The directory
|
||||
# stays root's, which is why the entrypoint writes through the file rather
|
||||
# than replacing it.
|
||||
COPY --from=build --chown=101:101 /build/dist /usr/share/nginx/html
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --chmod=0755 docker/entrypoint.sh /docker-entrypoint.d/40-api-base-url.sh
|
||||
@@ -0,0 +1,109 @@
|
||||
# Third-party notices
|
||||
|
||||
INBUXA Admin is licensed under the AGPL-3.0; see LICENSES/. This file records
|
||||
work by other people that ships inside it and the terms it comes under.
|
||||
|
||||
## Color palettes
|
||||
|
||||
Ten of the palettes offered under the user menu › Theme are the work of their own
|
||||
projects and are used under the MIT license. Only the published color values
|
||||
are used — no code, and nothing from anyone else's reimplementation of them.
|
||||
The values as fetched from each project are recorded in ihasmail's repository,
|
||||
in `.palette-sources/palettes-upstream.md`, and the shades between them are
|
||||
derived by ihasmail's `scripts/build-palettes.py`, which also lifts any tier that
|
||||
would not meet the contrast ihasmail claims. INBUXA Admin takes ihasmail's
|
||||
derived colors as they are (`scripts/import-palettes.py`).
|
||||
|
||||
### Dracula and Alucard
|
||||
|
||||
Copyright (c) 2016 Dracula Theme — https://github.com/dracula/dracula-theme
|
||||
Licensed under the MIT license. "Dracula" is the dark variant and "Alucard" the
|
||||
light one; both are published in that repository's own "Color Palette (OSS)"
|
||||
section.
|
||||
|
||||
### Gruvbox
|
||||
|
||||
Copyright (c) 2018 Pavel Pertsev — https://github.com/morhetz/gruvbox
|
||||
Licensed under the MIT license.
|
||||
|
||||
### Rosé Pine
|
||||
|
||||
Copyright (c) 2021 Rosé Pine — https://github.com/rose-pine/rose-pine-theme
|
||||
Licensed under the MIT license. The light variant is "Dawn".
|
||||
|
||||
### Tokyo Night
|
||||
|
||||
Copyright (c) 2019 enkia — https://github.com/enkia/tokyo-night-vscode-theme
|
||||
Licensed under the MIT license. The light variant is "Day".
|
||||
|
||||
### Catppuccin
|
||||
|
||||
Copyright (c) 2021 Catppuccin — https://github.com/catppuccin/palette
|
||||
Licensed under the MIT license. "Mocha" is the dark variant and "Latte" the
|
||||
light one; both are published in that repository's palette.json.
|
||||
|
||||
### Solarized
|
||||
|
||||
Copyright (c) 2011 Ethan Schoonover — https://github.com/altercation/solarized
|
||||
Licensed under the MIT license. Light and dark are both original to it, and
|
||||
share one set of accent values by design.
|
||||
|
||||
### Ayu
|
||||
|
||||
Copyright (c) Konstantin Pschera — https://github.com/ayu-theme/ayu-colors
|
||||
Licensed under the MIT license. The two signature accent colors come from the
|
||||
same author's ayu-theme/vscode-ayu, also MIT.
|
||||
|
||||
### Kanagawa
|
||||
|
||||
Copyright (c) 2021 Tommaso Laurenzi — https://github.com/rebelot/kanagawa.nvim
|
||||
Licensed under the MIT license. "Wave" is the dark variant and "Lotus" the
|
||||
light one. The theme takes its name from Hokusai's print.
|
||||
|
||||
### Everforest
|
||||
|
||||
Copyright (c) 2019 Sainnhe Park — https://github.com/sainnhe/everforest
|
||||
Licensed under the MIT license. The medium-contrast variant of each mode is
|
||||
the one used here.
|
||||
|
||||
### Primer
|
||||
|
||||
Copyright (c) GitHub, Inc. — https://github.com/primer/primitives
|
||||
Licensed under the MIT license, which covers the color values. "GitHub" and
|
||||
the Invertocat logo are trademarks of GitHub, Inc.; this palette is named
|
||||
"Primer" after the design system and is neither affiliated with nor endorsed
|
||||
by GitHub.
|
||||
---
|
||||
|
||||
The MIT license, under which all ten are used:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## Fonts
|
||||
|
||||
The interface's typefaces are bundled with it, from the Fontsource packages,
|
||||
under the SIL Open Font License 1.1. The full license text ships with each
|
||||
package (`LICENSE` in `@fontsource-variable/inter` and
|
||||
`@fontsource-variable/space-grotesk`), and it applies to the font files in the
|
||||
built app.
|
||||
|
||||
- **Inter**: Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter).
|
||||
- **Space Grotesk**: Copyright (c) 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk).
|
||||
@@ -1,158 +1,70 @@
|
||||
<p align="center">
|
||||
<a href="https://stalw.art">
|
||||
<img src="./img/logo-red.svg" height="150">
|
||||
</a>
|
||||
<img src="./img/brand/inbuxa-lockup-light.svg" alt="inbuxa" height="120">
|
||||
</p>
|
||||
|
||||
<h3 align="center">
|
||||
Web-based User Interface for Stalwart 🛡️
|
||||
</h3>
|
||||
<h3 align="center">INBUXA Admin</h3>
|
||||
|
||||
<br>
|
||||
The administration interface for the INBUXA mail server: every server setting,
|
||||
first-boot setup, and recovery, in the browser.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/stalwartlabs/webui/actions/workflows/build.yml"><img src="https://img.shields.io/github/actions/workflow/status/stalwartlabs/webui/build.yml?style=flat-square" alt="continuous integration"></a>
|
||||
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?label=license&style=flat-square" alt="License: AGPL v3"></a>
|
||||
|
||||
<a href="https://stalw.art/docs/get-started/"><img src="https://img.shields.io/badge/read_the-docs-red?style=flat-square" alt="Documentation"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://mastodon.social/@stalwartlabs"><img src="https://img.shields.io/mastodon/follow/109929667531941122?style=flat-square&logo=mastodon&color=%236364ff&label=Follow%20on%20Mastodon" alt="Mastodon"></a>
|
||||
|
||||
<a href="https://twitter.com/stalwartlabs"><img src="https://img.shields.io/twitter/follow/stalwartlabs?style=flat-square&logo=x&label=Follow%20on%20Twitter" alt="Twitter"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/jtgtCNj66U"><img src="https://img.shields.io/discord/923615863037390889?label=Join%20Discord&logo=discord&style=flat-square" alt="Discord"></a>
|
||||
|
||||
<a href="https://matrix.to/#/#stalwart:matrix.org"><img src="https://img.shields.io/matrix/stalwartmail%3Amatrix.org?label=Join%20Matrix&logo=matrix&style=flat-square" alt="Matrix"></a>
|
||||
</p>
|
||||
It is schema-driven. After signing in it fetches the server's schema and
|
||||
builds every form, list and menu from it, so it covers every setting the
|
||||
server has without hardcoding any of them.
|
||||
|
||||
## Features
|
||||
## Design
|
||||
|
||||
**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.
|
||||
- **One edition.** Every feature the server has is available here, with
|
||||
nothing held back. See the INBUXA server's `docs/spec/`.
|
||||
- **Runs anywhere, not on the mail server.** INBUXA Admin is its own
|
||||
deployment, never installed onto the mail server. It's pointed at the server
|
||||
either at build time (`VITE_API_BASE_URL`) or at deploy time:
|
||||
`<meta name="api-base-url" content="https://mail.example.com">` in
|
||||
`index.html`. Hosted like that, it signs in as the OAuth client
|
||||
`inbuxa-admin`, which the server registers when it's started with
|
||||
`INBUXA_ADMIN_URL` set to INBUXA Admin's address (for development,
|
||||
`http://localhost:5173`).
|
||||
- **INBUXA's look:** the logo and ihasmail's palette.
|
||||
- **Two-factor setup** names INBUXA as the issuer, and no longer makes
|
||||
authenticator apps fetch a logo from a third-party site.
|
||||
|
||||
Key features:
|
||||
## Developing
|
||||
|
||||
- **Schema-driven UI**: All forms, lists, and navigation are generated from a JSON schema fetched from `/api/schema` after login. No object types, field names, or layouts are hardcoded.
|
||||
- **JMAP protocol**: All data operations (queries, creates, updates, deletes, blob uploads) use JMAP (RFC 8620) with method chaining and result references.
|
||||
- **Permission-aware**: Every button, link, field, and section respects the user's permissions. Elements the user cannot access are hidden.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<img src="./img/demo.gif">
|
||||
|
||||
## Get Started
|
||||
|
||||
Stalwart WebUI is included with Stalwart Mail Server, to install Stalwart Mail Server on your server by following the instructions for your platform:
|
||||
|
||||
- [Linux / MacOS](https://stalw.art/docs/install/linux)
|
||||
- [Windows](https://stalw.art/docs/install/windows)
|
||||
- [Docker](https://stalw.art/docs/install/docker)
|
||||
|
||||
All documentation is available at [stalw.art/docs/get-started](https://stalw.art/docs/get-started).
|
||||
|
||||
## Getting started
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Node.js 18 or later
|
||||
- A running Stalwart instance (for JMAP API calls)
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
Configuration is done through Vite environment variables. Copy or edit `.env.development` in the project root:
|
||||
|
||||
```
|
||||
VITE_API_BASE_URL=http://localhost:443
|
||||
VITE_ACCESS_TOKEN=
|
||||
VITE_OAUTH_SCOPES=
|
||||
```
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `VITE_API_BASE_URL` | URL of the Stalwart server. Used for all API requests during development. In production builds (when empty or unset) requests are relative to the current origin. |
|
||||
| `VITE_ACCESS_TOKEN` | When set, skips the OAuth flow entirely and uses this token for all requests. Useful for local development and testing. |
|
||||
| `VITE_OAUTH_SCOPES` | Optional OAuth scopes. Omitted from the authorization request when empty. |
|
||||
|
||||
### OAuth client ID
|
||||
|
||||
The OAuth 2.0 client ID is not a build-time setting. It is read at runtime from a meta tag in `index.html`:
|
||||
|
||||
```html
|
||||
<meta name="oauth-client-id" content="" />
|
||||
```
|
||||
|
||||
The server rewrites the `content` attribute when it serves the page, so a single build works for any deployment. When no
|
||||
client ID is configured the attribute is left empty and the panel falls back to `stalwart-webui`.
|
||||
|
||||
### Bypassing OAuth for development
|
||||
|
||||
Set `VITE_ACCESS_TOKEN` to a valid bearer token to skip the login page and go straight to the admin panel. You can obtain a token from the Stalwart server's token endpoint or use an API key:
|
||||
|
||||
```
|
||||
VITE_ACCESS_TOKEN=your-bearer-token-here
|
||||
```
|
||||
|
||||
### Running the dev server
|
||||
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This starts Vite's development server with hot module replacement, typically at `http://localhost:5173`.
|
||||
|
||||
## Testing
|
||||
|
||||
Run the unit tests (Vitest):
|
||||
|
||||
```
|
||||
npm test
|
||||
```
|
||||
|
||||
Run tests in watch mode:
|
||||
|
||||
```
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
## Building for production
|
||||
|
||||
```
|
||||
```bash
|
||||
npm ci
|
||||
npm run dev # http://localhost:5173, against VITE_API_BASE_URL in .env.development
|
||||
npm run typecheck && npx eslint src/ && npx vitest run
|
||||
npm run build
|
||||
```
|
||||
|
||||
This runs the TypeScript compiler followed by Vite's production build. Output
|
||||
goes to the `dist/` directory.
|
||||
## Keeping up with upstream
|
||||
|
||||
To preview the production build locally:
|
||||
The upstream codebase's history contains no code under a proprietary license,
|
||||
so this is an ordinary git fork. `upstream` is a fetch-only remote:
|
||||
|
||||
```
|
||||
npm run preview
|
||||
```bash
|
||||
git fetch upstream --tags
|
||||
git merge v1.0.12 # the next release tag
|
||||
```
|
||||
|
||||
## Support
|
||||
## Versions
|
||||
|
||||
If you are having problems running Stalwart Mail Server, you found a bug or just have a question,
|
||||
do not hesitate to reach us on [Github Discussions](https://github.com/stalwartlabs/mail-server/discussions),
|
||||
[Reddit](https://www.reddit.com/r/stalwartlabs), [Discord](https://discord.gg/aVQr3jF8jd) or [Matrix](https://matrix.to/#/#stalwart:matrix.org).
|
||||
Additionally you may purchase a subscription to obtain priority support from Stalwart Labs LLC
|
||||
INBUXA Admin has its own dated version (`inbuxa-version.json`), shown with the
|
||||
upstream release it's based on: `INBUXA Admin 2026.9.18 (base 1.0.11)`.
|
||||
`package.json` keeps upstream's version, so upstream's bumps merge cleanly.
|
||||
|
||||
## License
|
||||
## Source code
|
||||
|
||||
This project is dual-licensed under the **GNU Affero General Public License v3.0** (AGPL-3.0; as published by the Free Software Foundation) and the **Stalwart Enterprise License v1 (SELv1)**:
|
||||
Every build carries its own source. The interface links to it (the user menu
|
||||
and the sign-in page), and the build writes it next to the app as
|
||||
`source.tar.gz`: the exact tree the running version was built from.
|
||||
|
||||
- The [GNU Affero General Public License v3.0](./LICENSES/AGPL-3.0-only.txt) is a free software license that ensures your freedom to use, modify, and distribute the software, with the condition that any modified versions of the software must also be distributed under the same license.
|
||||
- The [Stalwart Enterprise License v1 (SELv1)](./LICENSES/LicenseRef-SEL.txt) is a proprietary license designed for commercial use. It offers additional features and greater flexibility for businesses that do not wish to comply with the AGPL-3.0 license requirements.
|
||||
## License and credits
|
||||
|
||||
Each file in this project contains a license notice at the top, indicating the applicable license(s). The license notice follows the [REUSE guidelines](https://reuse.software/) to ensure clarity and consistency. The full text of each license is available in the [LICENSES](./LICENSES/) directory.
|
||||
|
||||
## Copyright
|
||||
Free software under the [GNU Affero General Public License, version 3](./LICENSES/AGPL-3.0-only.txt).
|
||||
|
||||
Copyright (C) 2024, Stalwart Labs LLC
|
||||
INBUXA Admin is forked from the upstream AGPL-3.0 web administration codebase
|
||||
originally developed by Stalwart Labs. Their copyright notices are kept on
|
||||
every file inherited from it, and INBUXA's own notice is added to the files it
|
||||
changes. Those files are offered upstream under the AGPL-3.0-only or a
|
||||
proprietary license. INBUXA uses them under the AGPL-3.0 only. INBUXA isn't
|
||||
affiliated with or endorsed by Stalwart Labs.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Security policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
INBUXA Admin is developed on `main`, and security fixes are applied there and
|
||||
in the latest release. Older tags are not backported.
|
||||
|
||||
| Version | Supported |
|
||||
| --- | --- |
|
||||
| `main` and the latest release | :white_check_mark: |
|
||||
| Older releases | :x: |
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please don't open a public issue for a security problem.** An issue is
|
||||
visible to everyone, including whoever would use it, before there is a fix.
|
||||
|
||||
Report it privately by email to:
|
||||
|
||||
**johnellisATlinuxDOTcom**
|
||||
|
||||
Include as much as you can of: what it lets someone do, how to reproduce it,
|
||||
the version or commit affected, and whether it needs an authenticated session
|
||||
or a particular role.
|
||||
|
||||
This is an administrative interface, so a few things are worth calling out as
|
||||
in scope even though they are not bugs in the usual sense: anything that lets
|
||||
a session act beyond the permissions its account holds, anything that leaks
|
||||
another tenant's data, and anything that exposes a token or a secret to a
|
||||
place it should not reach — the URL, the page, or storage that outlives the
|
||||
session.
|
||||
|
||||
You'll get an acknowledgement within a few days. A report that turns out to
|
||||
affect the mail server rather than this interface will be moved to
|
||||
[inbuxa-server](https://github.com/inbuxa/inbuxa-server), and one that affects
|
||||
upstream Stalwart's web interface will be passed to Stalwart Labs with credit
|
||||
to you.
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/sh
|
||||
# Point this deployment at its mail server, at container start.
|
||||
#
|
||||
# INBUXA Admin reads `<meta name="api-base-url">` from index.html when it was
|
||||
# not given VITE_API_BASE_URL at build time, which is what lets one image serve
|
||||
# any installation. This writes that tag from API_BASE_URL.
|
||||
#
|
||||
# nginx runs the files in /docker-entrypoint.d before starting, so this happens
|
||||
# once per container and the served index.html is already correct.
|
||||
set -eu
|
||||
|
||||
[ -n "${API_BASE_URL:-}" ] || exit 0
|
||||
|
||||
html=/usr/share/nginx/html/index.html
|
||||
[ -f "$html" ] || exit 0
|
||||
|
||||
# Escaped for sed's replacement, where & and the delimiter are special. A URL
|
||||
# containing either is unlikely, but a silently mangled API address is the kind
|
||||
# of failure that looks like the server being down.
|
||||
esc=$(printf '%s' "$API_BASE_URL" | sed 's/[&|]/\\&/g')
|
||||
tag="<meta name=\"api-base-url\" content=\"$esc\">"
|
||||
|
||||
# Written back through the existing file rather than with `sed -i`, which
|
||||
# replaces it and so needs to create a temp file in the directory. That
|
||||
# directory belongs to root in this image and nginx does not run as root, so
|
||||
# in-place editing is the one thing that cannot work here. The file itself is
|
||||
# ours, and truncating it is enough.
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
|
||||
if grep -q '<meta name="api-base-url"' "$html"; then
|
||||
sed "s|<meta name=\"api-base-url\"[^>]*>|$tag|" "$html" > "$tmp"
|
||||
else
|
||||
sed "s|<head>|<head>$tag|" "$html" > "$tmp"
|
||||
fi
|
||||
cat "$tmp" > "$html"
|
||||
|
||||
echo "api-base-url set to $API_BASE_URL"
|
||||
@@ -0,0 +1,25 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# A single-page app: every path that is not a file on disk is the app's own
|
||||
# route, and the app is what decides what it means. Without this, a reload
|
||||
# anywhere but the root is a 404 from nginx.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Hashed filenames, so the content at a given name never changes. index.html
|
||||
# is deliberately not in here: it is the file that names the current hashes,
|
||||
# and a cached one pins a deployment to the build it replaced.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 446 KiB |
@@ -1,25 +0,0 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" style="enable-background:new 0 0 680.5 252.1;" xml:space="preserve" viewBox="99.5 84.68 481.62 82.75">
|
||||
<style type="text/css">
|
||||
.st0{fill:#100E42;}
|
||||
.st1{fill:#DB2D54;}
|
||||
.st2{fill:#FFFFFF;}
|
||||
</style>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st1" d="M227.8,143.6c0.3,4.2,2.1,7.6,5.1,10.1c3.1,2.5,7.1,3.8,12.1,3.8c4.3,0,7.9-0.9,10.5-2.8c2.7-1.9,4-4.5,4-7.8 c0-2.4-0.7-4.3-2.2-5.7c-1.5-1.4-3.4-2.5-6-3.2c-2.5-0.7-6-1.5-10.6-2.3c-4.6-0.8-8.6-1.9-11.9-3.2c-3.3-1.3-6-3.3-8.1-6.1 c-2.1-2.7-3.1-6.3-3.1-10.7c0-4.1,1.1-7.7,3.2-10.9c2.1-3.2,5.1-5.7,9-7.4c3.8-1.8,8.2-2.6,13.2-2.6c5.1,0,9.6,1,13.7,2.9 c4,1.9,7.2,4.5,9.5,7.8c2.3,3.3,3.6,7.1,3.8,11.4h-11.5c-0.4-3.7-2-6.6-4.8-8.9c-2.8-2.2-6.3-3.4-10.6-3.4c-4.1,0-7.5,0.9-9.9,2.7 c-2.5,1.8-3.7,4.3-3.7,7.6c0,2.3,0.7,4.1,2.2,5.5c1.5,1.4,3.4,2.4,5.9,3.1c2.4,0.7,5.9,1.4,10.5,2.2c4.6,0.8,8.6,1.9,11.9,3.3 c3.3,1.4,6,3.4,8.2,6c2.1,2.6,3.2,6.1,3.2,10.5c0,4.2-1.1,8-3.4,11.3c-2.2,3.3-5.4,5.9-9.4,7.8c-4,1.9-8.6,2.8-13.7,2.8 c-5.6,0-10.6-1-14.9-3.1c-4.3-2-7.6-4.9-10-8.5c-2.4-3.6-3.7-7.8-3.7-12.5L227.8,143.6z"/>
|
||||
<path class="st1" d="M278.5,102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0,2,0.4,3.5,1.2,4.3c0.8,0.9,2.2,1.3,4.2,1.3h8.4v9.7h-10.6 c-5,0-8.6-1.2-10.8-3.5c-2.2-2.3-3.4-5.9-3.4-10.7V102.1z"/>
|
||||
<path class="st1" d="M356.8,114.6v52.2h-9.7l-1.2-7.9c-1.8,2.6-4.2,4.7-7,6.2c-2.9,1.6-6.2,2.3-10,2.3c-4.8,0-9-1.1-12.7-3.2 c-3.7-2.1-6.7-5.2-8.8-9.3c-2.1-4-3.2-8.8-3.2-14.2c0-5.3,1.1-10,3.2-14c2.1-4,5.1-7.2,8.8-9.4c3.7-2.2,7.9-3.3,12.6-3.3 c3.9,0,7.2,0.7,10.1,2.2c2.9,1.5,5.2,3.5,6.9,6.1l1.3-7.6H356.8z M341.7,153.3c2.8-3.2,4.2-7.3,4.2-12.4c0-5.2-1.4-9.4-4.2-12.6 c-2.8-3.3-6.5-4.9-11-4.9c-4.6,0-8.2,1.6-11,4.8c-2.8,3.2-4.2,7.4-4.2,12.5c0,5.2,1.4,9.4,4.2,12.6c2.8,3.2,6.5,4.8,11,4.8 C335.2,158.1,338.9,156.5,341.7,153.3z"/>
|
||||
<path class="st1" d="M365.5,97.5l11-2.1v71.3h-11V97.5z"/>
|
||||
<path class="st1" d="M380.3,114.6h11.6l11.9,39.9l11.9-39.9h10.1l11.4,39.9l12.3-39.9h11.2l-17.3,52.2h-11.8l-11-35.5l-11.4,35.5 l-11.9,0.1L380.3,114.6z"/>
|
||||
<path class="st1" d="M513.7,114.6v52.2H504l-1.2-7.9c-1.8,2.6-4.2,4.7-7,6.2c-2.9,1.6-6.2,2.3-10,2.3c-4.8,0-9-1.1-12.7-3.2 c-3.7-2.1-6.7-5.2-8.8-9.3c-2.1-4-3.2-8.8-3.2-14.2c0-5.3,1.1-10,3.2-14c2.1-4,5.1-7.2,8.8-9.4c3.7-2.2,7.9-3.3,12.6-3.3 c3.9,0,7.2,0.7,10.1,2.2c2.9,1.5,5.2,3.5,6.9,6.1l1.3-7.6H513.7z M498.6,153.3c2.8-3.2,4.2-7.3,4.2-12.4c0-5.2-1.4-9.4-4.2-12.6 c-2.8-3.3-6.5-4.9-11-4.9c-4.6,0-8.2,1.6-11,4.8c-2.8,3.2-4.2,7.4-4.2,12.5c0,5.2,1.4,9.4,4.2,12.6c2.8,3.2,6.5,4.8,11,4.8 C492.2,158.1,495.8,156.5,498.6,153.3z"/>
|
||||
<path class="st1" d="M551.3,114.6v10.3h-4.9c-4.6,0-7.8,1.5-9.9,4.4c-2,3-3.1,6.7-3.1,11.3v26.2h-11v-52.2h9.8l1.2,7.8 c1.5-2.4,3.4-4.4,5.8-5.8c2.4-1.4,5.6-2.1,9.6-2.1H551.3z"/>
|
||||
<path class="st1" d="M556.3,102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0,2,0.4,3.5,1.2,4.3c0.8,0.9,2.2,1.3,4.2,1.3h8.4v9.7h-10.6 c-5,0-8.6-1.2-10.8-3.5s-3.4-5.9-3.4-10.7V102.1z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st1" d="M149.1,84.7h-4.8l-44.8,25.9v8.3l44.8,25.9h4.8l44.8-25.9v-8.3L149.1,84.7z M182,114.7h-35.3V94.4L182,114.7z M146.7,135.1l-35.3-20.4l27-15.6v15.6v4.1v0.5l6.3,3.6h22.9L146.7,135.1z"/>
|
||||
<polygon class="st1" points="99.5,129.9 99.5,140.9 144.3,166.8 149.1,166.8 193.9,140.9 193.9,129.9 146.7,157.2 "/>
|
||||
<polygon class="st1" points="187.3,166.8 193.9,163 193.9,152 168.2,166.8 "/>
|
||||
<polygon class="st1" points="99.5,163 106.1,166.8 125.2,166.8 99.5,152 "/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"version": "2026.9.21.2"
|
||||
}
|
||||
@@ -5,9 +5,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<base href="/" />
|
||||
<meta name="oauth-client-id" content="" />
|
||||
<meta name="api-base-url" content="" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Portal</title>
|
||||
<title>INBUXA Admin</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
{
|
||||
"name": "stalwart-webui",
|
||||
"version": "1.0.7",
|
||||
"name": "inbuxa-admin",
|
||||
"version": "1.0.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stalwart-webui",
|
||||
"version": "1.0.7",
|
||||
"name": "inbuxa-admin",
|
||||
"version": "1.0.11",
|
||||
"dependencies": {
|
||||
"@daypicker/react": "^10.0.1",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/space-grotesk": "^5.3.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.20",
|
||||
@@ -541,6 +543,24 @@
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource-variable/inter": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/space-grotesk": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.3.0.tgz",
|
||||
"integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "stalwart-webui",
|
||||
"name": "inbuxa-admin",
|
||||
"private": true,
|
||||
"version": "1.0.11",
|
||||
"description": "Stalwart WebUI",
|
||||
"description": "INBUXA Admin, the administration interface for the INBUXA mail server",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -17,6 +17,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@daypicker/react": "^10.0.1",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/space-grotesk": "^5.3.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.20",
|
||||
@@ -68,4 +70,4 @@
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -1,7 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
@@ -333,7 +336,7 @@ export function BootstrapWizard() {
|
||||
<WizardShell>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.welcome', 'Welcome to Stalwart')}</h2>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.welcome', 'Welcome to INBUXA')}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('bootstrap.welcomeSubtitle', "Let's get your server set up.")}
|
||||
</p>
|
||||
@@ -468,7 +471,7 @@ function SuccessScreen({
|
||||
'bootstrap.credentialsCreated',
|
||||
'Your administrator account has been created. Write these down now: the password will not be shown again.',
|
||||
)
|
||||
: t('bootstrap.configuredSuccessfully', 'Stalwart has been configured successfully.')}
|
||||
: t('bootstrap.configuredSuccessfully', 'INBUXA has been configured successfully.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -500,7 +503,7 @@ function SuccessScreen({
|
||||
<span className="font-medium">{t('bootstrap.nextStepLabel', 'Next step:')}</span>{' '}
|
||||
{t(
|
||||
'bootstrap.nextStepBody',
|
||||
'restart Stalwart for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.',
|
||||
'restart INBUXA for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import inbuxaMark from '@/assets/inbuxa-mark.png';
|
||||
|
||||
/** Nothing to show yet: the cat, a line saying so, and what to do about it. */
|
||||
export function EmptyState({ title, hint, action }: { title: ReactNode; hint?: ReactNode; action?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 px-6 py-12 text-center">
|
||||
<img src={inbuxaMark} alt="" className="mb-1 h-14 w-auto opacity-90 grayscale-[15%]" />
|
||||
<p className="font-display text-base font-semibold text-foreground">{title}</p>
|
||||
{hint && <p className="max-w-sm text-sm text-muted-foreground">{hint}</p>}
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +1,24 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface EnterpriseUpsellProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EnterpriseUpsell({ open, onClose }: EnterpriseUpsellProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="gap-6">
|
||||
<DialogHeader className="space-y-4">
|
||||
<DialogTitle>{t('enterprise.trialTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('enterprise.trialDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:items-center">
|
||||
<a
|
||||
href="https://stalw.art/compare#why-isnt-feature-x-open-source"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-center text-xs text-muted-foreground underline-offset-4 hover:underline sm:mr-auto sm:text-left"
|
||||
>
|
||||
{t('enterprise.whyNotFree')}
|
||||
</a>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<a href="https://license.stalw.art/trial" target="_blank" rel="noopener noreferrer">
|
||||
{t('enterprise.trialButton')}
|
||||
</a>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
/**
|
||||
* INBUXA: nothing to sell. There is one edition, every feature is in it, and
|
||||
* the edition is never anything but complete (see accountStore), so this
|
||||
* never opens. It stays as an empty component so the places upstream calls
|
||||
* it from merge without conflicts.
|
||||
*/
|
||||
export function EnterpriseUpsell({ open }: EnterpriseUpsellProps) {
|
||||
void open;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createElement } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toneFor, type Tone } from '@/lib/iconTones';
|
||||
|
||||
const TONE_CLASSES: Record<Tone, string> = {
|
||||
teal: 'bg-teal-500/15 text-teal-700 dark:bg-teal-400/15 dark:text-teal-300',
|
||||
orange: 'bg-orange-400/20 text-orange-700 dark:bg-orange-400/15 dark:text-orange-300',
|
||||
sky: 'bg-sky-500/15 text-sky-700 dark:bg-sky-400/15 dark:text-sky-300',
|
||||
violet: 'bg-violet-500/15 text-violet-700 dark:bg-violet-400/15 dark:text-violet-300',
|
||||
rose: 'bg-rose-500/15 text-rose-700 dark:bg-rose-400/15 dark:text-rose-300',
|
||||
amber: 'bg-amber-400/20 text-amber-700 dark:bg-amber-400/15 dark:text-amber-300',
|
||||
emerald: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-400/15 dark:text-emerald-300',
|
||||
indigo: 'bg-indigo-500/15 text-indigo-700 dark:bg-indigo-400/15 dark:text-indigo-300',
|
||||
slate: 'bg-slate-500/15 text-slate-700 dark:bg-slate-400/15 dark:text-slate-300',
|
||||
};
|
||||
|
||||
function iconComponent(name: string): LucideIcons.LucideIcon {
|
||||
const pascal = name
|
||||
.split('-')
|
||||
.map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s))
|
||||
.join('');
|
||||
return ((LucideIcons as Record<string, unknown>)[pascal] as LucideIcons.LucideIcon | undefined) ?? LucideIcons.Circle;
|
||||
}
|
||||
|
||||
/**
|
||||
* A section's icon on a small colored tile. The color comes from what the
|
||||
* section is about (see iconTones), so the same kind of thing looks the same
|
||||
* everywhere, and a glance at the color finds it.
|
||||
*/
|
||||
export function IconTile({
|
||||
name,
|
||||
size = 'md',
|
||||
className,
|
||||
}: {
|
||||
name: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}) {
|
||||
const box = size === 'sm' ? 'h-6 w-6 rounded-md' : size === 'lg' ? 'h-10 w-10 rounded-xl' : 'h-7 w-7 rounded-lg';
|
||||
const glyph = size === 'sm' ? 'h-3.5 w-3.5' : size === 'lg' ? 'h-5 w-5' : 'h-4 w-4';
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex shrink-0 items-center justify-center', box, TONE_CLASSES[toneFor(name)], className)}
|
||||
>
|
||||
{createElement(iconComponent(name), { className: glyph, strokeWidth: 2, 'aria-hidden': true })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import inbuxaMark from '@/assets/inbuxa-mark.png';
|
||||
|
||||
export function LoadingFallback({ fullScreen = false }: { fullScreen?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center gap-3', fullScreen ? 'min-h-screen' : 'p-8')}>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
<img src={inbuxaMark} alt="" className="h-12 w-auto animate-bounce [animation-duration:1.4s]" />
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,58 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLogoState, loadLogoOnce, subscribeToLogo } from '@/lib/logoCache';
|
||||
import inbuxaMark from '@/assets/inbuxa-mark.png';
|
||||
|
||||
export function DefaultLogo() {
|
||||
const { t } = useTranslation();
|
||||
// The INBUXA compact lockup: the mark as an image, the wordmark as vector
|
||||
// paths in the current text color, so it reads on light and dark themes.
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="95 84 500 90"
|
||||
aria-label={t('logo.stalwartAlt', 'Stalwart Logo')}
|
||||
viewBox="165 35 616 130"
|
||||
aria-label={t('logo.inbuxaAlt', 'INBUXA')}
|
||||
className="h-7 w-auto max-w-[320px]"
|
||||
>
|
||||
<image x="165.85" y="35.00" width="109.39" height="130.00" href={inbuxaMark} />
|
||||
<path
|
||||
className="fill-current"
|
||||
d="M227.8 143.6c.3 4.2 2.1 7.6 5.1 10.1 3.1 2.5 7.1 3.8 12.1 3.8 4.3 0 7.9-.9 10.5-2.8 2.7-1.9 4-4.5 4-7.8 0-2.4-.7-4.3-2.2-5.7-1.5-1.4-3.4-2.5-6-3.2-2.5-.7-6-1.5-10.6-2.3-4.6-.8-8.6-1.9-11.9-3.2-3.3-1.3-6-3.3-8.1-6.1-2.1-2.7-3.1-6.3-3.1-10.7 0-4.1 1.1-7.7 3.2-10.9s5.1-5.7 9-7.4c3.8-1.8 8.2-2.6 13.2-2.6 5.1 0 9.6 1 13.7 2.9 4 1.9 7.2 4.5 9.5 7.8s3.6 7.1 3.8 11.4h-11.5c-.4-3.7-2-6.6-4.8-8.9-2.8-2.2-6.3-3.4-10.6-3.4-4.1 0-7.5.9-9.9 2.7-2.5 1.8-3.7 4.3-3.7 7.6 0 2.3.7 4.1 2.2 5.5 1.5 1.4 3.4 2.4 5.9 3.1 2.4.7 5.9 1.4 10.5 2.2 4.6.8 8.6 1.9 11.9 3.3 3.3 1.4 6 3.4 8.2 6 2.1 2.6 3.2 6.1 3.2 10.5 0 4.2-1.1 8-3.4 11.3-2.2 3.3-5.4 5.9-9.4 7.8-4 1.9-8.6 2.8-13.7 2.8-5.6 0-10.6-1-14.9-3.1-4.3-2-7.6-4.9-10-8.5-2.4-3.6-3.7-7.8-3.7-12.5l11.5.3zM278.5 102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0 2 .4 3.5 1.2 4.3.8.9 2.2 1.3 4.2 1.3h8.4v9.7h-10.6c-5 0-8.6-1.2-10.8-3.5-2.2-2.3-3.4-5.9-3.4-10.7v-50.5zM356.8 114.6v52.2h-9.7l-1.2-7.9c-1.8 2.6-4.2 4.7-7 6.2-2.9 1.6-6.2 2.3-10 2.3-4.8 0-9-1.1-12.7-3.2-3.7-2.1-6.7-5.2-8.8-9.3-2.1-4-3.2-8.8-3.2-14.2 0-5.3 1.1-10 3.2-14s5.1-7.2 8.8-9.4c3.7-2.2 7.9-3.3 12.6-3.3 3.9 0 7.2.7 10.1 2.2 2.9 1.5 5.2 3.5 6.9 6.1l1.3-7.6h9.7zm-15.1 38.7c2.8-3.2 4.2-7.3 4.2-12.4 0-5.2-1.4-9.4-4.2-12.6-2.8-3.3-6.5-4.9-11-4.9-4.6 0-8.2 1.6-11 4.8-2.8 3.2-4.2 7.4-4.2 12.5 0 5.2 1.4 9.4 4.2 12.6 2.8 3.2 6.5 4.8 11 4.8s8.2-1.6 11-4.8zM365.5 97.5l11-2.1v71.3h-11V97.5zM380.3 114.6h11.6l11.9 39.9 11.9-39.9h10.1l11.4 39.9 12.3-39.9h11.2l-17.3 52.2h-11.8l-11-35.5-11.4 35.5-11.9.1-17-52.3zM513.7 114.6v52.2H504l-1.2-7.9c-1.8 2.6-4.2 4.7-7 6.2-2.9 1.6-6.2 2.3-10 2.3-4.8 0-9-1.1-12.7-3.2-3.7-2.1-6.7-5.2-8.8-9.3-2.1-4-3.2-8.8-3.2-14.2 0-5.3 1.1-10 3.2-14s5.1-7.2 8.8-9.4c3.7-2.2 7.9-3.3 12.6-3.3 3.9 0 7.2.7 10.1 2.2 2.9 1.5 5.2 3.5 6.9 6.1l1.3-7.6h9.7zm-15.1 38.7c2.8-3.2 4.2-7.3 4.2-12.4 0-5.2-1.4-9.4-4.2-12.6-2.8-3.3-6.5-4.9-11-4.9-4.6 0-8.2 1.6-11 4.8-2.8 3.2-4.2 7.4-4.2 12.5 0 5.2 1.4 9.4 4.2 12.6 2.8 3.2 6.5 4.8 11 4.8 4.6 0 8.2-1.6 11-4.8zM551.3 114.6v10.3h-4.9c-4.6 0-7.8 1.5-9.9 4.4-2 3-3.1 6.7-3.1 11.3v26.2h-11v-52.2h9.8l1.2 7.8c1.5-2.4 3.4-4.4 5.8-5.8 2.4-1.4 5.6-2.1 9.6-2.1h2.5zM556.3 102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0 2 .4 3.5 1.2 4.3.8.9 2.2 1.3 4.2 1.3h8.4v9.7h-10.6c-5 0-8.6-1.2-10.8-3.5s-3.4-5.9-3.4-10.7v-50.5z"
|
||||
d="M70 0V496H196V0ZM133 554Q99 554 75.5 576.0Q52 598 52 634Q52 670 75.5 692.0Q99 714 133 714Q168 714 191.0 692.0Q214 670 214 634Q214 598 191.0 576.0Q168 554 133 554Z"
|
||||
transform="translate(303.25,151.90) scale(0.150000,-0.150000)"
|
||||
/>
|
||||
<path
|
||||
fill="#db2d54"
|
||||
d="M149.1 84.7h-4.8l-44.8 25.9v8.3l44.8 25.9h4.8l44.8-25.9v-8.3l-44.8-25.9zm32.9 30h-35.3V94.4l35.3 20.3zm-35.3 20.4-35.3-20.4 27-15.6v20.2l6.3 3.6h22.9l-20.9 12.2zM99.5 129.9v11l44.8 25.9h4.8l44.8-25.9v-11l-47.2 27.3zM187.3 166.8l6.6-3.8v-11l-25.7 14.8zM99.5 163l6.6 3.8h19.1L99.5 152z"
|
||||
className="fill-current"
|
||||
d="M70 0V496H194V431H212Q224 457 257.0 480.5Q290 504 357 504Q415 504 458.5 477.5Q502 451 526.0 404.5Q550 358 550 296V0H424V286Q424 342 396.5 370.0Q369 398 318 398Q260 398 228.0 359.5Q196 321 196 252V0Z"
|
||||
transform="translate(340.15,151.90) scale(0.150000,-0.150000)"
|
||||
/>
|
||||
<path
|
||||
className="fill-current"
|
||||
d="M368 -14Q301 -14 265.0 9.0Q229 32 212 60H194V0H70V700H196V439H214Q225 457 243.5 473.0Q262 489 292.5 499.5Q323 510 368 510Q428 510 479.0 480.5Q530 451 561.0 394.0Q592 337 592 256V240Q592 159 561.0 102.0Q530 45 479.0 15.5Q428 -14 368 -14ZM330 96Q388 96 427.0 133.5Q466 171 466 243V253Q466 325 427.5 362.5Q389 400 330 400Q272 400 233.0 362.5Q194 325 194 253V243Q194 171 233.0 133.5Q272 96 330 96Z"
|
||||
transform="translate(429.55,151.90) scale(0.150000,-0.150000)"
|
||||
/>
|
||||
<path
|
||||
className="fill-current"
|
||||
d="M259 -8Q201 -8 157.5 18.5Q114 45 90.0 92.0Q66 139 66 200V496H192V210Q192 154 219.5 126.0Q247 98 298 98Q356 98 388.0 136.5Q420 175 420 244V496H546V0H422V65H404Q392 40 359.0 16.0Q326 -8 259 -8Z"
|
||||
transform="translate(522.25,151.90) scale(0.150000,-0.150000)"
|
||||
/>
|
||||
<path
|
||||
className="fill-current"
|
||||
d="M26 0 206 250 28 496H174L287 331H305L418 496H564L386 250L566 0H418L305 167H287L174 0Z"
|
||||
transform="translate(611.65,151.90) scale(0.150000,-0.150000)"
|
||||
/>
|
||||
<path
|
||||
className="fill-current"
|
||||
d="M224 -14Q171 -14 129.0 4.5Q87 23 62.5 58.5Q38 94 38 145Q38 196 62.5 230.5Q87 265 130.5 282.5Q174 300 230 300H366V328Q366 363 344.0 385.5Q322 408 274 408Q227 408 204.0 386.5Q181 365 174 331L58 370Q70 408 96.5 439.5Q123 471 167.5 490.5Q212 510 276 510Q374 510 431.0 461.0Q488 412 488 319V134Q488 104 516 104H556V0H472Q435 0 411.0 18.0Q387 36 387 66V67H368Q364 55 350.0 35.5Q336 16 306.0 1.0Q276 -14 224 -14ZM246 88Q299 88 332.5 117.5Q366 147 366 196V206H239Q204 206 184.0 191.0Q164 176 164 149Q164 122 185.0 105.0Q206 88 246 88Z"
|
||||
transform="translate(697.45,151.90) scale(0.150000,-0.150000)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { IconTile } from '@/components/common/IconTile';
|
||||
|
||||
/**
|
||||
* The top of every page: the section's tile, a title that says where you are,
|
||||
* a line on what it's for, and the page's own actions on the right.
|
||||
*/
|
||||
export function PageHeader({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
leading,
|
||||
actions,
|
||||
}: {
|
||||
icon?: string | null;
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
leading?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 pb-1">
|
||||
<div className="flex min-w-0 items-center gap-3.5">
|
||||
{leading}
|
||||
{icon && <IconTile name={icon} size="lg" />}
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-2xl font-semibold leading-tight">{title}</h1>
|
||||
{subtitle && <p className="mt-0.5 text-sm text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SOURCE_URL } from '@/lib/sourceDownload';
|
||||
|
||||
/**
|
||||
* The AGPL's offer to everyone using this interface over the network: where
|
||||
* the source is, with the running version named beside it.
|
||||
*/
|
||||
export function SourceLink({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<a href={SOURCE_URL} target="_blank" rel="noopener noreferrer" className={className}>
|
||||
{t('source.download', 'Source code ({{version}}), AGPL-3.0', { version: __APP_VERSION__ })}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { humanize } from '@/lib/humanize';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { HelpPanel } from '@/help/HelpPanel';
|
||||
import { iconForView } from '@/lib/viewIcon';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useNavigate, useBlocker } from 'react-router-dom';
|
||||
@@ -53,6 +60,7 @@ import { SECRET_MASK } from '@/lib/jmapUtils';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { logFormChange } from '@/lib/debug';
|
||||
import { FieldWidget } from '@/components/forms/FieldWidget';
|
||||
import { DnsConnectCard } from '@/features/dns/DnsConnectCard';
|
||||
import { isSieveScriptField } from '@/lib/sievepad';
|
||||
|
||||
import type { Field, Fields, Form, FormField, Schema } from '@/types/schema';
|
||||
@@ -119,6 +127,15 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
return { ...fields, properties: filtered };
|
||||
}, [resolved, selectedVariant, schema]);
|
||||
|
||||
// INBUXA: whose fields these are, for their help ids: the variant's schema
|
||||
// (x:UserAccount) when the object has variants, else the object (x:Domain).
|
||||
const helpScope = useMemo(() => {
|
||||
if (!resolved) return undefined;
|
||||
const { sch, obj } = resolved;
|
||||
if (sch.type === 'single') return obj.objectName;
|
||||
return sch.variants.find((v) => v.name === selectedVariant)?.schemaName ?? obj.objectName;
|
||||
}, [resolved, selectedVariant]);
|
||||
|
||||
const currentForm = useMemo((): Form | null => {
|
||||
if (!schema || !resolved) return null;
|
||||
const { obj, sch } = resolved;
|
||||
@@ -686,7 +703,8 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
if (!resolved || !schema) return '';
|
||||
const { obj } = resolved;
|
||||
|
||||
if (isSingleton) return titleForm?.title ?? obj.objectType.description;
|
||||
// With no form of its own, the object's description is a sentence, not a title: spell out its name instead.
|
||||
if (isSingleton) return titleForm?.title ?? humanize(viewName);
|
||||
|
||||
const list = resolveList(schema, viewName, obj.objectName);
|
||||
const name = list?.singularName ?? obj.objectType.description;
|
||||
@@ -704,8 +722,9 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
}, [resolved, schema, isCreate, isSingleton, formData, viewName, titleForm, t]);
|
||||
|
||||
const formSubtitle = useMemo(() => {
|
||||
return titleForm?.subtitle;
|
||||
}, [titleForm]);
|
||||
if (titleForm?.subtitle) return titleForm.subtitle;
|
||||
return isSingleton && !titleForm ? resolved?.obj.objectType.description : undefined;
|
||||
}, [titleForm, isSingleton, resolved]);
|
||||
|
||||
if (!schema || !resolved) {
|
||||
return (
|
||||
@@ -745,15 +764,17 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{formTitle}</h1>
|
||||
{formSubtitle && <p className="text-sm text-muted-foreground mt-1">{formSubtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
leading={
|
||||
<Button type="button" variant="ghost" size="icon" className="rounded-xl" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
}
|
||||
icon={iconForView(schema, viewName)}
|
||||
title={formTitle}
|
||||
subtitle={formSubtitle}
|
||||
actions={<HelpPanel viewName={viewName} title={String(formTitle ?? '')} />}
|
||||
/>
|
||||
|
||||
{generalError && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
|
||||
@@ -770,6 +791,17 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
)}
|
||||
<CardContent className={section.title ? '' : 'pt-6'}>
|
||||
<div className="space-y-6">
|
||||
{resolved.obj.objectName === 'x:Domain' &&
|
||||
objectId &&
|
||||
!readOnly &&
|
||||
section.fields.some((sf) => sf.formField.name === 'dnsManagement') && (
|
||||
<DnsConnectCard
|
||||
domainId={objectId}
|
||||
automatic={
|
||||
(originalData.dnsManagement as { '@type'?: string } | undefined)?.['@type'] === 'Automatic'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{section.fields.map((sf) => {
|
||||
const { formField, field, visible, enterpriseDisabled } = sf;
|
||||
if (!visible) return null;
|
||||
@@ -815,6 +847,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
sieveScriptName={
|
||||
isSieveScriptField(resolved.obj.objectName, formField.name) ? scriptName : undefined
|
||||
}
|
||||
helpScope={helpScope}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -826,7 +859,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
<div className="opacity-60">{widget}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('enterprise.featureDisabled', 'This feature requires an Enterprise license.')}</p>
|
||||
<p>{t('enterprise.featureDisabled', "This feature isn't available on this server.")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -1004,7 +1037,7 @@ function buildSections(
|
||||
|
||||
if (!form) {
|
||||
const allFields = Object.entries(fields!.properties)
|
||||
.map(([name, field]) => buildRenderableField({ name, label: name }, field, isCreate, edition))
|
||||
.map(([name, field]) => buildRenderableField({ name, label: humanize(name) }, field, isCreate, edition))
|
||||
.filter((f): f is RenderableField => f !== null);
|
||||
return [{ fields: allFields }];
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { humanize } from '@/lib/humanize';
|
||||
import { useState, useEffect, useMemo, type KeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { HelpTip } from '@/help/HelpTip';
|
||||
import { fieldHelp } from '@/help/texts';
|
||||
import { describeDefault, differsFromDefault } from '@/help/defaults';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -61,6 +67,8 @@ export interface FieldWidgetProps {
|
||||
error?: string;
|
||||
schema: Schema;
|
||||
sieveScriptName?: string;
|
||||
/** INBUXA: the object or schema that owns this field, for its help id (`scope.field`). */
|
||||
helpScope?: string;
|
||||
}
|
||||
|
||||
function getRequiredMarker(field: Field, readOnly: boolean): 'required' | 'optional' | null {
|
||||
@@ -81,7 +89,17 @@ function getRequiredMarker(field: Field, readOnly: boolean): 'required' | 'optio
|
||||
|
||||
export function FieldWidget(props: FieldWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
const { field, formField, value, onChange, readOnly, error, schema, sieveScriptName } = props;
|
||||
const { field, formField, value, onChange, readOnly, error, schema, sieveScriptName, helpScope } = props;
|
||||
const helpId = helpScope ? `${helpScope}.${formField.name}` : undefined;
|
||||
// INBUXA: the option's default, for its tooltip, and whether it has been changed.
|
||||
const defaultValue = helpScope ? schema.fields[helpScope]?.defaults?.[formField.name] : undefined;
|
||||
const defaultWords = describeDefault(field, defaultValue, schema, {
|
||||
on: t('field.on', 'On'),
|
||||
off: t('field.off', 'Off'),
|
||||
none: t('field.none', 'None'),
|
||||
});
|
||||
const defaultNote = defaultWords ? t('field.default', 'Default: {{value}}', { value: defaultWords }) : null;
|
||||
const changed = defaultWords !== null && differsFromDefault(value, defaultValue);
|
||||
const ft = field.type;
|
||||
const edition = useEffectiveEdition();
|
||||
|
||||
@@ -231,12 +249,16 @@ export function FieldWidget(props: FieldWidgetProps) {
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<HelpTip id={helpId} text={fieldHelp(helpId, field.description)} footnote={defaultNote} />
|
||||
{changed && (
|
||||
<span
|
||||
className="rounded-full bg-primary/10 px-1.5 py-px text-[10px] font-medium text-primary"
|
||||
title={defaultNote ?? undefined}
|
||||
>
|
||||
{t('field.changed', 'changed')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{field.description && (
|
||||
<div className="text-xs text-muted-foreground prose prose-sm max-w-none [&_p]:m-0">
|
||||
<ReactMarkdown>{field.description.replace(/\\n/g, '\n')}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
{widget}
|
||||
{sieveScriptName !== undefined && ft.type === 'string' && (
|
||||
<SievepadButton scriptName={sieveScriptName} source={typeof value === 'string' ? value : ''} />
|
||||
@@ -1407,6 +1429,7 @@ function EmbeddedObjectField({
|
||||
|
||||
if (resolvedSchema.type === 'single') {
|
||||
const fields = resolvedSchema.fields;
|
||||
const helpScopeHere = resolvedSchema.schemaName ?? objectName;
|
||||
const form = resolveVariantForm(schema, objectName, objectName, resolvedSchema.schemaName);
|
||||
const formFields = form?.sections.flatMap((s) => s.fields) ?? [];
|
||||
|
||||
@@ -1425,6 +1448,7 @@ function EmbeddedObjectField({
|
||||
onChange={(v) => handleFieldChange(ff.name, v)}
|
||||
readOnly={readOnly}
|
||||
schema={schema}
|
||||
helpScope={helpScopeHere}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -1435,11 +1459,12 @@ function EmbeddedObjectField({
|
||||
<FieldWidget
|
||||
key={name}
|
||||
field={fieldDef}
|
||||
formField={{ name, label: name }}
|
||||
formField={{ name, label: humanize(name) }}
|
||||
value={objValue[name]}
|
||||
onChange={(v) => handleFieldChange(name, v)}
|
||||
readOnly={readOnly}
|
||||
schema={schema}
|
||||
helpScope={helpScopeHere}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1449,6 +1474,7 @@ function EmbeddedObjectField({
|
||||
const currentType = (objValue['@type'] as string) ?? resolvedSchema.variants[0]?.name ?? '';
|
||||
const currentVariant = resolvedSchema.variants.find((v) => v.name === currentType);
|
||||
const variantFields = currentVariant?.fields;
|
||||
const helpScopeHere = currentVariant?.schemaName ?? objectName;
|
||||
const variantForm = resolveVariantForm(schema, objectName, objectName, currentVariant?.schemaName);
|
||||
const variantFormFields = variantForm?.sections.flatMap((s) => s.fields) ?? [];
|
||||
|
||||
@@ -1489,6 +1515,7 @@ function EmbeddedObjectField({
|
||||
onChange={(v) => handleFieldChange(ff.name, v)}
|
||||
readOnly={readOnly}
|
||||
schema={schema}
|
||||
helpScope={helpScopeHere}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -1500,11 +1527,12 @@ function EmbeddedObjectField({
|
||||
<FieldWidget
|
||||
key={name}
|
||||
field={fieldDef}
|
||||
formField={{ name, label: name }}
|
||||
formField={{ name, label: humanize(name) }}
|
||||
value={objValue[name]}
|
||||
onChange={(v) => handleFieldChange(name, v)}
|
||||
readOnly={readOnly}
|
||||
schema={schema}
|
||||
helpScope={helpScopeHere}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
@@ -27,17 +30,15 @@ interface OtpAuthFieldProps {
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
const STALWART_IMAGE_URL = 'https://stalw.art/img/favicon-32x32.png';
|
||||
|
||||
function buildOtpAuthUrl(totp: OTPAuth.TOTP): string {
|
||||
const base = totp.toString();
|
||||
const sep = base.includes('?') ? '&' : '?';
|
||||
return `${base}${sep}image=${encodeURIComponent(STALWART_IMAGE_URL)}`;
|
||||
// No `image` parameter: it made authenticator apps fetch a logo from a
|
||||
// third-party site each time someone set up two-factor.
|
||||
return totp.toString();
|
||||
}
|
||||
|
||||
function generateTotp(): { totp: OTPAuth.TOTP; url: string } {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: 'Stalwart',
|
||||
issuer: 'INBUXA',
|
||||
label: 'account',
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { lazy, Suspense, useEffect, type ComponentType, type ReactNode } from 'react';
|
||||
@@ -13,6 +16,7 @@ import { DynamicList } from '@/components/lists/DynamicList';
|
||||
import { DynamicForm } from '@/components/forms/DynamicForm';
|
||||
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner';
|
||||
import type { Schema } from '@/types/schema';
|
||||
|
||||
function lazyFeature<M, P>(load: () => Promise<M>, select: (module: M) => ComponentType<P>) {
|
||||
@@ -35,10 +39,22 @@ const TraceDetailView = lazyFeature(
|
||||
() => import('@/features/tracing/components/TraceDetailView'),
|
||||
(m) => m.TraceDetailView,
|
||||
);
|
||||
const ConnectDnsPage = lazyFeature(
|
||||
() => import('@/features/dns/ConnectDnsPage'),
|
||||
(m) => m.ConnectDnsPage,
|
||||
);
|
||||
const ActionPage = lazyFeature(
|
||||
() => import('@/features/actions/ActionPage'),
|
||||
(m) => m.ActionPage,
|
||||
);
|
||||
const LegacyProtocolsPage = lazyFeature(
|
||||
() => import('@/features/hardening/LegacyProtocolsPage'),
|
||||
(m) => m.LegacyProtocolsPage,
|
||||
);
|
||||
const TenantLegacyProtocols = lazyFeature(
|
||||
() => import('@/features/hardening/TenantLegacyProtocols'),
|
||||
(m) => m.TenantLegacyProtocols,
|
||||
);
|
||||
|
||||
interface MainContentProps {
|
||||
viewName?: string;
|
||||
@@ -67,6 +83,19 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
return <DashboardView dashboardId={dashboardId} section={section ?? ''} />;
|
||||
}
|
||||
|
||||
// INBUXA: guided jobs. Always reached by choosing "Guide me", never by default.
|
||||
if (viewName.startsWith('Wizard/')) {
|
||||
const [, wizard, param] = viewName.split('/');
|
||||
if (wizard === 'dns' && param) {
|
||||
return <ConnectDnsPage domainId={param} />;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
Unknown guide: {wizard}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewName.startsWith('CustomComponent/')) {
|
||||
const componentName = viewName.slice('CustomComponent/'.length);
|
||||
if (componentName === 'Dashboard') {
|
||||
@@ -79,6 +108,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
if (componentName === 'LiveTracing') {
|
||||
return <LiveTracingPage />;
|
||||
}
|
||||
// INBUXA: Settings › Security › Hardening (legacy-protocols spec).
|
||||
if (componentName === 'LegacyProtocols') {
|
||||
return <LegacyProtocolsPage />;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
Unknown component: {componentName}
|
||||
@@ -100,6 +133,15 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
}
|
||||
|
||||
if (resolved.objectType.type === 'singleton') {
|
||||
// INBUXA: the Security settings carry the legacy protocols banner (LP-18).
|
||||
if (resolved.objectName === 'x:Security') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<LegacyProtocolsBanner />
|
||||
<DynamicForm viewName={viewName} objectId="singleton" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <DynamicForm viewName={viewName} objectId="singleton" />;
|
||||
}
|
||||
|
||||
@@ -112,10 +154,23 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
return <TraceDetailView viewName={viewName} objectId={id} />;
|
||||
}
|
||||
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
|
||||
if (!canUpdate) {
|
||||
return <DynamicViewPage viewName={viewName} objectId={id} />;
|
||||
const page = canUpdate ? (
|
||||
<DynamicForm viewName={viewName} objectId={id} />
|
||||
) : (
|
||||
<DynamicViewPage viewName={viewName} objectId={id} />
|
||||
);
|
||||
// INBUXA: a tenant's page carries its legacy protocols switch (LP-9). A
|
||||
// tenant admin reads its tenant without changing it (MT-12), and may
|
||||
// still turn the switch, so it shows on the read-only page too.
|
||||
if (resolved.objectName === 'x:Tenant') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<TenantLegacyProtocols tenantId={id} />
|
||||
{page}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <DynamicForm viewName={viewName} objectId={id} />;
|
||||
return page;
|
||||
}
|
||||
|
||||
return <DynamicList viewName={viewName} />;
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: tier two of the modern shell. Tier one is the layout switcher in the
|
||||
* top bar (Management / Settings / Account, straight from `schema.layouts`);
|
||||
* this bar carries the active layout's own top-level items, each container
|
||||
* opening its children in a menu. No sidebar, so a list or a form gets the
|
||||
* whole window width.
|
||||
*
|
||||
* The item count comes from the server's schema, so it is never known ahead of
|
||||
* time: the bar measures its items once, then keeps whatever fits and folds the
|
||||
* rest into "More". A layout too deep for a menu bar at all keeps the sidebar —
|
||||
* AdminPanel decides that, not this component.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
const { ChevronDown, Lock, MoreHorizontal } = LucideIcons;
|
||||
import { cn } from '@/lib/utils';
|
||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import {
|
||||
checkIsEnterprise,
|
||||
checkLinkVisible,
|
||||
pathMatchesView,
|
||||
resolveViewPath,
|
||||
subtreeContainsActive,
|
||||
subtreeHasVisibleLink,
|
||||
topItemKey,
|
||||
topItemVisible,
|
||||
visibleLinks,
|
||||
} from '@/lib/navTree';
|
||||
import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema';
|
||||
|
||||
/** Room kept for the "More" trigger when not everything fits. */
|
||||
const MORE_WIDTH = 92;
|
||||
|
||||
function howManyFit(widths: number[], available: number): number {
|
||||
let total = 0;
|
||||
for (const w of widths) {
|
||||
total += w;
|
||||
if (total > available) {
|
||||
let withMore = 0;
|
||||
for (let j = 0; j < widths.length; j++) {
|
||||
withMore += widths[j];
|
||||
if (withMore + MORE_WIDTH > available) return j;
|
||||
}
|
||||
return widths.length;
|
||||
}
|
||||
}
|
||||
return widths.length;
|
||||
}
|
||||
|
||||
const TRIGGER_CLASS =
|
||||
'relative flex h-12 shrink-0 items-center gap-1.5 whitespace-nowrap px-3 text-[13px] font-normal text-muted-foreground transition-colors hover:text-foreground';
|
||||
const TRIGGER_ACTIVE =
|
||||
"font-medium text-foreground after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:rounded-full after:bg-primary after:content-['']";
|
||||
|
||||
interface MenuBodyProps {
|
||||
items: LayoutSubItem[];
|
||||
sectionName: string;
|
||||
currentPath: string;
|
||||
edition: string;
|
||||
onPick: (viewName: string, locked: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A container's children. Direct links stay as items; a nested group becomes a
|
||||
* label over its own links, so "Emails" reads Queued / History: Inbound,
|
||||
* Outbound / Delivery tests rather than one flat list.
|
||||
*/
|
||||
function MenuBody({ items, sectionName, currentPath, edition, onPick }: MenuBodyProps) {
|
||||
return (
|
||||
<>
|
||||
{items.map((sub, i) => {
|
||||
if (sub.type === 'link') {
|
||||
if (!checkLinkVisible(sub.viewName)) return null;
|
||||
const enterprise = checkIsEnterprise(sub.viewName);
|
||||
if (enterprise && edition === 'oss') return null;
|
||||
const locked = enterprise && edition === 'community';
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={sub.viewName}
|
||||
className={cn(
|
||||
pathMatchesView(currentPath, sectionName, sub.viewName) && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
onClick={() => onPick(sub.viewName, locked)}
|
||||
>
|
||||
<span className="truncate">{sub.name || 'Overview'}</span>
|
||||
{locked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (!subtreeHasVisibleLink(sub.items, edition)) return null;
|
||||
const links = visibleLinks(sub.items, edition);
|
||||
return (
|
||||
<DropdownMenuGroup key={`${sub.name}-${i}`}>
|
||||
<DropdownMenuLabel className="pt-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{sub.name}
|
||||
</DropdownMenuLabel>
|
||||
{links.map((l) => (
|
||||
<DropdownMenuItem
|
||||
key={l.viewName}
|
||||
className={cn(
|
||||
pathMatchesView(currentPath, sectionName, l.viewName) && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
onClick={() => onPick(l.viewName, checkIsEnterprise(l.viewName) && edition === 'community')}
|
||||
>
|
||||
<span className="truncate">{l.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
item: LayoutItem;
|
||||
sectionName: string;
|
||||
currentPath: string;
|
||||
edition: string;
|
||||
onPick: (viewName: string, locked: boolean) => void;
|
||||
measureRef?: (el: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
function SectionNavItem({ item, sectionName, currentPath, edition, onPick, measureRef }: ItemProps) {
|
||||
if ('link' in item) {
|
||||
const { name, viewName } = item.link;
|
||||
const enterprise = checkIsEnterprise(viewName);
|
||||
const locked = enterprise && edition === 'community';
|
||||
const isActive = pathMatchesView(currentPath, sectionName, viewName);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={measureRef}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(TRIGGER_CLASS, isActive && TRIGGER_ACTIVE)}
|
||||
onClick={() => onPick(viewName, locked)}
|
||||
>
|
||||
{name}
|
||||
{locked && <Lock className="h-3 w-3" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const { name, items } = item.container;
|
||||
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
ref={measureRef}
|
||||
className={cn(TRIGGER_CLASS, 'data-[state=open]:text-foreground', containsActive && TRIGGER_ACTIVE)}
|
||||
>
|
||||
{name}
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-70 transition-transform duration-200 data-[state=open]:rotate-180" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={0} className="w-60">
|
||||
<MenuBody items={items} sectionName={sectionName} currentPath={currentPath} edition={edition} onPick={onPick} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionNav({ layout }: { layout: Layout }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const edition = useAccountStore((s) => s.edition);
|
||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||
|
||||
const items = useMemo(() => layout.items.filter((item) => topItemVisible(item, edition)), [layout, edition]);
|
||||
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const measured = useRef<number[]>([]);
|
||||
const [available, setAvailable] = useState(0);
|
||||
|
||||
/**
|
||||
* A different layout means different labels, so a measurement is only good
|
||||
* for the layout it was taken on: keeping the key beside the widths retires
|
||||
* the old ones without a reset pass.
|
||||
*/
|
||||
const measureKey = `${layout.name}|${edition}`;
|
||||
const [measurement, setMeasurement] = useState<{ key: string; widths: number[] } | null>(null);
|
||||
const widths = measurement?.key === measureKey ? measurement.widths : null;
|
||||
|
||||
/**
|
||||
* Attached only while a measurement is wanted. A ref closure is new on every
|
||||
* render, so React would re-run it — and force a reflow reading offsetWidth —
|
||||
* on each one; leaving it off once the widths are known keeps the bar free of
|
||||
* that on ordinary navigation.
|
||||
*/
|
||||
const measureRefFor = useCallback(
|
||||
(index: number) => (el: HTMLElement | null) => {
|
||||
if (el) measured.current[index] = el.offsetWidth;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (widths !== null) return;
|
||||
const seen = measured.current.slice(0, items.length);
|
||||
if (seen.length !== items.length || seen.some((w) => !w)) return;
|
||||
setMeasurement({ key: measureKey, widths: seen });
|
||||
}, [widths, items.length, measureKey, location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
setAvailable(el.clientWidth);
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
const ro = new ResizeObserver(() => setAvailable(el.clientWidth));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const onPick = useCallback(
|
||||
(viewName: string, locked: boolean) => {
|
||||
if (locked) {
|
||||
setUpsellOpen(true);
|
||||
return;
|
||||
}
|
||||
navigate(resolveViewPath(layout.name, viewName));
|
||||
},
|
||||
[navigate, layout.name],
|
||||
);
|
||||
|
||||
// Before the first measurement every item renders, clipped by the scroller.
|
||||
const shown = widths === null || available === 0 ? items.length : howManyFit(widths, available);
|
||||
const overflowed = items.slice(shown);
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={layout.name}
|
||||
className="sticky top-14 z-30 hidden h-12 items-stretch border-b bg-background px-4 md:flex"
|
||||
>
|
||||
<div ref={scrollerRef} className="flex min-w-0 flex-1 items-stretch overflow-hidden">
|
||||
{items.slice(0, shown).map((item, i) => (
|
||||
<SectionNavItem
|
||||
key={topItemKey(item)}
|
||||
item={item}
|
||||
sectionName={layout.name}
|
||||
currentPath={location.pathname}
|
||||
edition={edition}
|
||||
onPick={onPick}
|
||||
measureRef={widths === null ? measureRefFor(i) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{overflowed.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
TRIGGER_CLASS,
|
||||
'data-[state=open]:text-foreground',
|
||||
overflowed.some((item) =>
|
||||
'link' in item
|
||||
? pathMatchesView(location.pathname, layout.name, item.link.viewName)
|
||||
: subtreeContainsActive(item.container.items, location.pathname, layout.name),
|
||||
) && TRIGGER_ACTIVE,
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
{t('nav.more', 'More')}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={0} className="w-60">
|
||||
{overflowed.map((item, i) => {
|
||||
if ('link' in item) {
|
||||
const { name, viewName } = item.link;
|
||||
const enterprise = checkIsEnterprise(viewName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={viewName}
|
||||
className={cn(
|
||||
pathMatchesView(location.pathname, layout.name, viewName) && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
onClick={() => onPick(viewName, enterprise && edition === 'community')}
|
||||
>
|
||||
<span className="truncate">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
const { name, items: subItems } = item.container;
|
||||
return (
|
||||
<DropdownMenuGroup key={name}>
|
||||
{i > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{name}
|
||||
</DropdownMenuLabel>
|
||||
{visibleLinks(subItems, edition).map((l) => (
|
||||
<DropdownMenuItem
|
||||
key={l.viewName}
|
||||
className={cn(
|
||||
pathMatchesView(location.pathname, layout.name, l.viewName) &&
|
||||
'bg-accent text-accent-foreground',
|
||||
)}
|
||||
onClick={() => onPick(l.viewName, checkIsEnterprise(l.viewName) && edition === 'community')}
|
||||
>
|
||||
<span className="truncate">{l.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +1,44 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
const { ChevronDown, Lock } = LucideIcons;
|
||||
const { ChevronDown, Lock, PanelLeftClose, PanelLeftOpen } = LucideIcons;
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { IconTile } from '@/components/common/IconTile';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { visibleLayouts, isLinkEnterprise, isLinkVisible } from '@/lib/layout';
|
||||
import { sectionLandingLink } from '@/lib/lastVisited';
|
||||
import { visibleLayouts } from '@/lib/layout';
|
||||
import {
|
||||
checkIsEnterprise,
|
||||
checkLinkVisible,
|
||||
pathMatchesView,
|
||||
resolveViewPath,
|
||||
subtreeContainsActive,
|
||||
subtreeHasVisibleLink,
|
||||
visibleLinks,
|
||||
} from '@/lib/navTree';
|
||||
import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema';
|
||||
|
||||
function LucideIcon({ name, className }: { name: string; className?: string }) {
|
||||
const formatted = name
|
||||
.split('-')
|
||||
.map((s) => s[0].toUpperCase() + s.slice(1))
|
||||
.join('');
|
||||
const IconComp = (LucideIcons as Record<string, unknown>)[formatted] as LucideIcons.LucideIcon | undefined;
|
||||
if (!IconComp) return <LucideIcons.Circle className={className} />;
|
||||
return <IconComp className={className} />;
|
||||
}
|
||||
|
||||
function resolveViewPath(sectionName: string, viewName: string): string {
|
||||
return `/${sectionName}/${viewName}`;
|
||||
}
|
||||
|
||||
function pathMatchesView(currentPath: string, sectionName: string, viewName: string): boolean {
|
||||
const base = `/${sectionName}/${viewName}`;
|
||||
if (currentPath === base || currentPath.startsWith(`${base}/`)) return true;
|
||||
if (viewName === 'CustomComponent/Dashboard') {
|
||||
const dashBase = `/${sectionName}/Dashboard/`;
|
||||
return currentPath.startsWith(dashBase);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function subtreeContainsActive(items: LayoutSubItem[], currentPath: string, sectionName: string): boolean {
|
||||
for (const item of items) {
|
||||
if (item.type === 'link') {
|
||||
if (pathMatchesView(currentPath, sectionName, item.viewName)) return true;
|
||||
} else if (item.type === 'container') {
|
||||
if (subtreeContainsActive(item.items, currentPath, sectionName)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean {
|
||||
for (const item of items) {
|
||||
if (item.type === 'link') {
|
||||
if (!checkLinkVisible(item.viewName)) continue;
|
||||
const enterprise = checkIsEnterprise(item.viewName);
|
||||
if (enterprise && edition === 'oss') continue;
|
||||
return true;
|
||||
} else if (item.type === 'container') {
|
||||
if (subtreeHasVisibleLink(item.items, edition)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface AutoOpenCollapsibleProps {
|
||||
containsActive: boolean;
|
||||
children: React.ReactNode;
|
||||
@@ -88,27 +58,6 @@ function AutoOpenCollapsible({ containsActive, children }: AutoOpenCollapsiblePr
|
||||
);
|
||||
}
|
||||
|
||||
function checkLinkVisible(viewName: string): boolean {
|
||||
const schema = useSchemaStore.getState().schema;
|
||||
if (!schema) return true;
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
return isLinkVisible(
|
||||
schema,
|
||||
viewName,
|
||||
accountStore.edition,
|
||||
(prefix: string) => accountStore.hasObjectPermission(prefix, 'Get'),
|
||||
(perm: string) => accountStore.hasPermission(perm),
|
||||
);
|
||||
}
|
||||
|
||||
function checkIsEnterprise(viewName: string): boolean {
|
||||
const schema = useSchemaStore.getState().schema;
|
||||
if (!schema) return false;
|
||||
const edition = useAccountStore.getState().edition;
|
||||
return isLinkEnterprise(schema, viewName, edition);
|
||||
}
|
||||
|
||||
type ActiveItemRef = (el: HTMLButtonElement | null) => void;
|
||||
|
||||
interface SidebarSubItemProps {
|
||||
@@ -148,11 +97,10 @@ function SidebarSubItem({
|
||||
variant="ghost"
|
||||
ref={isActive ? activeItemRef : undefined}
|
||||
className={cn(
|
||||
'w-full justify-start gap-2 font-normal',
|
||||
isActive && 'bg-accent text-accent-foreground',
|
||||
depth > 0 && 'text-sm',
|
||||
'relative h-8 w-full justify-start gap-2 rounded-lg px-3 text-[13px] font-normal text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
isActive && 'bg-accent font-medium text-accent-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
||||
style={depth > 1 ? { paddingLeft: `${(depth - 1) * 12 + 12}px` } : undefined}
|
||||
onClick={() => {
|
||||
if (isLocked) {
|
||||
onUpsell();
|
||||
@@ -176,8 +124,8 @@ function SidebarSubItem({
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 font-normal text-sm"
|
||||
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
||||
className="h-8 w-full justify-start gap-1.5 rounded-lg px-3 text-[13px] font-normal text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
style={depth > 1 ? { paddingLeft: `${(depth - 1) * 12 + 12}px` } : undefined}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
|
||||
<span className="truncate">{item.name}</span>
|
||||
@@ -241,7 +189,10 @@ function SidebarTopItem({
|
||||
<Button
|
||||
variant="ghost"
|
||||
ref={isActive ? activeItemRef : undefined}
|
||||
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
|
||||
className={cn(
|
||||
'h-10 w-full justify-start gap-3 rounded-xl px-2 font-medium text-foreground/85 hover:bg-muted hover:text-foreground',
|
||||
isActive && 'bg-accent text-accent-foreground hover:bg-accent',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isLocked) {
|
||||
onUpsell();
|
||||
@@ -250,7 +201,7 @@ function SidebarTopItem({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
||||
<IconTile name={icon} />
|
||||
<span className="truncate">{name}</span>
|
||||
{isLocked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
|
||||
</Button>
|
||||
@@ -266,13 +217,19 @@ function SidebarTopItem({
|
||||
return (
|
||||
<AutoOpenCollapsible containsActive={containsActive}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
|
||||
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'h-10 w-full justify-start gap-3 rounded-xl px-2 font-medium text-foreground/85 hover:bg-muted hover:text-foreground',
|
||||
containsActive && 'text-foreground',
|
||||
)}
|
||||
>
|
||||
<IconTile name={icon} />
|
||||
<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.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CollapsibleContent className="ml-[1.35rem] mt-0.5 mb-1 space-y-0.5 border-l border-border pl-2.5">
|
||||
{items.map((sub) => (
|
||||
<SidebarSubItem
|
||||
key={sub.type === 'link' ? sub.viewName : sub.name}
|
||||
@@ -294,13 +251,94 @@ function SidebarTopItem({
|
||||
return null;
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
/** INBUXA: one entry of the collapsed sidebar: its tile, a label on hover, a menu for a group. */
|
||||
function RailItem({
|
||||
item,
|
||||
sectionName,
|
||||
currentPath,
|
||||
navigate,
|
||||
edition,
|
||||
}: {
|
||||
item: LayoutItem;
|
||||
sectionName: string;
|
||||
currentPath: string;
|
||||
navigate: ReturnType<typeof useNavigate>;
|
||||
edition: string;
|
||||
}) {
|
||||
const base = 'mx-auto flex h-11 w-11 items-center justify-center rounded-xl transition-colors hover:bg-muted';
|
||||
if ('link' in item) {
|
||||
const { name, icon, viewName } = item.link;
|
||||
if (!checkLinkVisible(viewName)) return null;
|
||||
const isActive = pathMatchesView(currentPath, sectionName, viewName);
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={name}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(base, isActive && 'bg-accent')}
|
||||
onClick={() => navigate(resolveViewPath(sectionName, viewName))}
|
||||
>
|
||||
<IconTile name={icon} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{name}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const { name, icon, items } = item.container;
|
||||
if (!subtreeHasVisibleLink(items, edition)) return null;
|
||||
const links = visibleLinks(items, edition);
|
||||
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" aria-label={name} className={cn(base, containsActive && 'bg-accent')}>
|
||||
<IconTile name={icon} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{name}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="right" align="start" className="w-56">
|
||||
<DropdownMenuLabel className="flex items-center gap-2">
|
||||
<IconTile name={icon} size="sm" />
|
||||
{name}
|
||||
</DropdownMenuLabel>
|
||||
{links.map((l) => (
|
||||
<DropdownMenuItem
|
||||
key={l.viewName}
|
||||
className={cn(pathMatchesView(currentPath, sectionName, l.viewName) && 'bg-accent text-accent-foreground')}
|
||||
onClick={() => navigate(resolveViewPath(sectionName, l.viewName))}
|
||||
>
|
||||
{l.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
/**
|
||||
* INBUXA: in the modern shell the section bar does the navigating on a wide
|
||||
* screen, but a phone has no room for it — the sidebar stays as the
|
||||
* slide-over behind the hamburger, and nothing else.
|
||||
*/
|
||||
mobileOnly?: boolean;
|
||||
}
|
||||
|
||||
export function Sidebar({ mobileOnly = false }: SidebarProps = {}) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const activeSection = useUIStore((s) => s.activeSection);
|
||||
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
|
||||
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
|
||||
const toggleSidebarCollapsed = useUIStore((s) => s.toggleSidebarCollapsed);
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const edition = useAccountStore((s) => s.edition);
|
||||
const permissions = useAccountStore((s) => s.permissions);
|
||||
@@ -333,12 +371,46 @@ export function Sidebar() {
|
||||
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
|
||||
if (!layout) return null;
|
||||
|
||||
const handleSectionClick = (target: Layout) => {
|
||||
setActiveSection(target.name);
|
||||
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
||||
const first = sectionLandingLink(schema, target, edition, canGet, hasPermission);
|
||||
if (first) navigate(`/${target.name}/${first}`);
|
||||
};
|
||||
// Folding to a rail is for wide screens; a phone keeps the slide-over, and so
|
||||
// does the modern shell, where the rail would sit under the section bar.
|
||||
const collapsed =
|
||||
!mobileOnly && sidebarCollapsed && typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<aside className="fixed top-14 left-0 bottom-0 z-30 flex w-[4.5rem] flex-col border-r bg-background">
|
||||
<div className="flex justify-center border-b py-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Expand sidebar"
|
||||
onClick={toggleSidebarCollapsed}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Expand sidebar</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto py-3 [scrollbar-width:none]">
|
||||
{layout.items.map((item) => (
|
||||
<RailItem
|
||||
key={'link' in item ? item.link.viewName : item.container.name}
|
||||
item={item}
|
||||
sectionName={layout.name}
|
||||
currentPath={location.pathname}
|
||||
navigate={navigate}
|
||||
edition={edition}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -347,7 +419,29 @@ export function Sidebar() {
|
||||
className="fixed inset-0 top-14 z-20 bg-black/40 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
<aside className="fixed top-14 left-0 bottom-0 z-30 flex w-64 flex-col border-r bg-background">
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed top-14 left-0 bottom-0 z-30 flex w-64 flex-col border-r bg-background',
|
||||
mobileOnly && 'md:hidden',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 pt-3 pb-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{layout.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Collapse sidebar"
|
||||
title="Collapse sidebar"
|
||||
onClick={toggleSidebarCollapsed}
|
||||
className={cn(
|
||||
'hidden h-7 w-7 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground md:flex',
|
||||
mobileOnly && 'md:hidden',
|
||||
)}
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-2 [scrollbar-width:thin]">
|
||||
<nav className="flex flex-col gap-0.5 px-2">
|
||||
{layout.items.map((item) => (
|
||||
@@ -365,40 +459,6 @@ export function Sidebar() {
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{layouts.length > 1 && (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-around border-t bg-background px-2 py-2">
|
||||
{layouts.map((target) => {
|
||||
const Icon = (LucideIcons as Record<string, unknown>)[
|
||||
target.icon
|
||||
.split('-')
|
||||
.map((s) => s[0].toUpperCase() + s.slice(1))
|
||||
.join('')
|
||||
] as LucideIcons.LucideIcon | undefined;
|
||||
const isActive = target.name === activeSection;
|
||||
return (
|
||||
<Tooltip key={target.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={target.name}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => handleSectionClick(target)}
|
||||
className={cn('h-9 w-9', isActive && 'bg-accent text-accent-foreground')}
|
||||
>
|
||||
{Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.Circle className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{target.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
|
||||
</aside>
|
||||
</>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons;
|
||||
const { Sun, Moon, User, LogOut, Check, Menu, Search, FileCode, Palette, LayoutTemplate } = LucideIcons;
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CommandPalette } from '@/components/common/CommandPalette';
|
||||
import {
|
||||
@@ -16,18 +19,26 @@ import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { isPaletteId, PALETTES } from '@/lib/palettes';
|
||||
import Logo from '@/components/common/Logo';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||
import { SOURCE_URL } from '@/lib/sourceDownload';
|
||||
import { visibleLayouts } from '@/lib/layout';
|
||||
import { sectionLandingLink } from '@/lib/lastVisited';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isAdminLayout, useUIStore } from '@/stores/uiStore';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createElement, useEffect, useState } from 'react';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
|
||||
@@ -46,8 +57,13 @@ export function TopBar() {
|
||||
const navigate = useNavigate();
|
||||
const theme = useUIStore((s) => s.theme);
|
||||
const toggleTheme = useUIStore((s) => s.toggleTheme);
|
||||
const palette = useUIStore((s) => s.palette);
|
||||
const setPalette = useUIStore((s) => s.setPalette);
|
||||
const adminLayout = useUIStore((s) => s.adminLayout);
|
||||
const setAdminLayout = useUIStore((s) => s.setAdminLayout);
|
||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||
const activeSection = useUIStore((s) => s.activeSection);
|
||||
const accounts = useAuthStore((s) => s.accounts);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
@@ -88,7 +104,7 @@ export function TopBar() {
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t('version.label', 'Stalwart WebUI v{{version}}', { version: __APP_VERSION__ })}
|
||||
{t('version.label', 'INBUXA Admin {{version}}', { version: __APP_VERSION__ })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -122,6 +138,47 @@ export function TopBar() {
|
||||
|
||||
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
||||
|
||||
{/* INBUXA: the three areas, one click away, where the eye already looks. */}
|
||||
{schema && navigableLayouts.length > 1 && (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div
|
||||
className="hidden items-center gap-0.5 rounded-xl bg-muted p-1 sm:flex"
|
||||
role="tablist"
|
||||
aria-label={t('sections', 'Sections')}
|
||||
>
|
||||
{navigableLayouts.map((layout) => {
|
||||
const isActive = layout.name === activeSection;
|
||||
return (
|
||||
<Tooltip key={layout.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-label={layout.name}
|
||||
onClick={() => {
|
||||
setActiveSection(layout.name);
|
||||
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
||||
const firstLink = sectionLandingLink(schema, layout, edition, canGet, hasPermission);
|
||||
if (firstLink) navigate(`/${layout.name}/${firstLink}`);
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-8 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] font-medium text-muted-foreground transition-colors hover:text-foreground lg:px-3',
|
||||
isActive && 'bg-card text-primary shadow-soft',
|
||||
)}
|
||||
>
|
||||
{createElement(getIcon(layout.icon), { className: 'h-4 w-4 shrink-0' })}
|
||||
<span className="hidden lg:inline">{layout.name}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{layout.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
|
||||
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -176,15 +233,65 @@ export function TopBar() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{edition !== 'enterprise' && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => setUpsellOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{t('tryEnterprise', 'Try Enterprise')}
|
||||
</DropdownMenuItem>
|
||||
{/* INBUXA: the shell is the reader's choice, the way the palette is. */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<LayoutTemplate className="mr-2 h-4 w-4" />
|
||||
{t('nav.layoutMenu', 'Layout')}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-56">
|
||||
<DropdownMenuRadioGroup
|
||||
value={adminLayout}
|
||||
onValueChange={(v) => isAdminLayout(v) && setAdminLayout(v)}
|
||||
>
|
||||
<DropdownMenuRadioItem value="modern" className="gap-2">
|
||||
{t('nav.layoutModern', 'Modern')}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="legacy" className="gap-2">
|
||||
{t('nav.layoutLegacy', 'Legacy')}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<p className="px-2 py-1.5 text-[11px] leading-snug text-muted-foreground">
|
||||
{adminLayout === 'modern'
|
||||
? t('nav.layoutModernHint', 'Sections across the top; the page gets the full width.')
|
||||
: t('nav.layoutLegacyHint', 'The sidebar, as the old web UI had it.')}
|
||||
</p>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* INBUXA: the same palettes as INBUXA webmail. */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
{t('theme.menu', 'Theme')}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-52">
|
||||
<DropdownMenuRadioGroup value={palette} onValueChange={(v) => isPaletteId(v) && setPalette(v)}>
|
||||
{PALETTES.map((p) => (
|
||||
<DropdownMenuRadioItem key={p.id} value={p.id} className="gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-3.5 w-3.5 shrink-0 rounded-full ring-1 ring-black/10 dark:ring-white/15"
|
||||
style={{ background: theme === 'dark' ? p.swatch[1] : p.swatch[0] }}
|
||||
/>
|
||||
<span className="notranslate" translate="no">
|
||||
{p.id === 'default' ? t('theme.classic', 'Classic') : p.name}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={SOURCE_URL} target="_blank" rel="noopener noreferrer">
|
||||
<FileCode className="mr-2 h-4 w-4" />
|
||||
{t('source.menu', 'Source code (AGPL-3.0)')}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { EmptyState } from '@/components/common/EmptyState';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { HelpPanel } from '@/help/HelpPanel';
|
||||
import { ObjectHoverCard } from '@/features/hovercards/ObjectHoverCard';
|
||||
import { iconForView } from '@/lib/viewIcon';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -265,7 +273,11 @@ function renderCellValue(
|
||||
case 'objectId': {
|
||||
const id = String(value);
|
||||
const display = getDisplayName(ft.objectName, id);
|
||||
return display ?? id;
|
||||
return (
|
||||
<ObjectHoverCard objectName={ft.objectName} id={id}>
|
||||
{display ?? id}
|
||||
</ObjectHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
case 'set': {
|
||||
@@ -1105,13 +1117,11 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{list.title}</h1>
|
||||
{list.subtitle && <p className="text-sm text-muted-foreground mt-1">{list.subtitle}</p>}
|
||||
</div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<PageHeader icon={iconForView(schema, viewName)} title={list.title} subtitle={list.subtitle} />
|
||||
<div className="flex items-center gap-2">
|
||||
<HelpPanel viewName={viewName} title={list.title} />
|
||||
{hasMassActions && selectedIds.size > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -1233,11 +1243,11 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-background shadow-sm">
|
||||
<div className="overflow-x-auto rounded-[calc(var(--radius-lg)-1px)]">
|
||||
<div className="rounded-xl border bg-card shadow-soft">
|
||||
<div className="overflow-x-auto rounded-[calc(var(--radius-xl)-1px)]">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted">
|
||||
<tr className="border-b bg-muted/60 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{hasMassActions && (
|
||||
<th className="w-10 px-3 py-3">
|
||||
<Checkbox
|
||||
@@ -1276,9 +1286,12 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
<tr>
|
||||
<td
|
||||
colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
|
||||
className="px-3 py-12 text-center text-muted-foreground"
|
||||
className="px-3"
|
||||
>
|
||||
{t('list.noResults', 'No results found')}
|
||||
<EmptyState
|
||||
title={t('list.emptyTitle', 'Nothing here yet')}
|
||||
hint={t('list.noResults', 'No results found')}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -1299,18 +1312,27 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{list.columns.map((col) => (
|
||||
<td key={col.name} className="px-3 py-2">
|
||||
{renderCellValue(
|
||||
item[col.name],
|
||||
fields[col.name],
|
||||
col.name,
|
||||
schema!,
|
||||
resolved.obj.objectName,
|
||||
getDisplayName,
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{list.columns.map((col, colIndex) => {
|
||||
const cell = renderCellValue(
|
||||
item[col.name],
|
||||
fields[col.name],
|
||||
col.name,
|
||||
schema!,
|
||||
resolved.obj.objectName,
|
||||
getDisplayName,
|
||||
);
|
||||
return (
|
||||
<td key={col.name} className="px-3 py-2">
|
||||
{colIndex === 0 && item[col.name] != null ? (
|
||||
<ObjectHoverCard objectName={resolved.obj.objectName} id={itemId}>
|
||||
{cell}
|
||||
</ObjectHoverCard>
|
||||
) : (
|
||||
cell
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
{hasItemActions && <td className="px-3 py-2 text-right">{renderItemActions(item)}</td>}
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
@@ -11,21 +14,21 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-1 focus-visible:ring-offset-background active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
outline: 'border border-input bg-card shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
sm: 'h-8 rounded-lg px-3 text-xs',
|
||||
lg: 'h-10 rounded-xl px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
@@ -9,7 +12,7 @@ import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...props} />
|
||||
<div ref={ref} className={cn('rounded-2xl border bg-card text-card-foreground shadow-soft', className)} {...props} />
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
@@ -22,7 +25,7 @@ CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
<div ref={ref} className={cn('font-display font-semibold leading-none', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
@@ -10,11 +13,11 @@ import type { TooltipPayload } from 'recharts/types/state/tooltipSlice';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const CHART_COLORS = [
|
||||
'hsl(var(--chart-1))',
|
||||
'hsl(var(--chart-2))',
|
||||
'hsl(var(--chart-3))',
|
||||
'hsl(var(--chart-4))',
|
||||
'hsl(var(--chart-5))',
|
||||
'var(--chart-1)',
|
||||
'var(--chart-2)',
|
||||
'var(--chart-3)',
|
||||
'var(--chart-4)',
|
||||
'var(--chart-5)',
|
||||
] as const;
|
||||
|
||||
export function getChartColor(index: number): string {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
@@ -14,7 +17,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'flex h-9 w-full rounded-lg border border-input bg-background/60 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:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ListChecks, SlidersHorizontal } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* "Guided or manual?" Every job that has a wizard asks this each time it
|
||||
* starts. Nothing is remembered: the wizard is always opt-in.
|
||||
*/
|
||||
export function LaunchChoice({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
guidedHint,
|
||||
manualHint,
|
||||
onGuided,
|
||||
onManual,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
guidedHint: string;
|
||||
manualHint?: string;
|
||||
onGuided: () => void;
|
||||
onManual: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const option = (
|
||||
icon: typeof ListChecks,
|
||||
heading: string,
|
||||
hint: string,
|
||||
onClick: () => void,
|
||||
accent: boolean,
|
||||
autoFocus: boolean,
|
||||
) => {
|
||||
const Icon = icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
autoFocus={autoFocus}
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
onClick();
|
||||
}}
|
||||
className={cn(
|
||||
'group flex flex-col items-start gap-3 rounded-xl border p-5 text-left transition-all',
|
||||
'hover:-translate-y-0.5 hover:border-primary/60 hover:shadow-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
accent ? 'border-primary/40 bg-primary/5' : 'border-border bg-card',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-xl',
|
||||
accent ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="font-medium">{heading}</span>
|
||||
<span className="text-sm text-muted-foreground">{hint}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{t('wizard.chooseHow', 'How would you like to do this?')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{option(ListChecks, t('wizard.guided', 'Guide me'), guidedHint, onGuided, true, true)}
|
||||
{option(
|
||||
SlidersHorizontal,
|
||||
t('wizard.manual', "I'll do it myself"),
|
||||
manualHint ?? t('wizard.manualHint', 'The full form, with every option at once.'),
|
||||
onManual,
|
||||
false,
|
||||
false,
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, ArrowRight, Check, Loader2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface WizardStep {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame every guided job shares: where you are in it, the step itself,
|
||||
* a side panel saying what this step does (and how to undo it, for the big
|
||||
* jobs), and the way forward or back. The steps own their content and decide
|
||||
* when "Next" is allowed; the shell never does anything on its own.
|
||||
*/
|
||||
export function WizardShell({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
steps,
|
||||
current,
|
||||
children,
|
||||
aside,
|
||||
canNext = true,
|
||||
busy = false,
|
||||
nextLabel,
|
||||
onBack,
|
||||
onNext,
|
||||
onCancel,
|
||||
hideFooter = false,
|
||||
}: {
|
||||
icon: string;
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
steps: WizardStep[];
|
||||
current: number;
|
||||
children: ReactNode;
|
||||
aside?: ReactNode;
|
||||
canNext?: boolean;
|
||||
busy?: boolean;
|
||||
nextLabel?: string;
|
||||
onBack?: () => void;
|
||||
onNext?: () => void;
|
||||
onCancel: () => void;
|
||||
hideFooter?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<PageHeader
|
||||
icon={icon}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
<X className="h-4 w-4" />
|
||||
{t('wizard.close', 'Close')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<ol className="flex flex-wrap items-center gap-x-2 gap-y-3" aria-label={t('wizard.progress', 'Progress')}>
|
||||
{steps.map((s, i) => {
|
||||
const done = i < current;
|
||||
const here = i === current;
|
||||
return (
|
||||
<li key={s.id} className="flex items-center gap-2" aria-current={here ? 'step' : undefined}>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-colors',
|
||||
done && 'bg-primary text-primary-foreground',
|
||||
here && 'bg-primary/15 text-primary ring-2 ring-primary',
|
||||
!done && !here && 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="h-3.5 w-3.5" /> : i + 1}
|
||||
</span>
|
||||
<span className={cn('text-sm', here ? 'font-medium text-foreground' : 'text-muted-foreground')}>
|
||||
{s.title}
|
||||
</span>
|
||||
{i < steps.length - 1 && <span className="mx-1 hidden h-px w-8 bg-border sm:block" aria-hidden />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<div className={cn('grid items-start gap-6', aside && 'lg:grid-cols-[minmax(0,1fr)_18rem]')}>
|
||||
<Card>
|
||||
<CardContent className="space-y-6 pt-6">{children}</CardContent>
|
||||
</Card>
|
||||
{aside && <aside className="space-y-4 text-sm">{aside}</aside>}
|
||||
</div>
|
||||
|
||||
{!hideFooter && (
|
||||
<div className="flex items-center justify-between">
|
||||
{onBack ? (
|
||||
<Button variant="ghost" onClick={onBack} disabled={busy}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('wizard.back', 'Back')}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{onNext && (
|
||||
<Button onClick={onNext} disabled={!canNext || busy}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{nextLabel ?? t('wizard.next', 'Next')}
|
||||
{!busy && <ArrowRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A side-panel note: what this step does, or how to undo it. */
|
||||
export function WizardNote({
|
||||
title,
|
||||
children,
|
||||
tone = 'plain',
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
tone?: 'plain' | 'undo';
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border p-4',
|
||||
tone === 'undo' ? 'border-emerald-500/30 bg-emerald-500/5' : 'border-border bg-card',
|
||||
)}
|
||||
>
|
||||
<p className="mb-1.5 font-medium">{title}</p>
|
||||
<div className="space-y-2 text-muted-foreground">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||
@@ -51,6 +54,7 @@ function ChartSizedContainer({
|
||||
);
|
||||
}
|
||||
import { Info } from 'lucide-react';
|
||||
import { GoLink } from './GoLink';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tooltip as UiTooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { getChartColor } from '@/components/ui/chart';
|
||||
@@ -242,6 +246,7 @@ export function DashboardChart({ chart, historySamples, historyWindow, period }:
|
||||
</UiTooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<GoLink metrics={chart.series.flatMap((x) => x.metrics)} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Greeting } from './Greeting';
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import type { Dashboard } from '../types/schema';
|
||||
import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner';
|
||||
import { useDashboardStore } from '../stores/dashboardStore';
|
||||
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
|
||||
import { useHistoryMetricsStore } from '../stores/historyMetricsStore';
|
||||
@@ -17,6 +23,18 @@ import { collectHistoryMetricIds, collectLiveMetricIds, periodKey, periodWindow,
|
||||
import { StatCard } from './StatCard';
|
||||
import { DashboardChart } from './DashboardChart';
|
||||
import { PeriodSelector } from './PeriodSelector';
|
||||
import { StatusLine } from './StatusLine';
|
||||
import { StorageTreemap } from './StorageTreemap';
|
||||
import { QueueWaiting } from './QueueWaiting';
|
||||
import { WeeklyHeatmap } from './WeeklyHeatmap';
|
||||
import { useServerFacts, type ServerFacts } from '../serverFacts';
|
||||
|
||||
/** INBUXA: live-metric cards the server's own objects can stand in for. */
|
||||
const FALLBACKS: Record<string, keyof ServerFacts> = {
|
||||
'user.count': 'users',
|
||||
'domain.count': 'domains',
|
||||
'queue.count': 'queued',
|
||||
};
|
||||
|
||||
interface DashboardViewProps {
|
||||
dashboardId: string;
|
||||
@@ -24,6 +42,7 @@ interface DashboardViewProps {
|
||||
}
|
||||
|
||||
export function DashboardView({ dashboardId, section }: DashboardViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const period = useDashboardStore((s) => s.period);
|
||||
@@ -35,6 +54,7 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) {
|
||||
const unsubscribeLive = useLiveMetricsStore((s) => s.unsubscribe);
|
||||
const liveStatus = useLiveMetricsStore((s) => s.status);
|
||||
const liveError = useLiveMetricsStore((s) => s.error);
|
||||
const { facts } = useServerFacts();
|
||||
|
||||
const dashboards = useMemo<Dashboard[]>(() => schema?.dashboards ?? [], [schema]);
|
||||
const dashboard = dashboards.find((d) => d.id === dashboardId);
|
||||
@@ -102,6 +122,9 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Greeting />
|
||||
<StatusLine facts={facts} />
|
||||
<LegacyProtocolsBanner />
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
{dashboards.length > 1 && (
|
||||
<Tabs value={dashboardId} onValueChange={(id) => navigate(`/${section}/Dashboard/${id}`)}>
|
||||
@@ -114,15 +137,22 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) {
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
{dashboards.length === 1 && <h1 className="text-xl font-semibold">{dashboard.label}</h1>}
|
||||
{dashboards.length === 1 && <h2 className="text-lg font-semibold">{dashboard.label}</h2>}
|
||||
|
||||
<PeriodSelector onRefresh={handleRefresh} loading={isLoading} />
|
||||
</div>
|
||||
|
||||
{liveStatus === 'error' && liveError && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{liveError}
|
||||
<div className="flex items-center gap-3 rounded-xl border border-highlight/40 bg-highlight-soft px-4 py-3 text-sm text-foreground">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 text-highlight" />
|
||||
<span>
|
||||
{/404/.test(liveError)
|
||||
? t(
|
||||
'dashboard.liveUnavailable',
|
||||
"Live numbers aren't available on this server yet. The rest of the dashboard still works.",
|
||||
)
|
||||
: liveError}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -134,11 +164,25 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) {
|
||||
card={card}
|
||||
historySamples={historySamples}
|
||||
historyWindow={historyWindow}
|
||||
fallback={
|
||||
card.metrics.length === 1 && FALLBACKS[card.metrics[0]]
|
||||
? (facts?.[FALLBACKS[card.metrics[0]]] as number | undefined)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dashboard.id === 'overview' && facts && (facts.storage || facts.waiting) && (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{facts.waiting && <QueueWaiting waiting={facts.waiting} />}
|
||||
{facts.storage && <StorageTreemap storage={facts.storage} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dashboard.id === 'overview' && <WeeklyHeatmap samples={historySamples} />}
|
||||
|
||||
{dashboard.charts && dashboard.charts.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{dashboard.charts.map((chart, i) => (
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import { hrefFor, useDashLink } from '../links';
|
||||
|
||||
/** "Open the queue ↗": the way from a chart to the page it's about. */
|
||||
export function GoLink({ metrics }: { metrics: string[] }) {
|
||||
const { t } = useTranslation();
|
||||
const link = useDashLink(metrics);
|
||||
if (!link) return null;
|
||||
return (
|
||||
<Link
|
||||
to={hrefFor(link)}
|
||||
className="ml-auto inline-flex shrink-0 items-center gap-1 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{t(`dashLink.${link.viewName}`, link.label)}
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { getAccountId, jmapQueryAndGet } from '@/services/jmap/client';
|
||||
import inbuxaMark from '@/assets/inbuxa-mark.png';
|
||||
|
||||
function partOfDay(hour: number): 'morning' | 'afternoon' | 'evening' {
|
||||
if (hour < 12) return 'morning';
|
||||
if (hour < 18) return 'afternoon';
|
||||
return 'evening';
|
||||
}
|
||||
|
||||
/**
|
||||
* The name to greet: the account's full name where it has one, otherwise the
|
||||
* name it signs in with. Looked up by the local part and matched on the whole
|
||||
* address, so an account of the same name on another domain can't answer.
|
||||
*/
|
||||
function useFullName(username: string | null): string | null {
|
||||
const [fullName, setFullName] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
let live = true;
|
||||
const localPart = username.split('@')[0];
|
||||
jmapQueryAndGet('x:Account', getAccountId('x:Account'), { filter: { name: localPart } }, [
|
||||
'description',
|
||||
'emailAddress',
|
||||
])
|
||||
.then((responses) => {
|
||||
const list =
|
||||
(responses[1]?.[1] as { list?: { description?: string | null; emailAddress?: string }[] }).list ?? [];
|
||||
const own = list.find((a) => a.emailAddress?.toLowerCase() === username.toLowerCase());
|
||||
const name = own?.description?.trim();
|
||||
if (live && name) setFullName(name);
|
||||
})
|
||||
.catch(() => {
|
||||
/* the sign-in name stands */
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [username]);
|
||||
return fullName;
|
||||
}
|
||||
|
||||
/** The dashboard's hello: to whoever is signed in, with a nod to the time of day. */
|
||||
export function Greeting() {
|
||||
const { t } = useTranslation();
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const fullName = useFullName(username);
|
||||
const name = fullName ?? username?.split('@')[0] ?? '';
|
||||
const part = partOfDay(new Date().getHours());
|
||||
const hello =
|
||||
part === 'morning'
|
||||
? t('greeting.morning', 'Good morning')
|
||||
: part === 'afternoon'
|
||||
? t('greeting.afternoon', 'Good afternoon')
|
||||
: t('greeting.evening', 'Good evening');
|
||||
return (
|
||||
<div className="flex items-center gap-4 rounded-2xl border bg-gradient-to-br from-accent/70 via-card to-card px-5 py-4 shadow-soft">
|
||||
<img src={inbuxaMark} alt="" className="h-12 w-auto drop-shadow-sm" />
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-2xl font-semibold">
|
||||
{hello}
|
||||
{name && `, ${name}`}
|
||||
</h1>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{username && (
|
||||
<>
|
||||
{t('greeting.signedInAs', 'Signed in as {{username}}', { username })}
|
||||
{' · '}
|
||||
</>
|
||||
)}
|
||||
{t('greeting.subtitle', "Here's how your mail server is doing.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowUpRight, PartyPopper } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { WaitingDomain } from '../serverFacts';
|
||||
|
||||
const MAX_ROWS = 8;
|
||||
|
||||
/**
|
||||
* Where outgoing mail is waiting, by the domain it's going to: a bar per
|
||||
* destination, split into waiting its turn, retrying after a refusal, and
|
||||
* given up on. A stuck provider stands out at a glance. A click opens the
|
||||
* queue filtered to that destination.
|
||||
*/
|
||||
export function QueueWaiting({ waiting }: { waiting: WaitingDomain[] }) {
|
||||
const { t } = useTranslation();
|
||||
const rows = waiting.slice(0, MAX_ROWS);
|
||||
const max = Math.max(1, ...rows.map((r) => r.scheduled + r.retrying + r.failed));
|
||||
const legend = [
|
||||
{ cls: 'bg-[var(--chart-1)]', label: t('queue.scheduled', 'Waiting its turn') },
|
||||
{ cls: 'bg-amber-500', label: t('queue.retrying', 'Retrying') },
|
||||
{ cls: 'bg-rose-500', label: t('queue.failed', 'Gave up') },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0 pb-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">{t('queue.title', 'Where mail is waiting')}</CardTitle>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{t('queue.subtitle', 'Outgoing recipients still in the queue, by destination')}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/Management/x:QueuedMessage"
|
||||
className="inline-flex shrink-0 items-center gap-1 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{t('queue.open', 'Open the queue')}
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rows.length === 0 ? (
|
||||
<div className="flex h-[228px] flex-col items-center justify-center gap-2 rounded-xl border border-dashed text-sm text-muted-foreground">
|
||||
<PartyPopper className="h-6 w-6 text-emerald-500" />
|
||||
{t('queue.empty', 'Nothing waiting. Everything has gone out.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{rows.map((r) => {
|
||||
const total = r.scheduled + r.retrying + r.failed;
|
||||
const seg = (n: number) => `${(n / max) * 100}%`;
|
||||
return (
|
||||
<Link
|
||||
key={r.domain}
|
||||
to={`/Management/x:QueuedMessage?f.to=${encodeURIComponent(r.domain)}`}
|
||||
className="group grid grid-cols-[minmax(0,9rem)_minmax(0,1fr)_2.5rem] items-center gap-3 text-sm"
|
||||
title={t('queue.rowTitle', '{{domain}}: {{s}} waiting, {{r}} retrying, {{f}} gave up', {
|
||||
domain: r.domain,
|
||||
s: r.scheduled,
|
||||
r: r.retrying,
|
||||
f: r.failed,
|
||||
})}
|
||||
>
|
||||
<span className="truncate font-mono text-xs group-hover:text-primary">{r.domain}</span>
|
||||
<span className="flex h-3 overflow-hidden rounded-full bg-muted">
|
||||
<span className="h-full bg-[var(--chart-1)] transition-all" style={{ width: seg(r.scheduled) }} />
|
||||
<span className="h-full bg-amber-500 transition-all" style={{ width: seg(r.retrying) }} />
|
||||
<span className="h-full bg-rose-500 transition-all" style={{ width: seg(r.failed) }} />
|
||||
</span>
|
||||
<span className="text-right tabular-nums text-muted-foreground">{total}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-wrap gap-4 pt-2 text-xs text-muted-foreground">
|
||||
{legend.map((l) => (
|
||||
<span key={l.label} className="inline-flex items-center gap-1.5">
|
||||
<span className={`h-2.5 w-2.5 rounded-full ${l.cls}`} />
|
||||
{l.label}
|
||||
</span>
|
||||
))}
|
||||
{waiting.length > MAX_ROWS &&
|
||||
t('queue.more', '+{{count}} more destinations', { count: waiting.length - MAX_ROWS })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { IconTile } from '@/components/common/IconTile';
|
||||
import { useMemo } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Info } from 'lucide-react';
|
||||
import { ArrowUpRight, Info } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { hrefFor, useDashLink } from '../links';
|
||||
import { LineChart, Line } from 'recharts';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -17,42 +23,31 @@ import { cardValue, formatValue, sparklineData, computeDelta } from '../helpers'
|
||||
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
|
||||
import { getChartColor } from '@/components/ui/chart';
|
||||
|
||||
const warnedIcons = new Set<string>();
|
||||
|
||||
function LucideIcon({ name, className }: { name: string; className?: string }) {
|
||||
const formatted = name
|
||||
.split('-')
|
||||
.map((s) => s[0].toUpperCase() + s.slice(1))
|
||||
.join('');
|
||||
const IconComp = (LucideIcons as Record<string, unknown>)[formatted] as LucideIcons.LucideIcon | undefined;
|
||||
if (!IconComp) {
|
||||
if (import.meta.env.DEV && !warnedIcons.has(name)) {
|
||||
warnedIcons.add(name);
|
||||
console.warn(`Unknown icon name: "${name}"`);
|
||||
}
|
||||
return <LucideIcons.HelpCircle className={className} />;
|
||||
}
|
||||
return <IconComp className={className} />;
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
card: CardSchema;
|
||||
historySamples: Metric[];
|
||||
historyWindow: { from: Date; to: Date };
|
||||
/** INBUXA: a value counted from the server's objects, used when live metrics aren't available. */
|
||||
fallback?: number;
|
||||
}
|
||||
|
||||
export function StatCard({ card, historySamples, historyWindow }: StatCardProps) {
|
||||
export function StatCard({ card, historySamples, historyWindow, fallback }: StatCardProps) {
|
||||
const liveSnapshot = useLiveMetricsStore((s) => s.snapshot);
|
||||
const liveStatus = useLiveMetricsStore((s) => s.status);
|
||||
const link = useDashLink(card.metrics);
|
||||
|
||||
const value = useMemo(() => {
|
||||
if (card.source === 'live' && fallback !== undefined && liveStatus !== 'open') return fallback;
|
||||
if (card.source === 'live') {
|
||||
const liveSamples = card.metrics.map((id) => liveSnapshot.get(id)).filter((m): m is Metric => m !== undefined);
|
||||
return cardValue(card, liveSamples);
|
||||
}
|
||||
return cardValue(card, historySamples);
|
||||
}, [card, liveSnapshot, historySamples]);
|
||||
}, [card, liveSnapshot, historySamples, fallback, liveStatus]);
|
||||
|
||||
const formattedValue = formatValue(value, card.format);
|
||||
// INBUXA: a live number the server can't report yet reads as unknown, not as zero.
|
||||
const unknown = card.source === 'live' && liveStatus !== 'open' && fallback === undefined;
|
||||
const formattedValue = unknown ? '—' : formatValue(value, card.format);
|
||||
|
||||
const { from, to } = historyWindow;
|
||||
|
||||
@@ -69,12 +64,21 @@ export function StatCard({ card, historySamples, historyWindow }: StatCardProps)
|
||||
return computeDelta(card, historySamples, from, to);
|
||||
}, [card, historySamples, from, to]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
const body = (
|
||||
<Card
|
||||
className={cn(
|
||||
'h-full transition-all hover:shadow-md',
|
||||
link &&
|
||||
'group-hover:-translate-y-0.5 group-hover:border-primary/50 group-focus-visible:ring-2 group-focus-visible:ring-ring',
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<LucideIcon name={card.icon} className="h-4 w-4 text-muted-foreground" />
|
||||
<IconTile name={card.icon} size="sm" />
|
||||
<span className="text-sm font-medium text-muted-foreground">{card.title}</span>
|
||||
{link && (
|
||||
<ArrowUpRight className="ml-auto h-4 w-4 shrink-0 text-muted-foreground/0 transition-colors group-hover:text-primary" />
|
||||
)}
|
||||
{card.description && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -89,7 +93,7 @@ export function StatCard({ card, historySamples, historyWindow }: StatCardProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-2xl font-bold">{formattedValue}</div>
|
||||
<div className="mt-3 font-display text-3xl font-semibold tracking-tight">{formattedValue}</div>
|
||||
|
||||
{(delta || sparkline) && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
@@ -119,4 +123,16 @@ export function StatCard({ card, historySamples, historyWindow }: StatCardProps)
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return link ? (
|
||||
<Link
|
||||
to={hrefFor(link)}
|
||||
className="group block focus-visible:outline-none"
|
||||
aria-label={`${card.title}: ${link.label}`}
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CircleAlert } from 'lucide-react';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import type { ServerFacts } from '../serverFacts';
|
||||
import { hrefFor, type DashLink } from '../links';
|
||||
|
||||
interface Phrase {
|
||||
text: string;
|
||||
link: DashLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* What needs a look, in one sentence: failed tasks, messages retrying,
|
||||
* recipients given up on. Nothing shows while all is well. Every phrase is
|
||||
* a link to where you'd deal with it.
|
||||
*/
|
||||
export function StatusLine({ facts }: { facts: ServerFacts | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { canViewObject } = usePermissions();
|
||||
if (!facts) return null;
|
||||
|
||||
const n = (key: string, count: number, one: string, other: string) =>
|
||||
t(key, { count, defaultValue_one: one, defaultValue_other: other });
|
||||
|
||||
const attention: Phrase[] = [];
|
||||
if (facts.failedTasks)
|
||||
attention.push({
|
||||
text: n('status.failedTasks', facts.failedTasks, '{{count}} failed task', '{{count}} failed tasks'),
|
||||
link: { viewName: 'x:Task/TaskFailed', section: 'Management', label: '' },
|
||||
});
|
||||
if (facts.retrying)
|
||||
attention.push({
|
||||
text: n('status.retrying', facts.retrying, '{{count}} message retrying', '{{count}} messages retrying'),
|
||||
link: { viewName: 'x:QueuedMessage', section: 'Management', label: '' },
|
||||
});
|
||||
const bounced = facts.waiting?.reduce((s, w) => s + w.failed, 0) ?? 0;
|
||||
if (bounced)
|
||||
attention.push({
|
||||
text: n('status.bounced', bounced, '{{count}} recipient failed', '{{count}} recipients failed'),
|
||||
link: { viewName: 'x:QueuedMessage', section: 'Management', label: '' },
|
||||
});
|
||||
|
||||
// Quiet when all is well: the line only appears when something needs a look.
|
||||
const visible = attention.filter((p) => canViewObject(p.link.viewName));
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
const lead = n('status.needsLook', visible.length, 'One thing needs a look:', '{{count}} things need a look:');
|
||||
|
||||
const list: ReactNode[] = visible.map((p, i) => (
|
||||
<Fragment key={p.text}>
|
||||
{i > 0 && (i === visible.length - 1 ? t('status.and', ' and ') : ', ')}
|
||||
<Link
|
||||
to={hrefFor(p.link)}
|
||||
className="font-medium text-foreground underline decoration-primary/40 decoration-2 underline-offset-4 transition-colors hover:decoration-primary"
|
||||
>
|
||||
{p.text}
|
||||
</Link>
|
||||
</Fragment>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-highlight/40 bg-highlight-soft px-5 py-3.5 text-[15px]">
|
||||
<CircleAlert className="h-5 w-5 shrink-0 text-highlight" />
|
||||
<p className="text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{lead}</span> {list}.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowUpRight, HardDrive } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatValue } from '../helpers';
|
||||
import { squarify } from '../treemap';
|
||||
import type { StorageUse } from '../serverFacts';
|
||||
|
||||
const MAX_TILES = 24;
|
||||
const HEIGHT = 260;
|
||||
|
||||
/** How full an account is, as a tile color: calm until it nears its quota. */
|
||||
function fillClass(u: StorageUse): string {
|
||||
if (!u.quota) return 'bg-[var(--chart-1)]/75 hover:bg-[var(--chart-1)]';
|
||||
const pct = u.used / u.quota;
|
||||
if (pct >= 0.9) return 'bg-rose-500/80 hover:bg-rose-500';
|
||||
if (pct >= 0.75) return 'bg-amber-500/80 hover:bg-amber-500';
|
||||
return 'bg-[var(--chart-1)]/75 hover:bg-[var(--chart-1)]';
|
||||
}
|
||||
|
||||
/**
|
||||
* Who uses the storage, as a map: every person is a tile sized by the space
|
||||
* their mail, files and calendars take, and colored by how close they are to
|
||||
* their quota. The biggest users are the biggest tiles; a click opens them.
|
||||
*/
|
||||
export function StorageTreemap({ storage }: { storage: StorageUse[] }) {
|
||||
const { t } = useTranslation();
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = box.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const { tiles, total, others } = useMemo(() => {
|
||||
const sorted = [...storage].filter((s) => s.used > 0).sort((a, b) => b.used - a.used);
|
||||
const top = sorted.slice(0, MAX_TILES);
|
||||
const rest = sorted.slice(MAX_TILES);
|
||||
const restUse = rest.reduce((s, r) => s + r.used, 0);
|
||||
const items: StorageUse[] = restUse
|
||||
? [
|
||||
...top,
|
||||
{ id: '', name: t('storage.others', '{{count}} others', { count: rest.length }), used: restUse, quota: null },
|
||||
]
|
||||
: top;
|
||||
return {
|
||||
tiles: squarify(items, (i) => i.used, width, HEIGHT),
|
||||
total: sorted.reduce((s, r) => s + r.used, 0),
|
||||
others: rest.length,
|
||||
};
|
||||
}, [storage, width, t]);
|
||||
|
||||
const nearFull = storage.filter((s) => s.quota && s.used / s.quota >= 0.9).length;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0 pb-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">{t('storage.title', 'Who uses the space')}</CardTitle>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{total > 0
|
||||
? t('storage.subtitle', '{{total}} across {{count}} people', {
|
||||
total: formatValue(total, 'bytes'),
|
||||
count: storage.length,
|
||||
})
|
||||
: t('storage.empty', 'No one has stored anything yet.')}
|
||||
{nearFull > 0 && (
|
||||
<span className="ml-2 font-medium text-rose-600 dark:text-rose-400">
|
||||
{t('storage.nearFull', {
|
||||
count: nearFull,
|
||||
defaultValue_one: '{{count}} nearly full',
|
||||
defaultValue_other: '{{count}} nearly full',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/Management/x:Account/User"
|
||||
className="inline-flex shrink-0 items-center gap-1 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{t('storage.seePeople', 'See people')}
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div ref={box} className="relative w-full overflow-hidden rounded-xl" style={{ height: HEIGHT }}>
|
||||
{total === 0 && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 rounded-xl border border-dashed text-sm text-muted-foreground">
|
||||
<HardDrive className="h-6 w-6" />
|
||||
{t('storage.emptyHint', 'Tiles appear here as people store mail and files.')}
|
||||
</div>
|
||||
)}
|
||||
{tiles.map(({ item, x, y, w, h }) => {
|
||||
const pct = item.quota ? Math.round((item.used / item.quota) * 100) : null;
|
||||
const label = `${item.name}: ${formatValue(item.used, 'bytes')}${pct !== null ? ` (${pct}%)` : ''}`;
|
||||
const roomy = w > 90 && h > 44;
|
||||
const style = { left: x + 1, top: y + 1, width: Math.max(0, w - 2), height: Math.max(0, h - 2) };
|
||||
const body = (
|
||||
<>
|
||||
{roomy && (
|
||||
<>
|
||||
<span className="block truncate text-xs font-medium">{item.name}</span>
|
||||
<span className="block text-[11px] opacity-80">
|
||||
{formatValue(item.used, 'bytes')}
|
||||
{pct !== null && ` · ${pct}%`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const cls = cn(
|
||||
'absolute overflow-hidden rounded-md p-2 text-left text-white transition-colors',
|
||||
item.id ? fillClass(item) : 'bg-muted-foreground/40',
|
||||
);
|
||||
return item.id ? (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={`/Management/x:Account/User/${item.id}`}
|
||||
title={label}
|
||||
className={cls}
|
||||
style={style}
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div key="others" title={label} className={cls} style={style}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{others > 0 && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t('storage.topOnly', 'The {{count}} biggest are shown on their own.', { count: MAX_TILES })}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { Metric } from '../types/metrics';
|
||||
import { weeklyGrid } from '../rhythm';
|
||||
|
||||
/**
|
||||
* The server's weekly rhythm: one square per hour of the week, darker the
|
||||
* more mail moved through it. Busy mornings, quiet weekends and a 3 a.m.
|
||||
* spike that shouldn't be there all show at a glance. Hidden until
|
||||
* monitoring has recorded something.
|
||||
*/
|
||||
export function WeeklyHeatmap({ samples }: { samples: Metric[] }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const grid = useMemo(() => weeklyGrid(samples), [samples]);
|
||||
const max = Math.max(...grid.flat());
|
||||
const days = useMemo(() => {
|
||||
const fmt = new Intl.DateTimeFormat(i18n.language, { weekday: 'short' });
|
||||
// 2024-01-01 was a Monday.
|
||||
return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(2024, 0, 1 + i)));
|
||||
}, [i18n.language]);
|
||||
|
||||
if (max <= 0) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t('rhythm.title', 'Your mail’s weekly rhythm')}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('rhythm.subtitle', 'Messages handled by hour and day, over the period above')}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="grid min-w-[560px] grid-cols-[2.5rem_repeat(24,minmax(0,1fr))] gap-1">
|
||||
{grid.map((row, d) => (
|
||||
<div key={d} className="contents">
|
||||
<span className="self-center text-xs text-muted-foreground">{days[d]}</span>
|
||||
{row.map((v, h) => (
|
||||
<span
|
||||
key={h}
|
||||
title={t('rhythm.cell', '{{day}} {{hour}}:00, {{count}} messages', {
|
||||
day: days[d],
|
||||
hour: String(h).padStart(2, '0'),
|
||||
count: v,
|
||||
})}
|
||||
className="aspect-square rounded-[3px] bg-[var(--chart-1)] transition-transform hover:scale-125"
|
||||
style={{ opacity: v === 0 ? 0.08 : 0.2 + 0.8 * (v / max) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<span />
|
||||
{Array.from({ length: 24 }, (_, h) => (
|
||||
<span key={h} className="text-center text-[10px] text-muted-foreground">
|
||||
{h % 6 === 0 ? h : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hrefFor, linkForMetrics } from './links';
|
||||
import { summarizeQueue } from './serverFacts';
|
||||
import { weeklyGrid } from './rhythm';
|
||||
|
||||
describe('linkForMetrics', () => {
|
||||
it('sends each number to where you act on it', () => {
|
||||
expect(linkForMetrics(['queue.count'])?.viewName).toBe('x:QueuedMessage');
|
||||
expect(linkForMetrics(['user.count'])?.viewName).toBe('x:Account/User');
|
||||
expect(linkForMetrics(['queue.message-queued'])?.viewName).toBe('x:Trace/InboundDelivery');
|
||||
expect(linkForMetrics(['queue.authenticated-message-queued'])?.viewName).toBe('x:Trace/OutboundDelivery');
|
||||
expect(linkForMetrics(['security.scan-ban'])).toMatchObject({ viewName: 'x:BlockedIp', section: 'Settings' });
|
||||
expect(linkForMetrics(['incoming-report.tls-report-with-warnings'])?.viewName).toBe('x:TlsExternalReport');
|
||||
});
|
||||
|
||||
it('takes the first metric that has a home', () => {
|
||||
expect(linkForMetrics(['no.such', 'domain.count'])?.viewName).toBe('x:Domain');
|
||||
expect(linkForMetrics(['no.such'])).toBeNull();
|
||||
});
|
||||
|
||||
it('carries filters the list page understands', () => {
|
||||
expect(hrefFor({ viewName: 'x:QueuedMessage', section: 'Management', label: '', filters: { to: 'a.com' } })).toBe(
|
||||
'/Management/x:QueuedMessage?f.to=a.com',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeQueue', () => {
|
||||
it('groups outstanding recipients by destination, busiest first', () => {
|
||||
const { waiting, retrying } = summarizeQueue([
|
||||
{
|
||||
recipients: {
|
||||
'[email protected]': { status: { '@type': 'TemporaryFailure' } },
|
||||
'[email protected]': { status: { '@type': 'Scheduled' } },
|
||||
'[email protected]': { status: { '@type': 'Completed' } },
|
||||
},
|
||||
},
|
||||
{ recipients: { '[email protected]': { status: { '@type': 'PermanentFailure' } } } },
|
||||
{ recipients: { '[email protected]': {} } },
|
||||
]);
|
||||
expect(waiting).toEqual([
|
||||
{ domain: 'gmail.com', scheduled: 2, retrying: 1, failed: 0 },
|
||||
{ domain: 'example.org', scheduled: 0, retrying: 0, failed: 1 },
|
||||
]);
|
||||
expect(retrying).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('weeklyGrid', () => {
|
||||
it('buckets samples by local weekday (Monday first) and hour', () => {
|
||||
const at = (d: Date, count: number, metric = 'queue.message-queued') => ({
|
||||
'@type': 'Counter' as const,
|
||||
metric,
|
||||
count,
|
||||
timestamp: d.toISOString(),
|
||||
});
|
||||
const monday9 = new Date(2024, 0, 1, 9, 30);
|
||||
const sunday23 = new Date(2024, 0, 7, 23, 5);
|
||||
const grid = weeklyGrid([at(monday9, 3), at(monday9, 2), at(sunday23, 1), at(monday9, 50, 'other.metric')]);
|
||||
expect(grid[0][9]).toBe(5);
|
||||
expect(grid[6][23]).toBe(1);
|
||||
expect(grid.flat().reduce((a, b) => a + b, 0)).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* Where each number on the dashboard leads: the page where you can act on
|
||||
* what it counts. Matched on the metric's name, most specific first; a card
|
||||
* or chart takes the link of its first metric that has one.
|
||||
*/
|
||||
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
|
||||
export interface DashLink {
|
||||
/** A view name from the server's layout, e.g. x:QueuedMessage. */
|
||||
viewName: string;
|
||||
/** The layout section the view lives in. */
|
||||
section: 'Management' | 'Settings';
|
||||
/** Filters applied on arrival, as the list page's own f.* URL filters. */
|
||||
filters?: Record<string, string>;
|
||||
/** What the link says, in a few words: "Open the queue". */
|
||||
label: string;
|
||||
}
|
||||
|
||||
const RULES: [RegExp, DashLink][] = [
|
||||
[/^user\.count$/, { viewName: 'x:Account/User', section: 'Management', label: 'See people' }],
|
||||
[/^domain\.count$/, { viewName: 'x:Domain', section: 'Management', label: 'See domains' }],
|
||||
[/^queue\.count$/, { viewName: 'x:QueuedMessage', section: 'Management', label: 'Open the queue' }],
|
||||
[
|
||||
/^queue\.message-queued$/,
|
||||
{ viewName: 'x:Trace/InboundDelivery', section: 'Management', label: 'See deliveries in' },
|
||||
],
|
||||
[/^queue\./, { viewName: 'x:Trace/OutboundDelivery', section: 'Management', label: 'See deliveries out' }],
|
||||
[/^delivery\./, { viewName: 'x:Trace/OutboundDelivery', section: 'Management', label: 'See deliveries out' }],
|
||||
[
|
||||
/^smtp\.connection-start$/,
|
||||
{ viewName: 'x:Trace/InboundDelivery', section: 'Management', label: 'See deliveries in' },
|
||||
],
|
||||
[/^security\.|^auth\.failed$/, { viewName: 'x:BlockedIp', section: 'Settings', label: 'See blocked IPs' }],
|
||||
[/^message-ingest\.(spam|ham)$/, { viewName: 'x:SpamSettings', section: 'Settings', label: 'Spam filter' }],
|
||||
[
|
||||
/^incoming-report\.dmarc/,
|
||||
{ viewName: 'x:DmarcExternalReport', section: 'Management', label: 'Open DMARC reports' },
|
||||
],
|
||||
[/^incoming-report\.tls/, { viewName: 'x:TlsExternalReport', section: 'Management', label: 'Open TLS reports' }],
|
||||
[/^server\.memory$|^store\./, { viewName: 'x:Log', section: 'Management', label: 'See the logs' }],
|
||||
[/^message-ingest\.|^dns\./, { viewName: 'x:Log', section: 'Management', label: 'See the logs' }],
|
||||
];
|
||||
|
||||
export function linkForMetrics(metrics: string[]): DashLink | null {
|
||||
for (const m of metrics) {
|
||||
const hit = RULES.find(([re]) => re.test(m));
|
||||
if (hit) return hit[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The address of a link, with its filters as the list page reads them. */
|
||||
export function hrefFor(link: DashLink): string {
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(link.filters ?? {})) q.set(`f.${k}`, v);
|
||||
const qs = q.toString();
|
||||
return `/${link.section}/${link.viewName}${qs ? `?${qs}` : ''}`;
|
||||
}
|
||||
|
||||
/** The page a set of metrics leads to, if the viewer may open it. */
|
||||
export function useDashLink(metrics: string[]): DashLink | null {
|
||||
const { canViewObject } = usePermissions();
|
||||
const link = linkForMetrics(metrics);
|
||||
return link && canViewObject(link.viewName) ? link : null;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Metric } from './types/metrics';
|
||||
|
||||
/** Received plus sent: every message the server handled. */
|
||||
export const RHYTHM_METRICS = [
|
||||
'queue.message-queued',
|
||||
'queue.authenticated-message-queued',
|
||||
'queue.dsn-queued',
|
||||
'queue.report-queued',
|
||||
];
|
||||
|
||||
/** Sum the samples into a 7×24 grid, Monday first, in the viewer's time zone. */
|
||||
export function weeklyGrid(samples: Metric[], metrics: string[] = RHYTHM_METRICS): number[][] {
|
||||
const want = new Set(metrics);
|
||||
const grid = Array.from({ length: 7 }, () => new Array<number>(24).fill(0));
|
||||
for (const s of samples) {
|
||||
if (!want.has(s.metric) || !s.timestamp) continue;
|
||||
const d = new Date(s.timestamp);
|
||||
if (Number.isNaN(d.getTime())) continue;
|
||||
grid[(d.getDay() + 6) % 7][d.getHours()] += s.count;
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* What the dashboard can know from the server's ordinary objects, with no
|
||||
* metrics at all: how many people, domains, queued messages, blocked
|
||||
* addresses, failed tasks and reports there are, who uses how much storage,
|
||||
* and where queued mail is stuck. One JMAP request, refreshed every minute.
|
||||
* A query the viewer isn't allowed to run just leaves that fact out.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
import type { JmapMethodCall } from '@/types/jmap';
|
||||
|
||||
export interface StorageUse {
|
||||
id: string;
|
||||
name: string;
|
||||
used: number;
|
||||
/** The account's disk quota in bytes, when it has one. */
|
||||
quota: number | null;
|
||||
}
|
||||
|
||||
export type RecipientState = 'Scheduled' | 'TemporaryFailure' | 'PermanentFailure' | 'Completed';
|
||||
|
||||
export interface WaitingDomain {
|
||||
domain: string;
|
||||
scheduled: number;
|
||||
retrying: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface ServerFacts {
|
||||
users?: number;
|
||||
groups?: number;
|
||||
domains?: number;
|
||||
queued?: number;
|
||||
blockedIps?: number;
|
||||
failedTasks?: number;
|
||||
dmarcReports?: number;
|
||||
tlsReports?: number;
|
||||
storage?: StorageUse[];
|
||||
waiting?: WaitingDomain[];
|
||||
/** Messages with at least one recipient in temporary failure. */
|
||||
retrying?: number;
|
||||
}
|
||||
|
||||
const COUNTS: [keyof ServerFacts, string, Record<string, unknown>?][] = [
|
||||
['users', 'x:Account', { '@type': 'User' }],
|
||||
['groups', 'x:Account', { '@type': 'Group' }],
|
||||
['domains', 'x:Domain'],
|
||||
['queued', 'x:QueuedMessage'],
|
||||
['blockedIps', 'x:BlockedIp'],
|
||||
['failedTasks', 'x:Task', { status: 'Failed' }],
|
||||
['dmarcReports', 'x:DmarcExternalReport'],
|
||||
['tlsReports', 'x:TlsExternalReport'],
|
||||
];
|
||||
|
||||
const DETAIL_LIMIT = 250;
|
||||
|
||||
function quotaOf(quotas: unknown): number | null {
|
||||
if (!quotas || typeof quotas !== 'object') return null;
|
||||
const q = quotas as Record<string, unknown>;
|
||||
const disk = q.maxDiskQuota ?? q.diskQuota ?? q.disk;
|
||||
return typeof disk === 'number' && disk > 0 ? disk : null;
|
||||
}
|
||||
|
||||
interface QueuedRow {
|
||||
recipients?: Record<string, { status?: { '@type'?: string } }>;
|
||||
}
|
||||
|
||||
export function summarizeQueue(list: QueuedRow[]): { waiting: WaitingDomain[]; retrying: number } {
|
||||
const byDomain = new Map<string, WaitingDomain>();
|
||||
let retrying = 0;
|
||||
for (const m of list) {
|
||||
let anyRetry = false;
|
||||
for (const [addr, r] of Object.entries(m.recipients ?? {})) {
|
||||
const state = (r.status?.['@type'] ?? 'Scheduled') as RecipientState;
|
||||
if (state === 'Completed') continue;
|
||||
const domain = addr.split('@')[1]?.toLowerCase() ?? addr;
|
||||
const row = byDomain.get(domain) ?? { domain, scheduled: 0, retrying: 0, failed: 0 };
|
||||
if (state === 'TemporaryFailure') {
|
||||
row.retrying++;
|
||||
anyRetry = true;
|
||||
} else if (state === 'PermanentFailure') row.failed++;
|
||||
else row.scheduled++;
|
||||
byDomain.set(domain, row);
|
||||
}
|
||||
if (anyRetry) retrying++;
|
||||
}
|
||||
const waiting = [...byDomain.values()].sort(
|
||||
(a, b) => b.scheduled + b.retrying + b.failed - (a.scheduled + a.retrying + a.failed),
|
||||
);
|
||||
return { waiting, retrying };
|
||||
}
|
||||
|
||||
async function fetchFacts(): Promise<ServerFacts> {
|
||||
const calls: JmapMethodCall[] = [];
|
||||
const accountFor = (obj: string) => {
|
||||
try {
|
||||
return getAccountId(obj);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
COUNTS.forEach(([key, obj, filter]) => {
|
||||
const accountId = accountFor(obj);
|
||||
if (!accountId) return;
|
||||
calls.push([`${obj}/query`, { accountId, filter, limit: 1, calculateTotal: true }, `c:${key}`]);
|
||||
});
|
||||
const accountAcct = accountFor('x:Account');
|
||||
if (accountAcct) {
|
||||
calls.push([
|
||||
'x:Account/query',
|
||||
{ accountId: accountAcct, filter: { '@type': 'User' }, limit: DETAIL_LIMIT },
|
||||
'q:storage',
|
||||
]);
|
||||
calls.push([
|
||||
'x:Account/get',
|
||||
{
|
||||
accountId: accountAcct,
|
||||
'#ids': { resultOf: 'q:storage', name: 'x:Account/query', path: '/ids' },
|
||||
properties: ['emailAddress', 'name', 'usedDiskQuota', 'quotas'],
|
||||
},
|
||||
'g:storage',
|
||||
]);
|
||||
}
|
||||
const queueAcct = accountFor('x:QueuedMessage');
|
||||
if (queueAcct) {
|
||||
calls.push(['x:QueuedMessage/query', { accountId: queueAcct, limit: DETAIL_LIMIT }, 'q:queue']);
|
||||
calls.push([
|
||||
'x:QueuedMessage/get',
|
||||
{
|
||||
accountId: queueAcct,
|
||||
'#ids': { resultOf: 'q:queue', name: 'x:QueuedMessage/query', path: '/ids' },
|
||||
properties: ['recipients'],
|
||||
},
|
||||
'g:queue',
|
||||
]);
|
||||
}
|
||||
|
||||
const responses = await jmapRequest(calls);
|
||||
const facts: ServerFacts = {};
|
||||
for (const [name, body, tag] of responses) {
|
||||
if (name === 'error') continue;
|
||||
const b = body as Record<string, unknown>;
|
||||
if (tag.startsWith('c:') && typeof b.total === 'number') {
|
||||
(facts as Record<string, unknown>)[tag.slice(2)] = b.total;
|
||||
} else if (tag === 'g:storage') {
|
||||
facts.storage = ((b.list as Record<string, unknown>[]) ?? []).map((a) => ({
|
||||
id: String(a.id),
|
||||
name: String(a.emailAddress ?? a.name ?? a.id),
|
||||
used: typeof a.usedDiskQuota === 'number' ? a.usedDiskQuota : 0,
|
||||
quota: quotaOf(a.quotas),
|
||||
}));
|
||||
} else if (tag === 'g:queue') {
|
||||
Object.assign(facts, summarizeQueue((b.list as QueuedRow[]) ?? []));
|
||||
}
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
const REFRESH_MS = 60_000;
|
||||
|
||||
export function useServerFacts() {
|
||||
const [facts, setFacts] = useState<ServerFacts | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
fetchFacts()
|
||||
.then((f) => {
|
||||
setFacts(f);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetching syncs with the server; state lands from the promise, not here.
|
||||
const first = setTimeout(refresh, 0);
|
||||
const timer = setInterval(refresh, REFRESH_MS);
|
||||
return () => {
|
||||
clearTimeout(first);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
return { facts, error, refresh };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { squarify } from './treemap';
|
||||
|
||||
describe('squarify', () => {
|
||||
it('fills the box, with areas in proportion to the values', () => {
|
||||
const tiles = squarify([6, 6, 4, 3, 2, 2, 1], (v) => v, 600, 400);
|
||||
expect(tiles).toHaveLength(7);
|
||||
const area = tiles.reduce((s, t) => s + t.w * t.h, 0);
|
||||
expect(area).toBeCloseTo(600 * 400, 3);
|
||||
for (const t of tiles) {
|
||||
expect(t.w * t.h).toBeCloseTo((t.item / 24) * 600 * 400, 3);
|
||||
expect(t.x).toBeGreaterThanOrEqual(-1e-9);
|
||||
expect(t.y).toBeGreaterThanOrEqual(-1e-9);
|
||||
expect(t.x + t.w).toBeLessThanOrEqual(600 + 1e-6);
|
||||
expect(t.y + t.h).toBeLessThanOrEqual(400 + 1e-6);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps tiles reasonably square', () => {
|
||||
const tiles = squarify([6, 6, 4, 3, 2, 2, 1], (v) => v, 600, 400);
|
||||
const worst = Math.max(...tiles.map((t) => Math.max(t.w / t.h, t.h / t.w)));
|
||||
expect(worst).toBeLessThan(4);
|
||||
});
|
||||
|
||||
it('gives nothing for nothing', () => {
|
||||
expect(squarify([0, 0], (v) => v, 100, 100)).toEqual([]);
|
||||
expect(squarify([], (v: number) => v, 100, 100)).toEqual([]);
|
||||
expect(squarify([1], (v) => v, 0, 100)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export interface Tile<T> {
|
||||
item: T;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Squarified treemap (Bruls, Huizing and van Wijk): lay the items out in a
|
||||
* w×h box, each with area proportional to its value, keeping tiles as close
|
||||
* to square as the values allow. Items with no value get no tile.
|
||||
*/
|
||||
export function squarify<T>(items: T[], value: (item: T) => number, w: number, h: number): Tile<T>[] {
|
||||
const nodes = items
|
||||
.map((item) => ({ item, v: Math.max(0, value(item)) }))
|
||||
.filter((n) => n.v > 0)
|
||||
.sort((a, b) => b.v - a.v);
|
||||
const total = nodes.reduce((s, n) => s + n.v, 0);
|
||||
if (total === 0 || w <= 0 || h <= 0) return [];
|
||||
const scale = (w * h) / total;
|
||||
const areas = nodes.map((n) => ({ item: n.item, a: n.v * scale }));
|
||||
|
||||
const out: Tile<T>[] = [];
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let rw = w;
|
||||
let rh = h;
|
||||
|
||||
const worst = (row: { a: number }[], side: number) => {
|
||||
const sum = row.reduce((s, r) => s + r.a, 0);
|
||||
const max = Math.max(...row.map((r) => r.a));
|
||||
const min = Math.min(...row.map((r) => r.a));
|
||||
return Math.max((side * side * max) / (sum * sum), (sum * sum) / (side * side * min));
|
||||
};
|
||||
|
||||
const place = (row: { item: T; a: number }[]) => {
|
||||
const sum = row.reduce((s, r) => s + r.a, 0);
|
||||
if (rw >= rh) {
|
||||
// A column on the left.
|
||||
const cw = sum / rh;
|
||||
let cy = y;
|
||||
for (const r of row) {
|
||||
const th = r.a / cw;
|
||||
out.push({ item: r.item, x, y: cy, w: cw, h: th });
|
||||
cy += th;
|
||||
}
|
||||
x += cw;
|
||||
rw -= cw;
|
||||
} else {
|
||||
// A row along the top.
|
||||
const ch = sum / rw;
|
||||
let cx = x;
|
||||
for (const r of row) {
|
||||
const tw = r.a / ch;
|
||||
out.push({ item: r.item, x: cx, y, w: tw, h: ch });
|
||||
cx += tw;
|
||||
}
|
||||
y += ch;
|
||||
rh -= ch;
|
||||
}
|
||||
};
|
||||
|
||||
let row: { item: T; a: number }[] = [];
|
||||
for (const node of areas) {
|
||||
const side = Math.min(rw, rh);
|
||||
if (row.length === 0 || worst([...row, node], side) <= worst(row, side)) {
|
||||
row.push(node);
|
||||
} else {
|
||||
place(row);
|
||||
row = [node];
|
||||
}
|
||||
}
|
||||
if (row.length) place(row);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Check, Copy, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { WizardNote, WizardShell } from '@/components/wizard/WizardShell';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RECORD_GROUPS } from './records';
|
||||
import { RESOLVER_NAME } from './liveCheck';
|
||||
import { useRecordChecks } from './useRecordChecks';
|
||||
import { ProgressRing, StateIcon } from './parts';
|
||||
import type { DomainInfo } from './ConnectDnsPage';
|
||||
import { hostLabel, pasteParts, type ZoneRecord } from './zone';
|
||||
|
||||
function CopyButton({ text, label }: { text: string; label: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [done, setDone] = useState(false);
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setDone(true);
|
||||
setTimeout(() => setDone(false), 1500);
|
||||
});
|
||||
}}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{done ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{done ? t('dnsCopy.copied', 'Copied') : label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The by-hand path, for hosts the server can't drive: every record set out
|
||||
* the way a host's DNS panel asks for it, a copy button on each part, and a
|
||||
* live tick as each one appears in public DNS.
|
||||
*/
|
||||
export function CopyStep({
|
||||
common,
|
||||
domain,
|
||||
zoneName,
|
||||
records,
|
||||
onBack,
|
||||
onDone,
|
||||
}: {
|
||||
common: Omit<Parameters<typeof WizardShell>[0], 'children'>;
|
||||
domain: DomainInfo;
|
||||
zoneName: string;
|
||||
records: ZoneRecord[];
|
||||
onBack: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { states, checking, lastChecked, check, liveCount, allLive } = useRecordChecks(records, domain.id, false);
|
||||
const pct = records.length ? Math.round((liveCount / records.length) * 100) : 0;
|
||||
const zoneText = records.map((r) => `${r.name}. IN ${r.type} ${r.value}`).join('\n');
|
||||
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
subtitle={t('dnsCopy.subtitle', 'Add these at your DNS host. Each one ticks green as the internet sees it.')}
|
||||
onBack={onBack}
|
||||
onNext={onDone}
|
||||
nextLabel={allLive ? t('dnsWizard.done', 'Done') : t('dnsCopy.later', 'I’ll finish later')}
|
||||
aside={
|
||||
<>
|
||||
<WizardNote title={t('dnsCopy.howTitle', 'How to add them')}>
|
||||
<p>
|
||||
{t(
|
||||
'dnsCopy.how1',
|
||||
'In your DNS host’s panel, add a record for each row: pick the type, paste the name and the value.',
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'dnsCopy.how2',
|
||||
'The name is shown the way most panels want it: “@” means {{zone}} itself. If yours asks for full names, add .{{zone}} to the end.',
|
||||
{ zone: zoneName },
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'dnsCopy.how3',
|
||||
'Set the TTL to Auto or one hour. If a record with the same name and type exists, replace it.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
<WizardNote title={t('dnsWizard.howChecked', 'How this is checked')}>
|
||||
<p>
|
||||
{t(
|
||||
'dnsWizard.howCheckedBody',
|
||||
'Every few seconds this page asks {{resolver}} for each record, so a green tick means the whole internet can see it.',
|
||||
{ resolver: RESOLVER_NAME },
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<ProgressRing pct={pct} done={allLive} />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{allLive
|
||||
? t('dnsWizard.allLive', 'All set. {{domain}} is live.', { domain: domain.name })
|
||||
: t('dnsWizard.progress', '{{live}} of {{total}} records are live', {
|
||||
live: liveCount,
|
||||
total: records.length,
|
||||
})}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1 text-xs text-muted-foreground">
|
||||
<Button variant="outline" size="sm" onClick={() => void check()} disabled={checking}>
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', checking && 'animate-spin')} />
|
||||
{t('dnsWizard.checkNow', 'Check now')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void navigator.clipboard.writeText(zoneText)}
|
||||
title={t('dnsCopy.zoneHint', 'For hosts that can import a zone file')}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t('dnsCopy.copyZone', 'Copy all as a zone file')}
|
||||
</Button>
|
||||
{lastChecked &&
|
||||
t('dnsWizard.lastChecked', 'Last checked {{time}}', { time: lastChecked.toLocaleTimeString() })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{RECORD_GROUPS.map((g) => {
|
||||
const rows = records.filter((r) => g.kinds.some((k) => k.kind === r.kind));
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<section key={g.id} className="space-y-2">
|
||||
<div>
|
||||
<h3 className="font-medium">{g.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{g.why}</p>
|
||||
</div>
|
||||
<div className="divide-y rounded-xl border">
|
||||
{rows.map((r, i) => {
|
||||
const host = hostLabel(r.name, zoneName);
|
||||
const { value, priority } = pasteParts(r);
|
||||
return (
|
||||
<div
|
||||
key={`${r.name}-${r.type}-${i}`}
|
||||
className="grid grid-cols-[1.25rem_minmax(0,1fr)] gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid-cols-[1.25rem_4.5rem_minmax(0,12rem)_minmax(0,1fr)] sm:items-center"
|
||||
>
|
||||
<StateIcon state={states.get(r)} />
|
||||
<span className="flex flex-col font-mono text-xs font-semibold leading-tight">
|
||||
{r.type}
|
||||
{priority && (
|
||||
<span className="font-sans text-[11px] font-normal text-muted-foreground">
|
||||
{t('dnsCopy.priority', 'priority {{p}}', { p: priority })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="col-start-2 flex min-w-0 items-center gap-1 sm:col-start-auto">
|
||||
<span className="truncate font-mono text-xs" title={r.name}>
|
||||
{host}
|
||||
</span>
|
||||
<CopyButton text={host} label={t('dnsCopy.copyName', 'Copy name')} />
|
||||
</span>
|
||||
<span className="col-start-2 flex min-w-0 items-center gap-1 sm:col-start-auto">
|
||||
<span className="truncate font-mono text-xs text-muted-foreground" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
<CopyButton text={value} label={t('dnsCopy.copyValue', 'Copy value')} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{records.some((r) => states.get(r) === 'different') && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<AlertTriangle className="mr-1 inline h-3.5 w-3.5 text-highlight" />
|
||||
{t(
|
||||
'dnsWizard.differentHelp',
|
||||
'An amber mark means that name has a different value right now, often an old record that hasn’t expired from caches yet.',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle2, Wand2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LaunchChoice } from '@/components/wizard/LaunchChoice';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
|
||||
/**
|
||||
* At the top of a domain's DNS section: an invitation to have the server
|
||||
* publish the records itself. It asks "guided or manual?" every time; manual
|
||||
* closes the question and leaves you at the DNS Management field just below.
|
||||
*/
|
||||
export function DnsConnectCard({ domainId, automatic }: { domainId: string; automatic: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const section = useSchemaStore((s) => s.viewToSection['x:Domain']) ?? 'Management';
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-primary/30 bg-primary/5 p-4">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
||||
{automatic ? <CheckCircle2 className="h-5 w-5" /> : <Wand2 className="h-5 w-5" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
{automatic
|
||||
? t('dnsCard.autoTitle', 'The server keeps this domain’s DNS up to date')
|
||||
: t('dnsCard.title', 'Let the server publish your DNS records')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{automatic
|
||||
? t('dnsCard.autoHint', 'See which records are live, or change what it publishes.')
|
||||
: t(
|
||||
'dnsCard.hint',
|
||||
'Connect your DNS host, such as Cloudflare, and skip copying records by hand. Optional.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant={automatic ? 'outline' : 'default'} onClick={() => setOpen(true)}>
|
||||
{automatic ? t('dnsCard.review', 'Check records') : t('dnsCard.start', 'Set it up')}
|
||||
</Button>
|
||||
<LaunchChoice
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={t('dnsCard.chooseTitle', 'Automatic DNS')}
|
||||
guidedHint={t(
|
||||
'dnsCard.guidedHint',
|
||||
'Pick your DNS host, paste a key, choose records, and watch them go live. About two minutes.',
|
||||
)}
|
||||
manualHint={t('dnsCard.manualHint', 'Use the DNS Management setting below, with every option at once.')}
|
||||
onGuided={() => navigate(`/${section}/Wizard/dns/${domainId}`)}
|
||||
onManual={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { providerForNameservers } from './detect';
|
||||
|
||||
describe('providerForNameservers', () => {
|
||||
it('knows the big hosts', () => {
|
||||
expect(providerForNameservers(['dean.ns.cloudflare.com', 'gina.ns.cloudflare.com.'])).toBe('Cloudflare');
|
||||
expect(providerForNameservers(['ns-421.awsdns-52.com', 'ns-1707.awsdns-21.co.uk'])).toBe('Route53');
|
||||
expect(providerForNameservers(['ns-cloud-a1.googledomains.com'])).toBe('GoogleCloudDns');
|
||||
expect(providerForNameservers(['ns1-01.azure-dns.com', 'ns2-01.azure-dns.net'])).toBe('AzureDns');
|
||||
expect(providerForNameservers(['ns1.digitalocean.com'])).toBe('DigitalOcean');
|
||||
expect(providerForNameservers(['hydrogen.ns.hetzner.com', 'helium.ns.hetzner.de'])).toBe('Hetzner');
|
||||
expect(providerForNameservers(['ns01.domaincontrol.com'])).toBe('Godaddy');
|
||||
expect(providerForNameservers(['dns1.gandi.net', 'e.gandi-ns.fr'])).toBe('GandiV5');
|
||||
});
|
||||
|
||||
it('refuses to guess for split or unknown hosting', () => {
|
||||
expect(providerForNameservers(['dns1.p08.nsone.net', 'ns-421.awsdns-52.com'])).toBeNull();
|
||||
expect(providerForNameservers(['ns1.example-hosting.net'])).toBeNull();
|
||||
expect(providerForNameservers(['dean.ns.cloudflare.com', 'ns1.example-hosting.net'])).toBeNull();
|
||||
expect(providerForNameservers([])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* Where is a domain's DNS hosted? Found the way any resolver would: the SOA
|
||||
* record names the zone that holds the domain (which may be a parent zone),
|
||||
* and the zone's NS records name the host. The host is then matched against
|
||||
* nameserver patterns we know for the providers the server can drive.
|
||||
*/
|
||||
import { dohQuery } from './liveCheck';
|
||||
|
||||
export interface DnsHosting {
|
||||
/** The zone holding the domain, e.g. example.com for mail.example.com. */
|
||||
zone: string;
|
||||
nameservers: string[];
|
||||
/** The server's provider type, when the host is one it can update. */
|
||||
variant: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nameserver suffixes by provider type. Only hosts whose nameservers are
|
||||
* unambiguous are listed: a wrong guess costs the user more than no guess.
|
||||
*/
|
||||
const NAMESERVERS: [RegExp, string][] = [
|
||||
[/\.ns\.cloudflare\.com$/, 'Cloudflare'],
|
||||
[/\.awsdns-\d+\.(com|net|org|co\.uk)$/, 'Route53'],
|
||||
[/^ns-cloud-[a-z]\d+\.googledomains\.com$/, 'GoogleCloudDns'],
|
||||
[/\.azure-dns\.(com|net|org|info)$/, 'AzureDns'],
|
||||
[/^ns\d\.digitalocean\.com$/, 'DigitalOcean'],
|
||||
[/\.ns\.hetzner\.(com|de)$/, 'Hetzner'],
|
||||
[/\.(ovh\.net|ovh\.ca|anycast\.me)$/, 'Ovh'],
|
||||
[/\.domaincontrol\.com$/, 'Godaddy'],
|
||||
[/\.porkbun\.com$/, 'Porkbun'],
|
||||
[/^ns\d\.desec\.(io|org)$/, 'DeSEC'],
|
||||
[/\.linode\.com$/, 'Linode'],
|
||||
[/\.vultr\.com$/, 'Vultr'],
|
||||
[/\.gandi\.net$|\.gandi-ns\.(fr|com|net)$/, 'GandiV5'],
|
||||
[/\.registrar-servers\.com$/, 'Namecheap'],
|
||||
[/\.dnsimple(-edge)?\.(com|net|org|info)$/, 'Dnsimple'],
|
||||
[/\.bunny\.net$/, 'Bunny'],
|
||||
[/\.nsone\.net$/, 'Ns1'],
|
||||
[/\.ui-dns\.(com|de|org|biz)$/, 'Ionos'],
|
||||
[/\.dnsmadeeasy\.com$/, 'DnsMadeEasy'],
|
||||
[/\.cloudns\.net$/, 'ClouDns'],
|
||||
[/^ns\d\.he\.net$/, 'Hurricane'],
|
||||
[/\.vercel-dns\.com$/, 'Vercel'],
|
||||
[/\.name\.com$/, 'NameDotCom'],
|
||||
[/\.inwx\.(de|net|eu)$/, 'Inwx'],
|
||||
[/\.transip\.(nl|net|eu)$/, 'Transip'],
|
||||
[/\.scw\.cloud$/, 'Scaleway'],
|
||||
[/\.infomaniak\.ch$/, 'Infomaniak'],
|
||||
[/\.dns-parking\.com$/, 'Hostinger'],
|
||||
[/\.akam\.net$/, 'EdgeDns'],
|
||||
[/\.exoscale\.(ch|net|io|com)$/, 'Exoscale'],
|
||||
[/\.netcup\.net$/, 'Netcup'],
|
||||
[/\.joker\.com$/, 'Joker'],
|
||||
[/\.glesys\.se$/, 'Glesys'],
|
||||
[/\.dreamhost\.com$/, 'Dreamhost'],
|
||||
[/\.easydns\.(com|net|org|info)$/, 'EasyDns'],
|
||||
[/\.ultradns\.(com|net|org|biz|info|co\.uk)$/, 'UltraDns'],
|
||||
[/\.mythic-beasts\.com$/, 'MythicBeasts'],
|
||||
[/\.luadns\.net$/, 'LuaDns'],
|
||||
[/\.spaceship\.net$/, 'Spaceship'],
|
||||
[/\.hosting\.de$/, 'HostingDe'],
|
||||
];
|
||||
|
||||
/** The provider type for a set of nameservers, if they all point to one we know. */
|
||||
export function providerForNameservers(nameservers: string[]): string | null {
|
||||
const hits = new Set<string>();
|
||||
for (const ns of nameservers) {
|
||||
const host = ns.toLowerCase().replace(/\.$/, '');
|
||||
const match = NAMESERVERS.find(([re]) => re.test(host));
|
||||
if (!match) return null;
|
||||
hits.add(match[1]);
|
||||
}
|
||||
return hits.size === 1 ? [...hits][0] : null;
|
||||
}
|
||||
|
||||
/** Find the zone and host for a domain; null when it isn't in public DNS yet. */
|
||||
export async function detectHosting(domain: string, signal?: AbortSignal): Promise<DnsHosting | null> {
|
||||
const soa = await dohQuery(domain, 'SOA', signal);
|
||||
const zoneRecord = [...soa.answer, ...soa.authority].find((a) => a.type === 6);
|
||||
const zone = zoneRecord?.name.replace(/\.$/, '').toLowerCase();
|
||||
// NXDOMAIN answers carry the TLD's SOA: that's "not registered", not a zone.
|
||||
if (!zone || !zone.includes('.') || soa.status === 3) return null;
|
||||
const ns = await dohQuery(zone, 'NS', signal);
|
||||
const nameservers = ns.answer.filter((a) => a.type === 2).map((a) => a.data.replace(/\.$/, '').toLowerCase());
|
||||
if (nameservers.length === 0) return null;
|
||||
return { zone, nameservers, variant: providerForNameservers(nameservers) };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* Is a record live in public DNS yet? Asked from the browser over DNS-over-HTTPS,
|
||||
* so the answer is what the rest of the internet sees, not what the mail
|
||||
* server believes it wrote. The resolver sees the names being checked, which
|
||||
* are public DNS names anyway; the page says which resolver it uses.
|
||||
*/
|
||||
import { normalizeValue, type ZoneRecord } from './zone';
|
||||
|
||||
export const RESOLVER_NAME = 'Cloudflare public DNS (1.1.1.1)';
|
||||
const RESOLVER = 'https://cloudflare-dns.com/dns-query';
|
||||
|
||||
/** Resource record type numbers, for reading the JSON answer. */
|
||||
const TYPE_NUMBERS: Record<string, number> = {
|
||||
A: 1,
|
||||
CNAME: 5,
|
||||
MX: 15,
|
||||
TXT: 16,
|
||||
AAAA: 28,
|
||||
SRV: 33,
|
||||
TLSA: 52,
|
||||
CAA: 257,
|
||||
};
|
||||
|
||||
export type LiveState = 'live' | 'different' | 'missing' | 'error';
|
||||
|
||||
export interface DohRecord {
|
||||
name: string;
|
||||
type: number;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface DohResult {
|
||||
/** 0 is an answer, 3 is NXDOMAIN (the name doesn't exist). */
|
||||
status: number;
|
||||
answer: DohRecord[];
|
||||
authority: DohRecord[];
|
||||
}
|
||||
|
||||
/** One DNS-over-HTTPS question, answered in the resolver's JSON form. */
|
||||
export async function dohQuery(name: string, type: string, signal?: AbortSignal): Promise<DohResult> {
|
||||
const url = `${RESOLVER}?name=${encodeURIComponent(name)}&type=${encodeURIComponent(type)}`;
|
||||
const res = await fetch(url, { headers: { Accept: 'application/dns-json' }, signal, cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`resolver answered ${res.status}`);
|
||||
const body = (await res.json()) as { Status: number; Answer?: DohRecord[]; Authority?: DohRecord[] };
|
||||
return { status: body.Status, answer: body.Answer ?? [], authority: body.Authority ?? [] };
|
||||
}
|
||||
|
||||
async function lookup(name: string, type: string, signal?: AbortSignal): Promise<string[]> {
|
||||
const { status, answer } = await dohQuery(name, type, signal);
|
||||
// NXDOMAIN (3) and "no data" both simply mean: not there yet.
|
||||
if (status !== 0 && status !== 3) throw new Error(`resolver status ${status}`);
|
||||
const want = TYPE_NUMBERS[type];
|
||||
return answer.filter((a) => want === undefined || a.type === want).map((a) => a.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a batch of records. Records sharing a name and type are looked up
|
||||
* once. A record is `live` when its exact value is published, `different`
|
||||
* when that name has other values of the type (another provider's SPF, say),
|
||||
* and `missing` when there is nothing.
|
||||
*/
|
||||
export async function checkRecords(records: ZoneRecord[], signal?: AbortSignal): Promise<Map<ZoneRecord, LiveState>> {
|
||||
const groups = new Map<string, ZoneRecord[]>();
|
||||
for (const r of records) {
|
||||
const key = `${r.name.toLowerCase()}|${r.type}`;
|
||||
groups.set(key, [...(groups.get(key) ?? []), r]);
|
||||
}
|
||||
const out = new Map<ZoneRecord, LiveState>();
|
||||
await Promise.all(
|
||||
[...groups.values()].map(async (group) => {
|
||||
const { name, type } = group[0];
|
||||
try {
|
||||
const found = (await lookup(name, type, signal)).map((d) => normalizeValue(type, d));
|
||||
for (const r of group) {
|
||||
const want = normalizeValue(type, r.value);
|
||||
out.set(r, found.includes(want) ? 'live' : found.length > 0 ? 'different' : 'missing');
|
||||
}
|
||||
} catch {
|
||||
for (const r of group) out.set(r, 'error');
|
||||
}
|
||||
}),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AlertTriangle, CheckCircle2, CircleDashed, Loader2, XCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { LiveState } from './liveCheck';
|
||||
|
||||
/** One record's state in public DNS, as an icon. */
|
||||
export function StateIcon({ state }: { state?: LiveState }) {
|
||||
switch (state) {
|
||||
case 'live':
|
||||
return <CheckCircle2 className="h-4 w-4 text-emerald-500 animate-in zoom-in" />;
|
||||
case 'different':
|
||||
return <AlertTriangle className="h-4 w-4 text-highlight" />;
|
||||
case 'error':
|
||||
return <XCircle className="h-4 w-4 text-destructive" />;
|
||||
case 'missing':
|
||||
return <CircleDashed className="h-4 w-4 text-muted-foreground" />;
|
||||
default:
|
||||
return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />;
|
||||
}
|
||||
}
|
||||
|
||||
/** How many of the records are live, as a ring that fills. */
|
||||
export function ProgressRing({ pct, done }: { pct: number; done: boolean }) {
|
||||
const r = 30;
|
||||
const c = 2 * Math.PI * r;
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" className="h-20 w-20 shrink-0 -rotate-90" role="img" aria-label={`${pct}%`}>
|
||||
<circle cx="36" cy="36" r={r} fill="none" strokeWidth="7" className="stroke-muted" />
|
||||
<circle
|
||||
cx="36"
|
||||
cy="36"
|
||||
r={r}
|
||||
fill="none"
|
||||
strokeWidth="7"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={c}
|
||||
strokeDashoffset={c - (c * pct) / 100}
|
||||
className={cn('transition-[stroke-dashoffset] duration-700', done ? 'stroke-emerald-500' : 'stroke-primary')}
|
||||
/>
|
||||
<text
|
||||
x="36"
|
||||
y="36"
|
||||
dominantBaseline="central"
|
||||
textAnchor="middle"
|
||||
className="rotate-90 fill-foreground text-[15px] font-semibold"
|
||||
style={{ transformOrigin: '36px 36px' }}
|
||||
>
|
||||
{pct}%
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* The DNS providers the guided setup leads with, and how to get each one's
|
||||
* credentials. The server supports many more; they're all offered under
|
||||
* "Another provider", with their fields taken from the server's schema.
|
||||
*
|
||||
* `variant` is the server's name for the provider type. The steps are ours:
|
||||
* keep them short, and aim for the narrowest credential that works.
|
||||
*/
|
||||
|
||||
export interface ProviderGuide {
|
||||
variant: string;
|
||||
name: string;
|
||||
/** One line on the tile. */
|
||||
blurb: string;
|
||||
/** How to make a credential, in order. */
|
||||
steps: string[];
|
||||
/** Where to start, on the provider's own site. */
|
||||
link?: string;
|
||||
/** The fields named the way the steps above name them. */
|
||||
fields?: Record<string, { label: string; hint?: string }>;
|
||||
}
|
||||
|
||||
export const FEATURED: ProviderGuide[] = [
|
||||
{
|
||||
variant: 'Cloudflare',
|
||||
name: 'Cloudflare',
|
||||
blurb: 'An API token limited to this one zone.',
|
||||
steps: [
|
||||
'In the Cloudflare dashboard, open My Profile → API Tokens and choose Create Token.',
|
||||
'Use the "Edit zone DNS" template.',
|
||||
'Under Zone Resources, pick Include → Specific zone → your domain, so the token can touch nothing else.',
|
||||
'Create the token and paste it below. Cloudflare shows it only once.',
|
||||
],
|
||||
link: 'https://dash.cloudflare.com/profile/api-tokens',
|
||||
fields: { secret: { label: 'API token', hint: 'The token from step 4.' } },
|
||||
},
|
||||
{
|
||||
variant: 'Route53',
|
||||
name: 'Amazon Route 53',
|
||||
blurb: 'An IAM access key allowed to change one hosted zone.',
|
||||
steps: [
|
||||
'In IAM, create a user or role for the mail server.',
|
||||
'Give it a policy that allows route53:ChangeResourceRecordSets and route53:ListResourceRecordSets on your hosted zone only, plus route53:ListHostedZonesByName.',
|
||||
'Create an access key for it and paste the key ID and secret below.',
|
||||
],
|
||||
link: 'https://console.aws.amazon.com/iam/',
|
||||
},
|
||||
{
|
||||
variant: 'GoogleCloudDns',
|
||||
name: 'Google Cloud DNS',
|
||||
blurb: 'A service account with the DNS Administrator role.',
|
||||
steps: [
|
||||
'In IAM & Admin → Service Accounts, create an account for the mail server.',
|
||||
'Grant it the DNS Administrator role on the project that holds your zone.',
|
||||
'Create a JSON key for it and paste the details below.',
|
||||
],
|
||||
link: 'https://console.cloud.google.com/iam-admin/serviceaccounts',
|
||||
},
|
||||
{
|
||||
variant: 'AzureDns',
|
||||
name: 'Azure DNS',
|
||||
blurb: 'An app registration with DNS Zone Contributor on your zone.',
|
||||
steps: [
|
||||
'Register an application in Microsoft Entra ID and create a client secret.',
|
||||
'On your DNS zone, open Access control (IAM) and give the app the DNS Zone Contributor role.',
|
||||
'Paste the tenant, client and subscription IDs and the secret below.',
|
||||
],
|
||||
link: 'https://portal.azure.com/',
|
||||
},
|
||||
{
|
||||
variant: 'DigitalOcean',
|
||||
name: 'DigitalOcean',
|
||||
blurb: 'A personal access token with domain access.',
|
||||
steps: [
|
||||
'Open API → Tokens and generate a new token.',
|
||||
'Give it the "domain" scopes (read and update) only.',
|
||||
'Paste the token below.',
|
||||
],
|
||||
link: 'https://cloud.digitalocean.com/account/api/tokens',
|
||||
fields: { secret: { label: 'Access token' } },
|
||||
},
|
||||
{
|
||||
variant: 'Hetzner',
|
||||
name: 'Hetzner DNS',
|
||||
blurb: 'A DNS API token.',
|
||||
steps: ['In the Hetzner DNS console, open API tokens and create one.', 'Paste the token below.'],
|
||||
link: 'https://dns.hetzner.com/settings/api-token',
|
||||
fields: { secret: { label: 'API token' } },
|
||||
},
|
||||
{
|
||||
variant: 'Ovh',
|
||||
name: 'OVHcloud',
|
||||
blurb: 'An application key and consumer key for the DNS API.',
|
||||
steps: [
|
||||
'Create API keys for your region, allowing GET, POST, PUT and DELETE on /domain/zone/*.',
|
||||
'Paste the application key, application secret and consumer key below.',
|
||||
],
|
||||
link: 'https://api.ovh.com/createToken/',
|
||||
},
|
||||
{
|
||||
variant: 'Godaddy',
|
||||
name: 'GoDaddy',
|
||||
blurb: 'A production API key and secret.',
|
||||
steps: [
|
||||
'In the GoDaddy developer portal, create a Production API key.',
|
||||
'Paste the key and secret below. GoDaddy only allows API access on some account types.',
|
||||
],
|
||||
link: 'https://developer.godaddy.com/keys',
|
||||
},
|
||||
{
|
||||
variant: 'Porkbun',
|
||||
name: 'Porkbun',
|
||||
blurb: 'An API key pair, with API access turned on for the domain.',
|
||||
steps: [
|
||||
'Open Account → API Access and create an API key.',
|
||||
'In Domain Management, turn on API Access for this domain.',
|
||||
'Paste the API key and secret below.',
|
||||
],
|
||||
link: 'https://porkbun.com/account/api',
|
||||
},
|
||||
{
|
||||
variant: 'DeSEC',
|
||||
name: 'deSEC',
|
||||
blurb: 'A token, ideally limited to this domain.',
|
||||
steps: [
|
||||
'In deSEC, open Token Management and create a token.',
|
||||
'Restrict it to this domain if you can, and paste it below.',
|
||||
],
|
||||
link: 'https://desec.io/tokens',
|
||||
fields: { secret: { label: 'Token' } },
|
||||
},
|
||||
];
|
||||
|
||||
/** Provider types the guided setup doesn't offer: retired ones. */
|
||||
export const HIDDEN_VARIANTS = new Set(['Deprecated1']);
|
||||
|
||||
/**
|
||||
* Fields the guided setup leaves at the server's defaults: timing and
|
||||
* bookkeeping. They stay editable on the DNS provider's own page.
|
||||
*/
|
||||
export const ADVANCED_FIELDS = new Set([
|
||||
'description',
|
||||
'memberTenantId',
|
||||
'pollingInterval',
|
||||
'propagationDelay',
|
||||
'propagationTimeout',
|
||||
'timeout',
|
||||
'ttl',
|
||||
]);
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { RecordKind } from './zone';
|
||||
|
||||
export interface RecordGroup {
|
||||
id: string;
|
||||
title: string;
|
||||
why: string;
|
||||
kinds: { kind: RecordKind; label: string; caution?: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The record types, grouped by what they do for you rather than by
|
||||
* their DNS type. The labels say what switching one on achieves.
|
||||
*/
|
||||
export const RECORD_GROUPS: RecordGroup[] = [
|
||||
{
|
||||
id: 'deliver',
|
||||
title: 'Receive mail',
|
||||
why: 'Tells the rest of the internet where mail for this domain goes.',
|
||||
kinds: [{ kind: 'mx', label: 'Mail exchanger (MX)' }],
|
||||
},
|
||||
{
|
||||
id: 'trust',
|
||||
title: 'Prove mail is really yours',
|
||||
why: 'Without these, big providers send your mail to spam or refuse it.',
|
||||
kinds: [
|
||||
{ kind: 'spf', label: 'Allowed senders (SPF)' },
|
||||
{ kind: 'dkim', label: 'Signing keys (DKIM)' },
|
||||
{ kind: 'dmarc', label: 'What to do with fakes (DMARC)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'secure',
|
||||
title: 'Keep mail encrypted on the way',
|
||||
why: 'Asks other servers to only deliver to you over a verified, encrypted connection, and to report when they can’t.',
|
||||
kinds: [
|
||||
{ kind: 'mtaSts', label: 'Require encryption (MTA-STS)' },
|
||||
{ kind: 'tlsRpt', label: 'Encryption failure reports (TLS-RPT)' },
|
||||
{
|
||||
kind: 'tlsa',
|
||||
label: 'Certificate pinning (DANE / TLSA)',
|
||||
caution: 'Only useful when the zone is signed with DNSSEC. Leave off unless you know it is.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'apps',
|
||||
title: 'Let mail apps set themselves up',
|
||||
why: 'People type their address and password; Thunderbird, Apple Mail, Outlook and phones find the rest.',
|
||||
kinds: [
|
||||
{ kind: 'srv', label: 'Service records (SRV)' },
|
||||
{ kind: 'autoConfig', label: 'Autoconfig' },
|
||||
{ kind: 'autoConfigLegacy', label: 'Thunderbird autoconfig' },
|
||||
{ kind: 'autoDiscover', label: 'Outlook autodiscover' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'certs',
|
||||
title: 'Limit who can issue certificates',
|
||||
why: 'Names the certificate authorities allowed to issue for this domain.',
|
||||
kinds: [{ kind: 'caa', label: 'Certificate authorities (CAA)' }],
|
||||
},
|
||||
];
|
||||
|
||||
/** The guided default: everything but TLSA, which needs DNSSEC to mean anything. */
|
||||
export const DEFAULT_KINDS: RecordKind[] = RECORD_GROUPS.flatMap((g) => g.kinds.map((k) => k.kind)).filter(
|
||||
(k) => k !== 'tlsa',
|
||||
);
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getAccountId, jmapQueryAndGet } from '@/services/jmap/client';
|
||||
import { checkRecords, type LiveState } from './liveCheck';
|
||||
import type { ZoneRecord } from './zone';
|
||||
|
||||
export interface DnsTask {
|
||||
id: string;
|
||||
'@type': string;
|
||||
domainId?: string;
|
||||
status?: { '@type': string; failureReason?: string };
|
||||
}
|
||||
|
||||
const POLL_MS = 8000;
|
||||
const GIVE_UP_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* Keep checking a set of records in public DNS, every few seconds for ten
|
||||
* minutes, and (for automatic DNS) the server's publishing task for the
|
||||
* domain. `task` is undefined until first read, null when none is queued.
|
||||
*/
|
||||
export function useRecordChecks(records: ZoneRecord[], domainId: string, watchTask: boolean) {
|
||||
const [states, setStates] = useState<Map<ZoneRecord, LiveState>>(new Map());
|
||||
const [task, setTask] = useState<DnsTask | null | undefined>(undefined);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [lastChecked, setLastChecked] = useState<Date | null>(null);
|
||||
const started = useRef<number | null>(null);
|
||||
|
||||
const check = useCallback(async () => {
|
||||
setChecking(true);
|
||||
if (watchTask) {
|
||||
try {
|
||||
const [, getRes] = await jmapQueryAndGet('x:Task', getAccountId('x:Task'), {}, ['@type', 'domainId', 'status']);
|
||||
const list = ((getRes?.[1] as { list?: DnsTask[] })?.list ?? []).filter(
|
||||
(x) => x['@type'] === 'DnsManagement' && x.domainId === domainId,
|
||||
);
|
||||
setTask(list[0] ?? null);
|
||||
} catch {
|
||||
setTask(null);
|
||||
}
|
||||
}
|
||||
setStates(await checkRecords(records));
|
||||
setLastChecked(new Date());
|
||||
setChecking(false);
|
||||
}, [domainId, records, watchTask]);
|
||||
|
||||
useEffect(() => {
|
||||
// Checking DNS and the task list syncs with outside systems, so state
|
||||
// lands from their callbacks, never synchronously in the effect.
|
||||
const first = setTimeout(() => {
|
||||
started.current = Date.now();
|
||||
void check();
|
||||
}, 0);
|
||||
const timer = setInterval(() => {
|
||||
if (started.current !== null && Date.now() - started.current > GIVE_UP_MS) return;
|
||||
void check();
|
||||
}, POLL_MS);
|
||||
return () => {
|
||||
clearTimeout(first);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [check]);
|
||||
|
||||
const liveCount = records.filter((r) => states.get(r) === 'live').length;
|
||||
return {
|
||||
states,
|
||||
task,
|
||||
checking,
|
||||
lastChecked,
|
||||
check,
|
||||
liveCount,
|
||||
allLive: records.length > 0 && liveCount === records.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hostLabel, normalizeValue, parseZone, pasteParts, summarizeFailure } from './zone';
|
||||
|
||||
const ZONE = `mail.example.com. IN TXT "v=spf1 a -all"
|
||||
example.com. IN TXT "v=spf1 mx -all"
|
||||
example.com. IN MX 10 mail.example.com.
|
||||
_dmarc.example.com. IN TXT "v=DMARC1; p=reject; rua=mailto:[email protected]"
|
||||
s1._domainkey.example.com. 300 IN TXT "v=DKIM1; k=ed25519; p=abc"
|
||||
_jmap._tcp.example.com. IN SRV 0 1 443 mail.example.com.
|
||||
mta-sts.example.com. IN CNAME mail.example.com.
|
||||
_mta-sts.example.com. IN TXT "v=STSv1; id=1"
|
||||
_smtp._tls.example.com. IN TXT "v=TLSRPTv1; rua=mailto:[email protected]"
|
||||
ua-auto-config.example.com. IN CNAME mail.example.com.
|
||||
autoconfig.example.com. IN CNAME mail.example.com.
|
||||
autodiscover.example.com. IN CNAME mail.example.com.
|
||||
example.com. IN CAA 0 issue "letsencrypt.org"
|
||||
_25._tcp.mail.example.com. IN TLSA 3 1 1 abcdef
|
||||
; a comment
|
||||
www.example.com. IN A 192.0.2.1`;
|
||||
|
||||
describe('parseZone', () => {
|
||||
const records = parseZone(ZONE);
|
||||
const kind = (name: string, type: string) => records.find((r) => r.name === name && r.type === type)?.kind;
|
||||
|
||||
it('sorts every record the server owns into its group', () => {
|
||||
expect(kind('example.com', 'MX')).toBe('mx');
|
||||
expect(kind('mail.example.com', 'TXT')).toBe('spf');
|
||||
expect(kind('example.com', 'TXT')).toBe('spf');
|
||||
expect(kind('_dmarc.example.com', 'TXT')).toBe('dmarc');
|
||||
expect(kind('s1._domainkey.example.com', 'TXT')).toBe('dkim');
|
||||
expect(kind('_jmap._tcp.example.com', 'SRV')).toBe('srv');
|
||||
expect(kind('mta-sts.example.com', 'CNAME')).toBe('mtaSts');
|
||||
expect(kind('_mta-sts.example.com', 'TXT')).toBe('mtaSts');
|
||||
expect(kind('_smtp._tls.example.com', 'TXT')).toBe('tlsRpt');
|
||||
expect(kind('ua-auto-config.example.com', 'CNAME')).toBe('autoConfig');
|
||||
expect(kind('autoconfig.example.com', 'CNAME')).toBe('autoConfigLegacy');
|
||||
expect(kind('autodiscover.example.com', 'CNAME')).toBe('autoDiscover');
|
||||
expect(kind('example.com', 'CAA')).toBe('caa');
|
||||
expect(kind('_25._tcp.mail.example.com', 'TLSA')).toBe('tlsa');
|
||||
});
|
||||
|
||||
it('skips comments and records it cannot place', () => {
|
||||
expect(records.some((r) => r.name === 'www.example.com')).toBe(false);
|
||||
expect(records).toHaveLength(14);
|
||||
});
|
||||
|
||||
it('reads past an optional TTL', () => {
|
||||
expect(records.find((r) => r.kind === 'dkim')?.value).toBe('"v=DKIM1; k=ed25519; p=abc"');
|
||||
});
|
||||
|
||||
it('copes with nothing', () => {
|
||||
expect(parseZone(undefined)).toEqual([]);
|
||||
expect(parseZone('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeValue', () => {
|
||||
it('joins TXT strings a resolver split up', () => {
|
||||
expect(normalizeValue('TXT', '"v=DKIM1; p=ab" "cd"')).toBe('v=DKIM1; p=abcd');
|
||||
expect(normalizeValue('TXT', '"v=spf1 mx -all"')).toBe(normalizeValue('TXT', '"v=spf1 mx -all"'));
|
||||
});
|
||||
|
||||
it('ignores case and trailing dots on names', () => {
|
||||
expect(normalizeValue('MX', '10 Mail.Example.com.')).toBe('10 mail.example.com');
|
||||
expect(normalizeValue('CNAME', 'mail.example.com.')).toBe(normalizeValue('CNAME', 'mail.example.com'));
|
||||
});
|
||||
|
||||
it('drops the quotes in CAA values', () => {
|
||||
expect(normalizeValue('CAA', '0 issue "letsencrypt.org"')).toBe('0 issue letsencrypt.org');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hostLabel', () => {
|
||||
it('names records relative to their zone', () => {
|
||||
expect(hostLabel('example.com', 'example.com')).toBe('@');
|
||||
expect(hostLabel('_dmarc.example.com', 'example.com')).toBe('_dmarc');
|
||||
expect(hostLabel('_dmarc.mail.example.com.', 'Example.com')).toBe('_dmarc.mail');
|
||||
expect(hostLabel('other.org', 'example.com')).toBe('other.org');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pasteParts', () => {
|
||||
const rec = (type: string, value: string) => ({ name: 'example.com', type, value, kind: 'mx' as const });
|
||||
it('unquotes and joins TXT', () => {
|
||||
expect(pasteParts(rec('TXT', '"v=DKIM1; p=ab" "cd"')).value).toBe('v=DKIM1; p=abcd');
|
||||
});
|
||||
it('splits the MX priority out', () => {
|
||||
expect(pasteParts(rec('MX', '10 mail.example.com.'))).toEqual({ value: 'mail.example.com', priority: '10' });
|
||||
});
|
||||
it('drops the trailing dot elsewhere', () => {
|
||||
expect(pasteParts(rec('CNAME', 'mail.example.com.')).value).toBe('mail.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeFailure', () => {
|
||||
const cf =
|
||||
'{"success":false,"errors":[{"code":6003,"message":"Invalid request headers","error_chain":[{"code":6111,"message":"Invalid format for Authorization header"}]}],"messages":[],"result":null}';
|
||||
const reason = [
|
||||
`Failed to set DNS RRSet for _smtp._tls.dev.test./TXT: Failed to set DNS RRSet: API error: BadRequest ${cf}`,
|
||||
`Failed to set DNS RRSet for dev.test./MX: Failed to set DNS RRSet: API error: BadRequest ${cf}`,
|
||||
].join('; ');
|
||||
|
||||
it('keeps each provider message once, the cause first, and counts the records', () => {
|
||||
expect(summarizeFailure(reason)).toEqual({
|
||||
messages: ['Invalid format for Authorization header', 'Invalid request headers'],
|
||||
records: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the first error when there is no provider JSON', () => {
|
||||
expect(summarizeFailure('Failed to build DNS updater: bad zone').messages).toEqual([
|
||||
'Failed to build DNS updater: bad zone',
|
||||
]);
|
||||
expect(summarizeFailure(undefined)).toEqual({ messages: [], records: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* The records a domain needs, read from the zone text the server builds for
|
||||
* it (the domain's `dnsZoneFile`), and sorted into the groups the server's
|
||||
* `publishRecords` setting switches on and off.
|
||||
*/
|
||||
|
||||
export type RecordKind =
|
||||
| 'mx'
|
||||
| 'spf'
|
||||
| 'dkim'
|
||||
| 'dmarc'
|
||||
| 'mtaSts'
|
||||
| 'tlsRpt'
|
||||
| 'srv'
|
||||
| 'autoConfig'
|
||||
| 'autoConfigLegacy'
|
||||
| 'autoDiscover'
|
||||
| 'caa'
|
||||
| 'tlsa';
|
||||
|
||||
export interface ZoneRecord {
|
||||
/** Owner name, without the trailing dot. */
|
||||
name: string;
|
||||
type: string;
|
||||
/** The record data as the zone text has it. */
|
||||
value: string;
|
||||
kind: RecordKind;
|
||||
}
|
||||
|
||||
/** Split a zone line into fields, keeping quoted strings whole. */
|
||||
function fields(line: string): string[] {
|
||||
const out: string[] = [];
|
||||
const re = /"((?:[^"\\]|\\.)*)"|(\S+)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(line))) out.push(m[1] !== undefined ? `"${m[1]}"` : m[2]);
|
||||
return out;
|
||||
}
|
||||
|
||||
const CLASSES = new Set(['IN', 'CH', 'HS']);
|
||||
|
||||
function kindOf(name: string, type: string, value: string): RecordKind | null {
|
||||
const n = name.toLowerCase();
|
||||
switch (type) {
|
||||
case 'MX':
|
||||
return 'mx';
|
||||
case 'SRV':
|
||||
return 'srv';
|
||||
case 'CAA':
|
||||
return 'caa';
|
||||
case 'TLSA':
|
||||
return 'tlsa';
|
||||
}
|
||||
if (n.includes('._domainkey.')) return 'dkim';
|
||||
if (n.startsWith('_dmarc.')) return 'dmarc';
|
||||
if (n.startsWith('mta-sts.') || n.startsWith('_mta-sts.')) return 'mtaSts';
|
||||
if (n.startsWith('_smtp._tls.')) return 'tlsRpt';
|
||||
if (n.startsWith('ua-auto-config.') || n.startsWith('_ua-auto-config.')) return 'autoConfig';
|
||||
if (n.startsWith('autoconfig.')) return 'autoConfigLegacy';
|
||||
if (n.startsWith('autodiscover.')) return 'autoDiscover';
|
||||
if (type === 'TXT' && /^"?v=spf1\b/i.test(value)) return 'spf';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse the zone text. Lines it can't place are left out, not guessed at. */
|
||||
export function parseZone(text: string | null | undefined): ZoneRecord[] {
|
||||
const out: ZoneRecord[] = [];
|
||||
for (const raw of (text ?? '').split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith(';')) continue;
|
||||
const f = fields(line);
|
||||
if (f.length < 3) continue;
|
||||
const name = f[0].replace(/\.$/, '');
|
||||
let i = 1;
|
||||
// Optional TTL and class, in either order.
|
||||
for (let k = 0; k < 2 && i < f.length; k++) {
|
||||
if (/^\d+$/.test(f[i]) || CLASSES.has(f[i].toUpperCase())) i++;
|
||||
}
|
||||
const type = (f[i] ?? '').toUpperCase();
|
||||
const value = f.slice(i + 1).join(' ');
|
||||
if (!type || !value) continue;
|
||||
const kind = kindOf(name, type, value);
|
||||
if (kind) out.push({ name, type, value, kind });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One value in a comparable form: TXT strings joined (resolvers split long
|
||||
* ones), quotes, case and trailing dots dropped, spaces collapsed.
|
||||
*/
|
||||
export function normalizeValue(type: string, value: string): string {
|
||||
let v = value.trim();
|
||||
if (type === 'TXT') {
|
||||
const parts = [...v.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]);
|
||||
v = parts.length > 0 ? parts.join('') : v;
|
||||
return v.replace(/\\"/g, '"').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
v = v.replace(/"/g, '').replace(/\s+/g, ' ').toLowerCase();
|
||||
return v
|
||||
.split(' ')
|
||||
.map((p) => p.replace(/\.$/, ''))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/** The name as DNS host panels ask for it: relative to the zone, "@" for the zone itself. */
|
||||
export function hostLabel(name: string, zone: string): string {
|
||||
const n = name.toLowerCase().replace(/\.$/, '');
|
||||
const z = zone.toLowerCase().replace(/\.$/, '');
|
||||
if (n === z) return '@';
|
||||
return n.endsWith(`.${z}`) ? n.slice(0, -(z.length + 1)) : n;
|
||||
}
|
||||
|
||||
/**
|
||||
* The record's value the way host panels want it pasted: TXT without the
|
||||
* quotes (long ones joined back together), and MX with its priority apart,
|
||||
* since nearly every panel has a separate box for it.
|
||||
*/
|
||||
export function pasteParts(r: ZoneRecord): { value: string; priority?: string } {
|
||||
const v = r.value.trim();
|
||||
if (r.type === 'TXT') {
|
||||
const parts = [...v.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]);
|
||||
return { value: parts.length ? parts.join('') : v };
|
||||
}
|
||||
if (r.type === 'MX') {
|
||||
const [priority, ...rest] = v.split(/\s+/);
|
||||
return { value: rest.join(' ').replace(/\.$/, ''), priority };
|
||||
}
|
||||
return { value: v.replace(/\.$/, '') };
|
||||
}
|
||||
|
||||
/**
|
||||
* A publishing failure, boiled down. The server reports each record's error
|
||||
* in full, so one bad credential repeats the same provider message a dozen
|
||||
* times; keep each distinct message once, innermost cause first.
|
||||
*/
|
||||
export function summarizeFailure(reason: string | undefined): { messages: string[]; records: number } {
|
||||
const text = reason ?? '';
|
||||
const records = (text.match(/Failed to set DNS RRSet for /g) ?? []).length;
|
||||
const found = [...text.matchAll(/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]);
|
||||
// Nested error chains list the cause last; show the most specific first.
|
||||
const messages = [...new Set(found.reverse())];
|
||||
if (messages.length === 0 && text) {
|
||||
const first = text.split(/;\s*/)[0].replace(/^Failed to set DNS RRSet for \S+: /, '');
|
||||
messages.push(first.length > 200 ? `${first.slice(0, 200)}…` : first);
|
||||
}
|
||||
return { messages, records };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: the banner while legacy mail protocols are off (LP-18), on the
|
||||
* Security settings and the dashboard. It says nothing when the switch is on,
|
||||
* when the reader may not see the policy, or when the server has no policy.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { fetchProtocolPolicy, fetchTenantPolicy } from './protocolPolicy';
|
||||
|
||||
export const LEGACY_PROTOCOLS_VIEW = 'CustomComponent/LegacyProtocols';
|
||||
|
||||
export function LegacyProtocolsBanner() {
|
||||
const { t } = useTranslation();
|
||||
const canGetServer = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Get'));
|
||||
const canGetTenant = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Get'));
|
||||
const [off, setOff] = useState<null | 'server' | 'tenant'>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canGetServer && !canGetTenant) return;
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
(async () => {
|
||||
// The server's switch first. Inside a tenant it can't be read, and the
|
||||
// tenant's own is the one to report (LP-18 at tenant scope).
|
||||
try {
|
||||
if (canGetServer) {
|
||||
const policy = await fetchProtocolPolicy(signal);
|
||||
if (!signal.aborted) setOff(policy.legacyProtocols === 'disabled' ? 'server' : null);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the tenant's.
|
||||
}
|
||||
try {
|
||||
if (canGetTenant) {
|
||||
const policy = await fetchTenantPolicy(null, signal);
|
||||
if (!signal.aborted) setOff(policy.legacyProtocols === 'disabled' ? 'tenant' : null);
|
||||
}
|
||||
} catch {
|
||||
// A banner is not worth an error: an older server simply has no switch.
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [canGetServer, canGetTenant]);
|
||||
|
||||
if (!off) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-xl border border-emerald-500/30 bg-emerald-500/5 px-4 py-3 text-sm">
|
||||
<ShieldCheck className="h-4 w-4 shrink-0 text-emerald-600" />
|
||||
<span>
|
||||
{t('legacyProtocols.bannerLead', 'Legacy mail protocols are')}{' '}
|
||||
<strong>{t('legacyProtocols.bannerOff', 'off')}</strong>{' '}
|
||||
{off === 'server'
|
||||
? t('legacyProtocols.bannerTail', 'on this server. Only INBUXA webmail and JMAP apps can sign in.')
|
||||
: t(
|
||||
'legacyProtocols.bannerTailTenant',
|
||||
'for your organization. Only INBUXA webmail and JMAP apps can sign in.',
|
||||
)}
|
||||
</span>
|
||||
{off === 'server' && (
|
||||
<Link to={`/Settings/${LEGACY_PROTOCOLS_VIEW}`} className="font-medium text-primary hover:underline">
|
||||
{t('legacyProtocols.review', 'Review')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: Settings › Security › Hardening, the server-wide legacy mail
|
||||
* protocols switch (legacy-protocols spec, LP-16, LP-17, LP-20, LP-21).
|
||||
*
|
||||
* Nobody should turn this on by accident or without understanding it, so the
|
||||
* statement is shown in full before the switch moves, and turning it on takes
|
||||
* a typed phrase. Turning it back on is one click: undoing a restriction must
|
||||
* never be the hard part.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Loader2, Lock, RotateCcw, ShieldCheck, ShieldOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
describeListener,
|
||||
fetchProtocolPolicy,
|
||||
PolicyUnavailable,
|
||||
protocolRows,
|
||||
updateProtocolPolicy,
|
||||
type ProtocolPolicy,
|
||||
type ProtocolRow,
|
||||
} from './protocolPolicy';
|
||||
import { ConfirmTurnOff, ImpactPanel, Statement } from './parts';
|
||||
|
||||
type Load = { kind: 'loading' } | { kind: 'ready'; policy: ProtocolPolicy } | { kind: 'error'; message: string };
|
||||
|
||||
export function LegacyProtocolsPage() {
|
||||
const { t } = useTranslation();
|
||||
const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Update'));
|
||||
const [load, setLoad] = useState<Load>({ kind: 'loading' });
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const loaded = useCallback(
|
||||
(fetching: Promise<ProtocolPolicy>, signal?: AbortSignal) =>
|
||||
fetching
|
||||
.then((policy) => {
|
||||
if (!signal?.aborted) setLoad({ kind: 'ready', policy });
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (signal?.aborted) return;
|
||||
setLoad({
|
||||
kind: 'error',
|
||||
message:
|
||||
e instanceof PolicyUnavailable
|
||||
? t('legacyProtocols.unavailable', 'This server does not offer the legacy protocols switch.')
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: String(e),
|
||||
});
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const refresh = useCallback(() => loaded(fetchProtocolPolicy()), [loaded]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void loaded(fetchProtocolPolicy(controller.signal), controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [loaded]);
|
||||
|
||||
const turn = useCallback(
|
||||
async (legacyProtocols: 'enabled' | 'disabled') => {
|
||||
setBusy(true);
|
||||
try {
|
||||
// Only legacyProtocols is sent. The server may still report closeSubmission
|
||||
// overruled by the SMTP lock (LP-21), which the selector already shows.
|
||||
await updateProtocolPolicy({ legacyProtocols });
|
||||
setConfirming(false);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('legacyProtocols.failed', 'The switch did not change'),
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[refresh, t],
|
||||
);
|
||||
|
||||
if (load.kind === 'loading') return <LoadingFallback />;
|
||||
if (load.kind === 'error') {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
{load.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { policy } = load;
|
||||
const off = policy.legacyProtocols === 'disabled';
|
||||
// What closes: what already did while the switch is off, what would otherwise.
|
||||
const listeners = off ? policy.savedListeners : policy.wouldClose;
|
||||
// Enabled with listeners still saved: some could not be put back (LP-5).
|
||||
const stranded = off ? [] : policy.savedListeners;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold">{t('legacyProtocols.title', 'Legacy mail protocols')}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
'legacyProtocols.subtitle',
|
||||
'Turn off IMAP, POP3, ManageSieve and sending from mail apps, so that only INBUXA webmail and JMAP apps can reach this server.',
|
||||
)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<StatusCard policy={policy} off={off} busy={busy} canUpdate={canUpdate} onTurnOn={() => turn('enabled')} />
|
||||
|
||||
{stranded.length > 0 && (
|
||||
<div className="rounded-xl border border-destructive/40 bg-destructive/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">
|
||||
{t('legacyProtocols.strandedTitle', 'Some listeners could not be reopened')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
'legacyProtocols.strandedBody',
|
||||
'Their ports may be taken by something else, or need a restart to bind. They are kept, and can be tried again:',
|
||||
)}{' '}
|
||||
{stranded.map(describeListener).join(', ')}
|
||||
</p>
|
||||
{canUpdate && (
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => turn('enabled')}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t('legacyProtocols.tryAgain', 'Try again')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProtocolTable rows={protocolRows(policy, listeners)} off={off} />
|
||||
|
||||
{!off && policy.recentLegacyUse && <ImpactPanel recent={policy.recentLegacyUse} />}
|
||||
|
||||
{(off || confirming) && <Statement scope={{ kind: 'server', listeners }} />}
|
||||
|
||||
{!off && canUpdate && !confirming && (
|
||||
<Button variant="destructive" onClick={() => setConfirming(true)}>
|
||||
<ShieldOff className="mr-2 h-4 w-4" />
|
||||
{t('legacyProtocols.turnOff', 'Turn off legacy protocols…')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!off && confirming && (
|
||||
<ConfirmTurnOff
|
||||
busy={busy}
|
||||
onConfirm={() => void turn('disabled')}
|
||||
onCancel={() => {
|
||||
setConfirming(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard({
|
||||
policy,
|
||||
off,
|
||||
busy,
|
||||
canUpdate,
|
||||
onTurnOn,
|
||||
}: {
|
||||
policy: ProtocolPolicy;
|
||||
off: boolean;
|
||||
busy: boolean;
|
||||
canUpdate: boolean;
|
||||
onTurnOn: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = off ? ShieldCheck : ShieldOff;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-4 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between',
|
||||
off ? 'border-emerald-500/30 bg-emerald-500/5' : 'bg-card',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className={cn('mt-0.5 h-5 w-5 shrink-0', off ? 'text-emerald-600' : 'text-muted-foreground')} />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{off
|
||||
? t('legacyProtocols.statusOff', 'Legacy mail protocols are off on this server.')
|
||||
: t('legacyProtocols.statusOn', 'Legacy mail protocols are on.')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{off
|
||||
? t('legacyProtocols.statusOffBody', 'Only INBUXA webmail and JMAP apps can sign in.')
|
||||
: t('legacyProtocols.statusOnBody', 'Mail apps can use IMAP, POP3 and ManageSieve.')}
|
||||
{policy.changedAt !== null && (
|
||||
<>
|
||||
{' '}
|
||||
{t('legacyProtocols.changedAt', 'Last changed {{when}}.', {
|
||||
when: new Date(policy.changedAt).toLocaleString(),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{off && canUpdate && (
|
||||
<Button variant="outline" disabled={busy} onClick={onTurnOn} className="shrink-0">
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('legacyProtocols.turnOn', 'Turn legacy protocols back on')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtocolTable({ rows, off }: { rows: ProtocolRow[]; off: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const stateText = (row: ProtocolRow) => {
|
||||
switch (row.state) {
|
||||
case 'locked':
|
||||
return t('legacyProtocols.rowLocked', 'Locked open');
|
||||
case 'refused':
|
||||
return t('legacyProtocols.rowRefused', 'Port open, sign-in refused');
|
||||
case 'closes':
|
||||
if (row.ports.length === 0) return t('legacyProtocols.rowNoListener', 'No listener');
|
||||
return off ? t('legacyProtocols.rowClosed', 'Closed') : t('legacyProtocols.rowWouldClose', 'Closes');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">{t('legacyProtocols.colProtocol', 'Protocol')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('legacyProtocols.colPorts', 'Ports')}</th>
|
||||
<th className="px-4 py-2 font-medium">
|
||||
{off ? t('legacyProtocols.colNow', 'Now') : t('legacyProtocols.colWhenOff', 'When turned off')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.key} className="border-t">
|
||||
<td className="px-4 py-2 font-medium">{row.label}</td>
|
||||
<td className="px-4 py-2 tabular-nums text-muted-foreground">
|
||||
{row.ports.length > 0 ? row.ports.join(', ') : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={cn('inline-flex items-center gap-1.5', row.state === 'locked' && 'text-muted-foreground')}
|
||||
>
|
||||
{row.state === 'locked' && <Lock className="h-3.5 w-3.5" />}
|
||||
{stateText(row)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="border-t bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
|
||||
{t(
|
||||
'legacyProtocols.lockNote',
|
||||
'Incoming mail (SMTP) and INBUXA webmail (JMAP) are locked open: closing them would stop mail arriving and lock everyone out, including you.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: one tenant's legacy mail protocols switch, on the tenant's page
|
||||
* (legacy-protocols spec, LP-9 to LP-18 at tenant scope).
|
||||
*
|
||||
* It closes no port -- other tenants share them (LP-13) -- so the statement
|
||||
* names no listener and carries no firewall note. It refuses sign-in over
|
||||
* legacy protocols on the tenant's domains. Turning it off takes the typed
|
||||
* phrase; turning it back on is one click, which the server refuses while
|
||||
* it has legacy protocols off itself (LP-9), and says so.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, ShieldCheck, ShieldOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getAccountId, jmapGet } from '@/services/jmap/client';
|
||||
import { fetchTenantPolicy, PolicyUnavailable, updateTenantPolicy, type TenantPolicy } from './protocolPolicy';
|
||||
import { ConfirmTurnOff, ImpactPanel, Statement } from './parts';
|
||||
|
||||
export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const canGet = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Get'));
|
||||
const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Update'));
|
||||
const [policy, setPolicy] = useState<TenantPolicy | null>(null);
|
||||
const [organization, setOrganization] = useState('');
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const loaded = useCallback(
|
||||
(fetching: Promise<TenantPolicy>, signal?: AbortSignal) =>
|
||||
fetching
|
||||
.then((p) => {
|
||||
if (!signal?.aborted) setPolicy(p);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
// An older server has no tenant switch: show nothing rather than an error.
|
||||
if (!signal?.aborted && !(e instanceof PolicyUnavailable)) console.error(e);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canGet) return;
|
||||
const controller = new AbortController();
|
||||
void loaded(fetchTenantPolicy(tenantId, controller.signal), controller.signal);
|
||||
// The organization's name, for the statement.
|
||||
jmapGet('x:Tenant', getAccountId('x:Tenant'), [tenantId], ['name'], controller.signal)
|
||||
.then((responses) => {
|
||||
const list = (responses[0]?.[1] as { list?: { name?: string }[] } | undefined)?.list;
|
||||
if (!controller.signal.aborted && list?.[0]?.name) setOrganization(list[0].name);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [tenantId, canGet, loaded]);
|
||||
|
||||
const turn = useCallback(
|
||||
async (value: 'enabled' | 'disabled') => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await updateTenantPolicy(tenantId, value);
|
||||
setConfirming(false);
|
||||
await loaded(fetchTenantPolicy(tenantId));
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('legacyProtocols.failed', 'The switch did not change'),
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[tenantId, loaded, t],
|
||||
);
|
||||
|
||||
if (!policy) return null;
|
||||
const off = policy.legacyProtocols === 'disabled';
|
||||
const name = organization || t('legacyProtocols.thisOrganization', 'this organization');
|
||||
|
||||
return (
|
||||
// Aligned with the tenant form beneath it.
|
||||
<section className="mx-auto max-w-4xl space-y-4 rounded-xl border p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
{off ? (
|
||||
<ShieldCheck className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600" />
|
||||
) : (
|
||||
<ShieldOff className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">{t('legacyProtocols.title', 'Legacy mail protocols')}</p>
|
||||
<p className={cn('text-sm', off ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{off
|
||||
? t(
|
||||
'legacyProtocols.tenantOff',
|
||||
'Off for {{organization}}. Only INBUXA webmail and JMAP apps can sign in to its domains.',
|
||||
{ organization: name },
|
||||
)
|
||||
: t(
|
||||
'legacyProtocols.tenantOn',
|
||||
'On for {{organization}}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.',
|
||||
{ organization: name },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canUpdate && off && (
|
||||
<Button variant="outline" disabled={busy} onClick={() => void turn('enabled')} className="shrink-0">
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('legacyProtocols.turnOn', 'Turn legacy protocols back on')}
|
||||
</Button>
|
||||
)}
|
||||
{canUpdate && !off && !confirming && (
|
||||
<Button variant="destructive" onClick={() => setConfirming(true)} className="shrink-0">
|
||||
{t('legacyProtocols.turnOff', 'Turn off legacy protocols…')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!off && confirming && (
|
||||
<>
|
||||
{policy.recentLegacyUse && <ImpactPanel recent={policy.recentLegacyUse} />}
|
||||
<Statement scope={{ kind: 'tenant', organization: name }} />
|
||||
<ConfirmTurnOff busy={busy} onConfirm={() => void turn('disabled')} onCancel={() => setConfirming(false)} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: the parts the server's switch (Settings › Security › Hardening) and
|
||||
* a tenant's switch (each tenant's page) share: the impact panel (LP-15), the
|
||||
* statement (LP-16) and the typed confirmation (LP-17).
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
ago,
|
||||
CONFIRM_PHRASE,
|
||||
describeListener,
|
||||
impactEntries,
|
||||
phraseMatches,
|
||||
type PolicyListener,
|
||||
type RecentUse,
|
||||
} from './protocolPolicy';
|
||||
|
||||
/**
|
||||
* The typed confirmation to turn legacy protocols off (LP-17): the button
|
||||
* stays disabled until the phrase matches exactly.
|
||||
*/
|
||||
export function ConfirmTurnOff({
|
||||
busy,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [typed, setTyped] = useState('');
|
||||
return (
|
||||
<form
|
||||
className="space-y-3 rounded-xl border p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (phraseMatches(typed)) onConfirm();
|
||||
}}
|
||||
>
|
||||
<Label htmlFor="legacy-confirm">
|
||||
{t('legacyProtocols.typeToConfirm', 'To confirm, type')}{' '}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">{CONFIRM_PHRASE}</code>
|
||||
</Label>
|
||||
<Input
|
||||
id="legacy-confirm"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" variant="destructive" disabled={busy || !phraseMatches(typed)}>
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('legacyProtocols.confirm', 'Turn off legacy protocols')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={onCancel}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The impact panel (LP-15): who would notice, shown before anything can
|
||||
* change. With nobody, it says so in one line.
|
||||
*/
|
||||
export function ImpactPanel({ recent }: { recent: RecentUse[] }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const entries = impactEntries(recent);
|
||||
// Read once, when the panel appears: "2 days ago" needn't tick.
|
||||
const [now] = useState(() => Date.now());
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<p className="rounded-xl border px-4 py-3 text-sm text-muted-foreground">
|
||||
{t('legacyProtocols.impactNone', 'No account used a legacy mail app in the last 30 days.')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section className="space-y-2 rounded-xl border p-4 text-sm">
|
||||
<p>
|
||||
<strong>
|
||||
{t('legacyProtocols.impactCount', {
|
||||
count: entries.length,
|
||||
defaultValue_one: '1 account used a legacy mail app in the last 30 days.',
|
||||
defaultValue_other: '{{count}} accounts used a legacy mail app in the last 30 days.',
|
||||
})}
|
||||
</strong>{' '}
|
||||
{t('legacyProtocols.impactLead', 'Their mail apps will stop working the moment you turn this on:')}
|
||||
</p>
|
||||
<ul className="max-h-72 space-y-1 overflow-y-auto">
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.name} className="flex flex-wrap gap-x-2">
|
||||
<span className="font-medium">{entry.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{entry.protocols.join(', ')} · {ago(entry.lastUsedAt, now, i18n.language)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Whose switch the statement is about: the server's, naming what closes, or a tenant's. */
|
||||
export type StatementScope = { kind: 'server'; listeners: PolicyListener[] } | { kind: 'tenant'; organization: string };
|
||||
|
||||
/**
|
||||
* The statement (LP-16). At server scope it names the ports that close and
|
||||
* carries the firewall note (LP-20); at tenant scope no port closes, so
|
||||
* neither is said (LP-13).
|
||||
*/
|
||||
export function Statement({ scope }: { scope: StatementScope }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section className="space-y-3 rounded-xl border border-amber-500/40 bg-amber-500/5 p-5 text-sm leading-relaxed">
|
||||
<p className="text-base font-semibold">
|
||||
{t('legacyProtocols.statementTitle', 'Only INBUXA webmail and JMAP apps will work.')}
|
||||
</p>
|
||||
<p>
|
||||
{scope.kind === 'server'
|
||||
? t(
|
||||
'legacyProtocols.statementLead',
|
||||
'Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone on this server.',
|
||||
)
|
||||
: t(
|
||||
'legacyProtocols.statementLeadTenant',
|
||||
'Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone in {{organization}}.',
|
||||
{ organization: scope.organization },
|
||||
)}
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
{t(
|
||||
'legacyProtocols.statementApps',
|
||||
'Phone and desktop mail apps will stop receiving and sending mail. That’s iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'legacyProtocols.statementFilters',
|
||||
'Filters managed from a mail app (ManageSieve) will stop working. Filters set in INBUXA webmail keep working.',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'legacyProtocols.statementUnaffected',
|
||||
'Incoming mail is not affected. Calendars and contacts are not affected.',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'legacyProtocols.statementWebmail',
|
||||
'People keep full access through INBUXA webmail, which can be installed as an app on phones and computers.',
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
{scope.kind === 'server' && (
|
||||
<p>
|
||||
{scope.listeners.length > 0
|
||||
? t('legacyProtocols.statementPorts', 'The IMAP, POP3 and ManageSieve ports will close: {{list}}.', {
|
||||
list: scope.listeners.map(describeListener).join(', '),
|
||||
})
|
||||
: t(
|
||||
'legacyProtocols.statementNoPorts',
|
||||
'No IMAP, POP3 or ManageSieve listeners are configured, so no ports will close.',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
{t(
|
||||
'legacyProtocols.statementSubmission',
|
||||
'Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and INBUXA webmail (JMAP) are not affected and cannot be turned off here.',
|
||||
)}
|
||||
</p>
|
||||
{scope.kind === 'server' && (
|
||||
<p>
|
||||
<strong>{t('legacyProtocols.firewallLead', 'This does not change your firewall or port forwarding.')}</strong>{' '}
|
||||
{t(
|
||||
'legacyProtocols.firewallBody',
|
||||
'INBUXA stops answering on these ports; anything that still routes them to this server — firewall rules, NAT port-forwards, a load balancer or proxy — is yours to reconcile.',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p>{t('legacyProtocols.statementUndo', 'You can turn legacy protocols back on at any time.')}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ago,
|
||||
CONFIRM_PHRASE,
|
||||
impactEntries,
|
||||
parsePolicy,
|
||||
parseTenantPolicy,
|
||||
phraseMatches,
|
||||
protocolRows,
|
||||
type ProtocolPolicy,
|
||||
} from './protocolPolicy';
|
||||
|
||||
// As inbuxa:ProtocolPolicy/get sends it: listeners keyed by the policy's own property names.
|
||||
const WIRE = {
|
||||
id: 'singleton',
|
||||
legacyProtocols: 'enabled',
|
||||
closeSubmission: false,
|
||||
savedListeners: [],
|
||||
changedAt: null,
|
||||
changedBy: null,
|
||||
lockedProtocols: ['smtp', 'lmtp', 'http'],
|
||||
wouldClose: [
|
||||
{ id: 'imaptls', legacyProtocols: 'imap', wouldClose: [993] },
|
||||
{ id: 'imap', legacyProtocols: 'imap', wouldClose: [143] },
|
||||
{ id: 'sieve', legacyProtocols: 'manageSieve', wouldClose: [4190] },
|
||||
],
|
||||
};
|
||||
|
||||
function policy(overrides: Partial<ProtocolPolicy> = {}): ProtocolPolicy {
|
||||
return { ...parsePolicy(WIRE), ...overrides };
|
||||
}
|
||||
|
||||
describe('parsePolicy', () => {
|
||||
it('reads listeners back into names, protocols and ports', () => {
|
||||
const p = parsePolicy(WIRE);
|
||||
expect(p.wouldClose[0]).toEqual({ name: 'imaptls', protocol: 'imap', ports: [993] });
|
||||
expect(p.lockedProtocols).toEqual(['smtp', 'lmtp', 'http']);
|
||||
expect(p.legacyProtocols).toBe('enabled');
|
||||
});
|
||||
|
||||
it('treats anything but "disabled" as enabled, and drops malformed listeners', () => {
|
||||
const p = parsePolicy({ legacyProtocols: 'maybe', wouldClose: [null, { legacyProtocols: 'imap' }] });
|
||||
expect(p.legacyProtocols).toBe('enabled');
|
||||
expect(p.wouldClose).toEqual([]);
|
||||
expect(p.changedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('protocolRows (LP-21)', () => {
|
||||
it('lists every mail protocol, with SMTP and JMAP locked', () => {
|
||||
const rows = protocolRows(policy(), policy().wouldClose);
|
||||
expect(rows.map((r) => r.key)).toEqual(['imap', 'pop3', 'manageSieve', 'submission', 'smtp', 'jmap']);
|
||||
expect(rows.find((r) => r.key === 'smtp')?.state).toBe('locked');
|
||||
expect(rows.find((r) => r.key === 'jmap')?.state).toBe('locked');
|
||||
});
|
||||
|
||||
it('gathers each protocol’s ports, sorted and without repeats', () => {
|
||||
const rows = protocolRows(policy(), policy().wouldClose);
|
||||
expect(rows.find((r) => r.key === 'imap')?.ports).toEqual([143, 993]);
|
||||
expect(rows.find((r) => r.key === 'pop3')?.ports).toEqual([]);
|
||||
expect(rows.find((r) => r.key === 'manageSieve')?.ports).toEqual([4190]);
|
||||
});
|
||||
|
||||
it('keeps submission locked while the server locks SMTP, whatever closeSubmission says', () => {
|
||||
const rows = protocolRows(policy({ closeSubmission: true }), []);
|
||||
expect(rows.find((r) => r.key === 'submission')?.state).toBe('locked');
|
||||
});
|
||||
|
||||
it('follows closeSubmission once the server unlocks SMTP, with no admin change', () => {
|
||||
const unlocked = policy({ lockedProtocols: ['lmtp', 'http'] });
|
||||
const listeners = [{ name: 'submissions', protocol: 'smtp', ports: [465] }];
|
||||
const closing = protocolRows({ ...unlocked, closeSubmission: true }, listeners);
|
||||
expect(closing.find((r) => r.key === 'submission')).toMatchObject({ state: 'closes', ports: [465] });
|
||||
const keeping = protocolRows({ ...unlocked, closeSubmission: false }, listeners);
|
||||
expect(keeping.find((r) => r.key === 'submission')?.state).toBe('refused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('phraseMatches (LP-17)', () => {
|
||||
it('accepts only the exact phrase', () => {
|
||||
expect(phraseMatches(CONFIRM_PHRASE)).toBe(true);
|
||||
expect(phraseMatches('Turn off legacy mail')).toBe(false);
|
||||
expect(phraseMatches(' turn off legacy mail')).toBe(false);
|
||||
expect(phraseMatches('turn off legacy')).toBe(false);
|
||||
expect(phraseMatches('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the impact panel (LP-15)', () => {
|
||||
it('reads nothing from a server too old to say, and an empty list as nobody', () => {
|
||||
expect(parsePolicy(WIRE).recentLegacyUse).toBeNull();
|
||||
expect(parsePolicy({ ...WIRE, recentLegacyUse: [] }).recentLegacyUse).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows each account once, with every protocol it used and its latest use', () => {
|
||||
const recent = parsePolicy({
|
||||
...WIRE,
|
||||
recentLegacyUse: [
|
||||
{ accountId: 'a', name: '[email protected]', protocol: 'submission', lastUsedAt: 100 },
|
||||
{ accountId: 'a', name: '[email protected]', protocol: 'imap', lastUsedAt: 300 },
|
||||
{ accountId: 'b', name: '[email protected]', protocol: 'pop3', lastUsedAt: 200 },
|
||||
{ accountId: 'c', name: 'bad' },
|
||||
],
|
||||
}).recentLegacyUse!;
|
||||
expect(impactEntries(recent)).toEqual([
|
||||
{ name: '[email protected]', protocols: ['IMAP', 'SMTP submission'], lastUsedAt: 300 },
|
||||
{ name: '[email protected]', protocols: ['POP3'], lastUsedAt: 200 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('says how long ago in words', () => {
|
||||
const now = Date.UTC(2026, 8, 21);
|
||||
expect(ago(now - 2 * 86400_000, now, 'en')).toBe('2 days ago');
|
||||
expect(ago(now - 3 * 3600_000, now, 'en')).toBe('3 hours ago');
|
||||
expect(ago(now - 10_000, now, 'en')).toBe('this minute');
|
||||
});
|
||||
});
|
||||
|
||||
describe("a tenant's switch", () => {
|
||||
it('reads the wire, and tells an older server from nobody', () => {
|
||||
const p = parseTenantPolicy({
|
||||
id: 'b',
|
||||
tenantId: 'b',
|
||||
legacyProtocols: 'disabled',
|
||||
changedAt: 5,
|
||||
recentLegacyUse: [{ accountId: 'c', name: '[email protected]', protocol: 'imap', lastUsedAt: 9 }],
|
||||
});
|
||||
expect(p).toMatchObject({ id: 'b', legacyProtocols: 'disabled', changedAt: 5 });
|
||||
expect(p.recentLegacyUse).toHaveLength(1);
|
||||
expect(parseTenantPolicy({ id: 'b' })).toMatchObject({ legacyProtocols: 'enabled', recentLegacyUse: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: `inbuxa:ProtocolPolicy`, the server-wide legacy mail protocols switch
|
||||
* (legacy-protocols spec). This module is the wire and the rules; the screen
|
||||
* and the banner draw from it.
|
||||
*/
|
||||
|
||||
import { getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
import type { JmapSetError } from '@/types/jmap';
|
||||
|
||||
export const INBUXA_CAPABILITY = 'urn:inbuxa:jmap';
|
||||
const OBJECT = 'inbuxa:ProtocolPolicy';
|
||||
|
||||
/** The phrase that turns legacy protocols off (LP-17). Turning them back on needs none. */
|
||||
export const CONFIRM_PHRASE = 'turn off legacy mail';
|
||||
|
||||
/** A listener the switch closed, or would close, by name and port (LP-16). */
|
||||
export interface PolicyListener {
|
||||
name: string;
|
||||
protocol: string;
|
||||
ports: number[];
|
||||
}
|
||||
|
||||
export interface ProtocolPolicy {
|
||||
legacyProtocols: 'enabled' | 'disabled';
|
||||
closeSubmission: boolean;
|
||||
/** Listeners taken away and not yet put back. Non-empty while enabled means some failed to reopen (LP-5). */
|
||||
savedListeners: PolicyListener[];
|
||||
/** Milliseconds since the epoch. */
|
||||
changedAt: number | null;
|
||||
changedBy: string | null;
|
||||
/** Registry protocols the switch may never close (LP-21), as the server says. */
|
||||
lockedProtocols: string[];
|
||||
/** What turning the switch off would close, whichever way it is set now (LP-16). */
|
||||
wouldClose: PolicyListener[];
|
||||
/**
|
||||
* Who signed in over a legacy protocol in the last 30 days (LP-15), or null
|
||||
* from a server too old to say -- which is not the same as nobody.
|
||||
*/
|
||||
recentLegacyUse: RecentUse[] | null;
|
||||
}
|
||||
|
||||
/** One account's last sign-in over one legacy protocol, as the server reports it. */
|
||||
export interface RecentUse {
|
||||
accountId: string;
|
||||
name: string;
|
||||
protocol: string;
|
||||
/** Milliseconds since the epoch. */
|
||||
lastUsedAt: number;
|
||||
}
|
||||
|
||||
/** One account on the impact panel: every protocol it used, and when it last did. */
|
||||
export interface ImpactEntry {
|
||||
name: string;
|
||||
protocols: string[];
|
||||
lastUsedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server sends each listener as an object keyed by the policy's own
|
||||
* property names: `id` is the listener's name, `legacyProtocols` its protocol
|
||||
* and `wouldClose` its ports. Read them back into something that says so.
|
||||
*/
|
||||
function parseListener(raw: unknown): PolicyListener | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
if (typeof r.id !== 'string') return null;
|
||||
return {
|
||||
name: r.id,
|
||||
protocol: typeof r.legacyProtocols === 'string' ? r.legacyProtocols : '',
|
||||
ports: Array.isArray(r.wouldClose) ? r.wouldClose.filter((p): p is number => typeof p === 'number') : [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseListeners(raw: unknown): PolicyListener[] {
|
||||
return Array.isArray(raw) ? raw.map(parseListener).filter((l): l is PolicyListener => l !== null) : [];
|
||||
}
|
||||
|
||||
export function parsePolicy(raw: Record<string, unknown>): ProtocolPolicy {
|
||||
return {
|
||||
legacyProtocols: raw.legacyProtocols === 'disabled' ? 'disabled' : 'enabled',
|
||||
closeSubmission: raw.closeSubmission === true,
|
||||
savedListeners: parseListeners(raw.savedListeners),
|
||||
changedAt: typeof raw.changedAt === 'number' ? raw.changedAt : null,
|
||||
changedBy: typeof raw.changedBy === 'string' ? raw.changedBy : null,
|
||||
lockedProtocols: Array.isArray(raw.lockedProtocols)
|
||||
? raw.lockedProtocols.filter((p): p is string => typeof p === 'string')
|
||||
: [],
|
||||
wouldClose: parseListeners(raw.wouldClose),
|
||||
recentLegacyUse: Array.isArray(raw.recentLegacyUse) ? parseRecent(raw.recentLegacyUse) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRecent(raw: unknown[]): RecentUse[] {
|
||||
return raw.flatMap((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return [];
|
||||
const r = entry as Record<string, unknown>;
|
||||
if (typeof r.name !== 'string' || typeof r.protocol !== 'string' || typeof r.lastUsedAt !== 'number') return [];
|
||||
return [
|
||||
{
|
||||
accountId: typeof r.accountId === 'string' ? r.accountId : '',
|
||||
name: r.name,
|
||||
protocol: r.protocol,
|
||||
lastUsedAt: r.lastUsedAt,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
const PROTOCOL_LABELS: Record<string, string> = {
|
||||
imap: 'IMAP',
|
||||
pop3: 'POP3',
|
||||
manageSieve: 'ManageSieve',
|
||||
submission: 'SMTP submission',
|
||||
};
|
||||
|
||||
/**
|
||||
* The impact panel's lines (LP-15): one per account, naming every protocol it
|
||||
* used and when it last used any, most recent first.
|
||||
*/
|
||||
export function impactEntries(recent: RecentUse[]): ImpactEntry[] {
|
||||
const byAccount = new Map<string, ImpactEntry>();
|
||||
for (const use of recent) {
|
||||
const key = use.accountId || use.name;
|
||||
const entry = byAccount.get(key) ?? { name: use.name, protocols: [], lastUsedAt: 0 };
|
||||
const label = PROTOCOL_LABELS[use.protocol] ?? use.protocol;
|
||||
if (!entry.protocols.includes(label)) entry.protocols.push(label);
|
||||
entry.lastUsedAt = Math.max(entry.lastUsedAt, use.lastUsedAt);
|
||||
byAccount.set(key, entry);
|
||||
}
|
||||
const order = Object.values(PROTOCOL_LABELS);
|
||||
return [...byAccount.values()]
|
||||
.map((e) => ({ ...e, protocols: e.protocols.sort((a, b) => order.indexOf(a) - order.indexOf(b)) }))
|
||||
.sort((a, b) => b.lastUsedAt - a.lastUsedAt || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** "2 days ago", "3 hours ago", "just now", in the reader's language. */
|
||||
export function ago(at: number, now: number, locale?: string): string {
|
||||
const seconds = Math.round((at - now) / 1000);
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
||||
const steps: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||
['day', 86400],
|
||||
['hour', 3600],
|
||||
['minute', 60],
|
||||
];
|
||||
for (const [unit, size] of steps) {
|
||||
if (Math.abs(seconds) >= size) return rtf.format(Math.round(seconds / size), unit);
|
||||
}
|
||||
return rtf.format(0, 'minute');
|
||||
}
|
||||
|
||||
/** Thrown when the server has no `inbuxa:ProtocolPolicy`, so callers can stay quiet about it. */
|
||||
export class PolicyUnavailable extends Error {}
|
||||
|
||||
export async function fetchProtocolPolicy(signal?: AbortSignal): Promise<ProtocolPolicy> {
|
||||
const accountId = getAccountId('x:NetworkListener');
|
||||
// No `properties`: the server answers with all of them, recentLegacyUse
|
||||
// included where it has it.
|
||||
const responses = await jmapRequest([[`${OBJECT}/get`, { accountId, ids: null }, '0']], signal, [INBUXA_CAPABILITY]);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name !== `${OBJECT}/get`) {
|
||||
const type = (result as { type?: string } | undefined)?.type;
|
||||
if (type === 'unknownMethod' || type === 'unknownCapability') throw new PolicyUnavailable(type);
|
||||
throw new Error((result as { description?: string } | undefined)?.description ?? type ?? 'Request failed');
|
||||
}
|
||||
const list = (result as { list?: Record<string, unknown>[] }).list ?? [];
|
||||
if (!list[0]) throw new PolicyUnavailable('notFound');
|
||||
return parsePolicy(list[0]);
|
||||
}
|
||||
|
||||
export interface SetOutcome {
|
||||
/** What the server stored differently from what was asked (LP-21), or nothing. */
|
||||
overruled: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export async function updateProtocolPolicy(
|
||||
update: Partial<Pick<ProtocolPolicy, 'legacyProtocols' | 'closeSubmission'>>,
|
||||
): Promise<SetOutcome> {
|
||||
const accountId = getAccountId('x:NetworkListener');
|
||||
const responses = await jmapRequest(
|
||||
[[`${OBJECT}/set`, { accountId, update: { singleton: update } }, '0']],
|
||||
undefined,
|
||||
[INBUXA_CAPABILITY],
|
||||
);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name !== `${OBJECT}/set`) {
|
||||
throw new Error((result as { description?: string } | undefined)?.description ?? 'Request failed');
|
||||
}
|
||||
const r = result as {
|
||||
updated?: Record<string, Record<string, unknown> | null> | null;
|
||||
notUpdated?: Record<string, JmapSetError> | null;
|
||||
};
|
||||
const failed = r.notUpdated?.singleton;
|
||||
if (failed) throw new Error(failed.description ?? failed.type);
|
||||
const stored = r.updated?.singleton;
|
||||
return { overruled: stored && Object.keys(stored).length > 0 ? stored : null };
|
||||
}
|
||||
|
||||
/** How a protocol stands under the switch, for the selector (LP-21). */
|
||||
export type RowState = 'closes' | 'refused' | 'locked';
|
||||
|
||||
export interface ProtocolRow {
|
||||
key: string;
|
||||
label: string;
|
||||
state: RowState;
|
||||
/** Ports that close with the switch; empty when nothing is listening or the row doesn't close. */
|
||||
ports: number[];
|
||||
}
|
||||
|
||||
function portsOf(listeners: PolicyListener[], protocol: string): number[] {
|
||||
const ports = listeners.filter((l) => l.protocol === protocol).flatMap((l) => l.ports);
|
||||
return [...new Set(ports)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every mail protocol the server speaks, in one place, with what the switch
|
||||
* does to each. The locked set comes from the server, so unlocking later is
|
||||
* a server change and no admin release (LP-21).
|
||||
*
|
||||
* `listeners` is what the switch closes: `wouldClose` while it's on, or
|
||||
* `savedListeners` once it's off.
|
||||
*/
|
||||
export function protocolRows(policy: ProtocolPolicy, listeners: PolicyListener[]): ProtocolRow[] {
|
||||
const locked = new Set(policy.lockedProtocols.map((p) => p.toLowerCase()));
|
||||
const legacy: ProtocolRow[] = [
|
||||
{ key: 'imap', label: 'IMAP', state: 'closes', ports: portsOf(listeners, 'imap') },
|
||||
{ key: 'pop3', label: 'POP3', state: 'closes', ports: portsOf(listeners, 'pop3') },
|
||||
{ key: 'manageSieve', label: 'ManageSieve', state: 'closes', ports: portsOf(listeners, 'manageSieve') },
|
||||
];
|
||||
const smtpLocked = locked.has('smtp');
|
||||
return [
|
||||
...legacy,
|
||||
{
|
||||
key: 'submission',
|
||||
label: 'SMTP submission',
|
||||
// Locked submission keeps its ports; sign-in over them is refused instead.
|
||||
state: smtpLocked ? 'locked' : policy.closeSubmission ? 'closes' : 'refused',
|
||||
ports: smtpLocked ? [] : portsOf(listeners, 'smtp'),
|
||||
},
|
||||
// Incoming mail and JMAP are never the switch's to close (LP-3, "Not affected, ever").
|
||||
{ key: 'smtp', label: 'SMTP (incoming mail)', state: 'locked', ports: [] },
|
||||
{ key: 'jmap', label: 'JMAP (INBUXA webmail)', state: 'locked', ports: [] },
|
||||
];
|
||||
}
|
||||
|
||||
/** Whether the typed confirmation matches (LP-17). Exact: no trimming, no case folding. */
|
||||
export function phraseMatches(typed: string): boolean {
|
||||
return typed === CONFIRM_PHRASE;
|
||||
}
|
||||
|
||||
export function describeListener(l: PolicyListener): string {
|
||||
return l.ports.length > 0 ? `${l.name} (${l.ports.join(', ')})` : l.name;
|
||||
}
|
||||
|
||||
// ---- A tenant's switch: inbuxa:TenantProtocolPolicy (LP-9 to LP-14) ----
|
||||
|
||||
const TENANT_OBJECT = 'inbuxa:TenantProtocolPolicy';
|
||||
|
||||
export interface TenantPolicy {
|
||||
/** The tenant's id, which is also the policy's. */
|
||||
id: string;
|
||||
legacyProtocols: 'enabled' | 'disabled';
|
||||
/** Milliseconds since the epoch. */
|
||||
changedAt: number | null;
|
||||
/** The tenant's own people who used a legacy mail app lately (LP-15), or null from an older server. */
|
||||
recentLegacyUse: RecentUse[] | null;
|
||||
}
|
||||
|
||||
export function parseTenantPolicy(raw: Record<string, unknown>): TenantPolicy {
|
||||
return {
|
||||
id: typeof raw.id === 'string' ? raw.id : '',
|
||||
legacyProtocols: raw.legacyProtocols === 'disabled' ? 'disabled' : 'enabled',
|
||||
changedAt: typeof raw.changedAt === 'number' ? raw.changedAt : null,
|
||||
recentLegacyUse: Array.isArray(raw.recentLegacyUse) ? parseRecent(raw.recentLegacyUse) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A tenant's switch. With no id, the caller's own tenant's -- which is how a
|
||||
* tenant administrator reads it; a server administrator names the tenant.
|
||||
*/
|
||||
export async function fetchTenantPolicy(tenantId: string | null, signal?: AbortSignal): Promise<TenantPolicy> {
|
||||
const accountId = getAccountId('x:Domain');
|
||||
const responses = await jmapRequest(
|
||||
[[`${TENANT_OBJECT}/get`, { accountId, ids: tenantId ? [tenantId] : null }, '0']],
|
||||
signal,
|
||||
[INBUXA_CAPABILITY],
|
||||
);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name !== `${TENANT_OBJECT}/get`) {
|
||||
const type = (result as { type?: string } | undefined)?.type;
|
||||
if (type === 'unknownMethod' || type === 'unknownCapability') throw new PolicyUnavailable(type);
|
||||
throw new Error((result as { description?: string } | undefined)?.description ?? type ?? 'Request failed');
|
||||
}
|
||||
const list = (result as { list?: Record<string, unknown>[] }).list ?? [];
|
||||
// A tenant admin's /get with no ids holds exactly its own tenant's.
|
||||
if (!list[0] || (!tenantId && list.length !== 1)) throw new PolicyUnavailable('notFound');
|
||||
return parseTenantPolicy(list[0]);
|
||||
}
|
||||
|
||||
/** Turns a tenant's switch. The server refuses turning it on while its own is off (LP-9). */
|
||||
export async function updateTenantPolicy(tenantId: string, legacyProtocols: 'enabled' | 'disabled'): Promise<void> {
|
||||
const accountId = getAccountId('x:Domain');
|
||||
const responses = await jmapRequest(
|
||||
[[`${TENANT_OBJECT}/set`, { accountId, update: { [tenantId]: { legacyProtocols } } }, '0']],
|
||||
undefined,
|
||||
[INBUXA_CAPABILITY],
|
||||
);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name !== `${TENANT_OBJECT}/set`) {
|
||||
throw new Error((result as { description?: string } | undefined)?.description ?? 'Request failed');
|
||||
}
|
||||
const failed = (result as { notUpdated?: Record<string, JmapSetError> | null }).notUpdated?.[tenantId];
|
||||
if (failed) throw new Error(failed.description ?? failed.type);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle2, CircleDashed, Globe, Loader2, UserRound } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { formatSize } from '@/lib/durationFormat';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CARD_OBJECTS, loadCardFacts, type CardFacts, type DomainFacts, type PersonFacts } from './facts';
|
||||
|
||||
const KIND_LABEL: Record<string, string> = { mx: 'Mail routing', spf: 'SPF', dkim: 'DKIM', dmarc: 'DMARC' };
|
||||
|
||||
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="text-right font-medium">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DomainCard({ f }: { f: DomainFacts }) {
|
||||
const { t } = useTranslation();
|
||||
const auto = (m: string) => (m === 'Automatic' ? t('hover.automatic', 'Automatic') : t('hover.manual', 'Manual'));
|
||||
const live = f.checks.filter((c) => c.live).length;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500/15 text-sky-700 dark:text-sky-300">
|
||||
<Globe className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{f.name}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{f.enabled ? t('hover.enabled', 'Receiving mail') : t('hover.disabled', 'Turned off')}
|
||||
{f.people !== undefined &&
|
||||
` · ${t('hover.people', { count: f.people, defaultValue_one: '{{count}} person', defaultValue_other: '{{count}} people' })}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{f.checks.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="font-medium">
|
||||
{live === f.checks.length
|
||||
? t('hover.dnsAllLive', 'DNS is in place')
|
||||
: t('hover.dnsSome', '{{live}} of {{total}} key records live', { live, total: f.checks.length })}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{f.checks.map((c) => (
|
||||
<span
|
||||
key={c.kind}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5',
|
||||
c.live
|
||||
? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{c.live ? <CheckCircle2 className="h-3 w-3" /> : <CircleDashed className="h-3 w-3" />}
|
||||
{KIND_LABEL[c.kind] ?? c.kind}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 border-t pt-2">
|
||||
<Row label={t('hover.dns', 'DNS records')}>{auto(f.dns)}</Row>
|
||||
<Row label={t('hover.dkim', 'Signing keys')}>{auto(f.dkim)}</Row>
|
||||
<Row label={t('hover.certs', 'Certificates')}>{auto(f.certs)}</Row>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonCard({ f }: { f: PersonFacts }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const pct = f.quota ? Math.min(100, Math.round((f.used / f.quota) * 100)) : null;
|
||||
const roleLabel =
|
||||
f.role === 'Admin'
|
||||
? t('hover.roleAdmin', 'Administrator')
|
||||
: f.role === 'Custom'
|
||||
? t('hover.roleCustom', 'Custom role')
|
||||
: t('hover.roleUser', 'User');
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/15 text-violet-700 dark:text-violet-300">
|
||||
<UserRound className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{f.name ?? f.address}</p>
|
||||
{f.name && <p className="truncate text-muted-foreground">{f.address}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('hover.storage', 'Storage')}</span>
|
||||
<span className="font-medium">
|
||||
{formatSize(f.used)}
|
||||
{f.quota ? ` / ${formatSize(f.quota)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{pct !== null && (
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
pct >= 90 ? 'bg-rose-500' : pct >= 75 ? 'bg-amber-500' : 'bg-primary',
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 border-t pt-2">
|
||||
<Row label={t('hover.role', 'Role')}>{roleLabel}</Row>
|
||||
{f.groups > 0 && <Row label={t('hover.groups', 'Groups')}>{f.groups}</Row>}
|
||||
{f.createdAt && (
|
||||
<Row label={t('hover.since', 'Here since')}>
|
||||
{new Date(f.createdAt).toLocaleDateString(i18n.language, { dateStyle: 'medium' })}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardBody({ objectName, id }: { objectName: string; id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [facts, setFacts] = useState<CardFacts | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
loadCardFacts(objectName, id).then((f) => {
|
||||
if (live) setFacts(f);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [objectName, id]);
|
||||
if (facts === undefined)
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t('hover.loading', 'Looking…')}
|
||||
</div>
|
||||
);
|
||||
if (!facts) return <p className="text-muted-foreground">{t('hover.unavailable', 'No details available.')}</p>;
|
||||
return facts.kind === 'domain' ? <DomainCard f={facts} /> : <PersonCard f={facts} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* A card with the essentials of a domain or person, on hover or focus of its
|
||||
* name in a list, so a glance answers "is this one healthy?" without
|
||||
* opening it. Other objects render their name unchanged.
|
||||
*/
|
||||
export function ObjectHoverCard({ objectName, id, children }: { objectName: string; id: string; children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!CARD_OBJECTS.has(objectName) || !id) return <>{children}</>;
|
||||
return (
|
||||
<TooltipProvider delayDuration={450}>
|
||||
<Tooltip open={open} onOpenChange={setOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default underline decoration-dotted decoration-muted-foreground/40 underline-offset-4">
|
||||
{children}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-72 border bg-popover p-4 text-xs text-popover-foreground shadow-soft"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{open && <CardBody objectName={objectName} id={id} />}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* What a hover card shows, fetched when the card opens and kept for a
|
||||
* minute, so moving across a list doesn't ask the server twice.
|
||||
*/
|
||||
import { getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
import { parseZone, type RecordKind, type ZoneRecord } from '@/features/dns/zone';
|
||||
import { checkRecords } from '@/features/dns/liveCheck';
|
||||
|
||||
export interface DomainFacts {
|
||||
kind: 'domain';
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
people?: number;
|
||||
dns: string;
|
||||
dkim: string;
|
||||
certs: string;
|
||||
/** The core records, and which are live in public DNS. */
|
||||
checks: { kind: RecordKind; live: boolean }[];
|
||||
}
|
||||
|
||||
export interface PersonFacts {
|
||||
kind: 'person';
|
||||
address: string;
|
||||
name?: string;
|
||||
used: number;
|
||||
quota: number | null;
|
||||
role?: string;
|
||||
groups: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export type CardFacts = DomainFacts | PersonFacts;
|
||||
|
||||
/** The records a domain can't work without, in the order the card lists them. */
|
||||
export const CORE_KINDS: RecordKind[] = ['mx', 'spf', 'dkim', 'dmarc'];
|
||||
|
||||
const TTL_MS = 60_000;
|
||||
const cache = new Map<string, { at: number; facts: Promise<CardFacts | null> }>();
|
||||
|
||||
function mode(value: unknown): string {
|
||||
return ((value as { '@type'?: string } | undefined)?.['@type'] ?? 'Manual').toString();
|
||||
}
|
||||
|
||||
/** Of the domain's core records, which are live. Kinds with no records (DKIM before keys exist) are left out. */
|
||||
export async function coreChecks(zone: ZoneRecord[], domain: string): Promise<{ kind: RecordKind; live: boolean }[]> {
|
||||
const apex = domain.toLowerCase();
|
||||
const core = zone.filter((r) => CORE_KINDS.includes(r.kind) && (r.kind !== 'spf' || r.name.toLowerCase() === apex));
|
||||
const states = await checkRecords(core);
|
||||
return CORE_KINDS.filter((k) => core.some((r) => r.kind === k)).map((kind) => ({
|
||||
kind,
|
||||
live: core.filter((r) => r.kind === kind).every((r) => states.get(r) === 'live'),
|
||||
}));
|
||||
}
|
||||
|
||||
async function domainFacts(id: string): Promise<DomainFacts | null> {
|
||||
const accountId = getAccountId('x:Domain');
|
||||
const responses = await jmapRequest([
|
||||
[
|
||||
'x:Domain/get',
|
||||
{
|
||||
accountId,
|
||||
ids: [id],
|
||||
properties: ['name', 'isEnabled', 'dnsManagement', 'dkimManagement', 'certificateManagement', 'dnsZoneFile'],
|
||||
},
|
||||
'd',
|
||||
],
|
||||
[
|
||||
'x:Account/query',
|
||||
{ accountId: getAccountId('x:Account'), filter: { domainId: id }, limit: 1, calculateTotal: true },
|
||||
'n',
|
||||
],
|
||||
]);
|
||||
const d = (responses.find((r) => r[2] === 'd')?.[1] as { list?: Record<string, unknown>[] })?.list?.[0];
|
||||
if (!d) return null;
|
||||
const count = responses.find((r) => r[2] === 'n' && r[0] !== 'error')?.[1] as { total?: number } | undefined;
|
||||
const name = String(d.name);
|
||||
const checks = await coreChecks(parseZone(d.dnsZoneFile as string | undefined), name).catch(
|
||||
(): DomainFacts['checks'] => [],
|
||||
);
|
||||
return {
|
||||
kind: 'domain',
|
||||
name,
|
||||
enabled: d.isEnabled !== false,
|
||||
people: count?.total,
|
||||
dns: mode(d.dnsManagement),
|
||||
dkim: mode(d.dkimManagement),
|
||||
certs: mode(d.certificateManagement),
|
||||
checks,
|
||||
};
|
||||
}
|
||||
|
||||
async function personFacts(id: string): Promise<PersonFacts | null> {
|
||||
const responses = await jmapRequest([
|
||||
[
|
||||
'x:Account/get',
|
||||
{
|
||||
accountId: getAccountId('x:Account'),
|
||||
ids: [id],
|
||||
properties: [
|
||||
'@type',
|
||||
'emailAddress',
|
||||
'name',
|
||||
'description',
|
||||
'usedDiskQuota',
|
||||
'quotas',
|
||||
'roles',
|
||||
'memberGroupIds',
|
||||
'createdAt',
|
||||
],
|
||||
},
|
||||
'a',
|
||||
],
|
||||
]);
|
||||
const a = (responses[0]?.[1] as { list?: Record<string, unknown>[] })?.list?.[0];
|
||||
if (!a) return null;
|
||||
const quotas = (a.quotas ?? {}) as Record<string, unknown>;
|
||||
const quota = typeof quotas.maxDiskQuota === 'number' && quotas.maxDiskQuota > 0 ? quotas.maxDiskQuota : null;
|
||||
const groups = a.memberGroupIds && typeof a.memberGroupIds === 'object' ? Object.keys(a.memberGroupIds).length : 0;
|
||||
return {
|
||||
kind: 'person',
|
||||
address: String(a.emailAddress ?? a.name ?? id),
|
||||
name: typeof a.description === 'string' && a.description.trim() ? a.description.trim() : undefined,
|
||||
used: typeof a.usedDiskQuota === 'number' ? a.usedDiskQuota : 0,
|
||||
quota,
|
||||
role: (a.roles as { '@type'?: string } | undefined)?.['@type'],
|
||||
groups,
|
||||
createdAt: typeof a.createdAt === 'string' ? a.createdAt : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Object types that have a hover card. */
|
||||
export const CARD_OBJECTS = new Set(['x:Domain', 'x:Account']);
|
||||
|
||||
export function loadCardFacts(objectName: string, id: string): Promise<CardFacts | null> {
|
||||
const key = `${objectName}|${id}`;
|
||||
const hit = cache.get(key);
|
||||
if (hit && Date.now() - hit.at < TTL_MS) return hit.facts;
|
||||
const facts = (objectName === 'x:Domain' ? domainFacts(id) : personFacts(id)).catch(() => null);
|
||||
cache.set(key, { at: Date.now(), facts });
|
||||
return facts;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { ArrowUpRight, CircleHelp, Lightbulb } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { resolveList, resolveObject, resolveSchema } from '@/lib/schemaResolver';
|
||||
import { humanize } from '@/lib/humanize';
|
||||
import type { Schema } from '@/types/schema';
|
||||
import { fieldHelp, PAGE_HELP } from './texts';
|
||||
import { manualUrl } from './manual';
|
||||
|
||||
interface OptionHelp {
|
||||
id: string;
|
||||
label: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the panel says about a page: what it's for, what people do
|
||||
* there, and every option on its form with its explanation, in form order.
|
||||
*/
|
||||
function pageHelp(schema: Schema, viewName: string) {
|
||||
const obj = resolveObject(schema, viewName);
|
||||
if (!obj) return null;
|
||||
const sch = resolveSchema(schema, obj.objectName);
|
||||
const ours = PAGE_HELP[viewName] ?? PAGE_HELP[obj.objectName];
|
||||
const about =
|
||||
ours?.about ?? (schema.objects[obj.objectName] as { description?: string } | undefined)?.description ?? '';
|
||||
|
||||
// A view of one variant (People is x:Account of @type User) shows that variant's options.
|
||||
const list = resolveList(schema, viewName, obj.objectName);
|
||||
const variantName = (list?.filtersStatic as Record<string, unknown> | undefined)?.['@type'];
|
||||
let scope = obj.objectName;
|
||||
let fields = sch?.type === 'single' ? sch.fields : null;
|
||||
if (sch?.type === 'multiple') {
|
||||
const v = sch.variants.find((x) => x.name === variantName) ?? sch.variants.find((x) => x.fields);
|
||||
scope = v?.schemaName ?? obj.objectName;
|
||||
fields = v?.fields ?? null;
|
||||
}
|
||||
const form = schema.forms[scope] ?? schema.forms[obj.objectName];
|
||||
const order = form?.sections.flatMap((s) => s.fields.map((f) => ({ name: f.name, label: f.label }))) ?? [];
|
||||
const names = order.length ? order : Object.keys(fields?.properties ?? {}).map((name) => ({ name, label: '' }));
|
||||
const options: OptionHelp[] = [];
|
||||
for (const { name, label } of names) {
|
||||
const field = fields?.properties[name];
|
||||
if (!field || field.update === 'serverSet' || name === '@type') continue;
|
||||
const id = `${scope}.${name}`;
|
||||
const text = fieldHelp(id, field.description);
|
||||
if (text) options.push({ id, label: label || humanize(name), text });
|
||||
}
|
||||
return { id: obj.objectName, about, tasks: ours?.tasks ?? [], options };
|
||||
}
|
||||
|
||||
/**
|
||||
* The "?" at the top of a page: a panel with what the page is for, the
|
||||
* things people usually do there, and a plain explanation of every option.
|
||||
* The manual link appears once a manual is configured.
|
||||
*/
|
||||
export function HelpPanel({ viewName, title }: { viewName: string; title: string }) {
|
||||
const { t } = useTranslation();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const [open, setOpen] = useState(false);
|
||||
const help = useMemo(() => (schema ? pageHelp(schema, viewName) : null), [schema, viewName]);
|
||||
if (!help || (!help.about && help.options.length === 0)) return null;
|
||||
const more = manualUrl(help.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-xl text-muted-foreground hover:text-primary"
|
||||
aria-label={t('help.page', 'Help for this page')}
|
||||
data-help-id={help.id}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<CircleHelp className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('help.page', 'Help for this page')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="left-auto right-0 top-0 flex h-dvh max-w-md translate-x-0 translate-y-0 flex-col gap-0 overflow-hidden rounded-none p-0 data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:rounded-l-2xl">
|
||||
<div className="border-b px-6 pb-4 pt-6">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-primary">{t('help.label', 'Help')}</p>
|
||||
<DialogTitle className="mt-1 text-xl">{title}</DialogTitle>
|
||||
{help.about && <DialogDescription className="mt-2 text-sm leading-relaxed">{help.about}</DialogDescription>}
|
||||
</div>
|
||||
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-5">
|
||||
{help.tasks.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('help.tasks', 'What people do here')}</h3>
|
||||
<ul className="space-y-2">
|
||||
{help.tasks.map((task) => (
|
||||
<li key={task} className="flex gap-2.5 text-sm">
|
||||
<Lightbulb className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
||||
<span>{task}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{help.options.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('help.options', 'The options on this page')}</h3>
|
||||
<dl className="divide-y rounded-xl border">
|
||||
{help.options.map((o) => (
|
||||
<div key={o.id} className="px-4 py-3" data-help-id={o.id}>
|
||||
<dt className="text-sm font-medium">{o.label}</dt>
|
||||
<dd className="mt-0.5 text-sm text-muted-foreground [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_p]:m-0">
|
||||
<ReactMarkdown>{o.text.replace(/\\n/g, '\n')}</ReactMarkdown>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
{more && (
|
||||
<div className="border-t px-6 py-4">
|
||||
<a
|
||||
href={more}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{t('help.manual', 'Read more in the admin manual')}
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { ArrowUpRight, Info } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { manualUrl } from './manual';
|
||||
|
||||
/**
|
||||
* The ⓘ beside an option: a sentence or two on what it does, on hover or
|
||||
* focus, and a way into the manual once there is one. `id` is the option's
|
||||
* stable help id, the key the manual links hang on.
|
||||
*/
|
||||
export function HelpTip({
|
||||
id,
|
||||
text,
|
||||
footnote,
|
||||
className,
|
||||
}: {
|
||||
id?: string;
|
||||
text?: string | null;
|
||||
/** A short line under the text, like the option's default. */
|
||||
footnote?: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!text && !footnote) return null;
|
||||
const more = id ? manualUrl(id) : null;
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-help-id={id}
|
||||
aria-label={t('help.about', 'About this option')}
|
||||
className={cn(
|
||||
'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:text-primary focus-visible:text-primary focus-visible:outline-none',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="max-w-xs space-y-1.5 border bg-popover px-3 py-2 text-xs leading-relaxed text-popover-foreground shadow-soft"
|
||||
>
|
||||
<div className="[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_p]:m-0">
|
||||
{text && <ReactMarkdown>{text.replace(/\\n/g, '\n')}</ReactMarkdown>}
|
||||
</div>
|
||||
{footnote && <p className="text-muted-foreground">{footnote}</p>}
|
||||
{more && (
|
||||
<a
|
||||
href={more}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 font-medium text-primary hover:underline"
|
||||
>
|
||||
{t('help.learnMore', 'Learn more')}
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Field, Schema } from '@/types/schema';
|
||||
import { describeDefault, differsFromDefault } from './defaults';
|
||||
|
||||
const schema = {
|
||||
enums: {
|
||||
Proto: [{ name: 'udp', label: 'UDP' }],
|
||||
RecType: [
|
||||
{ name: 'mx', label: 'MX records' },
|
||||
{ name: 'spf', label: 'SPF records' },
|
||||
],
|
||||
},
|
||||
schemas: { 'x:Mgmt': { type: 'multiple', variants: [{ name: 'Manual', label: 'Manual DNS management' }] } },
|
||||
} as unknown as Schema;
|
||||
const words = { on: 'On', off: 'Off', none: 'None' };
|
||||
const f = (type: Record<string, unknown>) => ({ description: '', update: 'mutable', type }) as unknown as Field;
|
||||
|
||||
describe('describeDefault', () => {
|
||||
it('says defaults the way people would', () => {
|
||||
expect(describeDefault(f({ type: 'boolean' }), true, schema, words)).toBe('On');
|
||||
expect(describeDefault(f({ type: 'number', format: 'duration' }), 300000, schema, words)).toBe('5m');
|
||||
expect(describeDefault(f({ type: 'enum', enumName: 'Proto' }), 'udp', schema, words)).toBe('UDP');
|
||||
expect(describeDefault(f({ type: 'object', objectName: 'x:Mgmt' }), { '@type': 'Manual' }, schema, words)).toBe(
|
||||
'Manual DNS management',
|
||||
);
|
||||
expect(
|
||||
describeDefault(
|
||||
f({ type: 'set', class: { type: 'enum', enumName: 'RecType' } }),
|
||||
{ mx: true, spf: true },
|
||||
schema,
|
||||
words,
|
||||
),
|
||||
).toBe('MX records, SPF records');
|
||||
expect(describeDefault(f({ type: 'string' }), 'mailto:postmaster', schema, words)).toBe('mailto:postmaster');
|
||||
});
|
||||
|
||||
it('stays quiet without a default', () => {
|
||||
expect(describeDefault(f({ type: 'string' }), undefined, schema, words)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('differsFromDefault', () => {
|
||||
it('compares by value, ignoring key order', () => {
|
||||
expect(differsFromDefault({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(false);
|
||||
expect(differsFromDefault(600000, 300000)).toBe(true);
|
||||
expect(differsFromDefault(undefined, 300000)).toBe(false);
|
||||
expect(differsFromDefault(true, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* An option's default, as words, and whether the current value differs
|
||||
* from it, so tooltips can say "Default: 5 min" and a form can mark what
|
||||
* someone has changed.
|
||||
*/
|
||||
import type { Field, Schema } from '@/types/schema';
|
||||
import { formatDuration, formatSize } from '@/lib/durationFormat';
|
||||
|
||||
function stable(v: unknown): string {
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
const o = v as Record<string, unknown>;
|
||||
return `{${Object.keys(o)
|
||||
.sort()
|
||||
.map((k) => `${JSON.stringify(k)}:${stable(o[k])}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
return JSON.stringify(v ?? null);
|
||||
}
|
||||
|
||||
/** Does the value differ from the default? An unset value counts as the default. */
|
||||
export function differsFromDefault(value: unknown, def: unknown): boolean {
|
||||
if (def === undefined) return false;
|
||||
if (value === undefined || value === null) return false;
|
||||
return stable(value) !== stable(def);
|
||||
}
|
||||
|
||||
/** The default as someone would say it, or null when there's nothing short to say. */
|
||||
export function describeDefault(
|
||||
field: Field,
|
||||
def: unknown,
|
||||
schema: Schema,
|
||||
words: { on: string; off: string; none: string },
|
||||
): string | null {
|
||||
if (def === undefined) return null;
|
||||
if (def === null) return words.none;
|
||||
const ft = field.type as { type: string; format?: string; enumName?: string; objectName?: string };
|
||||
if (typeof def === 'boolean') return def ? words.on : words.off;
|
||||
if (typeof def === 'number') {
|
||||
if (ft.format === 'duration') return formatDuration(def);
|
||||
if (ft.format === 'size') return formatSize(def);
|
||||
return String(def);
|
||||
}
|
||||
if (typeof def === 'string') {
|
||||
if (ft.type === 'enum' && ft.enumName) {
|
||||
return schema.enums[ft.enumName]?.find((e) => e.name === def)?.label ?? def;
|
||||
}
|
||||
return def.length > 60 ? null : def;
|
||||
}
|
||||
if (typeof def === 'object' && !Array.isArray(def)) {
|
||||
const variant = (def as Record<string, unknown>)['@type'];
|
||||
if (typeof variant === 'string' && ft.objectName) {
|
||||
const sch = schema.schemas[ft.objectName];
|
||||
const label = sch?.type === 'multiple' ? sch.variants.find((v) => v.name === variant)?.label : undefined;
|
||||
return label ?? variant;
|
||||
}
|
||||
const keys = Object.keys(def as Record<string, unknown>);
|
||||
if (ft.type === 'set') {
|
||||
if (keys.length === 0) return words.none;
|
||||
if (ft.enumName || (field.type as { class?: { enumName?: string } }).class?.enumName) {
|
||||
const en = (field.type as { class?: { enumName?: string } }).class?.enumName ?? ft.enumName!;
|
||||
const labels = keys.map((k) => schema.enums[en]?.find((e) => e.name === k)?.label ?? k);
|
||||
return labels.length > 4 ? `${labels.slice(0, 4).join(', ')}…` : labels.join(', ');
|
||||
}
|
||||
return keys.length > 4 ? `${keys.slice(0, 4).join(', ')}…` : keys.join(', ');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { manualUrl } from './manual';
|
||||
import { fieldHelp, FIELD_HELP } from './texts';
|
||||
|
||||
describe('manualUrl', () => {
|
||||
afterEach(() => document.querySelector('meta[name="manual-url"]')?.remove());
|
||||
|
||||
it('shows no link until a manual is configured', () => {
|
||||
expect(manualUrl('x:Domain.dnsManagement')).toBeNull();
|
||||
});
|
||||
|
||||
it('maps help ids to stable manual pages and anchors', () => {
|
||||
const meta = document.createElement('meta');
|
||||
meta.name = 'manual-url';
|
||||
meta.content = 'https://docs.example.org/admin/';
|
||||
document.head.appendChild(meta);
|
||||
expect(manualUrl('x:Domain')).toBe('https://docs.example.org/admin/reference/domain/');
|
||||
expect(manualUrl('x:Domain.dnsManagement')).toBe('https://docs.example.org/admin/reference/domain/#dnsmanagement');
|
||||
expect(manualUrl('x:DnsServerCloudflare.secret')).toBe(
|
||||
'https://docs.example.org/admin/reference/dns-server-cloudflare/#secret',
|
||||
);
|
||||
expect(manualUrl('x:Account/User')).toBe('https://docs.example.org/admin/reference/account-user/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fieldHelp', () => {
|
||||
it('prefers our words, then the schema description', () => {
|
||||
expect(fieldHelp('x:Domain.catchAllAddress', 'schema text')).toBe(FIELD_HELP['x:Domain.catchAllAddress']);
|
||||
expect(fieldHelp('x:Domain.unknownField', 'schema text')).toBe('schema text');
|
||||
expect(fieldHelp(undefined, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* Links from in-app help to the INBUXA admin manual (MkDocs). Every tooltip
|
||||
* and help panel carries a stable id, `x:Domain.dnsManagement` for a field
|
||||
* or `x:Domain` for a page, and this is the one place that turns an id into
|
||||
* an address. Until a manual is published there is no base URL and no link
|
||||
* is shown.
|
||||
*
|
||||
* The base URL comes from VITE_MANUAL_URL at build time, or from
|
||||
* <meta name="manual-url" content="https://…"> at deploy time.
|
||||
*/
|
||||
function manualBase(): string | null {
|
||||
const fromMeta =
|
||||
typeof document !== 'undefined' ? document.querySelector('meta[name="manual-url"]')?.getAttribute('content') : null;
|
||||
const base = (fromMeta || (import.meta.env.VITE_MANUAL_URL as string | undefined) || '').trim();
|
||||
return base ? base.replace(/\/+$/, '') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The manual page for a help id: `x:Domain.dnsManagement` becomes
|
||||
* `<base>/reference/domain/#dnsmanagement`, `x:Domain` becomes
|
||||
* `<base>/reference/domain/`. The manual's page names must follow this.
|
||||
*/
|
||||
export function manualUrl(id: string): string | null {
|
||||
const base = manualBase();
|
||||
if (!base) return null;
|
||||
const [object, field] = id.split('.', 2);
|
||||
const page = object
|
||||
.replace(/^x:/, '')
|
||||
.replace(/\//g, '-')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
||||
.toLowerCase();
|
||||
return `${base}/reference/${page}/${field ? `#${field.toLowerCase()}` : ''}`;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA's own help, in plain words, keyed by help id. Options without an
|
||||
* entry here fall back to the description the server's schema gives. Write
|
||||
* new entries in our own words: say what the option does for the person
|
||||
* using it, and what goes wrong if it's set badly. Keep a tooltip to one
|
||||
* or two sentences; the manual is where the detail goes.
|
||||
*
|
||||
* Keys: `object` for a page, `object.field` for an option on it.
|
||||
*/
|
||||
|
||||
export const FIELD_HELP: Record<string, string> = {
|
||||
// Domains
|
||||
'x:Domain.name': 'The domain people’s addresses end in, like example.com.',
|
||||
'x:Domain.aliases':
|
||||
'Other domains that deliver to the same people. Mail to [email protected] lands in [email protected].',
|
||||
'x:Domain.isEnabled': 'Turn off to stop accepting mail for this domain without deleting anything.',
|
||||
'x:Domain.catchAllAddress':
|
||||
'Where mail to addresses that don’t exist goes. Handy for small teams; on a busy domain it collects spam.',
|
||||
'x:Domain.subAddressing':
|
||||
'Lets people use [email protected] to sort or trace their mail. On for most domains.',
|
||||
'x:Domain.allowRelaying':
|
||||
'Forward mail for unknown people to another server, for domains split between two systems. Leave off otherwise.',
|
||||
'x:Domain.dkimManagement':
|
||||
'Signing keys that prove mail from this domain is really yours. Automatic creates and rotates them for you.',
|
||||
'x:Domain.certificateManagement':
|
||||
'The TLS certificate for this domain’s mail and web addresses. Automatic gets and renews one for you.',
|
||||
'x:Domain.dnsManagement':
|
||||
'Whether the server writes this domain’s DNS records itself through your DNS host, or you add them by hand.',
|
||||
'x:Domain.reportAddressUri':
|
||||
'Where other mail servers send reports about mail claiming to be from you (DMARC, TLS). Postmaster is a good choice.',
|
||||
'x:Domain.memberTenantId': 'The customer or organization this domain belongs to, if you host more than one.',
|
||||
'x:Domain.directoryId': 'Where this domain’s accounts and passwords are kept: here, or an outside directory.',
|
||||
'x:Domain.logo': 'A logo for this domain’s sign-in page and mail apps. A link or an uploaded image.',
|
||||
|
||||
// People
|
||||
'x:UserAccount.name': 'The part before the @. Together with the domain it makes the person’s address.',
|
||||
'x:UserAccount.description': 'The person’s full name, as others see it.',
|
||||
'x:UserAccount.aliases': 'More addresses that deliver to this person.',
|
||||
'x:UserAccount.quotas':
|
||||
'Limits for this person, like how much storage they may use. Empty means the server’s defaults.',
|
||||
'x:UserAccount.roles': 'What this person may do. Most people are plain users; admins manage the server.',
|
||||
'x:UserAccount.memberGroupIds': 'Groups this person belongs to. They share the group’s mail and can send as it.',
|
||||
'x:UserAccount.credentials': 'How this person signs in: a password, app passwords for mail apps, and more.',
|
||||
'x:UserAccount.locale': 'The language for messages the server sends this person.',
|
||||
'x:UserAccount.timeZone': 'Used for calendar invitations and scheduled messages.',
|
||||
'x:GroupAccount.name': 'The group’s address, before the @. Mail to it reaches every member.',
|
||||
|
||||
// DNS providers
|
||||
'x:DnsServerCloudflare.secret':
|
||||
'A Cloudflare API token that can edit DNS for your zone. Make it with the “Edit zone DNS” template.',
|
||||
'x:DnsServerCloudflare.email': 'Only for the old Global API Key. Leave empty when you use an API token.',
|
||||
'x:DnsServerCloud.secret': 'The API token or key from your DNS host. Give it DNS access for this domain only.',
|
||||
'x:DnsServerCloudflare.ttl': 'How long other servers may cache the records written. Five minutes is a good default.',
|
||||
'x:DnsServerCloud.ttl': 'How long other servers may cache the records written. Five minutes is a good default.',
|
||||
|
||||
// Security
|
||||
'x:BlockedIp.address': 'An address or network, like 203.0.113.7 or 203.0.113.0/24, refused before it can talk.',
|
||||
'x:BlockedIp.reason': 'A note for yourself on why it was blocked.',
|
||||
'x:BlockedIp.expiresAt': 'When the block lifts by itself. Empty means it stays until you remove it.',
|
||||
'x:AllowedIp.address': 'An address or network that is never blocked automatically, like your office or monitoring.',
|
||||
};
|
||||
|
||||
export interface PageHelp {
|
||||
/** What the page is for, in a sentence or two. */
|
||||
about: string;
|
||||
/** The things people come here to do. */
|
||||
tasks?: string[];
|
||||
}
|
||||
|
||||
export const PAGE_HELP: Record<string, PageHelp> = {
|
||||
'x:Domain': {
|
||||
about: 'The domains this server receives and sends mail for. Each person’s address belongs to one of them.',
|
||||
tasks: [
|
||||
'Add a domain, then publish its DNS records so mail can find you.',
|
||||
'Let the server publish DNS for you: open a domain and use “Set it up” in its DNS section.',
|
||||
'Turn on automatic DKIM and certificates so keys and certificates renew themselves.',
|
||||
],
|
||||
},
|
||||
'x:Account/User': {
|
||||
about: 'Everyone with a mailbox here. Each person has an address, a password and, optionally, limits.',
|
||||
tasks: [
|
||||
'Add a person and give them a password.',
|
||||
'Give someone more addresses with aliases.',
|
||||
'Set a storage limit under quotas.',
|
||||
],
|
||||
},
|
||||
'x:Account/Group': {
|
||||
about: 'Shared mailboxes, like sales@ or support@, that several people read and send from.',
|
||||
tasks: ['Create a group, then add people to it from their own page under Groups.'],
|
||||
},
|
||||
'x:MailingList': {
|
||||
about: 'Addresses that pass each message on to a list of recipients, inside or outside this server.',
|
||||
},
|
||||
'x:Tenant': {
|
||||
about: 'Separate customers or organizations on one server, each with their own domains, people and limits.',
|
||||
},
|
||||
'x:Role': {
|
||||
about: 'Named sets of permissions. Give a role to a person to let them do more, or less.',
|
||||
},
|
||||
'x:OAuthClient': {
|
||||
about: 'Apps allowed to sign people in through this server, like INBUXA webmail and INBUXA Admin.',
|
||||
},
|
||||
'x:DkimSignature': {
|
||||
about: 'The keys that sign outgoing mail so receivers can check it really came from you.',
|
||||
tasks: ['Let domains manage their own keys: set DKIM to automatic on the domain.'],
|
||||
},
|
||||
'x:QueuedMessage': {
|
||||
about:
|
||||
'Mail waiting to go out. Most leaves within seconds; what stays here is waiting for a server that isn’t answering.',
|
||||
tasks: [
|
||||
'See why a message is stuck: open it and look at each recipient’s status.',
|
||||
'Retry now, or cancel mail that will never be delivered.',
|
||||
],
|
||||
},
|
||||
'x:DnsServer': {
|
||||
about: 'Connections to your DNS hosts, so the server can publish and update its own DNS records.',
|
||||
tasks: ['Connect one the easy way: open a domain and use “Set it up” in its DNS section.'],
|
||||
},
|
||||
'x:BlockedIp': {
|
||||
about: 'Addresses refused before they can talk to the server. The server adds some itself when it spots attacks.',
|
||||
tasks: ['Unblock someone: find their address and delete the entry.'],
|
||||
},
|
||||
'x:AllowedIp': {
|
||||
about: 'Addresses the server never blocks by itself, like your office network or monitoring.',
|
||||
},
|
||||
'x:DmarcExternalReport': {
|
||||
about:
|
||||
'Reports from other mail providers on mail they received claiming to be from your domains, and whether it passed.',
|
||||
},
|
||||
'x:TlsExternalReport': {
|
||||
about: 'Reports from other mail providers on whether they could reach you over an encrypted connection.',
|
||||
},
|
||||
'x:Task': {
|
||||
about: 'Background work the server has scheduled: DNS updates, key rotation, certificate renewal and upkeep.',
|
||||
},
|
||||
'x:Task/TaskFailed': {
|
||||
about: 'Background work that failed. Each entry says why; most retry by themselves once the cause is fixed.',
|
||||
},
|
||||
'x:Log': {
|
||||
about: 'What the server has been doing, newest first. Useful for tracing a problem back to its cause.',
|
||||
},
|
||||
};
|
||||
|
||||
/** The help text for an option: ours when written, else the schema's. */
|
||||
export function fieldHelp(id: string | undefined, fallback?: string | null): string | null {
|
||||
return (id && FIELD_HELP[id]) || fallback || null;
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const APP_NAME = 'Stalwart WebUI';
|
||||
const APP_NAME = 'INBUXA Admin';
|
||||
|
||||
export function useDocumentTitle(title?: string | null) {
|
||||
useEffect(() => {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"bootstrap": {
|
||||
"clipboardBlocked": "Your browser blocked clipboard access.",
|
||||
"complete": "Setup complete",
|
||||
"configuredSuccessfully": "Stalwart has been configured successfully.",
|
||||
"configuredSuccessfully": "INBUXA has been configured successfully.",
|
||||
"copyFailed": "Copy failed",
|
||||
"credentialsCreated": "Your administrator account has been created. Write these down now: the password will not be shown again.",
|
||||
"emptyForm": "Setup form is empty. The server did not return any bootstrap fields.",
|
||||
@@ -23,11 +23,11 @@
|
||||
"failedToLoad": "Failed to load bootstrap state.",
|
||||
"finishSetup": "Finish setup",
|
||||
"loadingSetup": "Loading setup...",
|
||||
"nextStepBody": "restart Stalwart for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.",
|
||||
"nextStepBody": "restart INBUXA for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.",
|
||||
"nextStepLabel": "Next step:",
|
||||
"noConfirm": "The server did not confirm the update.",
|
||||
"stepOf": "Step {{current}} of {{total}}",
|
||||
"welcome": "Welcome to Stalwart",
|
||||
"welcome": "Welcome to INBUXA",
|
||||
"welcomeSubtitle": "Let's get your server set up."
|
||||
},
|
||||
"common": {
|
||||
@@ -61,7 +61,8 @@
|
||||
"preset7d": "Last 7 days",
|
||||
"preset90d": "Last 90 days",
|
||||
"title": "Dashboard",
|
||||
"to": "To"
|
||||
"to": "To",
|
||||
"liveUnavailable": "Live numbers aren't available on this server yet. The rest of the dashboard still works."
|
||||
},
|
||||
"deliveryTrace": {
|
||||
"attemptCount_one": "{{count}} attempt",
|
||||
@@ -131,13 +132,7 @@
|
||||
"seconds": "Seconds"
|
||||
},
|
||||
"enterprise": {
|
||||
"featureDisabled": "This feature requires an Enterprise license.",
|
||||
"trialTitle": "Unlock Enterprise Features",
|
||||
"trialDescription": "Get access to advanced features including multi-tenancy, AI-powered spam filtering, alerts, and more.",
|
||||
"trialButton": "Start 30-Day Free Trial",
|
||||
"requestTrial": "Request a free trial to unlock this feature.",
|
||||
"ossHidden": "This feature is not available in the open-source edition.",
|
||||
"whyNotFree": "Why is this not free?"
|
||||
"featureDisabled": "This feature isn't available on this server."
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
@@ -288,7 +283,8 @@
|
||||
"showing": "Showing {{from}}-{{to}} of {{total}} {{name}}",
|
||||
"showingItems": "Showing {{count}} items",
|
||||
"sort": "Sort",
|
||||
"unknownError": "Unknown error"
|
||||
"unknownError": "Unknown error",
|
||||
"emptyTitle": "Nothing here yet"
|
||||
},
|
||||
"login": {
|
||||
"continue": "Continue",
|
||||
@@ -299,11 +295,19 @@
|
||||
},
|
||||
"logo": {
|
||||
"alt": "Logo",
|
||||
"stalwartAlt": "Stalwart Logo"
|
||||
"inbuxaAlt": "INBUXA"
|
||||
},
|
||||
"logout": "Logout",
|
||||
"version": {
|
||||
"label": "Stalwart WebUI v{{version}}"
|
||||
"label": "INBUXA Admin {{version}}"
|
||||
},
|
||||
"nav": {
|
||||
"layoutLegacy": "Legacy",
|
||||
"layoutLegacyHint": "The sidebar, as the old web UI had it.",
|
||||
"layoutMenu": "Layout",
|
||||
"layoutModern": "Modern",
|
||||
"layoutModernHint": "Sections across the top; the page gets the full width.",
|
||||
"more": "More"
|
||||
},
|
||||
"oauth": {
|
||||
"backToLogin": "Back to login",
|
||||
@@ -378,12 +382,26 @@
|
||||
"traceNotFound": "Trace not found",
|
||||
"valuePlaceholder": "Value..."
|
||||
},
|
||||
"tryEnterprise": "Try Enterprise",
|
||||
"userMenu": "User menu",
|
||||
"view": {
|
||||
"couldNotResolve": "Could not resolve object",
|
||||
"failedToLoad": "Failed to load",
|
||||
"noGetResponse": "No get response",
|
||||
"objectNotFound": "Object not found"
|
||||
},
|
||||
"source": {
|
||||
"download": "Source code of this version ({{id}}), AGPL-3.0",
|
||||
"menu": "Source code (AGPL-3.0)"
|
||||
},
|
||||
"greeting": {
|
||||
"morning": "Good morning",
|
||||
"afternoon": "Good afternoon",
|
||||
"evening": "Good evening",
|
||||
"subtitle": "Here's how your mail server is doing.",
|
||||
"signedInAs": "Signed in as {{username}}"
|
||||
},
|
||||
"theme": {
|
||||
"menu": "Theme",
|
||||
"classic": "Classic"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +1,103 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
@import 'tailwindcss';
|
||||
@import './palettes.css';
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0.017 285.823);
|
||||
/* INBUXA, light: warm paper, the mark's navy for text, its teal to act. */
|
||||
--background: #fbfaf7;
|
||||
--foreground: #16262f;
|
||||
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0.017 285.823);
|
||||
--card: #ffffff;
|
||||
--card-foreground: #16262f;
|
||||
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0.017 285.823);
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #16262f;
|
||||
|
||||
--content-background: oklch(0.97 0.003 285.823);
|
||||
--content-background: #f4f1ea;
|
||||
|
||||
--primary: oklch(0.205 0.017 285.823);
|
||||
--primary-foreground: oklch(0.985 0.002 285.823);
|
||||
--primary: #0d8a82;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: oklch(0.965 0.005 285.823);
|
||||
--secondary-foreground: oklch(0.205 0.017 285.823);
|
||||
--secondary: #efece5;
|
||||
--secondary-foreground: #16262f;
|
||||
|
||||
--muted: oklch(0.93 0.005 285.823);
|
||||
--muted-foreground: oklch(0.556 0.015 285.823);
|
||||
--muted: #efece5;
|
||||
--muted-foreground: #5f6b73;
|
||||
|
||||
--accent: oklch(0.965 0.005 285.823);
|
||||
--accent-foreground: oklch(0.205 0.017 285.823);
|
||||
--accent: #e1f4f1;
|
||||
--accent-foreground: #0a5f59;
|
||||
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0.002 285.823);
|
||||
/* The cat's orange, for the few things that should feel warm. */
|
||||
--highlight: #f59e3f;
|
||||
--highlight-soft: #fdebd5;
|
||||
|
||||
--border: oklch(0.922 0.007 285.823);
|
||||
--input: oklch(0.922 0.007 285.823);
|
||||
--ring: oklch(0.708 0.015 285.823);
|
||||
--destructive: #d9383a;
|
||||
--destructive-foreground: #ffffff;
|
||||
|
||||
--radius: 0.5rem;
|
||||
--border: #e7e2d8;
|
||||
--input: #ddd7cb;
|
||||
--ring: #0d8a82;
|
||||
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--radius: 0.75rem;
|
||||
--shadow-soft: 0 1px 2px rgb(22 38 47 / 0.04), 0 4px 16px rgb(22 38 47 / 0.05);
|
||||
|
||||
--chart-1: #0d8a82;
|
||||
--chart-2: #f59e3f;
|
||||
--chart-3: #1c4053;
|
||||
--chart-4: #46cac3;
|
||||
--chart-5: #b45309;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0.002 285.823);
|
||||
/* INBUXA, dark: the lockup's own background, lifted surfaces, bright teal. */
|
||||
--background: #0e1c26;
|
||||
--foreground: #e7eef2;
|
||||
|
||||
--card: oklch(0.205 0.007 285.823);
|
||||
--card-foreground: oklch(0.985 0.002 285.823);
|
||||
--card: #132633;
|
||||
--card-foreground: #e7eef2;
|
||||
|
||||
--popover: oklch(0.205 0.007 285.823);
|
||||
--popover-foreground: oklch(0.985 0.002 285.823);
|
||||
--popover: #152a38;
|
||||
--popover-foreground: #e7eef2;
|
||||
|
||||
--content-background: oklch(0.115 0.005 285.823);
|
||||
--content-background: #0b1720;
|
||||
|
||||
--primary: oklch(0.985 0.002 285.823);
|
||||
--primary-foreground: oklch(0.205 0.017 285.823);
|
||||
--primary: #46cac3;
|
||||
--primary-foreground: #062a28;
|
||||
|
||||
--secondary: oklch(0.274 0.009 285.823);
|
||||
--secondary-foreground: oklch(0.985 0.002 285.823);
|
||||
--secondary: #1a3140;
|
||||
--secondary-foreground: #e7eef2;
|
||||
|
||||
--muted: oklch(0.274 0.009 285.823);
|
||||
--muted-foreground: oklch(0.708 0.015 285.823);
|
||||
--muted: #1a3140;
|
||||
--muted-foreground: #93a8b6;
|
||||
|
||||
--accent: oklch(0.274 0.009 285.823);
|
||||
--accent-foreground: oklch(0.985 0.002 285.823);
|
||||
--accent: #133d40;
|
||||
--accent-foreground: #9ff0e9;
|
||||
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0.002 285.823);
|
||||
--highlight: #f9a34c;
|
||||
--highlight-soft: #3a2a18;
|
||||
|
||||
--border: oklch(0.274 0.009 285.823);
|
||||
--input: oklch(0.274 0.009 285.823);
|
||||
--ring: oklch(0.553 0.013 285.823);
|
||||
--destructive: #f87171;
|
||||
--destructive-foreground: #0b1720;
|
||||
|
||||
--chart-1: 220 70% 60%;
|
||||
--chart-2: 160 60% 55%;
|
||||
--chart-3: 30 80% 60%;
|
||||
--chart-4: 280 65% 65%;
|
||||
--chart-5: 340 75% 60%;
|
||||
--border: #1f3847;
|
||||
--input: #274455;
|
||||
--ring: #46cac3;
|
||||
|
||||
--shadow-soft: 0 1px 2px rgb(0 0 0 / 0.25), 0 6px 20px rgb(0 0 0 / 0.2);
|
||||
|
||||
--chart-1: #46cac3;
|
||||
--chart-2: #f9a34c;
|
||||
--chart-3: #9fc2d6;
|
||||
--chart-4: #67e8f9;
|
||||
--chart-5: #fbbf24;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -107,6 +123,9 @@
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-highlight: var(--highlight);
|
||||
--color-highlight-soft: var(--highlight-soft);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
@@ -114,16 +133,20 @@
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-chart-1: hsl(var(--chart-1));
|
||||
--color-chart-2: hsl(var(--chart-2));
|
||||
--color-chart-3: hsl(var(--chart-3));
|
||||
--color-chart-4: hsl(var(--chart-4));
|
||||
--color-chart-5: hsl(var(--chart-5));
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: 'Space Grotesk Variable', 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--shadow-soft: var(--shadow-soft);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -132,7 +155,24 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-feature-settings: 'cv11', 'ss01';
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: color-mix(in oklab, var(--primary) 30%, transparent);
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in oklab, var(--muted-foreground) 35%, transparent) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* The theme follows the account, the same way INBUXA webmail's does: from
|
||||
* `settings.json` in the account's own JMAP Files, inside the `ihasmail`
|
||||
* folder. The two apps read and write the same keys, so a palette picked in
|
||||
* either is the one both open with, on any device.
|
||||
*
|
||||
* The admin touches only the theme's keys: `palette`, `mode`, and the derived
|
||||
* `theme` that older webmail builds read. Everything else in the file is the
|
||||
* webmail's and is written back exactly as it was read, from a fresh read
|
||||
* made just before each write. localStorage stays as the cache that paints
|
||||
* the first frame, as in the webmail.
|
||||
*/
|
||||
import { apiFetch } from '@/services/api';
|
||||
import { jmapRequest } from '@/services/jmap/client';
|
||||
import { isPaletteId, type PaletteId } from '@/lib/palettes';
|
||||
|
||||
const FILENODE = 'urn:ietf:params:jmap:filenode';
|
||||
const APP_FOLDER = 'ihasmail';
|
||||
const FILE = 'settings.json';
|
||||
const TYPE = 'application/json';
|
||||
const DEBOUNCE_MS = 1500;
|
||||
|
||||
type Mode = 'system' | 'light' | 'dark';
|
||||
|
||||
interface Target {
|
||||
accountId: string;
|
||||
uploadUrl: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
let target: Target | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** `mode` is set only when the user picked light or dark; a palette change keeps the stored mode. */
|
||||
interface Choice {
|
||||
palette: PaletteId;
|
||||
mode: Mode | null;
|
||||
fallbackMode: Mode;
|
||||
}
|
||||
let pending: Choice | null = null;
|
||||
let chain: Promise<void> = Promise.resolve();
|
||||
|
||||
/** A server URL, as a path for apiFetch (which adds the API base). */
|
||||
function pathOf(url: string): string {
|
||||
try {
|
||||
const u = new URL(url, 'http://placeholder');
|
||||
return u.pathname + u.search;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remember where the account's Files are, from the JMAP session. Null turns syncing off. */
|
||||
export function setAccountSettingsTarget(session: Record<string, unknown> | null): void {
|
||||
const primary = (session?.primaryAccounts ?? {}) as Record<string, string>;
|
||||
const accountId = primary[FILENODE];
|
||||
const uploadUrl = session?.uploadUrl;
|
||||
const downloadUrl = session?.downloadUrl;
|
||||
target =
|
||||
accountId && typeof uploadUrl === 'string' && typeof downloadUrl === 'string'
|
||||
? { accountId, uploadUrl, downloadUrl }
|
||||
: null;
|
||||
if (!target) {
|
||||
pending = null;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function call<T>(method: string, args: Record<string, unknown>): Promise<T> {
|
||||
const [res] = await jmapRequest([[method, args, '0']], undefined, [FILENODE]);
|
||||
if (!res || res[0] === 'error') throw new Error(`${method} failed`);
|
||||
return res[1] as T;
|
||||
}
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
nodeType?: string;
|
||||
blobId?: string | null;
|
||||
}
|
||||
|
||||
async function children(t: Target, parentId: string | null): Promise<Node[]> {
|
||||
const filter = parentId ? { parentId } : { isTopLevel: true };
|
||||
const responses = await jmapRequest(
|
||||
[
|
||||
['FileNode/query', { accountId: t.accountId, filter, limit: 1000 }, 'q'],
|
||||
[
|
||||
'FileNode/get',
|
||||
{
|
||||
accountId: t.accountId,
|
||||
'#ids': { resultOf: 'q', name: 'FileNode/query', path: '/ids' },
|
||||
properties: ['id', 'name', 'parentId', 'nodeType', 'blobId'],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
],
|
||||
undefined,
|
||||
[FILENODE],
|
||||
);
|
||||
const get = responses.find((r) => r[2] === 'g');
|
||||
if (!get || get[0] === 'error') throw new Error('FileNode/get failed');
|
||||
return ((get[1] as { list?: Node[] }).list ?? []) as Node[];
|
||||
}
|
||||
|
||||
async function findFolder(t: Target): Promise<string | null> {
|
||||
const top = await children(t, null);
|
||||
return top.find((n) => n.name === APP_FOLDER && !n.parentId && n.nodeType === 'directory')?.id ?? null;
|
||||
}
|
||||
|
||||
async function readFile(
|
||||
t: Target,
|
||||
folderId: string | null,
|
||||
): Promise<{ node: Node | null; body: Record<string, unknown> }> {
|
||||
if (!folderId) return { node: null, body: {} };
|
||||
const node = (await children(t, folderId)).find((n) => n.name === FILE && n.parentId === folderId) ?? null;
|
||||
if (!node?.blobId) return { node, body: {} };
|
||||
const url = t.downloadUrl
|
||||
.replace('{accountId}', encodeURIComponent(t.accountId))
|
||||
.replace('{blobId}', encodeURIComponent(node.blobId))
|
||||
.replace('{name}', FILE)
|
||||
.replace('{type}', encodeURIComponent(TYPE));
|
||||
const res = await apiFetch(pathOf(url));
|
||||
if (!res.ok) throw new Error(`settings download failed (${res.status})`);
|
||||
const parsed = (await res.json()) as unknown;
|
||||
return {
|
||||
node,
|
||||
body: parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {},
|
||||
};
|
||||
}
|
||||
|
||||
/** The account's theme, or null when there is none to read (no file, no Files, a failure). */
|
||||
export async function loadAccountTheme(): Promise<{ palette: PaletteId | null; mode: Mode | null } | null> {
|
||||
const t = target;
|
||||
if (!t) return null;
|
||||
try {
|
||||
const { body } = await readFile(t, await findFolder(t));
|
||||
const palette = isPaletteId(body.palette) ? body.palette : null;
|
||||
const mode = body.mode === 'light' || body.mode === 'dark' || body.mode === 'system' ? body.mode : null;
|
||||
if (!palette && !mode) return null;
|
||||
return { palette, mode };
|
||||
} catch {
|
||||
// A settings file we can't read must never cost anyone the admin; the cache stands.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The old single-value `theme`, derived exactly as the webmail does (its lib/palette.ts). */
|
||||
function legacyTheme(palette: PaletteId, mode: Mode, prefersDark: boolean): 'system' | 'light' | 'dark' | 'ihasmail' {
|
||||
const effective = mode === 'system' ? (prefersDark ? 'dark' : 'light') : mode;
|
||||
if (palette === 'ihasmail' && effective === 'dark') return 'ihasmail';
|
||||
if (palette === 'default' && mode === 'system') return 'system';
|
||||
return effective;
|
||||
}
|
||||
|
||||
async function write(t: Target, choice: Choice): Promise<void> {
|
||||
let folderId = await findFolder(t);
|
||||
if (!folderId) {
|
||||
const created = await call<{ created?: Record<string, { id: string }> }>('FileNode/set', {
|
||||
accountId: t.accountId,
|
||||
create: { d: { parentId: null, name: APP_FOLDER, nodeType: 'directory' } },
|
||||
});
|
||||
folderId = created.created?.d?.id ?? null;
|
||||
if (!folderId) throw new Error('could not create the settings folder');
|
||||
}
|
||||
const { node, body } = await readFile(t, folderId);
|
||||
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||
const stored = body.mode === 'light' || body.mode === 'dark' || body.mode === 'system' ? body.mode : null;
|
||||
const mode = choice.mode ?? stored ?? choice.fallbackMode;
|
||||
const next = { ...body, palette: choice.palette, mode, theme: legacyTheme(choice.palette, mode, prefersDark) };
|
||||
const json = JSON.stringify(next, null, 2);
|
||||
const blob = new Blob([json], { type: TYPE });
|
||||
const up = await apiFetch(pathOf(t.uploadUrl.replace('{accountId}', encodeURIComponent(t.accountId))), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': TYPE },
|
||||
body: blob,
|
||||
});
|
||||
if (!up.ok) throw new Error(`settings upload failed (${up.status})`);
|
||||
const { blobId } = (await up.json()) as { blobId: string };
|
||||
if (node) {
|
||||
await call('FileNode/set', {
|
||||
accountId: t.accountId,
|
||||
update: { [node.id]: { blobId, type: TYPE, size: blob.size } },
|
||||
});
|
||||
} else {
|
||||
await call('FileNode/set', {
|
||||
accountId: t.accountId,
|
||||
create: { s: { parentId: folderId, name: FILE, blobId, type: TYPE, nodeType: 'file' } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue the theme for the account. `mode` is null for a palette-only change, which keeps the
|
||||
* account's stored mode (a webmail "system" stays "system"); `fallbackMode` is used only when
|
||||
* nothing is stored. Coalesces: the newest choice wins, one write after changes stop.
|
||||
*/
|
||||
export function queueAccountTheme(palette: PaletteId, mode: Mode | null, fallbackMode: Mode): void {
|
||||
if (!target) return;
|
||||
pending = { palette, mode: mode ?? pending?.mode ?? null, fallbackMode };
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
const t = target;
|
||||
const choice = pending;
|
||||
pending = null;
|
||||
if (!t || !choice) return;
|
||||
// One write at a time, in order.
|
||||
chain = chain.then(() => write(t, choice)).catch(() => undefined);
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { humanize } from './humanize';
|
||||
|
||||
describe('humanize', () => {
|
||||
it('spells out property names', () => {
|
||||
expect(humanize('defaultCertificateId')).toBe('Default certificate ID');
|
||||
expect(humanize('mailExchangers')).toBe('Mail exchangers');
|
||||
expect(humanize('proxyTrustedNetworks')).toBe('Proxy trusted networks');
|
||||
expect(humanize('maxConnections')).toBe('Max connections');
|
||||
});
|
||||
it('keeps acronyms', () => {
|
||||
expect(humanize('useHttpsForSmtp')).toBe('Use HTTPS for SMTP');
|
||||
expect(humanize('oauthClientId')).toBe('OAuth client ID');
|
||||
expect(humanize('DNSServer')).toBe('DNS server');
|
||||
});
|
||||
it('names views', () => {
|
||||
expect(humanize('x:SystemSettings')).toBe('System settings');
|
||||
expect(humanize('x:Account/User')).toBe('User');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/** Words that stay in capitals, or in their own casing, when a name is spelled out. */
|
||||
const SPECIAL: Record<string, string> = {
|
||||
id: 'ID',
|
||||
ids: 'IDs',
|
||||
url: 'URL',
|
||||
urls: 'URLs',
|
||||
uri: 'URI',
|
||||
uris: 'URIs',
|
||||
tls: 'TLS',
|
||||
dns: 'DNS',
|
||||
mx: 'MX',
|
||||
ip: 'IP',
|
||||
ips: 'IPs',
|
||||
api: 'API',
|
||||
http: 'HTTP',
|
||||
https: 'HTTPS',
|
||||
smtp: 'SMTP',
|
||||
imap: 'IMAP',
|
||||
pop3: 'POP3',
|
||||
jmap: 'JMAP',
|
||||
dkim: 'DKIM',
|
||||
spf: 'SPF',
|
||||
dmarc: 'DMARC',
|
||||
arc: 'ARC',
|
||||
acme: 'ACME',
|
||||
ldap: 'LDAP',
|
||||
sql: 'SQL',
|
||||
ttl: 'TTL',
|
||||
oauth: 'OAuth',
|
||||
oidc: 'OIDC',
|
||||
sni: 'SNI',
|
||||
mta: 'MTA',
|
||||
dav: 'DAV',
|
||||
cal: 'Cal',
|
||||
ai: 'AI',
|
||||
llm: 'LLM',
|
||||
otp: 'OTP',
|
||||
totp: 'TOTP',
|
||||
s3: 'S3',
|
||||
};
|
||||
|
||||
/**
|
||||
* `defaultCertificateId` → "Default certificate ID", `x:SystemSettings` →
|
||||
* "System settings". For names the server gives no label of its own: a
|
||||
* person reads words, not identifiers.
|
||||
*/
|
||||
export function humanize(name: string): string {
|
||||
const bare = name.replace(/^x:/, '').split('/').pop() ?? name;
|
||||
const words = bare
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
return words
|
||||
.map((w, i) => {
|
||||
const special = SPECIAL[w.toLowerCase()];
|
||||
if (special) return special;
|
||||
const lower = w.toLowerCase();
|
||||
return i === 0 ? lower[0].toUpperCase() + lower.slice(1) : lower;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export type Tone = 'teal' | 'orange' | 'sky' | 'violet' | 'rose' | 'amber' | 'emerald' | 'indigo' | 'slate';
|
||||
|
||||
/**
|
||||
* Colors by meaning, for the icons the server's layout names:
|
||||
* - teal: mail itself;
|
||||
* - rose: security;
|
||||
* - amber: storage and data;
|
||||
* - sky: network and connectivity;
|
||||
* - violet: people and identity;
|
||||
* - indigo: monitoring and reports;
|
||||
* - emerald: automation and tasks;
|
||||
* - orange: look and feel;
|
||||
* - slate: the rest.
|
||||
*/
|
||||
const TONES: Record<string, Tone> = {
|
||||
'layout-dashboard': 'teal',
|
||||
mail: 'teal',
|
||||
inbox: 'teal',
|
||||
send: 'teal',
|
||||
'mail-minus': 'teal',
|
||||
plane: 'teal',
|
||||
route: 'sky',
|
||||
globe: 'sky',
|
||||
cable: 'sky',
|
||||
zap: 'sky',
|
||||
monitor: 'sky',
|
||||
'shield-check': 'rose',
|
||||
'shield-alert': 'rose',
|
||||
lock: 'rose',
|
||||
fingerprint: 'rose',
|
||||
'key-round': 'rose',
|
||||
'key-square': 'rose',
|
||||
filter: 'rose',
|
||||
database: 'amber',
|
||||
archive: 'amber',
|
||||
boxes: 'amber',
|
||||
folder: 'amber',
|
||||
search: 'amber',
|
||||
'search-code': 'amber',
|
||||
users: 'violet',
|
||||
'circle-user': 'violet',
|
||||
contact: 'violet',
|
||||
calendar: 'violet',
|
||||
activity: 'indigo',
|
||||
'chart-line': 'indigo',
|
||||
'file-text': 'indigo',
|
||||
'list-checks': 'emerald',
|
||||
clock: 'emerald',
|
||||
brain: 'emerald',
|
||||
'file-code': 'emerald',
|
||||
palette: 'orange',
|
||||
'app-window': 'orange',
|
||||
settings: 'slate',
|
||||
'sliders-horizontal': 'slate',
|
||||
};
|
||||
|
||||
const FALLBACK: Tone[] = ['teal', 'sky', 'violet', 'amber', 'emerald', 'indigo', 'orange', 'rose'];
|
||||
|
||||
/** The tone for an icon name: by meaning where known, otherwise a stable pick from its name. */
|
||||
export function toneFor(name: string): Tone {
|
||||
const known = TONES[name];
|
||||
if (known) return known;
|
||||
let h = 0;
|
||||
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||||
return FALLBACK[h % FALLBACK.length];
|
||||
}
|
||||