Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2053518d1 | |||
| 6d391d13bd | |||
| df701fddd2 |
@@ -37,17 +37,24 @@ govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.1
|
|||||||
|
|
||||||
Local development uses `webui/package.json`, which may point at sibling module checkouts while active development is happening.
|
Local development uses `webui/package.json`, which may point at sibling module checkouts while active development is happening.
|
||||||
|
|
||||||
Release WebUI installs should use `webui/package.release.json`. It points module dependencies at the same tagged git repositories. To generate a release lockfile, copy it over `package.json` in a release branch or build workspace and then run `npm install` there:
|
Release WebUI installs should use `webui/package.release.json`. It points module dependencies at the same tagged git repositories. After the module tags referenced there exist, generate the committed release lockfile without touching the development package files:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core/webui
|
cd /mnt/DATA/git/govoplan-core
|
||||||
cp package.release.json package.json
|
scripts/generate-release-lock.sh
|
||||||
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm install
|
cd webui
|
||||||
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
|
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
The module repositories include root-level npm package manifests so git installs can resolve `@govoplan/files-webui`, `@govoplan/mail-webui`, and `@govoplan/campaign-webui` from repository roots even though their source lives below `webui/src`.
|
The module repositories include root-level npm package manifests so git installs can resolve `@govoplan/files-webui`, `@govoplan/mail-webui`, and `@govoplan/campaign-webui` from repository roots even though their source lives below `webui/src`.
|
||||||
|
|
||||||
|
The normal release path is automated by `scripts/push-release-tag.sh`: it bumps or accepts the target version, updates Python/WebUI/module manifest versions, commits/tags/pushes the module repositories first, regenerates `webui/package-lock.release.json`, and then commits/tags/pushes core. If the working tree has already been bumped, pass the current version explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-core
|
||||||
|
scripts/push-release-tag.sh --version 0.1.2
|
||||||
|
```
|
||||||
|
|
||||||
### Release lockfile strategy
|
### Release lockfile strategy
|
||||||
|
|
||||||
The supported release composition currently is the full Multi Seal Mail product: core plus files, mail, and campaign. Keep one committed full-product release lockfile at `webui/package-lock.release.json`, generated from `webui/package.release.json` in a clean release workspace. Development `package-lock.json` may continue to point at local `file:` dependencies.
|
The supported release composition currently is the full Multi Seal Mail product: core plus files, mail, and campaign. Keep one committed full-product release lockfile at `webui/package-lock.release.json`, generated from `webui/package.release.json` in a clean release workspace. Development `package-lock.json` may continue to point at local `file:` dependencies.
|
||||||
@@ -59,6 +66,6 @@ Frontend module permutations are regression-tested through `GOVOPLAN_WEBUI_MODUL
|
|||||||
- Keep Python package versions, WebUI package versions, and git tags aligned.
|
- Keep Python package versions, WebUI package versions, and git tags aligned.
|
||||||
- Tag core, files, mail, and campaign repositories together.
|
- Tag core, files, mail, and campaign repositories together.
|
||||||
- Update `requirements-release.txt` and `webui/package.release.json` when the release tag changes.
|
- Update `requirements-release.txt` and `webui/package.release.json` when the release tag changes.
|
||||||
- Generate the committed full-product release lockfile from `package.release.json` in a clean build workspace.
|
- Generate the committed full-product release lockfile from `package.release.json` with `scripts/generate-release-lock.sh`.
|
||||||
- Add separate release manifest/lockfile pairs only for module compositions that are shipped as their own products.
|
- Add separate release manifest/lockfile pairs only for module compositions that are shipped as their own products.
|
||||||
- Do not commit local sibling paths into release manifests.
|
- Do not commit local sibling paths into release manifests.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.2"
|
version = "0.1.4"
|
||||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
81
scripts/generate-release-lock.sh
Normal file
81
scripts/generate-release-lock.sh
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Usage:
|
||||||
|
scripts/generate-release-lock.sh [options]
|
||||||
|
|
||||||
|
Generates webui/package-lock.release.json from webui/package.release.json in a
|
||||||
|
temporary workspace. The normal development package.json and package-lock.json
|
||||||
|
are left untouched.
|
||||||
|
|
||||||
|
Run this after the module git tags referenced by package.release.json exist and
|
||||||
|
are reachable, and before tagging the core release commit that should contain
|
||||||
|
the regenerated release lockfile.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--npm <path> npm executable to use.
|
||||||
|
-h, --help Show this help.
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
WEBUI="$ROOT/webui"
|
||||||
|
NPM_BIN="${NPM:-}"
|
||||||
|
|
||||||
|
if [[ -z "$NPM_BIN" ]]; then
|
||||||
|
if [[ -x "/home/zemion/.nvm/versions/node/v22.22.3/bin/npm" ]]; then
|
||||||
|
NPM_BIN="/home/zemion/.nvm/versions/node/v22.22.3/bin/npm"
|
||||||
|
else
|
||||||
|
NPM_BIN="npm"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--npm)
|
||||||
|
[[ $# -ge 2 ]] || fail "missing value for $1"
|
||||||
|
NPM_BIN="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -f "$WEBUI/package.release.json" ]] || fail "missing $WEBUI/package.release.json"
|
||||||
|
command -v "$NPM_BIN" >/dev/null 2>&1 || fail "npm executable not found: $NPM_BIN"
|
||||||
|
|
||||||
|
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/govoplan-release-lock.XXXXXXXX")"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TMP_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
cp "$WEBUI/package.release.json" "$TMP_DIR/package.json"
|
||||||
|
if [[ -f "$WEBUI/package-lock.release.json" ]]; then
|
||||||
|
cp "$WEBUI/package-lock.release.json" "$TMP_DIR/package-lock.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Generating release lockfile from $WEBUI/package.release.json"
|
||||||
|
echo "Temporary workspace: $TMP_DIR"
|
||||||
|
(
|
||||||
|
cd "$TMP_DIR"
|
||||||
|
PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" install --package-lock-only --ignore-scripts
|
||||||
|
)
|
||||||
|
|
||||||
|
cp "$TMP_DIR/package-lock.json" "$WEBUI/package-lock.release.json"
|
||||||
|
echo "Updated $WEBUI/package-lock.release.json"
|
||||||
136
scripts/launch-dev.sh
Normal file
136
scripts/launch-dev.sh
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="${GOVOPLAN_CORE_ROOT:-/mnt/DATA/git/govoplan-core}"
|
||||||
|
WEBUI_ROOT="$ROOT/webui"
|
||||||
|
PYTHON="${PYTHON:-$ROOT/.venv/bin/python}"
|
||||||
|
NODE_BIN="${NODE_BIN:-/home/zemion/.nvm/versions/node/v22.22.3/bin}"
|
||||||
|
NPM="${NPM:-$NODE_BIN/npm}"
|
||||||
|
|
||||||
|
BACKEND_HOST="${GOVOPLAN_BACKEND_HOST:-127.0.0.1}"
|
||||||
|
BACKEND_PORT="${GOVOPLAN_BACKEND_PORT:-8000}"
|
||||||
|
FRONTEND_HOST="${GOVOPLAN_FRONTEND_HOST:-127.0.0.1}"
|
||||||
|
FRONTEND_PORT="${GOVOPLAN_FRONTEND_PORT:-5173}"
|
||||||
|
OPEN_BROWSER="${OPEN_BROWSER:-1}"
|
||||||
|
|
||||||
|
LOG_DIR="$ROOT/runtime/dev-launcher"
|
||||||
|
BACKEND_LOG="$LOG_DIR/backend.log"
|
||||||
|
FRONTEND_LOG="$LOG_DIR/frontend.log"
|
||||||
|
BACKEND_URL="http://$BACKEND_HOST:$BACKEND_PORT"
|
||||||
|
FRONTEND_URL="http://$FRONTEND_HOST:$FRONTEND_PORT"
|
||||||
|
|
||||||
|
backend_pid=""
|
||||||
|
frontend_pid=""
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'launch-dev: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
port_is_free() {
|
||||||
|
"$PYTHON" - "$1" "$2" <<'PY'
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
|
||||||
|
host = sys.argv[1]
|
||||||
|
port = int(sys.argv[2])
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
try:
|
||||||
|
sock.bind((host, port))
|
||||||
|
except OSError:
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_url() {
|
||||||
|
"$PYTHON" - "$1" <<'PY'
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
url = sys.argv[1]
|
||||||
|
deadline = time.monotonic() + 60
|
||||||
|
last_error = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=2) as response:
|
||||||
|
if 200 <= response.status < 500:
|
||||||
|
raise SystemExit(0)
|
||||||
|
except Exception as exc: # noqa: BLE001 - printed only on timeout.
|
||||||
|
last_error = exc
|
||||||
|
time.sleep(1)
|
||||||
|
print(f"Timed out waiting for {url}: {last_error}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "${frontend_pid:-}" ] && kill -0 "$frontend_pid" 2>/dev/null; then
|
||||||
|
kill "$frontend_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [ -n "${backend_pid:-}" ] && kill -0 "$backend_pid" 2>/dev/null; then
|
||||||
|
kill "$backend_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
[ -x "$PYTHON" ] || fail "Python virtualenv not found at $PYTHON. Run: cd $ROOT && ./.venv/bin/python -m pip install -r requirements-dev.txt"
|
||||||
|
[ -x "$NPM" ] || fail "npm not found at $NPM. Set NODE_BIN or NPM to your Node installation."
|
||||||
|
[ -f "$WEBUI_ROOT/package.json" ] || fail "WebUI package.json not found at $WEBUI_ROOT/package.json"
|
||||||
|
[ -d "$WEBUI_ROOT/node_modules" ] || fail "WebUI node_modules missing. Run: cd $WEBUI_ROOT && PATH=$NODE_BIN:\$PATH $NPM install"
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
: > "$BACKEND_LOG"
|
||||||
|
: > "$FRONTEND_LOG"
|
||||||
|
|
||||||
|
port_is_free "$BACKEND_HOST" "$BACKEND_PORT" || fail "$BACKEND_URL is already in use"
|
||||||
|
port_is_free "$FRONTEND_HOST" "$FRONTEND_PORT" || fail "$FRONTEND_URL is already in use"
|
||||||
|
|
||||||
|
printf 'Starting GovOPlaN backend at %s\n' "$BACKEND_URL"
|
||||||
|
(
|
||||||
|
cd "$ROOT"
|
||||||
|
"$PYTHON" -m govoplan_core.devserver --host "$BACKEND_HOST" --port "$BACKEND_PORT"
|
||||||
|
) >"$BACKEND_LOG" 2>&1 &
|
||||||
|
backend_pid="$!"
|
||||||
|
|
||||||
|
printf 'Waiting for %s/health\n' "$BACKEND_URL"
|
||||||
|
wait_for_url "$BACKEND_URL/health" || {
|
||||||
|
tail -n 80 "$BACKEND_LOG" >&2 || true
|
||||||
|
fail "backend did not become healthy"
|
||||||
|
}
|
||||||
|
|
||||||
|
printf 'Starting GovOPlaN WebUI at %s\n' "$FRONTEND_URL"
|
||||||
|
(
|
||||||
|
cd "$WEBUI_ROOT"
|
||||||
|
PATH="$WEBUI_ROOT/node_modules/.bin:$NODE_BIN:$PATH" \
|
||||||
|
VITE_API_PROXY_TARGET="$BACKEND_URL" \
|
||||||
|
"$NPM" run dev
|
||||||
|
) >"$FRONTEND_LOG" 2>&1 &
|
||||||
|
frontend_pid="$!"
|
||||||
|
|
||||||
|
printf 'Waiting for %s\n' "$FRONTEND_URL"
|
||||||
|
wait_for_url "$FRONTEND_URL" || {
|
||||||
|
tail -n 80 "$FRONTEND_LOG" >&2 || true
|
||||||
|
fail "frontend did not become reachable"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$OPEN_BROWSER" = "1" ] && command -v xdg-open >/dev/null 2>&1; then
|
||||||
|
xdg-open "$FRONTEND_URL" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
GovOPlaN is running.
|
||||||
|
Web UI: $FRONTEND_URL
|
||||||
|
API: $BACKEND_URL/api/v1
|
||||||
|
Health: $BACKEND_URL/health
|
||||||
|
|
||||||
|
Logs:
|
||||||
|
Backend: $BACKEND_LOG
|
||||||
|
WebUI: $FRONTEND_LOG
|
||||||
|
|
||||||
|
Press Ctrl+C to stop both processes.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
wait
|
||||||
@@ -16,11 +16,14 @@ By default the script asks which version part to bump:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--bump <kind> Version bump: major, minor, subversion, or patch.
|
--bump <kind> Version bump: major, minor, subversion, or patch.
|
||||||
--version <x.y.z> Explicit target version instead of a bump.
|
--version <x.y.z> Explicit target version instead of a bump. May equal
|
||||||
|
the current repo versions when they were bumped already.
|
||||||
-r, --remote <name> Remote to push to. Defaults to origin.
|
-r, --remote <name> Remote to push to. Defaults to origin.
|
||||||
-b, --branch <name> Branch to update in every repo. Defaults to each current branch.
|
-b, --branch <name> Branch to update in every repo. Defaults to each current branch.
|
||||||
-m, --message <text> Commit message. Defaults to "Release v<x.y.z>".
|
-m, --message <text> Commit message. Defaults to "Release v<x.y.z>".
|
||||||
--tag-message <text> Annotated tag message. Defaults to the commit message.
|
--tag-message <text> Annotated tag message. Defaults to the commit message.
|
||||||
|
--skip-release-lock Do not regenerate core webui/package-lock.release.json.
|
||||||
|
--npm <path> npm executable for lockfile generation.
|
||||||
-y, --yes Do not prompt before committing, tagging, and pushing.
|
-y, --yes Do not prompt before committing, tagging, and pushing.
|
||||||
-n, --dry-run Print intended actions without changing files or git state.
|
-n, --dry-run Print intended actions without changing files or git state.
|
||||||
-h, --help Show this help.
|
-h, --help Show this help.
|
||||||
@@ -46,10 +49,10 @@ fail() {
|
|||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
PARENT="$(dirname "$ROOT")"
|
PARENT="$(dirname "$ROOT")"
|
||||||
REPOS=(
|
REPOS=(
|
||||||
"$ROOT"
|
|
||||||
"$PARENT/govoplan-files"
|
"$PARENT/govoplan-files"
|
||||||
"$PARENT/govoplan-mail"
|
"$PARENT/govoplan-mail"
|
||||||
"$PARENT/govoplan-campaign"
|
"$PARENT/govoplan-campaign"
|
||||||
|
"$ROOT"
|
||||||
)
|
)
|
||||||
|
|
||||||
REMOTE="origin"
|
REMOTE="origin"
|
||||||
@@ -60,6 +63,8 @@ COMMIT_MESSAGE=""
|
|||||||
TAG_MESSAGE=""
|
TAG_MESSAGE=""
|
||||||
DRY_RUN=0
|
DRY_RUN=0
|
||||||
YES=0
|
YES=0
|
||||||
|
GENERATE_RELEASE_LOCK=1
|
||||||
|
NPM_BIN="${NPM:-}"
|
||||||
|
|
||||||
if [[ -z "${PYTHON:-}" ]]; then
|
if [[ -z "${PYTHON:-}" ]]; then
|
||||||
if [[ -x "$ROOT/.venv/bin/python" ]]; then
|
if [[ -x "$ROOT/.venv/bin/python" ]]; then
|
||||||
@@ -69,6 +74,14 @@ if [[ -z "${PYTHON:-}" ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$NPM_BIN" ]]; then
|
||||||
|
if [[ -x "/home/zemion/.nvm/versions/node/v22.22.3/bin/npm" ]]; then
|
||||||
|
NPM_BIN="/home/zemion/.nvm/versions/node/v22.22.3/bin/npm"
|
||||||
|
else
|
||||||
|
NPM_BIN="npm"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
normalize_bump() {
|
normalize_bump() {
|
||||||
local value="${1,,}"
|
local value="${1,,}"
|
||||||
case "$value" in
|
case "$value" in
|
||||||
@@ -174,6 +187,133 @@ print(f"{name}: {old_version} -> {new_version}")
|
|||||||
PYCODE
|
PYCODE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
update_manifest_version() {
|
||||||
|
local repo="$1"
|
||||||
|
local project_name="$2"
|
||||||
|
local version="$3"
|
||||||
|
local manifest_path=""
|
||||||
|
|
||||||
|
case "$project_name" in
|
||||||
|
govoplan-core)
|
||||||
|
manifest_path="$repo/src/govoplan_core/access/manifest.py"
|
||||||
|
;;
|
||||||
|
govoplan-files)
|
||||||
|
manifest_path="$repo/src/govoplan_files/backend/manifest.py"
|
||||||
|
;;
|
||||||
|
govoplan-mail)
|
||||||
|
manifest_path="$repo/src/govoplan_mail/backend/manifest.py"
|
||||||
|
;;
|
||||||
|
govoplan-campaign)
|
||||||
|
manifest_path="$repo/src/govoplan_campaign/backend/manifest.py"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "unknown project for manifest version update: $project_name"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[[ -f "$manifest_path" ]] || fail "missing module manifest: $manifest_path"
|
||||||
|
|
||||||
|
"$PYTHON" - "$manifest_path" "$version" <<'PYCODE'
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = pathlib.Path(sys.argv[1])
|
||||||
|
new_version = sys.argv[2]
|
||||||
|
text = path.read_text()
|
||||||
|
text, count = re.subn(
|
||||||
|
r'(?m)^(\s*version=)["\'][^"\']+["\'](,?\s*)$',
|
||||||
|
rf'\1"{new_version}"\2',
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
if count != 1:
|
||||||
|
raise SystemExit(f"could not update ModuleManifest.version in {path}")
|
||||||
|
path.write_text(text)
|
||||||
|
PYCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
update_webui_package() {
|
||||||
|
local repo="$1"
|
||||||
|
local project_name="$2"
|
||||||
|
local version="$3"
|
||||||
|
local package_path=""
|
||||||
|
local release_package_path="$repo/webui/package.release.json"
|
||||||
|
|
||||||
|
for package_path in "$repo/package.json" "$repo/webui/package.json"; do
|
||||||
|
[[ -f "$package_path" ]] || continue
|
||||||
|
"$PYTHON" - "$package_path" "$project_name" "$version" <<'PYCODE'
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = pathlib.Path(sys.argv[1])
|
||||||
|
project_name = sys.argv[2]
|
||||||
|
new_version = sys.argv[3]
|
||||||
|
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
data["version"] = new_version
|
||||||
|
if project_name != "govoplan-core":
|
||||||
|
peers = data.setdefault("peerDependencies", {})
|
||||||
|
if "@govoplan/core-webui" in peers:
|
||||||
|
peers["@govoplan/core-webui"] = f"^{new_version}"
|
||||||
|
path.write_text(json.dumps(data, indent=2) + "\n")
|
||||||
|
PYCODE
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$project_name" == "govoplan-core" && -f "$release_package_path" ]]; then
|
||||||
|
"$PYTHON" - "$release_package_path" "$version" <<'PYCODE'
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = pathlib.Path(sys.argv[1])
|
||||||
|
new_version = sys.argv[2]
|
||||||
|
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
data["version"] = new_version
|
||||||
|
dependencies = data.setdefault("dependencies", {})
|
||||||
|
for name, spec in list(dependencies.items()):
|
||||||
|
if name in {"@govoplan/files-webui", "@govoplan/mail-webui", "@govoplan/campaign-webui"}:
|
||||||
|
dependencies[name] = re.sub(r"#v\d+\.\d+\.\d+$", f"#v{new_version}", spec)
|
||||||
|
path.write_text(json.dumps(data, indent=2) + "\n")
|
||||||
|
PYCODE
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
update_version_files() {
|
||||||
|
local repo="$1"
|
||||||
|
local version="$2"
|
||||||
|
local project_name="${PROJECT_NAMES[$repo]}"
|
||||||
|
|
||||||
|
update_pyproject "$repo" "$version"
|
||||||
|
update_manifest_version "$repo" "$project_name" "$version"
|
||||||
|
update_webui_package "$repo" "$project_name" "$version"
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh_development_webui_lock() {
|
||||||
|
[[ -f "$ROOT/webui/package.json" ]] || return 0
|
||||||
|
[[ -f "$ROOT/webui/package-lock.json" ]] || return 0
|
||||||
|
|
||||||
|
run env PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" --prefix "$ROOT/webui" install --package-lock-only --ignore-scripts
|
||||||
|
}
|
||||||
|
|
||||||
|
generate_release_lock() {
|
||||||
|
if [[ "$GENERATE_RELEASE_LOCK" -eq 0 ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -x "$ROOT/scripts/generate-release-lock.sh" ]] || fail "missing executable release lock generator: $ROOT/scripts/generate-release-lock.sh"
|
||||||
|
run "$ROOT/scripts/generate-release-lock.sh" --npm "$NPM_BIN"
|
||||||
|
}
|
||||||
|
|
||||||
print_command() {
|
print_command() {
|
||||||
printf '+'
|
printf '+'
|
||||||
printf ' %q' "$@"
|
printf ' %q' "$@"
|
||||||
@@ -276,6 +416,15 @@ while [[ $# -gt 0 ]]; do
|
|||||||
TAG_MESSAGE="$2"
|
TAG_MESSAGE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--skip-release-lock)
|
||||||
|
GENERATE_RELEASE_LOCK=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--npm)
|
||||||
|
[[ $# -ge 2 ]] || fail "missing value for $1"
|
||||||
|
NPM_BIN="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-y|--yes)
|
-y|--yes)
|
||||||
YES=1
|
YES=1
|
||||||
shift
|
shift
|
||||||
@@ -305,6 +454,9 @@ prompt_for_bump
|
|||||||
if ! command -v "$PYTHON" >/dev/null 2>&1; then
|
if ! command -v "$PYTHON" >/dev/null 2>&1; then
|
||||||
fail "Python interpreter not found: $PYTHON"
|
fail "Python interpreter not found: $PYTHON"
|
||||||
fi
|
fi
|
||||||
|
if ! command -v "$NPM_BIN" >/dev/null 2>&1; then
|
||||||
|
fail "npm executable not found: $NPM_BIN"
|
||||||
|
fi
|
||||||
|
|
||||||
declare -A PROJECT_NAMES
|
declare -A PROJECT_NAMES
|
||||||
declare -A OLD_VERSIONS
|
declare -A OLD_VERSIONS
|
||||||
@@ -349,6 +501,9 @@ fi
|
|||||||
validate_version "$TARGET_VERSION" || fail "target version must be x.y.z: $TARGET_VERSION"
|
validate_version "$TARGET_VERSION" || fail "target version must be x.y.z: $TARGET_VERSION"
|
||||||
|
|
||||||
for repo in "${REPOS[@]}"; do
|
for repo in "${REPOS[@]}"; do
|
||||||
|
if [[ "$TARGET_VERSION" == "${OLD_VERSIONS[$repo]}" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
if ! version_gt "$TARGET_VERSION" "${OLD_VERSIONS[$repo]}"; then
|
if ! version_gt "$TARGET_VERSION" "${OLD_VERSIONS[$repo]}"; then
|
||||||
fail "target version $TARGET_VERSION must be greater than ${OLD_VERSIONS[$repo]} for ${PROJECT_NAMES[$repo]}"
|
fail "target version $TARGET_VERSION must be greater than ${OLD_VERSIONS[$repo]} for ${PROJECT_NAMES[$repo]}"
|
||||||
fi
|
fi
|
||||||
@@ -382,6 +537,11 @@ echo " version: $TARGET_VERSION"
|
|||||||
echo " tag: $TAG"
|
echo " tag: $TAG"
|
||||||
echo " remote: $REMOTE"
|
echo " remote: $REMOTE"
|
||||||
echo " commit message: $COMMIT_MESSAGE"
|
echo " commit message: $COMMIT_MESSAGE"
|
||||||
|
if [[ "$GENERATE_RELEASE_LOCK" -eq 1 ]]; then
|
||||||
|
echo " release lock: regenerate core webui/package-lock.release.json after module tags are pushed"
|
||||||
|
else
|
||||||
|
echo " release lock: skipped"
|
||||||
|
fi
|
||||||
echo
|
echo
|
||||||
|
|
||||||
for repo in "${REPOS[@]}"; do
|
for repo in "${REPOS[@]}"; do
|
||||||
@@ -394,13 +554,15 @@ confirm_release
|
|||||||
|
|
||||||
for repo in "${REPOS[@]}"; do
|
for repo in "${REPOS[@]}"; do
|
||||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||||
echo "Would update $repo/pyproject.toml to $TARGET_VERSION"
|
echo "Would update version files in $repo to $TARGET_VERSION"
|
||||||
else
|
else
|
||||||
update_pyproject "$repo" "$TARGET_VERSION"
|
update_version_files "$repo" "$TARGET_VERSION"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
for repo in "${REPOS[@]}"; do
|
refresh_development_webui_lock
|
||||||
|
|
||||||
|
for repo in "${REPOS[@]:0:3}"; do
|
||||||
run git -C "$repo" add -A
|
run git -C "$repo" add -A
|
||||||
|
|
||||||
if [[ "$DRY_RUN" -eq 0 ]]; then
|
if [[ "$DRY_RUN" -eq 0 ]]; then
|
||||||
@@ -413,10 +575,24 @@ for repo in "${REPOS[@]}"; do
|
|||||||
run git -C "$repo" tag -a "$TAG" -m "$TAG_MESSAGE"
|
run git -C "$repo" tag -a "$TAG" -m "$TAG_MESSAGE"
|
||||||
done
|
done
|
||||||
|
|
||||||
for repo in "${REPOS[@]}"; do
|
for repo in "${REPOS[@]:0:3}"; do
|
||||||
run git -C "$repo" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$repo]}" "refs/tags/$TAG"
|
run git -C "$repo" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$repo]}" "refs/tags/$TAG"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
generate_release_lock
|
||||||
|
|
||||||
|
run git -C "$ROOT" add -A
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" -eq 0 ]]; then
|
||||||
|
if git -C "$ROOT" diff --cached --quiet; then
|
||||||
|
fail "no staged changes for ${PROJECT_NAMES[$ROOT]} after version bump"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
run git -C "$ROOT" commit -m "$COMMIT_MESSAGE"
|
||||||
|
run git -C "$ROOT" tag -a "$TAG" -m "$TAG_MESSAGE"
|
||||||
|
run git -C "$ROOT" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$ROOT]}" "refs/tags/$TAG"
|
||||||
|
|
||||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||||
echo "Dry run complete for $TAG across all GovOPlaN repos."
|
echo "Dry run complete for $TAG across all GovOPlaN repos."
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="access",
|
id="access",
|
||||||
name="Access",
|
name="Access",
|
||||||
version="1.0.0",
|
version="0.1.4",
|
||||||
permissions=ACCESS_PERMISSIONS,
|
permissions=ACCESS_PERMISSIONS,
|
||||||
role_templates=ACCESS_ROLE_TEMPLATES,
|
role_templates=ACCESS_ROLE_TEMPLATES,
|
||||||
migration_spec=MigrationSpec(module_id="access", metadata=AccessBase.metadata),
|
migration_spec=MigrationSpec(module_id="access", metadata=AccessBase.metadata),
|
||||||
@@ -116,4 +116,3 @@ manifest = ModuleManifest(
|
|||||||
|
|
||||||
def get_manifest() -> ModuleManifest:
|
def get_manifest() -> ModuleManifest:
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|||||||
@@ -473,33 +473,44 @@ def tenant_owner_user_ids(session: Session, tenant_id: str) -> set[str]:
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
owner_ids: set[str] = set()
|
owner_ids: set[str] = set()
|
||||||
|
user_ids = [user.id for user in users]
|
||||||
|
if not user_ids:
|
||||||
|
return owner_ids
|
||||||
|
|
||||||
|
roles_by_user_id: dict[str, list[Role]] = {user_id: [] for user_id in user_ids}
|
||||||
|
direct_role_rows = (
|
||||||
|
session.query(UserRoleAssignment.user_id, Role)
|
||||||
|
.join(Role, UserRoleAssignment.role_id == Role.id)
|
||||||
|
.filter(
|
||||||
|
UserRoleAssignment.tenant_id == tenant_id,
|
||||||
|
UserRoleAssignment.user_id.in_(user_ids),
|
||||||
|
Role.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for user_id, role in direct_role_rows:
|
||||||
|
roles_by_user_id.setdefault(user_id, []).append(role)
|
||||||
|
|
||||||
|
group_role_rows = (
|
||||||
|
session.query(UserGroupMembership.user_id, Role)
|
||||||
|
.join(GroupRoleAssignment, UserGroupMembership.group_id == GroupRoleAssignment.group_id)
|
||||||
|
.join(Role, GroupRoleAssignment.role_id == Role.id)
|
||||||
|
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||||
|
.filter(
|
||||||
|
UserGroupMembership.tenant_id == tenant_id,
|
||||||
|
UserGroupMembership.user_id.in_(user_ids),
|
||||||
|
Group.is_active.is_(True),
|
||||||
|
Role.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for user_id, role in group_role_rows:
|
||||||
|
roles_by_user_id.setdefault(user_id, []).append(role)
|
||||||
|
|
||||||
for user in users:
|
for user in users:
|
||||||
direct_roles = (
|
|
||||||
session.query(Role)
|
|
||||||
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
|
|
||||||
.filter(
|
|
||||||
UserRoleAssignment.tenant_id == tenant_id,
|
|
||||||
UserRoleAssignment.user_id == user.id,
|
|
||||||
Role.tenant_id == tenant_id,
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
group_roles = (
|
|
||||||
session.query(Role)
|
|
||||||
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
|
|
||||||
.join(UserGroupMembership, UserGroupMembership.group_id == GroupRoleAssignment.group_id)
|
|
||||||
.join(Group, Group.id == UserGroupMembership.group_id)
|
|
||||||
.filter(
|
|
||||||
UserGroupMembership.tenant_id == tenant_id,
|
|
||||||
UserGroupMembership.user_id == user.id,
|
|
||||||
Group.is_active.is_(True),
|
|
||||||
Role.tenant_id == tenant_id,
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
effective = [
|
effective = [
|
||||||
scope
|
scope
|
||||||
for role in direct_roles + group_roles
|
for role in roles_by_user_id.get(user.id, [])
|
||||||
for scope in (role.permissions or [])
|
for scope in (role.permissions or [])
|
||||||
]
|
]
|
||||||
if scopes_grant(effective, "admin:roles:write") and scopes_grant(effective, "campaign:send"):
|
if scopes_grant(effective, "admin:roles:write") and scopes_grant(effective, "campaign:send"):
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ from govoplan_core.api.v1.admin_schemas import (
|
|||||||
TenantListResponse,
|
TenantListResponse,
|
||||||
TenantOwnerCandidate,
|
TenantOwnerCandidate,
|
||||||
TenantOwnerCandidateListResponse,
|
TenantOwnerCandidateListResponse,
|
||||||
|
TenantSettingsItem,
|
||||||
|
TenantSettingsUpdateRequest,
|
||||||
TenantUpdateRequest,
|
TenantUpdateRequest,
|
||||||
UserAdminItem,
|
UserAdminItem,
|
||||||
UserCreateRequest,
|
UserCreateRequest,
|
||||||
@@ -275,6 +277,16 @@ def _tenant_item(session: Session, tenant: Tenant) -> TenantAdminItem:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_settings_item(tenant: Tenant) -> TenantSettingsItem:
|
||||||
|
return TenantSettingsItem(
|
||||||
|
id=tenant.id,
|
||||||
|
slug=tenant.slug,
|
||||||
|
name=tenant.name,
|
||||||
|
default_locale=tenant.default_locale,
|
||||||
|
settings=tenant.settings or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
|
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
|
||||||
memberships = (
|
memberships = (
|
||||||
session.query(User, Tenant)
|
session.query(User, Tenant)
|
||||||
@@ -539,6 +551,41 @@ def update_tenant(
|
|||||||
return _tenant_item(session, tenant)
|
return _tenant_item(session, tenant)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenant/settings", response_model=TenantSettingsItem)
|
||||||
|
def get_tenant_settings(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("admin:settings:read")),
|
||||||
|
):
|
||||||
|
tenant = session.get(Tenant, principal.tenant_id)
|
||||||
|
if tenant is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||||
|
return _tenant_settings_item(tenant)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/tenant/settings", response_model=TenantSettingsItem)
|
||||||
|
def update_tenant_settings(
|
||||||
|
payload: TenantSettingsUpdateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("admin:settings:write")),
|
||||||
|
):
|
||||||
|
tenant = session.get(Tenant, principal.tenant_id)
|
||||||
|
if tenant is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||||
|
tenant.default_locale = payload.default_locale.strip() or "en"
|
||||||
|
session.add(tenant)
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
action="tenant.settings.updated",
|
||||||
|
object_type="tenant",
|
||||||
|
object_id=tenant.id,
|
||||||
|
details={"default_locale": tenant.default_locale},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _tenant_settings_item(tenant)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users", response_model=UserListResponse)
|
@router.get("/users", response_model=UserListResponse)
|
||||||
def list_users(
|
def list_users(
|
||||||
tenant_id: str | None = Query(default=None),
|
tenant_id: str | None = Query(default=None),
|
||||||
|
|||||||
@@ -123,6 +123,20 @@ class TenantUpdateRequest(BaseModel):
|
|||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TenantSettingsItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
default_locale: str = Field(default="en", min_length=1, max_length=20)
|
||||||
|
settings: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantSettingsUpdateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
default_locale: str = Field(min_length=1, max_length=20)
|
||||||
|
|
||||||
|
|
||||||
class RoleSummary(BaseModel):
|
class RoleSummary(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
slug: str = Field(min_length=1, max_length=100)
|
slug: str = Field(min_length=1, max_length=100)
|
||||||
|
|||||||
@@ -71,6 +71,123 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
payload = response.json()
|
payload = response.json()
|
||||||
return {"Authorization": f"Bearer {payload['access_token']}"}, payload
|
return {"Authorization": f"Bearer {payload['access_token']}"}, payload
|
||||||
|
|
||||||
|
def test_recipient_import_mapping_profiles_are_db_backed(self) -> None:
|
||||||
|
headers, _ = self._login()
|
||||||
|
payload = {
|
||||||
|
"name": "ERP export",
|
||||||
|
"columnCount": 3,
|
||||||
|
"headers": ["email", "name", "field.customer_id"],
|
||||||
|
"normalizedHeaders": ["email", "name", "field_customer_id"],
|
||||||
|
"orderedHeaderFingerprint": "ordered-123",
|
||||||
|
"unorderedHeaderFingerprint": "unordered-123",
|
||||||
|
"delimiter": ";",
|
||||||
|
"headerRows": 1,
|
||||||
|
"quoted": True,
|
||||||
|
"valueSeparators": ",;|",
|
||||||
|
"mappings": [
|
||||||
|
{"columnIndex": 0, "kind": "to"},
|
||||||
|
{"columnIndex": 1, "kind": "name"},
|
||||||
|
{"columnIndex": 2, "kind": "new_field", "newFieldName": "customer_id"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
created = self.client.post("/api/v1/campaigns/recipient-import/mapping-profiles", headers=headers, json=payload)
|
||||||
|
self.assertEqual(created.status_code, 201, created.text)
|
||||||
|
created_body = created.json()
|
||||||
|
self.assertEqual(created_body["name"], "ERP export")
|
||||||
|
self.assertEqual(created_body["columnCount"], 3)
|
||||||
|
self.assertEqual(created_body["mappings"][2]["newFieldName"], "customer_id")
|
||||||
|
profile_id = created_body["id"]
|
||||||
|
|
||||||
|
listed = self.client.get("/api/v1/campaigns/recipient-import/mapping-profiles", headers=headers)
|
||||||
|
self.assertEqual(listed.status_code, 200, listed.text)
|
||||||
|
self.assertEqual([profile["id"] for profile in listed.json()["profiles"]], [profile_id])
|
||||||
|
|
||||||
|
updated_payload = {**payload, "name": "ERP export updated", "valueSeparators": "|"}
|
||||||
|
updated = self.client.put(
|
||||||
|
f"/api/v1/campaigns/recipient-import/mapping-profiles/{profile_id}",
|
||||||
|
headers=headers,
|
||||||
|
json=updated_payload,
|
||||||
|
)
|
||||||
|
self.assertEqual(updated.status_code, 200, updated.text)
|
||||||
|
self.assertEqual(updated.json()["name"], "ERP export updated")
|
||||||
|
self.assertEqual(updated.json()["valueSeparators"], "|")
|
||||||
|
|
||||||
|
deleted = self.client.delete(f"/api/v1/campaigns/recipient-import/mapping-profiles/{profile_id}", headers=headers)
|
||||||
|
self.assertEqual(deleted.status_code, 204, deleted.text)
|
||||||
|
|
||||||
|
listed_after_delete = self.client.get("/api/v1/campaigns/recipient-import/mapping-profiles", headers=headers)
|
||||||
|
self.assertEqual(listed_after_delete.status_code, 200, listed_after_delete.text)
|
||||||
|
self.assertEqual(listed_after_delete.json()["profiles"], [])
|
||||||
|
|
||||||
|
def test_campaign_entries_store_recipient_import_provenance(self) -> None:
|
||||||
|
headers, _ = self._login()
|
||||||
|
campaign_json = {
|
||||||
|
"version": "1.0",
|
||||||
|
"campaign": {"id": "import-provenance", "name": "Import provenance", "mode": "test"},
|
||||||
|
"fields": [{"name": "department", "type": "string", "required": False}],
|
||||||
|
"global_values": {},
|
||||||
|
"server": {},
|
||||||
|
"recipients": {
|
||||||
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
|
"allow_individual_to": True,
|
||||||
|
},
|
||||||
|
"template": {"subject": "Hello", "text": "Hello"},
|
||||||
|
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
|
||||||
|
"entries": {
|
||||||
|
"inline": [
|
||||||
|
{
|
||||||
|
"id": "recipient-1",
|
||||||
|
"to": [{"email": "recipient@example.org", "type": "to"}],
|
||||||
|
"fields": {"department": "Finance"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"imports": [
|
||||||
|
{
|
||||||
|
"id": "recipient-import-test",
|
||||||
|
"imported_at": "2026-06-26T12:00:00Z",
|
||||||
|
"mode": "replace",
|
||||||
|
"source_type": "xlsx",
|
||||||
|
"filename": "recipients.xlsx",
|
||||||
|
"sheet_name": "Recipients",
|
||||||
|
"encoding": None,
|
||||||
|
"delimiter": None,
|
||||||
|
"header_rows": 1,
|
||||||
|
"quoted": None,
|
||||||
|
"value_separators": ",;|",
|
||||||
|
"rows_total": 1,
|
||||||
|
"valid_rows": 1,
|
||||||
|
"invalid_rows": 0,
|
||||||
|
"imported_rows": 1,
|
||||||
|
"field_names_created": [],
|
||||||
|
"attachment_patterns": 0,
|
||||||
|
"mapping": [
|
||||||
|
{"column_index": 0, "header": "email", "kind": "to"},
|
||||||
|
{"column_index": 1, "header": "field.department", "kind": "field", "field_name": "department"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"validation_policy": {"missing_email": "block", "template_error": "block"},
|
||||||
|
"delivery": {"rate_limit": {"messages_per_minute": 60}},
|
||||||
|
"status_tracking": {"enabled": True},
|
||||||
|
}
|
||||||
|
created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
||||||
|
self.assertEqual(created.status_code, 200, created.text)
|
||||||
|
campaign_id = created.json()["campaign"]["id"]
|
||||||
|
version_id = created.json()["version"]["id"]
|
||||||
|
|
||||||
|
detail = self.client.get(f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", headers=headers)
|
||||||
|
self.assertEqual(detail.status_code, 200, detail.text)
|
||||||
|
stored_import = detail.json()["raw_json"]["entries"]["imports"][0]
|
||||||
|
self.assertEqual(stored_import["source_type"], "xlsx")
|
||||||
|
self.assertEqual(stored_import["sheet_name"], "Recipients")
|
||||||
|
self.assertEqual(stored_import["mapping"][1]["field_name"], "department")
|
||||||
|
|
||||||
|
validated = self.client.post(f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": False})
|
||||||
|
self.assertEqual(validated.status_code, 200, validated.text)
|
||||||
|
self.assertTrue(validated.json()["ok"], validated.text)
|
||||||
|
|
||||||
def _create_built_delivery_campaign(
|
def _create_built_delivery_campaign(
|
||||||
self,
|
self,
|
||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
@@ -549,6 +666,47 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(invalid_share.status_code, 400, invalid_share.text)
|
self.assertEqual(invalid_share.status_code, 400, invalid_share.text)
|
||||||
|
|
||||||
|
campaign = self.client.post(
|
||||||
|
"/api/v1/campaigns/new",
|
||||||
|
headers=headers,
|
||||||
|
json={"external_id": "bulk-file-share", "name": "Bulk file share"},
|
||||||
|
)
|
||||||
|
self.assertEqual(campaign.status_code, 200, campaign.text)
|
||||||
|
campaign_id = campaign.json()["campaign"]["id"]
|
||||||
|
|
||||||
|
bulk_share = self.client.post(
|
||||||
|
"/api/v1/files/bulk-shares",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"file_ids": [first_file["id"], second_file["id"], first_file["id"]],
|
||||||
|
"target_type": "campaign",
|
||||||
|
"target_id": campaign_id,
|
||||||
|
"permission": "read",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(bulk_share.status_code, 200, bulk_share.text)
|
||||||
|
self.assertEqual(bulk_share.json()["shared_count"], 2)
|
||||||
|
self.assertEqual(len(bulk_share.json()["shares"]), 2)
|
||||||
|
|
||||||
|
repeated_bulk_share = self.client.post(
|
||||||
|
"/api/v1/files/bulk-shares",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"file_ids": [first_file["id"], second_file["id"]],
|
||||||
|
"target_type": "campaign",
|
||||||
|
"target_id": campaign_id,
|
||||||
|
"permission": "read",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(repeated_bulk_share.status_code, 200, repeated_bulk_share.text)
|
||||||
|
self.assertEqual(repeated_bulk_share.json()["shared_count"], 2)
|
||||||
|
|
||||||
|
campaign_files = self.client.get("/api/v1/files", headers=headers, params={"campaign_id": campaign_id})
|
||||||
|
self.assertEqual(campaign_files.status_code, 200, campaign_files.text)
|
||||||
|
self.assertEqual({item["id"] for item in campaign_files.json()["files"]}, {first_file["id"], second_file["id"]})
|
||||||
|
self.assertTrue(all(item["shares"] for item in campaign_files.json()["files"]))
|
||||||
|
|
||||||
|
|
||||||
download = self.client.get(f"/api/v1/files/{first_file['id']}/download", headers=headers)
|
download = self.client.get(f"/api/v1/files/{first_file['id']}/download", headers=headers)
|
||||||
self.assertEqual(download.status_code, 200, download.text)
|
self.assertEqual(download.status_code, 200, download.text)
|
||||||
self.assertIn("filename*=UTF-8''report.txt", download.headers.get("content-disposition", ""))
|
self.assertIn("filename*=UTF-8''report.txt", download.headers.get("content-disposition", ""))
|
||||||
@@ -1704,14 +1862,40 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
set_failures(smtp_reject_recipients_containing=None)
|
set_failures(smtp_reject_recipients_containing=None)
|
||||||
|
|
||||||
from govoplan_campaign.backend.db.models import CampaignJob, SendAttempt
|
from govoplan_campaign.backend.db.models import CampaignJob, SendAttempt
|
||||||
|
from govoplan_campaign.backend.sending.jobs import send_campaign_job
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
||||||
self.assertEqual(job.send_status, "smtp_accepted")
|
self.assertEqual(job.send_status, "smtp_accepted")
|
||||||
self.assertIn("reject-me@example.org", job.last_error or "")
|
self.assertIn("reject-me@example.org", job.last_error or "")
|
||||||
|
job_id = job.id
|
||||||
|
attempt_count = job.attempt_count
|
||||||
attempt = session.query(SendAttempt).filter(SendAttempt.job_id == job.id).one()
|
attempt = session.query(SendAttempt).filter(SendAttempt.job_id == job.id).one()
|
||||||
self.assertEqual(attempt.status, "smtp_accepted_with_refusals")
|
self.assertEqual(attempt.status, "smtp_accepted_with_refusals")
|
||||||
self.assertIn("reject-me@example.org", attempt.smtp_response or "")
|
self.assertIn("reject-me@example.org", attempt.smtp_response or "")
|
||||||
|
|
||||||
|
retry_accepted = self.client.post(
|
||||||
|
f"/api/v1/campaigns/{campaign_id}/jobs/retry",
|
||||||
|
headers=headers,
|
||||||
|
json={"version_id": version_id, "job_ids": [job_id], "include_permanent": True, "enqueue_celery": False},
|
||||||
|
)
|
||||||
|
self.assertEqual(retry_accepted.status_code, 200, retry_accepted.text)
|
||||||
|
self.assertEqual(retry_accepted.json()["result"]["selected_count"], 0)
|
||||||
|
self.assertIn("not an explicit retry candidate", retry_accepted.json()["result"]["skipped"][0]["reason"])
|
||||||
|
|
||||||
|
unattempted_accepted = self.client.post(
|
||||||
|
f"/api/v1/campaigns/{campaign_id}/jobs/send-unattempted",
|
||||||
|
headers=headers,
|
||||||
|
json={"version_id": version_id, "job_ids": [job_id], "enqueue_celery": False},
|
||||||
|
)
|
||||||
|
self.assertEqual(unattempted_accepted.status_code, 200, unattempted_accepted.text)
|
||||||
|
self.assertEqual(unattempted_accepted.json()["result"]["selected_count"], 0)
|
||||||
|
|
||||||
|
with SessionLocal() as session:
|
||||||
|
direct_result = send_campaign_job(session, job_id=job_id, use_rate_limit=False)
|
||||||
|
self.assertEqual(direct_result.status, "already_accepted")
|
||||||
|
job = session.get(CampaignJob, job_id)
|
||||||
|
self.assertEqual(job.attempt_count, attempt_count)
|
||||||
|
|
||||||
def test_worker_loss_becomes_unknown_and_requires_reconciliation_before_retry(self) -> None:
|
def test_worker_loss_becomes_unknown_and_requires_reconciliation_before_retry(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
campaign_id, version_id = self._create_built_delivery_campaign(
|
campaign_id, version_id = self._create_built_delivery_campaign(
|
||||||
@@ -1727,6 +1911,11 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(queued.status_code, 200, queued.text)
|
self.assertEqual(queued.status_code, 200, queued.text)
|
||||||
self.assertEqual(queued.json()["queued_count"], 2)
|
self.assertEqual(queued.json()["queued_count"], 2)
|
||||||
|
|
||||||
|
queued_summary = self.client.get(f"/api/v1/campaigns/{campaign_id}/summary", headers=headers, params={"version_id": version_id})
|
||||||
|
self.assertEqual(queued_summary.status_code, 200, queued_summary.text)
|
||||||
|
self.assertEqual(queued_summary.json()["cards"]["queued_or_active"], 2)
|
||||||
|
self.assertEqual(queued_summary.json()["status_counts"]["send"]["queued"], 2)
|
||||||
|
|
||||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, SendAttempt
|
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, SendAttempt
|
||||||
from govoplan_campaign.backend.sending.jobs import send_campaign_job
|
from govoplan_campaign.backend.sending.jobs import send_campaign_job
|
||||||
|
|
||||||
@@ -1799,6 +1988,11 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(filtered_page.json()["total_unfiltered"], 2)
|
self.assertEqual(filtered_page.json()["total_unfiltered"], 2)
|
||||||
self.assertEqual(filtered_page.json()["filtered_counts"]["send"]["outcome_unknown"], 1)
|
self.assertEqual(filtered_page.json()["filtered_counts"]["send"]["outcome_unknown"], 1)
|
||||||
|
|
||||||
|
summary = self.client.get(f"/api/v1/campaigns/{campaign_id}/summary", headers=headers, params={"version_id": version_id})
|
||||||
|
self.assertEqual(summary.status_code, 200, summary.text)
|
||||||
|
self.assertEqual(summary.json()["cards"]["outcome_unknown"], 1)
|
||||||
|
self.assertEqual(summary.json()["status_counts"]["send"]["outcome_unknown"], 1)
|
||||||
|
|
||||||
detail = self.client.get(
|
detail = self.client.get(
|
||||||
f"/api/v1/campaigns/{campaign_id}/jobs/{uncertain_job_id}",
|
f"/api/v1/campaigns/{campaign_id}/jobs/{uncertain_job_id}",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -1815,6 +2009,12 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(resolved.status_code, 200, resolved.text)
|
self.assertEqual(resolved.status_code, 200, resolved.text)
|
||||||
self.assertEqual(resolved.json()["result"]["send_status"], "failed_temporary")
|
self.assertEqual(resolved.json()["result"]["send_status"], "failed_temporary")
|
||||||
|
|
||||||
|
reconciled_report = self.client.get(f"/api/v1/campaigns/{campaign_id}/report", headers=headers, params={"version_id": version_id})
|
||||||
|
self.assertEqual(reconciled_report.status_code, 200, reconciled_report.text)
|
||||||
|
self.assertEqual(reconciled_report.json()["cards"]["outcome_unknown"], 0)
|
||||||
|
self.assertEqual(reconciled_report.json()["cards"]["failed"], 1)
|
||||||
|
self.assertEqual(reconciled_report.json()["status_counts"]["send"]["failed_temporary"], 1)
|
||||||
|
|
||||||
retry = self.client.post(
|
retry = self.client.post(
|
||||||
f"/api/v1/campaigns/{campaign_id}/jobs/retry",
|
f"/api/v1/campaigns/{campaign_id}/jobs/retry",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -2187,6 +2387,19 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
tenant_owner_headers = {"Authorization": f"Bearer {tenant_owner_login.json()['access_token']}"}
|
tenant_owner_headers = {"Authorization": f"Bearer {tenant_owner_login.json()['access_token']}"}
|
||||||
self.assertEqual(self.client.get("/api/v1/admin/users", headers=tenant_owner_headers).status_code, 200)
|
self.assertEqual(self.client.get("/api/v1/admin/users", headers=tenant_owner_headers).status_code, 200)
|
||||||
self.assertEqual(self.client.get("/api/v1/admin/tenants", headers=tenant_owner_headers).status_code, 403)
|
self.assertEqual(self.client.get("/api/v1/admin/tenants", headers=tenant_owner_headers).status_code, 403)
|
||||||
|
tenant_settings = self.client.get("/api/v1/admin/tenant/settings", headers=tenant_owner_headers)
|
||||||
|
self.assertEqual(tenant_settings.status_code, 200, tenant_settings.text)
|
||||||
|
self.assertEqual(tenant_settings.json()["default_locale"], "de")
|
||||||
|
updated_tenant_settings = self.client.patch(
|
||||||
|
"/api/v1/admin/tenant/settings",
|
||||||
|
headers=tenant_owner_headers,
|
||||||
|
json={"default_locale": "de-AT"},
|
||||||
|
)
|
||||||
|
self.assertEqual(updated_tenant_settings.status_code, 200, updated_tenant_settings.text)
|
||||||
|
self.assertEqual(updated_tenant_settings.json()["default_locale"], "de-AT")
|
||||||
|
refreshed_tenant_owner = self.client.get("/api/v1/auth/me", headers=tenant_owner_headers)
|
||||||
|
self.assertEqual(refreshed_tenant_owner.status_code, 200, refreshed_tenant_owner.text)
|
||||||
|
self.assertEqual(refreshed_tenant_owner.json()["active_tenant"]["default_locale"], "de-AT")
|
||||||
|
|
||||||
# Global account suspension immediately invalidates all of its tenant
|
# Global account suspension immediately invalidates all of its tenant
|
||||||
# sessions. Reactivation restores access without changing memberships.
|
# sessions. Reactivation restores access without changing memberships.
|
||||||
@@ -2242,6 +2455,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertIn("user.created", actions)
|
self.assertIn("user.created", actions)
|
||||||
self.assertIn("group.created", actions)
|
self.assertIn("group.created", actions)
|
||||||
self.assertIn("role.created", actions)
|
self.assertIn("role.created", actions)
|
||||||
|
self.assertIn("tenant.settings.updated", actions)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
system_settings_columns = {column["name"] for column in inspector.get_columns("system_settings")}
|
system_settings_columns = {column["name"] for column in inspector.get_columns("system_settings")}
|
||||||
governance_assignment_columns = {column["name"] for column in inspector.get_columns("governance_template_assignments")}
|
governance_assignment_columns = {column["name"] for column in inspector.get_columns("governance_template_assignments")}
|
||||||
|
import_profile_columns = {column["name"] for column in inspector.get_columns("campaign_recipient_import_mapping_profiles")}
|
||||||
|
import_profile_indexes = {index["name"] for index in inspector.get_indexes("campaign_recipient_import_mapping_profiles")}
|
||||||
account_count = connection.execute(text("SELECT COUNT(*) FROM accounts")).scalar_one()
|
account_count = connection.execute(text("SELECT COUNT(*) FROM accounts")).scalar_one()
|
||||||
user_count = connection.execute(text("SELECT COUNT(*) FROM users")).scalar_one()
|
user_count = connection.execute(text("SELECT COUNT(*) FROM users")).scalar_one()
|
||||||
system_owner_count = connection.execute(text(
|
system_owner_count = connection.execute(text(
|
||||||
@@ -114,8 +116,13 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
self.assertIn("governance_templates", tables)
|
self.assertIn("governance_templates", tables)
|
||||||
self.assertIn("governance_template_assignments", tables)
|
self.assertIn("governance_template_assignments", tables)
|
||||||
self.assertIn("campaign_shares", tables)
|
self.assertIn("campaign_shares", tables)
|
||||||
|
self.assertIn("campaign_recipient_import_mapping_profiles", tables)
|
||||||
self.assertIn("owner_user_id", campaign_columns)
|
self.assertIn("owner_user_id", campaign_columns)
|
||||||
self.assertIn("owner_group_id", campaign_columns)
|
self.assertIn("owner_group_id", campaign_columns)
|
||||||
|
self.assertIn("ordered_header_fingerprint", import_profile_columns)
|
||||||
|
self.assertIn("unordered_header_fingerprint", import_profile_columns)
|
||||||
|
self.assertIn("mappings", import_profile_columns)
|
||||||
|
self.assertIn("ix_recipient_import_profiles_ordered_fp", import_profile_indexes)
|
||||||
self.assertIn("scope", audit_columns)
|
self.assertIn("scope", audit_columns)
|
||||||
self.assertIn("ix_audit_log_scope", audit_indexes)
|
self.assertIn("ix_audit_log_scope", audit_indexes)
|
||||||
self.assertIn("ix_audit_log_scope_created_at", audit_indexes)
|
self.assertIn("ix_audit_log_scope_created_at", audit_indexes)
|
||||||
@@ -409,4 +416,3 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
self.assertIn("uq_file_folders_active_group_path", folder_indexes)
|
self.assertIn("uq_file_folders_active_group_path", folder_indexes)
|
||||||
finally:
|
finally:
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import textwrap
|
import textwrap
|
||||||
|
import tomllib
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -25,6 +27,13 @@ from govoplan_core.server.config import GovoplanServerConfig
|
|||||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||||
from govoplan_files.backend.storage.backends import LocalFilesystemStorageBackend, StorageBackendError
|
from govoplan_files.backend.storage.backends import LocalFilesystemStorageBackend, StorageBackendError
|
||||||
|
|
||||||
|
_MANIFEST_PROJECT_MODULES = {
|
||||||
|
"access": "govoplan_core",
|
||||||
|
"files": "govoplan_files",
|
||||||
|
"mail": "govoplan_mail",
|
||||||
|
"campaigns": "govoplan_campaign",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _settings(root: Path) -> SimpleNamespace:
|
def _settings(root: Path) -> SimpleNamespace:
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
@@ -73,12 +82,31 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
return create_app(config), settings
|
return create_app(config), settings
|
||||||
|
|
||||||
|
def _source_project_version(self, package_module: str) -> str:
|
||||||
|
module = importlib.import_module(package_module)
|
||||||
|
module_file = getattr(module, "__file__", None)
|
||||||
|
if not module_file:
|
||||||
|
self.fail(f"Could not locate source package for {package_module}")
|
||||||
|
for path in Path(module_file).resolve().parents:
|
||||||
|
pyproject_path = path / "pyproject.toml"
|
||||||
|
if pyproject_path.exists():
|
||||||
|
with pyproject_path.open("rb") as handle:
|
||||||
|
return str(tomllib.load(handle)["project"]["version"])
|
||||||
|
self.fail(f"Could not locate pyproject.toml for {package_module}")
|
||||||
|
|
||||||
def test_discovers_installed_core_and_product_module_manifests(self) -> None:
|
def test_discovers_installed_core_and_product_module_manifests(self) -> None:
|
||||||
manifests = available_module_manifests()
|
manifests = available_module_manifests()
|
||||||
self.assertTrue({"access", "files", "mail", "campaigns"}.issubset(manifests))
|
self.assertTrue({"access", "files", "mail", "campaigns"}.issubset(manifests))
|
||||||
self.assertEqual(manifests["campaigns"].dependencies, ("access",))
|
self.assertEqual(manifests["campaigns"].dependencies, ("access",))
|
||||||
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail"))
|
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail"))
|
||||||
|
|
||||||
|
def test_module_manifest_versions_match_source_project_versions(self) -> None:
|
||||||
|
manifests = available_module_manifests()
|
||||||
|
for manifest_id, package_module in _MANIFEST_PROJECT_MODULES.items():
|
||||||
|
with self.subTest(manifest_id=manifest_id):
|
||||||
|
self.assertIn(manifest_id, manifests)
|
||||||
|
self.assertEqual(self._source_project_version(package_module), manifests[manifest_id].version)
|
||||||
|
|
||||||
def test_enabled_module_permutations_register_expected_routes(self) -> None:
|
def test_enabled_module_permutations_register_expected_routes(self) -> None:
|
||||||
cases = (
|
cases = (
|
||||||
("core_only", (), {"access"}, set()),
|
("core_only", (), {"access"}, set()),
|
||||||
|
|||||||
83
webui/package-lock.json
generated
83
webui/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
||||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
|
"read-excel-file": "^9.2.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.6"
|
"vite": "^6.0.6"
|
||||||
},
|
},
|
||||||
@@ -32,16 +33,15 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-campaign/webui": {
|
"../../govoplan-campaign/webui": {
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
"read-excel-file": "^9.2.0"
|
||||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.1",
|
"@govoplan/core-webui": "^0.1.4",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -55,9 +55,9 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-files/webui": {
|
"../../govoplan-files/webui": {
|
||||||
"name": "@govoplan/files-webui",
|
"name": "@govoplan/files-webui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.1",
|
"@govoplan/core-webui": "^0.1.4",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -74,9 +74,12 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-mail/webui": {
|
"../../govoplan-mail/webui": {
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.1",
|
"@govoplan/core-webui": "^0.1.4",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -1363,6 +1366,16 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xmldom/xmldom": {
|
||||||
|
"version": "0.9.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
|
||||||
|
"integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.38",
|
"version": "2.10.38",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
|
||||||
@@ -1554,6 +1567,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1579,6 +1599,13 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -1658,6 +1685,13 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-int64": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.48",
|
"version": "2.0.48",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
|
||||||
@@ -1790,6 +1824,21 @@
|
|||||||
"react-dom": ">=18"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/read-excel-file": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-jwSi8Q9oCmswrYMkuiKvJ6VdHbAXGQnANSOaHBNQZEm2XvaXx8d0t/V7oepgHqT9YWxlQE3HEuCtDZq8BZBmvw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xmldom/xmldom": "^0.9.10",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
|
"unzipper-esm": "^0.13.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.62.2",
|
"version": "4.62.2",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||||
@@ -1900,6 +1949,20 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/unzipper-esm": {
|
||||||
|
"version": "0.13.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/unzipper-esm/-/unzipper-esm-0.13.2.tgz",
|
||||||
|
"integrity": "sha512-lt8GtgDYV8YcAFZNQuLyR2QvHI8C/TstpgsdjUn9ZxiWLJgn+e5uW6DsO3e/HUJVuWD57ZLLFMZ9xk26tePuHQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.2",
|
||||||
|
"node-int64": "^0.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.1",
|
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.4",
|
||||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.1",
|
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
|
||||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.1"
|
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
@@ -710,14 +710,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@govoplan/campaign-webui": {
|
"node_modules/@govoplan/campaign-webui": {
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#39ad3500e2548e74c38a3c75c3cefa85cbf4a00b",
|
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#fb6fb67d451858cca891d8a2fe5b6b8790f75f0d",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.1",
|
"read-excel-file": "^9.2.0"
|
||||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.1"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.1",
|
"@govoplan/core-webui": "^0.1.4",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -730,10 +729,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@govoplan/files-webui": {
|
"node_modules/@govoplan/files-webui": {
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#b5f7c43028e7a2b5cc261835b66ec0923cb30970",
|
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#ee5b06b1e7d60f458ceb9ed943369c55201698ce",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.1",
|
"@govoplan/core-webui": "^0.1.4",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -749,10 +748,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@govoplan/mail-webui": {
|
"node_modules/@govoplan/mail-webui": {
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#3cd8c45c42d76265e895466e6ce67ad67221c8df",
|
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#3743b236130d6684b5e8d406f5070e3ff9e33d07",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.1",
|
"@govoplan/core-webui": "^0.1.4",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -1266,6 +1265,15 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xmldom/xmldom": {
|
||||||
|
"version": "0.9.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
|
||||||
|
"integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.38",
|
"version": "2.10.38",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
|
||||||
@@ -1447,6 +1455,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1470,6 +1484,12 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -1542,6 +1562,12 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-int64": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.48",
|
"version": "2.0.48",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
|
||||||
@@ -1665,6 +1691,20 @@
|
|||||||
"react-dom": ">=18"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/read-excel-file": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-jwSi8Q9oCmswrYMkuiKvJ6VdHbAXGQnANSOaHBNQZEm2XvaXx8d0t/V7oepgHqT9YWxlQE3HEuCtDZq8BZBmvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xmldom/xmldom": "^0.9.10",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
|
"unzipper-esm": "^0.13.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.62.2",
|
"version": "4.62.2",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||||
@@ -1768,6 +1808,19 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/unzipper-esm": {
|
||||||
|
"version": "0.13.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/unzipper-esm/-/unzipper-esm-0.13.2.tgz",
|
||||||
|
"integrity": "sha512-lt8GtgDYV8YcAFZNQuLyR2QvHI8C/TstpgsdjUn9ZxiWLJgn+e5uW6DsO3e/HUJVuWD57ZLLFMZ9xk26tePuHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.2",
|
||||||
|
"node-int64": "^0.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -25,18 +25,19 @@
|
|||||||
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"
|
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
|
||||||
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
||||||
|
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui"
|
"@govoplan/mail-webui": "file:../../govoplan-mail/webui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.2",
|
||||||
|
"@types/react-dom": "^19.0.2",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
"@types/react": "^19.0.2",
|
"read-excel-file": "^9.2.0",
|
||||||
"@types/react-dom": "^19.0.2",
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.6"
|
"vite": "^6.0.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -22,9 +22,9 @@
|
|||||||
"preview": "vite preview --host 127.0.0.1 --port 4173"
|
"preview": "vite preview --host 127.0.0.1 --port 4173"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.1",
|
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
|
||||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.1",
|
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
|
||||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.1"
|
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
|
|||||||
@@ -174,6 +174,14 @@ export type SystemSettingsItem = {
|
|||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TenantSettingsItem = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
default_locale: string;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
export type RetentionRunResponse = {
|
export type RetentionRunResponse = {
|
||||||
result: {
|
result: {
|
||||||
dry_run: boolean;
|
dry_run: boolean;
|
||||||
@@ -277,6 +285,14 @@ export function updateTenant(settings: ApiSettings, tenantId: string, payload: P
|
|||||||
return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchTenantSettings(settings: ApiSettings): Promise<TenantSettingsItem> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/tenant/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string }): Promise<TenantSettingsItem> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/tenant/settings", { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
|
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
|
||||||
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users");
|
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users");
|
||||||
return response.users;
|
return response.users;
|
||||||
|
|||||||
113
webui/src/components/FileDropZone.tsx
Normal file
113
webui/src/components/FileDropZone.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||||
|
import { UploadCloud } from "lucide-react";
|
||||||
|
|
||||||
|
export type FileDropZoneProps = {
|
||||||
|
accept?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
busy?: boolean;
|
||||||
|
progress?: number | null;
|
||||||
|
label?: ReactNode;
|
||||||
|
actionLabel?: ReactNode;
|
||||||
|
busyLabel?: ReactNode;
|
||||||
|
progressLabel?: ReactNode;
|
||||||
|
note?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
inputLabel?: string;
|
||||||
|
onFiles: (files: File[]) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FileDropZone({
|
||||||
|
accept,
|
||||||
|
multiple = true,
|
||||||
|
disabled = false,
|
||||||
|
busy = false,
|
||||||
|
progress = null,
|
||||||
|
label = "Drop files here",
|
||||||
|
actionLabel = "or click to select files",
|
||||||
|
busyLabel = "Uploading files",
|
||||||
|
progressLabel,
|
||||||
|
note,
|
||||||
|
className = "",
|
||||||
|
inputLabel = "Drop files here or click to select files",
|
||||||
|
onFiles
|
||||||
|
}: FileDropZoneProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const progressValue = typeof progress === "number" && Number.isFinite(progress) ? Math.max(0, Math.min(100, progress)) : null;
|
||||||
|
const showProgress = busy || progressValue !== null;
|
||||||
|
const interactionDisabled = disabled || busy;
|
||||||
|
const zoneClassName = `file-drop-zone ${dragActive ? "is-active" : ""} ${showProgress ? "is-busy" : ""} ${className}`.trim();
|
||||||
|
const roundedProgress = progressValue === null ? null : Math.round(progressValue);
|
||||||
|
const progressStyle = progressValue === null ? undefined : { "--file-drop-progress": `${progressValue}%` } as CSSProperties;
|
||||||
|
|
||||||
|
async function handleFiles(fileList: FileList | File[]) {
|
||||||
|
if (interactionDisabled) return;
|
||||||
|
const files = Array.from(fileList);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
try {
|
||||||
|
await onFiles(files);
|
||||||
|
} finally {
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={zoneClassName}
|
||||||
|
role="button"
|
||||||
|
tabIndex={interactionDisabled ? -1 : 0}
|
||||||
|
aria-label={inputLabel}
|
||||||
|
aria-disabled={interactionDisabled}
|
||||||
|
aria-busy={showProgress || undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (!interactionDisabled) inputRef.current?.click();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if ((event.key === "Enter" || event.key === " ") && !interactionDisabled) {
|
||||||
|
event.preventDefault();
|
||||||
|
inputRef.current?.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!interactionDisabled) setDragActive(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragActive(false)}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragActive(false);
|
||||||
|
if (!interactionDisabled) void handleFiles(event.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showProgress ? (
|
||||||
|
<span
|
||||||
|
className={`file-drop-progress ${progressValue === null ? "is-indeterminate" : ""}`.trim()}
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Upload progress"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={roundedProgress ?? undefined}
|
||||||
|
style={progressStyle}
|
||||||
|
>
|
||||||
|
<span>{roundedProgress === null ? "" : `${roundedProgress}%`}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<UploadCloud size={28} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<strong>{showProgress ? busyLabel : label}</strong>
|
||||||
|
<span>{showProgress ? progressLabel ?? (roundedProgress === null ? "Uploading…" : `${roundedProgress}% uploaded`) : actionLabel}</span>
|
||||||
|
{note && <span className="muted small-text">{note}</span>}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
multiple={multiple}
|
||||||
|
hidden
|
||||||
|
onChange={(event) => event.target.files && void handleFiles(event.target.files)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -115,9 +115,13 @@ export default function MessageDisplayPanel({
|
|||||||
<strong><Archive size={15} aria-hidden="true" /> {archive.label}</strong>
|
<strong><Archive size={15} aria-hidden="true" /> {archive.label}</strong>
|
||||||
<span>{archive.items.length} file{archive.items.length === 1 ? "" : "s"} inside ZIP</span>
|
<span>{archive.items.length} file{archive.items.length === 1 ? "" : "s"} inside ZIP</span>
|
||||||
</div>
|
</div>
|
||||||
{archive.protected && <small><LockKeyhole size={13} aria-hidden="true" /> Password protected</small>}
|
{archive.protected && (
|
||||||
|
<div className="message-display-attachment-protection">
|
||||||
|
<small><LockKeyhole size={13} aria-hidden="true" /> Password protected</small>
|
||||||
|
{archive.protectionNote && <small className="message-display-attachment-protection-note">{formatProtectionNote(archive.protectionNote)}</small>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
{archive.protectionNote && <p className="muted small-note">{formatProtectionNote(archive.protectionNote)}</p>}
|
|
||||||
<div className="message-display-attachment-list">
|
<div className="message-display-attachment-list">
|
||||||
{archive.items.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
|
{archive.items.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import Button from "./Button";
|
import Button from "./Button";
|
||||||
|
import Dialog from "./Dialog";
|
||||||
import DismissibleAlert from "./DismissibleAlert";
|
import DismissibleAlert from "./DismissibleAlert";
|
||||||
|
|
||||||
export type UnsavedNavigationAction = () => void;
|
export type UnsavedNavigationAction = () => void;
|
||||||
@@ -139,23 +140,26 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
|||||||
<UnsavedChangesContext.Provider value={value}>
|
<UnsavedChangesContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
{pendingAction && registration && (
|
{pendingAction && registration && (
|
||||||
<div className="overlay-backdrop" role="dialog" aria-modal="true">
|
<Dialog
|
||||||
<div className="modal-panel unsaved-changes-dialog">
|
open
|
||||||
<header className="modal-header">
|
role="alertdialog"
|
||||||
<h2>{registration.title ?? "Unsaved changes"}</h2>
|
title={registration.title ?? "Unsaved changes"}
|
||||||
<button className="modal-close" onClick={() => setPendingAction(null)} disabled={saving}>x</button>
|
className="unsaved-changes-dialog"
|
||||||
</header>
|
footerClassName="unsaved-changes-actions"
|
||||||
<div className="modal-body">
|
closeOnBackdrop={!saving}
|
||||||
<p>{registration.message ?? "This page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p>
|
closeDisabled={saving}
|
||||||
{saveError && <DismissibleAlert tone="danger" resetKey={saveError}>{saveError}</DismissibleAlert>}
|
onClose={() => setPendingAction(null)}
|
||||||
</div>
|
footer={(
|
||||||
<footer className="modal-footer unsaved-changes-actions">
|
<>
|
||||||
<Button onClick={() => setPendingAction(null)} disabled={saving}>Cancel</Button>
|
<Button onClick={() => setPendingAction(null)} disabled={saving}>Cancel</Button>
|
||||||
<Button onClick={handleDiscardAndLeave} disabled={saving}>Discard</Button>
|
<Button onClick={handleDiscardAndLeave} disabled={saving}>Discard</Button>
|
||||||
<Button variant="primary" onClick={() => void handleSaveAndLeave()} disabled={saving}>{saving ? "Saving..." : "Save and leave"}</Button>
|
<Button variant="primary" onClick={() => void handleSaveAndLeave()} disabled={saving}>{saving ? "Saving..." : "Save and leave"}</Button>
|
||||||
</footer>
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
>
|
||||||
|
<p>{registration.message ?? "This page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p>
|
||||||
|
{saveError && <DismissibleAlert tone="danger" resetKey={saveError}>{saveError}</DismissibleAlert>}
|
||||||
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</UnsavedChangesContext.Provider>
|
</UnsavedChangesContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -762,7 +762,7 @@ export default function DataGrid<T>({
|
|||||||
pageSize={paginationPageSize}
|
pageSize={paginationPageSize}
|
||||||
totalRows={paginationTotal}
|
totalRows={paginationTotal}
|
||||||
pageCount={paginationPageCount}
|
pageCount={paginationPageCount}
|
||||||
pageSizeOptions={pagination.pageSizeOptions ?? [25, 50, 100]}
|
pageSizeOptions={pagination.pageSizeOptions ?? [10, 25, 50, 100]}
|
||||||
disabled={pagination.disabled}
|
disabled={pagination.disabled}
|
||||||
onPageChange={pagination.onPageChange}
|
onPageChange={pagination.onPageChange}
|
||||||
onPageSizeChange={pagination.onPageSizeChange}
|
onPageSizeChange={pagination.onPageSizeChange}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function AdminAuditPanel({
|
|||||||
const [items, setItems] = useState<AuditAdminItem[]>([]);
|
const [items, setItems] = useState<AuditAdminItem[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(50);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
const [query, setQuery] = useState<DataGridQueryState>(DEFAULT_QUERY);
|
const [query, setQuery] = useState<DataGridQueryState>(DEFAULT_QUERY);
|
||||||
const [selected, setSelected] = useState<AuditAdminItem | null>(null);
|
const [selected, setSelected] = useState<AuditAdminItem | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -106,7 +106,7 @@ export default function AdminAuditPanel({
|
|||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
totalRows: total,
|
totalRows: total,
|
||||||
pageSizeOptions: [25, 50, 100, 250],
|
pageSizeOptions: [10, 25, 50, 100, 250],
|
||||||
disabled: loading,
|
disabled: loading,
|
||||||
onPageChange: setPage,
|
onPageChange: setPage,
|
||||||
onPageSizeChange: (next) => { setPageSize(next); setPage(1); }
|
onPageSizeChange: (next) => { setPageSize(next); setPage(1); }
|
||||||
|
|||||||
@@ -43,13 +43,14 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
|||||||
|
|
||||||
{hasSystemArea(availableSections) && <Card title="System administration">
|
{hasSystemArea(availableSections) && <Card title="System administration">
|
||||||
<div className="admin-overview-grid">
|
<div className="admin-overview-grid">
|
||||||
{availableSections.has("system-settings") && <AreaLink title="Settings" text="Instance defaults and tenant governance capabilities." onClick={() => onSelect("system-settings")} />}
|
{availableSections.has("system-settings") && <AreaLink title="General" text="Instance defaults and tenant governance capabilities." onClick={() => onSelect("system-settings")} />}
|
||||||
{availableSections.has("system-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level override permissions." onClick={() => onSelect("system-retention")} />}
|
|
||||||
{availableSections.has("system-tenants") && <AreaLink title="Tenants" text="Create, suspend and govern tenant spaces." onClick={() => onSelect("system-tenants")} />}
|
{availableSections.has("system-tenants") && <AreaLink title="Tenants" text="Create, suspend and govern tenant spaces." onClick={() => onSelect("system-tenants")} />}
|
||||||
{availableSections.has("system-users") && <AreaLink title="Users" text="Global accounts, tenant memberships and system-role assignments." onClick={() => onSelect("system-users")} />}
|
|
||||||
{availableSections.has("system-groups") && <AreaLink title="Central groups" text="Group definitions made available or required in selected tenants." onClick={() => onSelect("system-groups")} />}
|
|
||||||
{availableSections.has("system-roles") && <AreaLink title="System roles" text="Instance-wide roles assigned directly to global accounts." onClick={() => onSelect("system-roles")} />}
|
{availableSections.has("system-roles") && <AreaLink title="System roles" text="Instance-wide roles assigned directly to global accounts." onClick={() => onSelect("system-roles")} />}
|
||||||
{availableSections.has("system-role-templates") && <AreaLink title="Tenant roles" text="Centrally governed tenant roles and their availability across tenants." onClick={() => onSelect("system-role-templates")} />}
|
{availableSections.has("system-role-templates") && <AreaLink title="Tenant roles" text="Centrally governed tenant roles and their availability across tenants." onClick={() => onSelect("system-role-templates")} />}
|
||||||
|
{availableSections.has("system-groups") && <AreaLink title="Groups" text="Group definitions made available or required in selected tenants." onClick={() => onSelect("system-groups")} />}
|
||||||
|
{availableSections.has("system-users") && <AreaLink title="Users" text="Global accounts, tenant memberships and system-role assignments." onClick={() => onSelect("system-users")} />}
|
||||||
|
{availableSections.has("system-mail-servers") && <AreaLink title="Mail servers" text="Reusable encrypted SMTP/IMAP profiles and mail policy." onClick={() => onSelect("system-mail-servers")} />}
|
||||||
|
{availableSections.has("system-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level override permissions." onClick={() => onSelect("system-retention")} />}
|
||||||
{availableSections.has("system-audit") && <AreaLink title="Audit" text="System-level administrative history." onClick={() => onSelect("system-audit")} />}
|
{availableSections.has("system-audit") && <AreaLink title="Audit" text="System-level administrative history." onClick={() => onSelect("system-audit")} />}
|
||||||
</div>
|
</div>
|
||||||
</Card>}
|
</Card>}
|
||||||
@@ -64,15 +65,13 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
|||||||
|
|
||||||
<Card title="Tenant administration">
|
<Card title="Tenant administration">
|
||||||
<div className="admin-overview-grid">
|
<div className="admin-overview-grid">
|
||||||
{availableSections.has("tenant-settings") && <AreaLink title="Settings" text="Tenant-specific settings and governance overrides." onClick={() => onSelect("tenant-settings")} />}
|
{availableSections.has("tenant-settings") && <AreaLink title="General" text="Tenant locale and tenant-specific settings." onClick={() => onSelect("tenant-settings")} />}
|
||||||
{availableSections.has("tenant-users") && <AreaLink title="Users" text="Membership status, groups and direct roles in the active tenant." onClick={() => onSelect("tenant-users")} />}
|
|
||||||
{availableSections.has("tenant-groups") && <AreaLink title="Groups" text="Tenant memberships and inherited roles." onClick={() => onSelect("tenant-groups")} />}
|
|
||||||
{availableSections.has("tenant-roles") && <AreaLink title="Roles" text="Tenant permission bundles and system-managed role copies." onClick={() => onSelect("tenant-roles")} />}
|
{availableSections.has("tenant-roles") && <AreaLink title="Roles" text="Tenant permission bundles and system-managed role copies." onClick={() => onSelect("tenant-roles")} />}
|
||||||
{availableSections.has("tenant-api-keys") && <AreaLink title="API keys" text="Scoped automation credentials capped by owner permissions." onClick={() => onSelect("tenant-api-keys")} />}
|
{availableSections.has("tenant-groups") && <AreaLink title="Groups" text="Tenant memberships and inherited roles." onClick={() => onSelect("tenant-groups")} />}
|
||||||
|
{availableSections.has("tenant-users") && <AreaLink title="Users" text="Membership status, groups and direct roles in the active tenant." onClick={() => onSelect("tenant-users")} />}
|
||||||
{availableSections.has("tenant-mail-servers") && <AreaLink title="Mail servers" text="Reusable encrypted SMTP/IMAP profiles and mail policy." onClick={() => onSelect("tenant-mail-servers")} />}
|
{availableSections.has("tenant-mail-servers") && <AreaLink title="Mail servers" text="Reusable encrypted SMTP/IMAP profiles and mail policy." onClick={() => onSelect("tenant-mail-servers")} />}
|
||||||
{availableSections.has("tenant-retention") && <AreaLink title="Retention" text="Tenant-level privacy retention limits inherited by owned objects." onClick={() => onSelect("tenant-retention")} />}
|
{availableSections.has("tenant-retention") && <AreaLink title="Retention" text="Tenant-level privacy retention limits inherited by owned objects." onClick={() => onSelect("tenant-retention")} />}
|
||||||
{availableSections.has("tenant-user-retention") && <AreaLink title="User retention" text="Retention limits for user-owned campaigns." onClick={() => onSelect("tenant-user-retention")} />}
|
{availableSections.has("tenant-api-keys") && <AreaLink title="API keys" text="Scoped automation credentials capped by owner permissions." onClick={() => onSelect("tenant-api-keys")} />}
|
||||||
{availableSections.has("tenant-group-retention") && <AreaLink title="Group retention" text="Retention limits for group-owned campaigns." onClick={() => onSelect("tenant-group-retention")} />}
|
|
||||||
{availableSections.has("tenant-audit") && <AreaLink title="Audit" text="Tenant-level administrative history only." onClick={() => onSelect("tenant-audit")} />}
|
{availableSections.has("tenant-audit") && <AreaLink title="Audit" text="Tenant-level administrative history only." onClick={() => onSelect("tenant-audit")} />}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -82,7 +81,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasSystemArea(sections: ReadonlySet<string>): boolean {
|
function hasSystemArea(sections: ReadonlySet<string>): boolean {
|
||||||
return ["system-settings", "system-retention", "system-tenants", "system-users", "system-groups", "system-roles", "system-role-templates", "system-audit"].some((section) => sections.has(section));
|
return ["system-settings", "system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-mail-servers", "system-retention", "system-audit"].some((section) => sections.has(section));
|
||||||
}
|
}
|
||||||
|
|
||||||
function Metric({ title, value, text }: { title: string; value: string | number; text: string }) {
|
function Metric({ title, value, text }: { title: string; value: string | number; text: string }) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { adminReadScopes, hasAnyScope, hasScope } from "../../utils/permissions"
|
|||||||
import AdminOverviewPanel from "./AdminOverviewPanel";
|
import AdminOverviewPanel from "./AdminOverviewPanel";
|
||||||
import SystemUsersPanel from "./SystemUsersPanel";
|
import SystemUsersPanel from "./SystemUsersPanel";
|
||||||
import SystemSettingsPanel from "./SystemSettingsPanel";
|
import SystemSettingsPanel from "./SystemSettingsPanel";
|
||||||
|
import TenantSettingsPanel from "./TenantSettingsPanel";
|
||||||
import GovernanceTemplatesPanel from "./GovernanceTemplatesPanel";
|
import GovernanceTemplatesPanel from "./GovernanceTemplatesPanel";
|
||||||
import SystemRolesPanel from "./SystemRolesPanel";
|
import SystemRolesPanel from "./SystemRolesPanel";
|
||||||
import TenantsPanel from "./TenantsPanel";
|
import TenantsPanel from "./TenantsPanel";
|
||||||
@@ -18,7 +19,6 @@ import ApiKeysPanel from "./ApiKeysPanel";
|
|||||||
import AdminAuditPanel from "./AdminAuditPanel";
|
import AdminAuditPanel from "./AdminAuditPanel";
|
||||||
import MailProfilesPanel from "./MailProfilesPanel";
|
import MailProfilesPanel from "./MailProfilesPanel";
|
||||||
import RetentionPoliciesPanel from "./RetentionPoliciesPanel";
|
import RetentionPoliciesPanel from "./RetentionPoliciesPanel";
|
||||||
import PageTitle from "../../components/PageTitle";
|
|
||||||
import { usePlatformUiCapability } from "../../platform/ModuleContext";
|
import { usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||||
|
|
||||||
type AdminSection =
|
type AdminSection =
|
||||||
@@ -120,42 +120,42 @@ export default function AdminPage({
|
|||||||
{
|
{
|
||||||
title: "SYSTEM",
|
title: "SYSTEM",
|
||||||
items: [
|
items: [
|
||||||
...(available.has("system-settings") ? [{ id: "system-settings" as const, label: "Settings" }] : []),
|
...(available.has("system-settings") ? [{ id: "system-settings" as const, label: "General" }] : []),
|
||||||
...(available.has("system-retention") ? [{ id: "system-retention" as const, label: "Retention" }] : []),
|
|
||||||
...(available.has("system-mail-servers") ? [{ id: "system-mail-servers" as const, label: "Mail servers" }] : []),
|
|
||||||
...(available.has("system-tenants") ? [{ id: "system-tenants" as const, label: "Tenants" }] : []),
|
...(available.has("system-tenants") ? [{ id: "system-tenants" as const, label: "Tenants" }] : []),
|
||||||
...(available.has("system-users") ? [{ id: "system-users" as const, label: "Users" }] : []),
|
|
||||||
...(available.has("system-groups") ? [{ id: "system-groups" as const, label: "Groups" }] : []),
|
|
||||||
...(available.has("system-roles") ? [{ id: "system-roles" as const, label: "System roles" }] : []),
|
...(available.has("system-roles") ? [{ id: "system-roles" as const, label: "System roles" }] : []),
|
||||||
...(available.has("system-role-templates") ? [{ id: "system-role-templates" as const, label: "Tenant roles" }] : []),
|
...(available.has("system-role-templates") ? [{ id: "system-role-templates" as const, label: "Tenant roles" }] : []),
|
||||||
|
...(available.has("system-groups") ? [{ id: "system-groups" as const, label: "Groups" }] : []),
|
||||||
|
...(available.has("system-users") ? [{ id: "system-users" as const, label: "Users" }] : []),
|
||||||
|
...(available.has("system-mail-servers") ? [{ id: "system-mail-servers" as const, label: "Mail servers" }] : []),
|
||||||
|
...(available.has("system-retention") ? [{ id: "system-retention" as const, label: "Retention" }] : []),
|
||||||
...(available.has("system-audit") ? [{ id: "system-audit" as const, label: "Audit" }] : [])
|
...(available.has("system-audit") ? [{ id: "system-audit" as const, label: "Audit" }] : [])
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "TENANT",
|
title: "TENANT",
|
||||||
items: [
|
items: [
|
||||||
...(available.has("tenant-settings") ? [{ id: "tenant-settings" as const, label: "Settings" }] : []),
|
...(available.has("tenant-settings") ? [{ id: "tenant-settings" as const, label: "General" }] : []),
|
||||||
...(available.has("tenant-users") ? [{ id: "tenant-users" as const, label: "Users" }] : []),
|
|
||||||
...(available.has("tenant-groups") ? [{ id: "tenant-groups" as const, label: "Groups" }] : []),
|
|
||||||
...(available.has("tenant-roles") ? [{ id: "tenant-roles" as const, label: "Roles" }] : []),
|
...(available.has("tenant-roles") ? [{ id: "tenant-roles" as const, label: "Roles" }] : []),
|
||||||
...(available.has("tenant-api-keys") ? [{ id: "tenant-api-keys" as const, label: "API keys" }] : []),
|
...(available.has("tenant-groups") ? [{ id: "tenant-groups" as const, label: "Groups" }] : []),
|
||||||
|
...(available.has("tenant-users") ? [{ id: "tenant-users" as const, label: "Users" }] : []),
|
||||||
...(available.has("tenant-mail-servers") ? [{ id: "tenant-mail-servers" as const, label: "Mail servers" }] : []),
|
...(available.has("tenant-mail-servers") ? [{ id: "tenant-mail-servers" as const, label: "Mail servers" }] : []),
|
||||||
...(available.has("tenant-retention") ? [{ id: "tenant-retention" as const, label: "Retention" }] : []),
|
...(available.has("tenant-retention") ? [{ id: "tenant-retention" as const, label: "Retention" }] : []),
|
||||||
|
...(available.has("tenant-api-keys") ? [{ id: "tenant-api-keys" as const, label: "API keys" }] : []),
|
||||||
...(available.has("tenant-audit") ? [{ id: "tenant-audit" as const, label: "Audit" }] : [])
|
...(available.has("tenant-audit") ? [{ id: "tenant-audit" as const, label: "Audit" }] : [])
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "USER",
|
|
||||||
items: [
|
|
||||||
...(available.has("tenant-user-mail-servers") ? [{ id: "tenant-user-mail-servers" as const, label: "User mail" }] : []),
|
|
||||||
...(available.has("tenant-user-retention") ? [{ id: "tenant-user-retention" as const, label: "User retention" }] : []),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "GROUP",
|
title: "GROUP",
|
||||||
items: [
|
items: [
|
||||||
...(available.has("tenant-group-mail-servers") ? [{ id: "tenant-group-mail-servers" as const, label: "Group mail" }] : []),
|
...(available.has("tenant-group-mail-servers") ? [{ id: "tenant-group-mail-servers" as const, label: "Mail servers" }] : []),
|
||||||
...(available.has("tenant-group-retention") ? [{ id: "tenant-group-retention" as const, label: "Group retention" }] : []),
|
...(available.has("tenant-group-retention") ? [{ id: "tenant-group-retention" as const, label: "Retention" }] : []),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "USER",
|
||||||
|
items: [
|
||||||
|
...(available.has("tenant-user-mail-servers") ? [{ id: "tenant-user-mail-servers" as const, label: "Mail servers" }] : []),
|
||||||
|
...(available.has("tenant-user-retention") ? [{ id: "tenant-user-retention" as const, label: "Retention" }] : []),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
].filter((group) => group.items.length > 0);
|
].filter((group) => group.items.length > 0);
|
||||||
@@ -197,14 +197,10 @@ export default function AdminPage({
|
|||||||
{active === "tenant-retention" && <RetentionPoliciesPanel settings={settings} scopeType="tenant" canWrite={hasScope(auth, "admin:policies:write")} />}
|
{active === "tenant-retention" && <RetentionPoliciesPanel settings={settings} scopeType="tenant" canWrite={hasScope(auth, "admin:policies:write")} />}
|
||||||
{active === "tenant-user-retention" && <RetentionPoliciesPanel settings={settings} scopeType="user" canWrite={hasScope(auth, "admin:policies:write")} />}
|
{active === "tenant-user-retention" && <RetentionPoliciesPanel settings={settings} scopeType="user" canWrite={hasScope(auth, "admin:policies:write")} />}
|
||||||
{active === "tenant-group-retention" && <RetentionPoliciesPanel settings={settings} scopeType="group" canWrite={hasScope(auth, "admin:policies:write")} />}
|
{active === "tenant-group-retention" && <RetentionPoliciesPanel settings={settings} scopeType="group" canWrite={hasScope(auth, "admin:policies:write")} />}
|
||||||
{active === "tenant-settings" && <PreparationPage title="Tenant settings" text="Tenant-specific policy values are now represented by typed governance overrides on the system Tenants page; further campaign-policy inheritance will build on that foundation." />}
|
{active === "tenant-settings" && <TenantSettingsPanel settings={settings} canWrite={hasScope(auth, "admin:settings:write")} onAuthRefresh={refreshAuth} />}
|
||||||
{active === "tenant-audit" && <AdminAuditPanel settings={settings} auth={auth} />}
|
{active === "tenant-audit" && <AdminAuditPanel settings={settings} auth={auth} />}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PreparationPage({ title, text }: { title: string; text: string }) {
|
|
||||||
return <div className="admin-section-page"><div className="page-heading workspace-heading"><div><PageTitle>{title}</PageTitle><p>{text}</p></div></div><Card><p className="muted">This boundary is intentionally visible but not presented as an implemented editor.</p></Card></div>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export default function SystemSettingsPanel({ settings, canWrite }: { settings:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminPageLayout
|
<AdminPageLayout
|
||||||
title="System settings"
|
title="System general settings"
|
||||||
description="Instance-wide defaults and tenant governance capabilities. Retention policy management has its own system section."
|
description="Instance-wide defaults and tenant governance capabilities. Retention policy management has its own system section."
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
|
|||||||
86
webui/src/features/admin/TenantSettingsPanel.tsx
Normal file
86
webui/src/features/admin/TenantSettingsPanel.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ApiSettings } from "../../types";
|
||||||
|
import Button from "../../components/Button";
|
||||||
|
import Card from "../../components/Card";
|
||||||
|
import FormField from "../../components/FormField";
|
||||||
|
import { fetchTenantSettings, updateTenantSettings, type TenantSettingsItem } from "../../api/admin";
|
||||||
|
import AdminPageLayout from "./components/AdminPageLayout";
|
||||||
|
import { adminErrorMessage } from "./adminUtils";
|
||||||
|
|
||||||
|
const fallback: TenantSettingsItem = {
|
||||||
|
id: "",
|
||||||
|
slug: "",
|
||||||
|
name: "",
|
||||||
|
default_locale: "en",
|
||||||
|
settings: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TenantSettingsPanel({
|
||||||
|
settings,
|
||||||
|
canWrite,
|
||||||
|
onAuthRefresh
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
canWrite: boolean;
|
||||||
|
onAuthRefresh: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState<TenantSettingsItem>(fallback);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
setDraft(await fetchTenantSettings(settings));
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const saved = await updateTenantSettings(settings, { default_locale: draft.default_locale });
|
||||||
|
setDraft(saved);
|
||||||
|
setSuccess("Tenant general settings saved.");
|
||||||
|
await onAuthRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminPageLayout
|
||||||
|
title="Tenant general settings"
|
||||||
|
description="Settings for the active tenant context."
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
success={success}
|
||||||
|
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.default_locale.trim()}>{busy ? "Saving..." : "Save general settings"}</Button></>}
|
||||||
|
>
|
||||||
|
<div className="admin-settings-form">
|
||||||
|
<Card title="Locale">
|
||||||
|
<FormField label="Tenant locale" help="Used as this tenant's locale default for tenant-aware views and future formatting defaults.">
|
||||||
|
<input value={draft.default_locale} disabled={!canWrite || busy} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<dl className="detail-list">
|
||||||
|
<div><dt>Tenant</dt><dd>{draft.name || "-"}</dd></div>
|
||||||
|
<div><dt>Slug</dt><dd>{draft.slug || "-"}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AdminPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import type { ApiSettings, LoginResponse } from "../../types";
|
import type { ApiSettings, LoginResponse } from "../../types";
|
||||||
import { login } from "../../api/auth";
|
import { login } from "../../api/auth";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
|
import Dialog from "../../components/Dialog";
|
||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
import PasswordField from "../../components/PasswordField";
|
import PasswordField from "../../components/PasswordField";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||||
@@ -23,6 +24,7 @@ export default function LoginModal({
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const formId = useId();
|
||||||
|
|
||||||
async function submit(event: React.FormEvent) {
|
async function submit(event: React.FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -40,27 +42,27 @@ export default function LoginModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay-backdrop" role="dialog" aria-modal="true">
|
<Dialog
|
||||||
<form className="modal-panel" onSubmit={submit}>
|
open
|
||||||
<header className="modal-header">
|
title={title}
|
||||||
<h2>{title}</h2>
|
onClose={onClose}
|
||||||
<button className="modal-close" type="button" onClick={onClose}>×</button>
|
footer={(
|
||||||
</header>
|
<>
|
||||||
<div className="modal-body form-grid">
|
|
||||||
{message && <DismissibleAlert tone="info" dismissible={false}>{message}</DismissibleAlert>}
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
|
||||||
<FormField label="Email">
|
|
||||||
<input type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Password">
|
|
||||||
<PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} />
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<footer className="modal-footer">
|
|
||||||
<Button type="button" onClick={onClose}>Cancel</Button>
|
<Button type="button" onClick={onClose}>Cancel</Button>
|
||||||
<Button type="submit" variant="primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</Button>
|
<Button type="submit" form={formId} variant="primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</Button>
|
||||||
</footer>
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<form id={formId} className="form-grid" onSubmit={submit}>
|
||||||
|
{message && <DismissibleAlert tone="info" dismissible={false}>{message}</DismissibleAlert>}
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
<FormField label="Email">
|
||||||
|
<input type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Password">
|
||||||
|
<PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} />
|
||||||
|
</FormField>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export { default as Dialog } from "./components/Dialog";
|
|||||||
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
||||||
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
|
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
|
||||||
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
|
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
|
||||||
|
export { default as FileDropZone } from "./components/FileDropZone";
|
||||||
|
export type { FileDropZoneProps } from "./components/FileDropZone";
|
||||||
export { default as FormField } from "./components/FormField";
|
export { default as FormField } from "./components/FormField";
|
||||||
export { default as LoadingFrame } from "./components/LoadingFrame";
|
export { default as LoadingFrame } from "./components/LoadingFrame";
|
||||||
export { default as LoadingIndicator } from "./components/LoadingIndicator";
|
export { default as LoadingIndicator } from "./components/LoadingIndicator";
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react";
|
import { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react";
|
||||||
|
import packageInfo from "../../package.json";
|
||||||
import Button from "../components/Button";
|
import Button from "../components/Button";
|
||||||
|
import Dialog from "../components/Dialog";
|
||||||
|
import { usePlatformModules } from "../platform/ModuleContext";
|
||||||
|
import type { PlatformWebModule } from "../types";
|
||||||
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
|
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
|
||||||
|
|
||||||
export default function HelpMenu() {
|
export default function HelpMenu() {
|
||||||
@@ -11,6 +15,7 @@ export default function HelpMenu() {
|
|||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const helpContext = helpContextForPathname(location.pathname);
|
const helpContext = helpContextForPathname(location.pathname);
|
||||||
|
const modules = usePlatformModules();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function openContextHelp(event: KeyboardEvent) {
|
function openContextHelp(event: KeyboardEvent) {
|
||||||
@@ -69,54 +74,50 @@ export default function HelpMenu() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{contextOpen && <ContextHelpModal context={helpContext} onClose={() => setContextOpen(false)} />}
|
{contextOpen && <ContextHelpModal context={helpContext} onClose={() => setContextOpen(false)} />}
|
||||||
{aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
|
{aboutOpen && <AboutModal modules={modules} onClose={() => setAboutOpen(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContextHelpModal({ context, onClose }: { context: HelpContext; onClose: () => void }) {
|
function ContextHelpModal({ context, onClose }: { context: HelpContext; onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" data-help-context={context.id}>
|
<Dialog
|
||||||
<div className="modal-panel">
|
open
|
||||||
<header className="modal-header">
|
title="Context help"
|
||||||
<h2>Context help</h2>
|
onClose={onClose}
|
||||||
<button className="modal-close" onClick={onClose}>×</button>
|
footer={<Button variant="primary" onClick={onClose}>Close</Button>}
|
||||||
</header>
|
>
|
||||||
<div className="modal-body">
|
<div className="help-panel-section" data-help-context={context.id}>
|
||||||
<div className="help-panel-section">
|
<h3>{context.title}</h3>
|
||||||
<h3>{context.title}</h3>
|
<p className="mono-small">Help context: {context.id}</p>
|
||||||
<p className="mono-small">Help context: {context.id}</p>
|
<p className="muted">This area is prepared for context-sensitive help. Future help content can use this context identifier, or the equivalent help query parameter <span className="kbd">{helpQueryForContext(context)}</span>, to open the right page or section.</p>
|
||||||
<p className="muted">This area is prepared for context-sensitive help. Future help content can use this context identifier, or the equivalent help query parameter <span className="kbd">{helpQueryForContext(context)}</span>, to open the right page or section.</p>
|
|
||||||
</div>
|
|
||||||
<div className="help-panel-section">
|
|
||||||
<h3>Next actions</h3>
|
|
||||||
<p className="muted">The first guided help content can cover campaign creation, review, attachment resolution and sending preparation.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="help-panel-section">
|
||||||
|
<h3>Next actions</h3>
|
||||||
|
<p className="muted">The first guided help content can cover campaign creation, review, attachment resolution and sending preparation.</p>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AboutModal({ onClose }: { onClose: () => void }) {
|
function AboutModal({ modules, onClose }: { modules: PlatformWebModule[]; onClose: () => void }) {
|
||||||
|
const moduleSummary = modules.length
|
||||||
|
? modules.map((module) => `${module.label} v${module.version}`).join(", ")
|
||||||
|
: "Core shell only";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay-backdrop" role="dialog" aria-modal="true">
|
<Dialog
|
||||||
<div className="modal-panel">
|
open
|
||||||
<header className="modal-header">
|
title="About GovOPlaN"
|
||||||
<h2>About MultiMailer</h2>
|
onClose={onClose}
|
||||||
<button className="modal-close" onClick={onClose}>×</button>
|
footer={<Button variant="primary" onClick={onClose}>Close</Button>}
|
||||||
</header>
|
>
|
||||||
<div className="modal-body">
|
<div className="about-logo" />
|
||||||
<div className="about-logo" />
|
<p><strong>GovOPlaN WebUI</strong></p>
|
||||||
<p><strong>MultiMailer WebUI</strong></p>
|
<p className="muted">Core WebUI version {packageInfo.version}</p>
|
||||||
<p className="muted">Version 0.1.0 — early development build.</p>
|
<p>GovOPlaN is a modular platform runner for governed workflows, tenant-aware administration, managed files, mail settings and campaign execution.</p>
|
||||||
<p>MultiMailer is a local-first / server-assisted campaign mailer for structured, personalized bulk messages with attachment resolution, review workflows and auditable sending.</p>
|
<p className="muted">Installed modules: {moduleSummary}</p>
|
||||||
<p><a href="https://add-ideas.de" target="_blank" rel="noreferrer">add-ideas.de</a></p>
|
<p><a href="https://add-ideas.de" target="_blank" rel="noreferrer">add-ideas.de</a></p>
|
||||||
<p className="muted">License: project license pending / to be finalized. Backend components are currently development prototypes.</p>
|
</Dialog>
|
||||||
</div>
|
|
||||||
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.public-content {
|
.public-content {
|
||||||
min-height: calc(100vh - 64px);
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
height: 100%;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 18% 18%, rgba(239, 107, 58, .13), transparent 24rem),
|
radial-gradient(circle at 18% 18%, rgba(239, 107, 58, .13), transparent 24rem),
|
||||||
radial-gradient(circle at 78% 14%, rgba(126, 166, 197, .15), transparent 22rem),
|
radial-gradient(circle at 78% 14%, rgba(126, 166, 197, .15), transparent 22rem),
|
||||||
|
|||||||
@@ -1092,6 +1092,16 @@
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-display-attachment-protection {
|
||||||
|
justify-items: end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.message-display-attachment-protection-note {
|
||||||
|
display: block;
|
||||||
|
max-width: 240px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.message-display-headers summary {
|
.message-display-headers summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -1264,3 +1274,70 @@
|
|||||||
.unsaved-changes-actions {
|
.unsaved-changes-actions {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-drop-zone {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 170px;
|
||||||
|
border: 1px dashed var(--line-dark);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .16s ease, background .16s ease, box-shadow .16s ease;
|
||||||
|
}
|
||||||
|
.file-drop-zone:hover,
|
||||||
|
.file-drop-zone:focus-visible,
|
||||||
|
.file-drop-zone.is-active {
|
||||||
|
border-color: #0d6efd;
|
||||||
|
background: rgba(13, 110, 253, .08);
|
||||||
|
}
|
||||||
|
.file-drop-zone:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(13, 110, 253, .18);
|
||||||
|
}
|
||||||
|
.file-drop-zone[aria-disabled="true"] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: .65;
|
||||||
|
}
|
||||||
|
.file-drop-zone.is-busy {
|
||||||
|
border-color: #0d6efd;
|
||||||
|
background: rgba(13, 110, 253, .08);
|
||||||
|
cursor: progress;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.file-drop-progress {
|
||||||
|
--file-drop-progress: 0%;
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: conic-gradient(#0d6efd var(--file-drop-progress), rgba(13, 110, 253, .16) 0);
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.file-drop-progress::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(13, 110, 253, .12);
|
||||||
|
}
|
||||||
|
.file-drop-progress > span {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.file-drop-progress.is-indeterminate {
|
||||||
|
background: conic-gradient(#0d6efd 0 32%, rgba(13, 110, 253, .16) 32% 100%);
|
||||||
|
animation: file-drop-progress-spin .85s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes file-drop-progress-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
background: var(--bar, #f5f4f1);
|
background: var(--panel, #f7f6f4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-title {
|
.dialog-title {
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 12px 18px;
|
padding: 12px 18px;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
background: var(--bar, #f5f4f1);
|
background: var(--panel, #f7f6f4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-dialog {
|
.confirm-dialog {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 58px 1fr; }
|
.app-shell { height: 100vh; min-height: 0; display: grid; grid-template-columns: 58px 1fr; overflow: hidden; }
|
||||||
.icon-rail { background: var(--rail-bg); color: #c7c6c0; display: flex; flex-direction: column; align-items: center; min-height: 100vh; box-shadow: inset -1px 0 rgba(0,0,0,.35); z-index: 1000; }
|
.icon-rail { background: var(--rail-bg); color: #c7c6c0; display: flex; flex-direction: column; align-items: center; height: 100vh; min-height: 0; box-shadow: inset -1px 0 rgba(0,0,0,.35); z-index: 1000; }
|
||||||
.brand-mark { width: 34px; height: 34px; margin: 15px 0 14px; border-radius: 50%; background: conic-gradient(#ef6b3a 0 20%, #f2c66d 0 40%, #80b9b0 0 60%, #7e9fc0 0 80%, #56545f 0); color: transparent; font-size: 0; position: relative; }
|
.brand-mark { width: 34px; height: 34px; margin: 15px 0 14px; border-radius: 50%; background: conic-gradient(#ef6b3a 0 20%, #f2c66d 0 40%, #80b9b0 0 60%, #7e9fc0 0 80%, #56545f 0); color: transparent; font-size: 0; position: relative; }
|
||||||
.brand-mark::after { position: absolute;
|
.brand-mark::after { position: absolute;
|
||||||
top: 9px;
|
top: 9px;
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
.icon-nav { width: 100%; display: flex; flex-direction: column; }
|
.icon-nav { width: 100%; display: flex; flex-direction: column; }
|
||||||
.icon-nav-item { height: 52px; display: grid; place-items: center; color: #a7a49f; border-left: 3px solid transparent; text-decoration: none; }
|
.icon-nav-item { height: 52px; display: grid; place-items: center; color: #a7a49f; border-left: 3px solid transparent; text-decoration: none; }
|
||||||
.icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: #fff; border-left-color: var(--accent); }
|
.icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: #fff; border-left-color: var(--accent); }
|
||||||
.app-main { min-width: 0; display: grid; grid-template-rows: 64px 51px 1fr; min-height: 100vh; }
|
.app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); }
|
||||||
.titlebar { background: #fbfbfa; border-bottom: 1px solid var(--line); display: flex; align-items: center; padding: 0 18px; gap: 36px; z-index: 100; box-shadow: 0px 0px 10px 0px darkgrey; }
|
.titlebar { background: #fbfbfa; border-bottom: 1px solid var(--line); display: flex; align-items: center; padding: 0 18px; gap: 36px; z-index: 100; box-shadow: 0px 0px 10px 0px darkgrey; }
|
||||||
.tenant-selector { height: 40px; min-width: 210px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: #fff; display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: inset 0 1px 0 #fff, 0 1px 2px rgba(0,0,0,.05); }
|
.tenant-selector { height: 40px; min-width: 210px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: #fff; display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: inset 0 1px 0 #fff, 0 1px 2px rgba(0,0,0,.05); }
|
||||||
.tenant-label, .tenant-caret, .muted { color: var(--muted); }
|
.tenant-label, .tenant-caret, .muted { color: var(--muted); }
|
||||||
@@ -27,7 +27,8 @@
|
|||||||
.crumb { display: inline-flex; align-items: center; gap: 4px; }
|
.crumb { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
.breadcrumb-actions { margin-left: auto; display: flex; gap: 10px; }
|
.breadcrumb-actions { margin-left: auto; display: flex; gap: 10px; }
|
||||||
.ghost-button { border: 0; background: transparent; color: #77736d; font-weight: 700; }
|
.ghost-button { border: 0; background: transparent; color: #77736d; font-weight: 700; }
|
||||||
.workspace { height: calc(100vh - 112px); display: grid; grid-template-columns: 198px 1fr; }
|
.app-content { min-height: 0; overflow: hidden; }
|
||||||
|
.workspace { height: 100%; min-height: 0; display: grid; grid-template-columns: 198px minmax(0, 1fr); }
|
||||||
.section-sidebar { background: #c8c4bf; border-right: 1px solid var(--line-dark); padding: 18px 0; border-left: 3px solid #afada9; box-shadow: 0px 0px 10px 0px darkgrey; z-index: 80; overflow-y: scroll; }
|
.section-sidebar { background: #c8c4bf; border-right: 1px solid var(--line-dark); padding: 18px 0; border-left: 3px solid #afada9; box-shadow: 0px 0px 10px 0px darkgrey; z-index: 80; overflow-y: scroll; }
|
||||||
.section-title { font-size: 12px; font-weight: 800; color: #7f7b75; padding: 0 22px 14px; letter-spacing: .06em; }
|
.section-title { font-size: 12px; font-weight: 800; color: #7f7b75; padding: 0 22px 14px; letter-spacing: .06em; }
|
||||||
.section-title-lower { margin-top: 28px; }
|
.section-title-lower { margin-top: 28px; }
|
||||||
@@ -35,13 +36,13 @@
|
|||||||
.section-link:hover, .section-link.active { background: rgba(255,255,255,.35); color: #3e3e3f; }
|
.section-link:hover, .section-link.active { background: rgba(255,255,255,.35); color: #3e3e3f; }
|
||||||
.section-link.active { border-left: 3px solid var(--accent); font-weight: 700; }
|
.section-link.active { border-left: 3px solid var(--accent); font-weight: 700; }
|
||||||
.section-link.subtle { font-size: 13px; }
|
.section-link.subtle { font-size: 13px; }
|
||||||
.workspace-content { min-width: 0; max-width: 100%; overflow: auto; }
|
.workspace-content { min-width: 0; max-width: 100%; min-height: 0; overflow: auto; }
|
||||||
.content-pad { min-width: 0; max-width: 100%; box-sizing: border-box; padding: 28px 34px; }
|
.content-pad { min-width: 0; max-width: 100%; box-sizing: border-box; padding: 28px 34px; }
|
||||||
.page-heading { margin-bottom: 22px; }
|
.page-heading { margin-bottom: 22px; }
|
||||||
.page-heading h1 { margin: 0; font-size: 26px; color: var(--text-strong); font-weight: 600; }
|
.page-heading h1 { margin: 0; font-size: 26px; color: var(--text-strong); font-weight: 600; }
|
||||||
.page-heading p { margin: 6px 0 0; color: var(--muted); }
|
.page-heading p { margin: 6px 0 0; color: var(--muted); }
|
||||||
.page-heading.split { display: flex; align-items: center; justify-content: space-between; }
|
.page-heading.split { display: flex; align-items: center; justify-content: space-between; }
|
||||||
.panel, .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: scroll; }
|
.panel, .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
||||||
.card-header { min-height: 56px; padding: 0 24px; border-bottom: 1px solid var(--line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
|
.card-header { min-height: 56px; padding: 0 24px; border-bottom: 1px solid var(--line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
|
||||||
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
|
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
|
||||||
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
|
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
|
||||||
|
|||||||
@@ -46,5 +46,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; font-family: var(--font); color: var(--text); background: var(--bg); }
|
html, body, #root { height: 100%; }
|
||||||
|
body { margin: 0; overflow: hidden; font-family: var(--font); color: var(--text); background: var(--bg); }
|
||||||
a { color: inherit; }
|
a { color: inherit; }
|
||||||
|
|||||||
@@ -317,8 +317,116 @@ export type FilesFolderTreeProps = {
|
|||||||
depth?: number;
|
depth?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FilesOwnerType = "user" | "group";
|
||||||
|
|
||||||
|
export type FilesFileSpace = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
owner_type: FilesOwnerType;
|
||||||
|
owner_id: string;
|
||||||
|
description?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FilesFileShare = {
|
||||||
|
id: string;
|
||||||
|
target_type: string;
|
||||||
|
target_id: string;
|
||||||
|
permission: string;
|
||||||
|
created_at: string;
|
||||||
|
revoked_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FilesManagedFile = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
owner_type: FilesOwnerType;
|
||||||
|
owner_id: string;
|
||||||
|
display_path: string;
|
||||||
|
filename: string;
|
||||||
|
description?: string | null;
|
||||||
|
size_bytes: number;
|
||||||
|
content_type?: string | null;
|
||||||
|
checksum_sha256: string;
|
||||||
|
version_id: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
audit_relevant: boolean;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
shares?: FilesFileShare[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FilesFileFolder = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
owner_type: FilesOwnerType;
|
||||||
|
owner_id: string;
|
||||||
|
path: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FilesFileSpacesResponse = { spaces: FilesFileSpace[] };
|
||||||
|
export type FilesFileListResponse = { files: FilesManagedFile[] };
|
||||||
|
export type FilesFileFoldersResponse = { folders: FilesFileFolder[] };
|
||||||
|
export type FilesPatternResolveResponse = {
|
||||||
|
patterns: { pattern: string; matches: FilesManagedFile[] }[];
|
||||||
|
unmatched: FilesManagedFile[];
|
||||||
|
};
|
||||||
|
export type FilesFileBulkShareResponse = { shares: FilesFileShare[]; shared_count: number };
|
||||||
|
|
||||||
|
export type FilesManagedFileLinkTarget = {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
permission?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FilesManagedFileChooserMode = "folder" | "attachment";
|
||||||
|
export type FilesManagedAttachmentChoiceMode = "file" | "pattern";
|
||||||
|
|
||||||
|
export type FilesManagedFolderSelection = {
|
||||||
|
space: FilesFileSpace;
|
||||||
|
folderPath: string;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FilesManagedAttachmentSelection = FilesManagedFolderSelection & {
|
||||||
|
fileFilter: string;
|
||||||
|
matchCount: number;
|
||||||
|
fileIds: string[];
|
||||||
|
selectionType: FilesManagedAttachmentChoiceMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FilesManagedFileChooserProps = {
|
||||||
|
open: boolean;
|
||||||
|
settings: ApiSettings;
|
||||||
|
mode: FilesManagedFileChooserMode;
|
||||||
|
storageScope?: string;
|
||||||
|
source?: string;
|
||||||
|
basePath?: string;
|
||||||
|
initialPattern?: string;
|
||||||
|
rememberKey?: string;
|
||||||
|
previewContext?: Record<string, string>;
|
||||||
|
linkTarget?: FilesManagedFileLinkTarget;
|
||||||
|
renderPatternPreview?: (pattern: string, context?: Record<string, string>) => string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelectFolder?: (selection: FilesManagedFolderSelection) => void;
|
||||||
|
onSelectAttachment?: (selection: FilesManagedAttachmentSelection) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export type FilesFileExplorerUiCapability = {
|
export type FilesFileExplorerUiCapability = {
|
||||||
FolderTree: ComponentType<FilesFolderTreeProps>;
|
FolderTree: ComponentType<FilesFolderTreeProps>;
|
||||||
|
ManagedFileChooser?: ComponentType<FilesManagedFileChooserProps>;
|
||||||
|
listFileSpaces?: (settings: ApiSettings) => Promise<FilesFileSpacesResponse>;
|
||||||
|
listFiles?: (settings: ApiSettings, params?: { owner_type?: FilesOwnerType | string; owner_id?: string; campaign_id?: string; path_prefix?: string }) => Promise<FilesFileListResponse>;
|
||||||
|
listFolders?: (settings: ApiSettings, params: { owner_type: FilesOwnerType; owner_id: string }) => Promise<FilesFileFoldersResponse>;
|
||||||
|
resolveFilePatterns?: (
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: { patterns: string[]; owner_type?: FilesOwnerType; owner_id?: string; campaign_id?: string; path_prefix?: string; include_unmatched?: boolean; case_sensitive?: boolean }
|
||||||
|
) => Promise<FilesPatternResolveResponse>;
|
||||||
|
shareFilesWithTarget?: (settings: ApiSettings, fileIds: string[], target: FilesManagedFileLinkTarget) => Promise<FilesFileBulkShareResponse>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlatformFrontendRouteInfo = {
|
export type PlatformFrontendRouteInfo = {
|
||||||
|
|||||||
Reference in New Issue
Block a user