3 Commits

Author SHA1 Message Date
a2053518d1 Release v0.1.4 2026-07-02 15:01:18 +02:00
6d391d13bd Generate release WebUI lockfile 2026-06-26 01:42:23 +02:00
df701fddd2 Release v0.1.3 2026-06-26 01:39:19 +02:00
35 changed files with 1446 additions and 198 deletions

View File

@@ -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.

View File

@@ -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"

View 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
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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"):

View File

@@ -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),

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()),

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
}, },

View File

@@ -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",

View File

@@ -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;

View 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)}
/>
</>
);
}

View File

@@ -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>

View File

@@ -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>
); );

View File

@@ -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}

View File

@@ -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); }

View File

@@ -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 }) {

View File

@@ -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>;
}

View File

@@ -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}

View 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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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";

View File

@@ -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>
); );
} }

View File

@@ -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),

View File

@@ -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); }
}

View File

@@ -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 {

View File

@@ -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;}

View File

@@ -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; }

View File

@@ -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 = {