Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fcd37650d | ||
|
|
13ec05da83 | ||
|
|
e7acf56753 | ||
|
|
83e29b3ef1 | ||
|
|
67f61f18d0 | ||
|
|
f34537adcf | ||
|
|
931d1fa06a | ||
|
|
47b9ab4398 | ||
|
|
4653de6d30 | ||
|
|
671857722d | ||
|
|
62b0455388 | ||
|
|
372722a903 | ||
|
|
35ed6a2858 | ||
|
|
bd778adf12 | ||
|
|
1a19e96bcb | ||
|
|
6dc6ad0936 | ||
|
|
87336981d3 | ||
|
|
d8bebb531f | ||
|
|
57c06e38a4 | ||
|
|
f6fc34fab3 | ||
|
|
98dcd3b1e9 | ||
|
|
98f8487784 | ||
|
|
1f199fdc1d | ||
|
|
8ddb9c6dbd | ||
|
|
81c720d630 | ||
|
|
3bf36de0c8 | ||
|
|
b648c1c267 | ||
|
|
a4b330bace | ||
|
|
971afbecc1 | ||
|
|
2ac022df50 | ||
|
|
7b2047681e | ||
|
|
2aae8ed842 | ||
|
|
2a8778c905 | ||
|
|
2dc224e882 | ||
|
|
3d5c05ffb9 | ||
|
|
f46614a483 | ||
|
|
30d6c23908 | ||
|
|
bde63c1322 | ||
|
|
dfa5667548 | ||
|
|
64f031201e | ||
|
|
77a9060c00 | ||
|
|
7082566f51 | ||
|
|
cd02a9f806 | ||
|
|
59048e5ad3 | ||
|
|
9377684fb1 | ||
|
|
26e1f31945 | ||
|
|
611ae0624e | ||
|
|
68b0826587 | ||
|
|
eda3302298 | ||
|
|
295170a842 | ||
|
|
30e5059b94 | ||
|
|
6fd17c2ade | ||
|
|
61651b1ed1 | ||
|
|
daa40ec72d | ||
|
|
c2c07293b7 | ||
|
|
91b282d746 | ||
|
|
dda7adf565 | ||
|
|
bde8455df5 | ||
|
|
bde9f14832 | ||
|
|
3f99a2fc9e | ||
|
|
147660aef3 | ||
|
|
5d7ae230ce | ||
|
|
2a35019b21 | ||
|
|
4ba2d34555 | ||
|
|
cfe8ca96e1 | ||
|
|
e1a12b2c23 | ||
|
|
e44f2ac97e | ||
|
|
9b5870ca69 | ||
|
|
2e8bb9983a | ||
|
|
d0a1cee6fd | ||
|
|
19663610d7 | ||
|
|
36167eaa84 | ||
|
|
c71175e596 | ||
|
|
68d08dbae6 | ||
|
|
7cce5c0393 | ||
|
|
6b6ff72c38 | ||
|
|
57a5c692be | ||
|
|
84290a67be | ||
|
|
ada2b3a7a1 | ||
|
|
3338ceb5eb | ||
|
|
15b189357e | ||
|
|
ab79288be8 | ||
|
|
e6e1612435 | ||
|
|
f01f50922e | ||
|
|
48b18a853f | ||
|
|
665a392ce0 | ||
|
|
505e65f319 | ||
|
|
177b2aca57 | ||
|
|
3512f935d1 | ||
|
|
12908ab706 | ||
|
|
a10ee48ef3 | ||
|
|
31b4ea2ecd | ||
|
|
0271df4338 | ||
|
|
7e9aefcfa1 | ||
|
|
b966d285a9 | ||
|
|
16466c7296 | ||
|
|
2ff4b7847e | ||
|
|
46fc221f9e | ||
|
|
b15098a6eb | ||
|
|
0f15132ec0 | ||
|
|
3f3f3a36b1 | ||
|
|
5d77a5d7ef | ||
|
|
75876725df | ||
|
|
2416f1863b | ||
|
|
568b7137ea | ||
|
|
0bb098438a | ||
|
|
cab43b8d06 | ||
|
|
4d817ea932 | ||
|
|
b8f668d25a | ||
|
|
9254a7fa20 | ||
|
|
4ff15fffaa | ||
|
|
218a584fb3 |
@@ -6,6 +6,10 @@ node_modules
|
||||
!.env.example
|
||||
!.env.dev.example
|
||||
scripts/
|
||||
# ...except the first-party plugin builder, which the image build runs
|
||||
# (see Dockerfile). Without this the whole scripts/ dir is absent from the
|
||||
# build context and the RUN step fails with "Cannot find module".
|
||||
!scripts/build-plugins.mjs
|
||||
TODO.md
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
+10
-4
@@ -15,9 +15,15 @@
|
||||
DEV_MOCK_JMAP=true
|
||||
|
||||
# Point the app at its own mock endpoint.
|
||||
# IMPORTANT: This must match the origin the app runs on (default: port 3000).
|
||||
# Using a different port (e.g. 3001) will cause CORS errors.
|
||||
JMAP_SERVER_URL=/api/dev-jmap
|
||||
# IMPORTANT: must be an ABSOLUTE URL matching the origin the app runs on
|
||||
# (default: port 3000) - NOT a relative path. A relative path here makes
|
||||
# /api/auth/stalwart-context 400 on every request (resolveTrustedJmapUrl
|
||||
# rejects it), which silently breaks the real server-side session-cookie
|
||||
# flow that S/MIME enrollment, offline sync, and the AI server/retrieval
|
||||
# routes all depend on. The client-side mock fetch works either way, which
|
||||
# is why this is easy to miss - it only bites features needing a real
|
||||
# server-side session identity.
|
||||
JMAP_SERVER_URL=http://localhost:3000/api/dev-jmap
|
||||
|
||||
# =============================================================================
|
||||
# App
|
||||
@@ -29,7 +35,7 @@ APP_NAME=Bulwark Webmail (Dev)
|
||||
# Session & Settings Sync (optional for dev)
|
||||
# =============================================================================
|
||||
|
||||
SESSION_SECRET=dev-secret-not-for-production
|
||||
SESSION_SECRET=dev-secret-not-for-production-32chars
|
||||
SETTINGS_SYNC_ENABLED=true
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
name: Build Electron Desktop App
|
||||
|
||||
# Phase 1 of the VNCprodbuild rollout (~/.claude/skills/VNCprodbuild/SKILL.md
|
||||
# on the machine that authored this - Phase 1 step 8). Builds the desktop
|
||||
# shell (electron/) for macOS, Windows, and Linux on every release, or
|
||||
# on-demand via workflow_dispatch for a one-off test build.
|
||||
#
|
||||
# Ships UNSIGNED. There's no Apple Developer ID or Windows code-signing cert
|
||||
# yet (VNCprodbuild Phase 1 step 9 - both are human-owned purchases, not
|
||||
# something CI can provide). CSC_IDENTITY_AUTO_DISCOVERY: "false" below stops
|
||||
# electron-builder from probing for a macOS signing identity it won't find.
|
||||
# Adding real certs later needs no rewrite here - just add CSC_LINK/
|
||||
# CSC_KEY_PASSWORD (macOS) and/or WIN_CSC_LINK/WIN_CSC_KEY_PASSWORD (Windows)
|
||||
# as repo secrets and electron-builder picks them up automatically.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest, ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build standalone Next.js server
|
||||
run: npm run build:standalone
|
||||
|
||||
- name: Bundle Electron main/preload
|
||||
run: npm run build:electron
|
||||
|
||||
# Only Linux runners lack a display server by default - macOS/Windows
|
||||
# GitHub-hosted runners can launch a real (if headless) GUI session
|
||||
# without one.
|
||||
- name: Install Xvfb (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
|
||||
# Required gate (VNCprodbuild Phase 1 step 2) before any packaging or
|
||||
# artifact-upload step below, on every OS in the matrix - a
|
||||
# platform-specific regression in electron/main.ts (path handling,
|
||||
# spawn behavior, etc.) should fail exactly the leg it breaks, not
|
||||
# slip through because only one OS was ever smoke-tested.
|
||||
- name: Run Electron smoke test (Linux, via Xvfb)
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run --auto-servernum npm run test:electron
|
||||
|
||||
- name: Run Electron smoke test
|
||||
if: runner.os != 'Linux'
|
||||
run: npm run test:electron
|
||||
|
||||
- name: Package
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
run: npx electron-builder --config electron-builder.config.js --publish ${{ github.event_name == 'release' && 'always' || 'never' }}
|
||||
|
||||
- name: Upload artifact (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vncmail-plus-desktop-${{ matrix.os }}
|
||||
path: |
|
||||
dist-electron-builds/*.dmg
|
||||
dist-electron-builds/*.zip
|
||||
dist-electron-builds/*.exe
|
||||
dist-electron-builds/*.AppImage
|
||||
dist-electron-builds/*.deb
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
@@ -0,0 +1,28 @@
|
||||
name: PR Verify
|
||||
|
||||
# Required status check on `main` (Settings -> Branches). Mirrors the GitLab
|
||||
# CI `verify` stage (.gitlab-ci.yml) so both remotes gate merges the same
|
||||
# way: typecheck, lint, translations, and a real production build — no
|
||||
# registry, no cluster, nothing that can be blocked by infra that's down.
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm run lint
|
||||
- run: npm run test:translations
|
||||
- run: npm run build
|
||||
env:
|
||||
GIT_COMMIT: ${{ github.sha }}
|
||||
+18
-5
@@ -38,6 +38,14 @@ yarn-error.log*
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# electron (see electron/, scripts/build-electron.mjs, electron-builder.config.js)
|
||||
/dist-electron/
|
||||
/dist-electron-builds/
|
||||
|
||||
# playwright output
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -51,13 +59,18 @@ next-env.d.ts
|
||||
# Sibling repos
|
||||
/repos/
|
||||
|
||||
# k8s deploy secret (create from deploy/k8s/secret.example.yaml)
|
||||
/deploy/k8s/secret.yaml
|
||||
# k8s deploy secrets (create from the matching overlay's secret.example.yaml)
|
||||
/deploy/k8s/overlays/*/secret.yaml
|
||||
|
||||
# S/MIME plugin build output (rebuild with: cd vnc/plugins/smime && npm run build)
|
||||
vnc/plugins/smime/node_modules/
|
||||
vnc/plugins/smime/dist/
|
||||
# First-party plugin build output (rebuild with: npm run build:plugins).
|
||||
# vnc/plugins/build/ is the staging dir the server installs from at startup
|
||||
# (see lib/admin/bundled-plugins.ts) - built, never committed.
|
||||
vnc/plugins/build/
|
||||
vnc/plugins/*/node_modules/
|
||||
vnc/plugins/*/dist/
|
||||
vnc/plugins/smime/smime-vnc.zip
|
||||
vnc/plugins/smime/smime.zip
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
electron-ai-local-index-result.png
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
# GitLab-CI dev→prod pipeline for VNCmail+ — GitOps via ArgoCD.
|
||||
#
|
||||
# Design:
|
||||
# - MR into `dev`: verify only (typecheck/lint/unit test/build check). No
|
||||
# push, no deploy — this is the multi-developer merge gate.
|
||||
# - Push to `dev`: build+push an immutable `sha-<sha>` tag with Docker +
|
||||
# docker-in-docker, then commit a one-line tag-bump into
|
||||
# overlays/dev/image-tag/kustomization.yaml (`[skip ci]`). ArgoCD's
|
||||
# `vncmail-dev` Application syncs it automatically.
|
||||
# - Push to `main`: NEVER rebuilds. `main` only advances via
|
||||
# `git merge --ff-only dev`, so main's HEAD commit already has a built
|
||||
# image. This job just bumps overlays/prod/image-tag/kustomization.yaml
|
||||
# to point at that same tag. The actual promotion gate is a HUMAN
|
||||
# clicking Sync on the `vncmail-prod` ArgoCD Application.
|
||||
#
|
||||
# Deliberately single-platform (linux/amd64) — this pipeline serves two
|
||||
# known amd64 microk8s clusters, not public multi-arch distribution (that's
|
||||
# what the GHCR release workflows are for, untouched by this file).
|
||||
#
|
||||
# Prerequisite this file assumes:
|
||||
# - A GitLab Runner with Docker-in-Docker service support (Kubernetes or
|
||||
# Docker executor). The `docker:28.4.0-dind` service requires privileged
|
||||
# mode on most Kubernetes executors.
|
||||
# - Either "allow this job token to push to this project" enabled
|
||||
# (Settings → CI/CD → Job token permissions), OR a project access token
|
||||
# with `write_repository` scope in $GITLAB_PUSH_TOKEN. The bump jobs
|
||||
# try CI_JOB_TOKEN first (see the script).
|
||||
#
|
||||
# deploy/k8s/ca/ (the EJBCA internal CA) is never referenced anywhere below,
|
||||
# and neither ArgoCD Application in deploy/argocd/ points at it — that stays
|
||||
# a fully manual, human-only runbook (see deploy/k8s/ca/README.md).
|
||||
|
||||
stages:
|
||||
- verify
|
||||
- build
|
||||
- bump-dev
|
||||
- bump-prod
|
||||
|
||||
variables:
|
||||
IMAGE: $CI_REGISTRY_IMAGE
|
||||
GIT_STRATEGY: clone
|
||||
DOCKER_DRIVER: overlay2
|
||||
# DinD service is reached at the `docker` alias (set explicitly on the
|
||||
# service below), not localhost. TLS disabled so the daemon listens on
|
||||
# plaintext 2375 — same pattern as the working vnc-localidp pipeline.
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verify — required check on every MR into dev. No registry, no cluster.
|
||||
# ---------------------------------------------------------------------------
|
||||
verify:
|
||||
stage: verify
|
||||
image: node:24-alpine
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
script:
|
||||
- npm ci
|
||||
- npm run typecheck
|
||||
- npm run lint
|
||||
- npm run test:translations
|
||||
- npm run build
|
||||
# test:integration is deliberately NOT here — it spins up a real Stalwart
|
||||
# fixture via docker-compose, which needs an actual Docker daemon this
|
||||
# runner's Kubernetes executor doesn't provide without privileged mode
|
||||
# (see the build job below). Candidate for a separate scheduled job on a
|
||||
# differently-configured runner, not a blocker on every MR.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build — push to dev only. Builds once; main never rebuilds (see header).
|
||||
# ---------------------------------------------------------------------------
|
||||
build:
|
||||
stage: build
|
||||
image: docker:28.4.0
|
||||
services:
|
||||
- name: docker:28.4.0-dind
|
||||
alias: docker
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "dev"'
|
||||
before_script:
|
||||
- until docker info; do sleep 1; done
|
||||
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
|
||||
script:
|
||||
- >
|
||||
docker build
|
||||
--build-arg GIT_COMMIT=$CI_COMMIT_SHA
|
||||
-t "$IMAGE:sha-$CI_COMMIT_SHORT_SHA"
|
||||
-t "$IMAGE:dev-latest"
|
||||
.
|
||||
- docker push "$IMAGE:sha-$CI_COMMIT_SHORT_SHA"
|
||||
- docker push "$IMAGE:dev-latest"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bump-dev — no cluster access. Commits the just-built tag into the overlay
|
||||
# ArgoCD watches; ArgoCD's automated sync does the actual apply.
|
||||
# ---------------------------------------------------------------------------
|
||||
bump-dev:
|
||||
stage: bump-dev
|
||||
# alpine/git:2.47.0 was never published on Docker Hub — the 2.47.x line
|
||||
# starts at 2.47.1. Using 2.47.2 (latest 2.47.x).
|
||||
image: alpine/git:2.47.2
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "dev"'
|
||||
script:
|
||||
- TAG="sha-$CI_COMMIT_SHORT_SHA"
|
||||
- |
|
||||
cat > deploy/k8s/overlays/dev/image-tag/kustomization.yaml <<EOF
|
||||
# Owned by CI (bump-dev job in .gitlab-ci.yml) - regenerated every
|
||||
# push to dev. Do not hand-edit; edits here get overwritten.
|
||||
apiVersion: kustomize.config.k8s.io/v1alpha1
|
||||
kind: Component
|
||||
images:
|
||||
- name: vncmail-plus
|
||||
newName: $IMAGE
|
||||
newTag: $TAG
|
||||
EOF
|
||||
- git config user.name "vncmail-ci"
|
||||
- git config user.email "ci@vnc.biz"
|
||||
- git add deploy/k8s/overlays/dev/image-tag/kustomization.yaml
|
||||
- |
|
||||
if git diff --cached --quiet; then
|
||||
echo "No change (tag already pinned) - nothing to commit"
|
||||
else
|
||||
git commit -m "chore(deploy): pin dev to $TAG [skip ci]"
|
||||
git push "https://gitlab-ci-token:${GITLAB_PUSH_TOKEN:-$CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:dev
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bump-prod — no cluster access, no rebuild. Points overlays/prod at the
|
||||
# exact tag already running on dev. Does NOT deploy anything: vncmail-prod's
|
||||
# ArgoCD Application has manual sync, so this only prepares what a human
|
||||
# would be syncing, it doesn't sync it.
|
||||
# ---------------------------------------------------------------------------
|
||||
bump-prod:
|
||||
stage: bump-prod
|
||||
image: alpine/git:2.47.2
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
|
||||
script:
|
||||
- TAG="sha-$CI_COMMIT_SHORT_SHA"
|
||||
- echo "main advanced to $CI_COMMIT_SHA (must be a dev commit, ff-only) - that image already exists as $IMAGE:$TAG"
|
||||
- |
|
||||
cat > deploy/k8s/overlays/prod/image-tag/kustomization.yaml <<EOF
|
||||
# Owned by CI (bump-prod job in .gitlab-ci.yml) - regenerated every
|
||||
# push to main. Do not hand-edit; edits here get overwritten. Bumping
|
||||
# this is NOT the same as deploying it - vncmail-prod's ArgoCD
|
||||
# Application has manual sync, see the note in the parent
|
||||
# kustomization.yaml.
|
||||
apiVersion: kustomize.config.k8s.io/v1alpha1
|
||||
kind: Component
|
||||
images:
|
||||
- name: vncmail-plus
|
||||
newName: $IMAGE
|
||||
newTag: $TAG
|
||||
EOF
|
||||
- git config user.name "vncmail-ci"
|
||||
- git config user.email "ci@vnc.biz"
|
||||
- git add deploy/k8s/overlays/prod/image-tag/kustomization.yaml
|
||||
- |
|
||||
if git diff --cached --quiet; then
|
||||
echo "No change (tag already pinned) - nothing to commit"
|
||||
else
|
||||
git commit -m "chore(deploy): point prod overlay at $TAG (not synced - manual gate in ArgoCD) [skip ci]"
|
||||
git push "https://gitlab-ci-token:${GITLAB_PUSH_TOKEN:-$CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:main
|
||||
fi
|
||||
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.9 (2026-08-07)
|
||||
|
||||
### Bug Fixes (Phase 1 — VNCmailgraph audit)
|
||||
|
||||
- **Mail**: Network transport failures now throw `TransportError` instead of returning empty results, so offline/network-down is distinguishable from an empty folder (#C1)
|
||||
- **Mail**: Push handler now refreshes contacts and files on remote state changes (#H1)
|
||||
- **Calendar**: Recurrence expansion IDs use `::occurrence::` delimiter to prevent collision with shared-event prefixes (#C2)
|
||||
- **Calendar**: Cross-account event aggregation now deduplicates by UID + recurrenceId, preventing phantom duplicates (#C3)
|
||||
- **Calendar**: `calendarTasksEnabled` admin policy now enforced at runtime, not just in settings UI (#H13)
|
||||
- **Tasks**: All task mutations (update, delete, toggle) now have error handling with store error state (#H14)
|
||||
- **Settings**: `updateSetting()` now checks admin policy lock before writing; `force` opt-in for legitimate bypassers (#C7)
|
||||
- **Settings**: `autoSelectReplyIdentity` now defaults to `true` — auto-identity selection on by default (#H18)
|
||||
- **Templates**: HTML template bodies are now sanitized with DOMPurify on import to prevent stored XSS (#H7)
|
||||
- **Auth**: User authentication endpoints now rate-limited — 10 attempts per (IP + username) per 15 minutes (#H3)
|
||||
- **Auth**: Admin sessions now support token revocation via JTI blacklist on logout (#C4)
|
||||
- **Auth**: Secure cookie flag now derived from `x-forwarded-proto`, not `NODE_ENV` (#H8)
|
||||
- **Auth**: OAuth token exchange error logs no longer leak `access_token` (#H4)
|
||||
- **Auth**: `isHashed()` no longer accepts bcrypt prefixes — scrypt-only, preventing lockout from bcrypt passwords (#H9)
|
||||
- **Push**: WS→SSE fallback now awaits state snapshot before reconciliation to prevent missed deliveries (#H2)
|
||||
- **Push**: Offline event handler added — push transports pause when browser goes offline, reconnect on online (#C8)
|
||||
- **Index**: FTS5 schema-drop now logs a warning so operators know a rebuild is needed (#C6)
|
||||
|
||||
---
|
||||
|
||||
## 1.7.8 (2026-07-22)
|
||||
|
||||
### Features
|
||||
|
||||
+10
@@ -21,6 +21,12 @@ ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
||||
# `git rev-parse` inside the build can't find it - CI must pass it in.
|
||||
ARG GIT_COMMIT=unknown
|
||||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
# Build the first-party plugins (vnc/plugins/*) that ship with this fork -
|
||||
# currently the audited S/MIME plugin, which the server installs into its
|
||||
# plugin registry at startup (lib/admin/bundled-plugins.ts). Each plugin has
|
||||
# its own package.json + lockfile, so this does its own npm ci.
|
||||
# Runs BEFORE next build so a broken plugin fails the image build.
|
||||
RUN node scripts/build-plugins.mjs
|
||||
RUN npx next build --webpack
|
||||
|
||||
FROM node:24-alpine AS runner
|
||||
@@ -43,6 +49,10 @@ RUN apk upgrade --no-cache && \
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
# Staged first-party plugin bundles. Read by path at runtime, so Next's output
|
||||
# file tracing does not carry them into .next/standalone - copy explicitly or
|
||||
# the image boots with the S/MIME policy toggle on and no plugin installed.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/vnc/plugins/build ./vnc/plugins/build
|
||||
RUN mkdir -p /app/data/settings /app/data/admin /app/data/admin-state /app/data/telemetry && chown -R nextjs:nodejs /app/data
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
+106
-13
@@ -18,8 +18,8 @@ of truth; VNCmail+ is the UI. It deploys as a **container on Kubernetes
|
||||
except `/tmp`, so Bulwark's `mkdir ./data` crashes (`ENOENT /var/task/data`).
|
||||
You cannot point its data dirs at a remote host either (they're POSIX paths,
|
||||
not URLs). Bulwark's native model is a container + persistent volumes.
|
||||
- So VNCmail+ runs as a Docker image (`ghcr.io/brvncde-dotcom/vncmail-plus-*`)
|
||||
with **4 persistent volumes**, exactly like the existing `bulwark.sandbox.vnc.de`.
|
||||
- So VNCmail+ runs as a Docker image with **4 persistent volumes**, exactly
|
||||
like the existing `bulwark.sandbox.vnc.de`.
|
||||
- JMAP calls go through **server-side `/api/*` routes** (`proxy.ts`) → server-to-
|
||||
server to Stalwart, **no browser CORS**. Config is **runtime-read**.
|
||||
|
||||
@@ -27,37 +27,130 @@ of truth; VNCmail+ is the UI. It deploys as a **container on Kubernetes
|
||||
|
||||
| Branch | Role |
|
||||
|--------|------|
|
||||
| `main` | **Production** — CI builds `…/vncmail-plus-beta`. Only updated by an explicit promote. |
|
||||
| `dev` | Integration + QA — CI builds `…/vncmail-plus-dev` on push. Default working branch. |
|
||||
| `vnc/*`| Feature branches for UI work (branch off `dev`, PR into `dev`). |
|
||||
| `main` | **Production.** Only updated by `git merge --ff-only dev`, then an explicit manual promote in CI. No prod environment exists yet — see "CI/CD" below. |
|
||||
| `dev` | Integration + QA — default working branch. Every push auto-builds and auto-deploys to the sandbox (`vncmail.sandbox.vnc.de`). |
|
||||
| `vnc/*`| Feature branches for UI work (branch off `dev`, MR into `dev` — required, gated by CI). |
|
||||
|
||||
All VNC customization lives under `vnc/` (see `vnc/VNC-CHANGES.md`).
|
||||
|
||||
## CI/CD — GitLab (canonical) + ArgoCD GitOps, Vercel-style dev→prod
|
||||
|
||||
Multiple developers work on this repo now. `.gitlab-ci.yml` on
|
||||
[gitlab.vnc.biz](https://gitlab.vnc.biz/gitlab-instance-b9b5cf2f/vncmail-plus)
|
||||
(the canonical remote — GitHub `origin` is a passive mirror, not where CI or
|
||||
deploys happen) builds images and bumps a tag in git; **ArgoCD does the
|
||||
actual deploying** — already installed and idle on the `dev-k8s-1/2/3`
|
||||
cluster, discovered when standing this up. GitLab CI needs zero cluster
|
||||
credentials as a result.
|
||||
|
||||
Two real clusters, confirmed by direct inspection:
|
||||
|
||||
| Cluster | Role | Notes |
|
||||
|---|---|---|
|
||||
| `dev-k8s-1/2/3` | dev/sandbox | ~hours old when set up here. Traefik, metallb, cert-manager (`letsencrypt-staging` issuer only), **ArgoCD already running**. |
|
||||
| `node1/node2/node3` | prod (HA) | Older, rook-ceph+traefik+metallb+cert-manager, but **zero apps and zero ClusterIssuers** — genuinely a clean slate. |
|
||||
|
||||
Neither cluster had a `vncmail` namespace, `vnc-ca` namespace, or `bulwark`
|
||||
ingress — the "live sandbox at vncmail.sandbox.vnc.de" referenced earlier in
|
||||
this doc's history was aspirational (manifests + docs existed, nothing was
|
||||
ever actually applied). The ingress manifests also assumed nginx (`class:
|
||||
public`, an nginx body-size annotation) — fixed to Traefik's real
|
||||
`ingressClassName: traefik` (Traefik has no default body-size cap, so no
|
||||
replacement annotation is needed).
|
||||
|
||||
Flow:
|
||||
|
||||
1. **MR into `dev`** → `verify` stage (typecheck/lint/unit test/build).
|
||||
Required check — no push, no deploy.
|
||||
2. **Merge to `dev`** → `build` pushes one image,
|
||||
`registry.gitlab.vnc.biz/.../vncmail-plus:sha-<sha>`, then `bump-dev`
|
||||
commits that tag into `deploy/k8s/overlays/dev/image-tag/kustomization.yaml`
|
||||
(`[skip ci]`). ArgoCD's `vncmail-dev` Application picks up the git change.
|
||||
3. **Merge to `main`** (fast-forward only, see below) → `bump-prod` points
|
||||
`overlays/prod/image-tag/` at that same tag — **no rebuild**. The actual
|
||||
promotion gate is a **human clicking Sync** on the `vncmail-prod` ArgoCD
|
||||
Application, which is permanently manual-sync (never automated) — that's
|
||||
the Vercel-style "Promote to Production" button, just living in ArgoCD's
|
||||
UI instead of GitLab's.
|
||||
|
||||
### What's left to wire up (one-time, human steps)
|
||||
|
||||
1. **Add the ArgoCD deploy key to GitLab** — Project → Settings → Repository
|
||||
→ Deploy keys → add (read-only is enough):
|
||||
```
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOURjX/Y9zfB785DyLEF1GUq4HhWujrqeXag8oxdMciq argocd@dev-k8s (vncmail-plus read-only)
|
||||
```
|
||||
Until this is added, `vncmail-dev`'s ArgoCD Application (already created,
|
||||
`kubectl -n argocd get application vncmail-dev`) shows a benign
|
||||
`ComparisonError` (SSH handshake failing) — expected, not a bug.
|
||||
2. **Let CI push tag-bumps back to this repo** — either enable "this project
|
||||
can be accessed by CI/CD job tokens from other projects" → actually
|
||||
simpler: Settings → CI/CD → Job token permissions → allow this project's
|
||||
own job token to push to itself, OR create a Project Access Token
|
||||
(`write_repository` scope) and add it as a masked CI/CD variable
|
||||
`GITLAB_PUSH_TOKEN` (the pipeline tries that first, falls back to
|
||||
`CI_JOB_TOKEN`).
|
||||
3. **One-time namespace bootstrap** (CI/ArgoCD deliberately never manage
|
||||
secret contents — see `deploy/k8s/README.md` §3):
|
||||
```bash
|
||||
# against dev-k8s (ArgoCD's CreateNamespace=true will make `vncmail` on
|
||||
# first sync, or create it yourself first — either order works)
|
||||
kubectl create secret docker-registry ghcr-pull -n vncmail ... # or make the GHCR package public
|
||||
cp deploy/k8s/overlays/dev/secret.example.yaml secret.yaml # edit SESSION_SECRET
|
||||
kubectl apply -f secret.yaml
|
||||
```
|
||||
4. **First sync** — ArgoCD UI at `https://argo.devcluster.vnc.de`
|
||||
(username `admin`, password: `kubectl -n argocd get secret
|
||||
argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d`
|
||||
— rotate it after logging in once) → `vncmail-dev` → Sync. Once that's
|
||||
clean, flip `deploy/argocd/vncmail-dev-app.yaml`'s commented-out
|
||||
`automated:` block on and re-apply, so dev auto-syncs on every push from
|
||||
then on.
|
||||
5. **Production** (later, deliberately not wired yet): decide a real
|
||||
hostname, stand up prod Stalwart, register `node1-3` as an ArgoCD-managed
|
||||
cluster, apply `deploy/argocd/vncmail-prod-app.yaml`, fill in real
|
||||
`overlays/prod` values, create a real ClusterIssuer on `node1-3` (there
|
||||
isn't one today), then click Sync once — deliberately not before.
|
||||
|
||||
Historical note: the old `-dev`/`-beta` GHCR image-name split
|
||||
(`.github/workflows/docker-publish.yml`) is retired by this — one image name
|
||||
now, environment lives only in the tag.
|
||||
|
||||
## Deploy (Kubernetes / microk8s)
|
||||
|
||||
Full runbook: **[deploy/k8s/README.md](deploy/k8s/README.md)**. In short:
|
||||
|
||||
1. CI builds the image on push to `dev`/`main` → `ghcr.io/brvncde-dotcom/vncmail-plus-dev` (`.github/workflows/docker-publish.yml`).
|
||||
2. `kubectl apply` the manifests in `deploy/k8s/` (namespace, 4 PVCs, deployment, service, ingress) + a `secret.yaml` (from `secret.example.yaml`) + a `ghcr-pull` image-pull secret.
|
||||
3. Point `vncmail.sandbox.vnc.de` DNS at the ingress; cert-manager issues TLS.
|
||||
1. CI (above) builds and pushes the image, one name/many tags, to GitLab's
|
||||
registry.
|
||||
2. `kubectl apply -k deploy/k8s/overlays/dev` (or `overlays/prod`, once real)
|
||||
— base manifests (namespace, 4 PVCs, deployment, service, ingress) live in
|
||||
`deploy/k8s/base/`, environment differences (namespace, hostname, replica
|
||||
count) are overlay patches.
|
||||
3. DNS + a `secret.yaml` (from the overlay's `secret.example.yaml`, gitignored,
|
||||
created once by hand — CI never manages secret contents) + an image-pull
|
||||
secret are the remaining manual, human, one-time steps per environment.
|
||||
|
||||
Runs alongside the existing `bulwark.sandbox.vnc.de`. Match your cluster's
|
||||
StorageClass / IngressClass / cert issuer to bulwark's (see the runbook).
|
||||
|
||||
## Deploy workflow (dev-first — ALWAYS)
|
||||
|
||||
Same flow as every other VNC/SRC repo:
|
||||
Same flow as every other VNC/SRC repo, now enforced structurally by CI rather
|
||||
than by convention:
|
||||
|
||||
1. Work on `dev` (or `vnc/*` → PR into `dev`). Push to `dev` → CI builds the `-dev` image → `kubectl -n vncmail rollout restart deploy/vncmail-plus` to pull it. QA at `vncmail.sandbox.vnc.de`.
|
||||
1. Work on `dev` (or `vnc/*` → MR into `dev`, CI-gated). Merge → auto-builds
|
||||
and auto-deploys to `vncmail.sandbox.vnc.de`. QA there.
|
||||
2. **Promote to production only on explicit go-live** — merge `dev` → `main`:
|
||||
```bash
|
||||
git log dev..main # MUST be empty — main must have nothing dev lacks (else prod would revert)
|
||||
git checkout main && git merge --ff-only dev
|
||||
git push origin main # CI builds the production image
|
||||
git push gitlab main # never GitHub — opens the manual `promote` job, does not run it
|
||||
git checkout dev
|
||||
```
|
||||
Then roll the production deployment to the new image (pin its digest — see deploy/k8s/README.md).
|
||||
Never push straight to `main`. Never let a dev→main merge silently revert prod.
|
||||
Then click `promote` in the GitLab pipeline UI (protected `production`
|
||||
environment — requires the right role) once prod actually exists (see
|
||||
"CI/CD" above). Never push straight to `main`. Never let a dev→main merge
|
||||
silently revert prod.
|
||||
|
||||
## Syncing upstream (Bulwark releases)
|
||||
|
||||
|
||||
@@ -97,9 +97,12 @@ export default function CalendarPage() {
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
|
||||
removeCalendar, clearCalendarEvents,
|
||||
refreshAllSubscriptions, icalSubscriptions,
|
||||
newEventPrefill, setNewEventPrefill,
|
||||
} = useCalendarStore();
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||
const calendarTasksEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarTasksEnabled'));
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks: userTasksEnabled, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||
const enableCalendarTasks = userTasksEnabled && calendarTasksEnabled;
|
||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
|
||||
@@ -462,6 +465,19 @@ export default function CalendarPage() {
|
||||
setShowEventModal(true);
|
||||
}, [selectedDate, setSelectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!newEventPrefill) return;
|
||||
setEditEvent(null);
|
||||
if (newEventPrefill.date) {
|
||||
const d = new Date(newEventPrefill.date);
|
||||
if (!isNaN(d.getTime())) {
|
||||
setDefaultModalDate(d);
|
||||
setSelectedDate(d);
|
||||
}
|
||||
}
|
||||
setShowEventModal(true);
|
||||
}, [newEventPrefill, setSelectedDate]);
|
||||
|
||||
const openEditModal = useCallback((event: CalendarEvent) => {
|
||||
setEditEvent(event);
|
||||
setDefaultModalDate(undefined);
|
||||
@@ -1196,6 +1212,9 @@ export default function CalendarPage() {
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onContextMenuEmpty={handleContextMenuEmpty}
|
||||
onCreateAtTime={openCreateModal}
|
||||
onEditEvent={openEditModal}
|
||||
onDeleteEvent={handleDeleteContextMenu}
|
||||
onDuplicateEvent={handleDuplicateContextMenu}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
isMobile={isMobile}
|
||||
pendingPreview={pendingPreview}
|
||||
@@ -1488,10 +1507,14 @@ export default function CalendarPage() {
|
||||
onDelete={handleDeleteEvent}
|
||||
onDuplicate={handleDuplicateEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); setNewEventPrefill(null); }}
|
||||
onPreviewChange={setPendingPreview}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={false}
|
||||
prefillTitle={editEvent ? undefined : newEventPrefill?.title}
|
||||
prefillDescription={editEvent ? undefined : newEventPrefill?.description}
|
||||
prefillParticipants={editEvent ? undefined : newEventPrefill?.participants}
|
||||
prefillDate={editEvent ? undefined : newEventPrefill?.date}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1618,9 +1641,13 @@ export default function CalendarPage() {
|
||||
onDelete={handleDeleteEvent}
|
||||
onDuplicate={handleDuplicateEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); setNewEventPrefill(null); }}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={true}
|
||||
prefillTitle={editEvent ? undefined : newEventPrefill?.title}
|
||||
prefillDescription={editEvent ? undefined : newEventPrefill?.description}
|
||||
prefillParticipants={editEvent ? undefined : newEventPrefill?.participants}
|
||||
prefillDate={editEvent ? undefined : newEventPrefill?.date}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useProTabStore } from "@/stores/pro-tab-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
@@ -411,6 +412,31 @@ export default function FilesPage() {
|
||||
await shareResource(id, principalId, rights);
|
||||
}, [shareResource]);
|
||||
|
||||
const handleSendAsAttachment = useCallback((names: string[]) => {
|
||||
const store = useFileStore.getState();
|
||||
const fileAtts = names
|
||||
.map((name) => {
|
||||
const r = store.resources.find((res) => res.name === name);
|
||||
if (!r || r.isDirectory || !r.blobId) return null;
|
||||
return {
|
||||
blobId: r.blobId,
|
||||
name: r.name,
|
||||
type: r.contentType || "application/octet-stream",
|
||||
size: r.contentLength,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{ blobId: string; name: string; type: string; size: number }>;
|
||||
|
||||
if (fileAtts.length === 0) return;
|
||||
|
||||
useProTabStore.getState().openComposeTab({
|
||||
sessionId: Date.now(),
|
||||
mode: "compose",
|
||||
replyTo: { attachments: fileAtts },
|
||||
title: fileAtts.length === 1 ? fileAtts[0].name : `${fileAtts.length} attachments`,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Pro shell only: all connected accounts are equal top-level entries at
|
||||
// the root. The root path "/" itself is a cross-account picker - no
|
||||
// account's files are shown until the user enters one.
|
||||
@@ -554,6 +580,7 @@ export default function FilesPage() {
|
||||
ownAccountId={filesAccountId}
|
||||
sharingEnabled={sharingEnabled}
|
||||
onShare={handleShare}
|
||||
onSendAsAttachment={handleSendAsAttachment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -134,12 +134,20 @@ export default function LoginPage() {
|
||||
const isMobileHandoff = Boolean(mobileRedirectUri);
|
||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginLogoLightUrlIsCustom, loginLogoDarkUrlIsCustom, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const { activeThemeId, installedThemes } = useThemeStore(useShallow((s) => ({ activeThemeId: s.activeThemeId, installedThemes: s.installedThemes })));
|
||||
// Active theme may carry its own brand logo (VNClagoon wordmark, SRC mark);
|
||||
// fall back to the globally configured login logo.
|
||||
const effLoginLogo = resolveThemeLogo(installedThemes, activeThemeId, resolvedTheme === 'dark', loginLogoLightUrl, loginLogoDarkUrl);
|
||||
// an explicitly-configured logo (Branding tab / LOGIN_LOGO_*_URL) wins
|
||||
// over that, falling back to the theme's logo only when nothing was set.
|
||||
const effLoginLogo = resolveThemeLogo(
|
||||
installedThemes,
|
||||
activeThemeId,
|
||||
resolvedTheme === 'dark',
|
||||
loginLogoLightUrl,
|
||||
loginLogoDarkUrl,
|
||||
loginLogoLightUrlIsCustom || loginLogoDarkUrlIsCustom,
|
||||
);
|
||||
|
||||
// Login logo sizing: when a max height/width is configured, drop the fixed
|
||||
// 64×64 box so the logo (e.g. a wide wordmark) can render at its true size.
|
||||
|
||||
@@ -32,6 +32,7 @@ import { usePromptDialog } from "@/hooks/use-prompt-dialog";
|
||||
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { playNotificationSound } from "@/lib/notification-sound";
|
||||
import { isElectronShell, showElectronNotification } from "@/lib/electron-bridge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
||||
@@ -66,6 +67,7 @@ import { findDraftIdentityId, resolveReplyFrom, type ReplyFromResolution } from
|
||||
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
|
||||
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
||||
import { AiAskButton } from "@/components/ai/ai-ask-button";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
@@ -1062,7 +1064,67 @@ export default function Home() {
|
||||
debug.log('push', `[Push] Push notifications enabled for ${cleanups.length} account(s)`);
|
||||
}
|
||||
|
||||
// CATCH-UP for the desktop shell's local search index. The index's normal
|
||||
// trigger is a push StateChange (stores/email-store.ts's handleStateChange),
|
||||
// but nothing was pushed while the app was closed - and the polling
|
||||
// transport has no signal for contacts or files at all (client.ts's
|
||||
// buildStatePollingRequest covers Mailbox/Email/Calendar/CalendarEvent/
|
||||
// SieveScript only). So backfill a bounded recent window once per session,
|
||||
// after push is wired. Fire-and-forget; a no-op outside Electron.
|
||||
//
|
||||
// RETRIED, not one-shot. The first attempt races the login flow's own
|
||||
// POST /api/auth/stalwart-context (stores/auth-store.ts's
|
||||
// syncStalwartAuthContext) - if the index route is hit before that cookie
|
||||
// is minted it 401s, and a single silent attempt would leave the index
|
||||
// empty until the next app restart with nothing telling anyone why (this
|
||||
// exact silence hid the packaged app's missing-SESSION_SECRET bug against
|
||||
// a real mailbox). requestIndex() already distinguishes the permanent
|
||||
// cases (404/503 -> unavailable) from the retryable ones, so retrying is
|
||||
// cheap and self-limiting.
|
||||
const catchUpRetryDelaysMs = [4000, 20000, 60000];
|
||||
let catchUpCancelled = false;
|
||||
let catchUpTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const runCatchUp = async (attempt: number) => {
|
||||
if (catchUpCancelled) return;
|
||||
try {
|
||||
const { catchUpIndex, getRetentionDays } = await import('@/lib/mail-index-client');
|
||||
const result = await catchUpIndex(
|
||||
useAccountStore.getState().getActiveAccount()?.cookieSlot,
|
||||
getRetentionDays(),
|
||||
);
|
||||
if (!result.ok && !result.unavailable && attempt + 1 < catchUpRetryDelaysMs.length) {
|
||||
catchUpTimer = setTimeout(() => void runCatchUp(attempt + 1), catchUpRetryDelaysMs[attempt + 1]);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* the index is optional */
|
||||
}
|
||||
// The offline REPLICA's launch catch-up. Same reasoning as the index's,
|
||||
// plus one of its own: a `/changes` cursor cannot tell us about anything
|
||||
// that happened while the process was dead, so a cycle at launch is what
|
||||
// drains the backlog. One cycle is bounded, so a first sync of a large
|
||||
// mailbox needs several - `chainSync` runs them with a hard cap.
|
||||
//
|
||||
// Sequenced AFTER the index (including its retries) rather than in
|
||||
// parallel: both write the same SQLite file, and although `busy_timeout`
|
||||
// makes concurrent writers safe, there is no reason to spend the
|
||||
// contention during first paint.
|
||||
if (catchUpCancelled) return;
|
||||
try {
|
||||
const { chainSync } = await import('@/lib/offline-replica-client');
|
||||
await chainSync({ slot: useAccountStore.getState().getActiveAccount()?.cookieSlot });
|
||||
} catch {
|
||||
/* the replica is optional */
|
||||
}
|
||||
};
|
||||
// Deliberately after the initial mailbox fetch settles: the catch-up is a
|
||||
// background nicety and must not compete with first paint.
|
||||
catchUpTimer = setTimeout(() => void runCatchUp(0), catchUpRetryDelaysMs[0]);
|
||||
|
||||
return () => {
|
||||
catchUpCancelled = true;
|
||||
clearTimeout(catchUpTimer);
|
||||
cleanups.forEach((fn) => fn());
|
||||
};
|
||||
}, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, handleStateChange, setPushConnected, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts]);
|
||||
@@ -1186,13 +1248,34 @@ export default function Home() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedEmail?.id, isScheduledView]);
|
||||
|
||||
// Handle new email notifications - play sound
|
||||
// Handle new email notifications - play sound, and (in the Electron shell)
|
||||
// fire a native OS notification. This effect is the transport-agnostic
|
||||
// "genuinely new unread mail arrived" signal - stores/email-store.ts's
|
||||
// refreshCurrentMailbox() already filters out sends/moves/drafts and only
|
||||
// sets newEmailNotification for a real new top-of-inbox message, and it
|
||||
// fires identically whether the underlying JMAP StateChange arrived over
|
||||
// the WebSocket push connection (lib/jmap/client.ts's connectWebSocket),
|
||||
// SSE, or the polling fallback - no need to duplicate this per transport.
|
||||
useEffect(() => {
|
||||
if (newEmailNotification) {
|
||||
const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState();
|
||||
if (emailNotificationsEnabled && emailNotificationSound) {
|
||||
playNotificationSound(notificationSoundChoice);
|
||||
}
|
||||
if (emailNotificationsEnabled && isElectronShell()) {
|
||||
// Same fallback text public/sw.js's push handler already uses for
|
||||
// its (also un-translated) system notifications - a native OS
|
||||
// notification body isn't run through next-intl either way, so
|
||||
// matching that existing precedent instead of introducing new
|
||||
// translation keys for a rarely-hit fallback.
|
||||
const sender = newEmailNotification.from?.[0];
|
||||
const senderName = sender?.name || sender?.email || 'New mail';
|
||||
const body = newEmailNotification.subject || newEmailNotification.preview || '(no subject)';
|
||||
void showElectronNotification(senderName, {
|
||||
body,
|
||||
tag: `bulwark-mail:${newEmailNotification.id}`,
|
||||
});
|
||||
}
|
||||
debug.log('email', 'New email received:', newEmailNotification.subject);
|
||||
clearNewEmailNotification();
|
||||
}
|
||||
@@ -3104,6 +3187,7 @@ export default function Home() {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<AiAskButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ import {
|
||||
Bug,
|
||||
SwatchBook,
|
||||
Download,
|
||||
Sparkles,
|
||||
Upload,
|
||||
Share2,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -45,6 +48,7 @@ import { LayoutSettings } from '@/components/settings/layout-settings';
|
||||
import { LanguageSettings } from '@/components/settings/language-settings';
|
||||
import { ReadingSettings } from '@/components/settings/reading-settings';
|
||||
import { ComposingSettings } from '@/components/settings/composing-settings';
|
||||
import { SignatureSettings } from '@/components/settings/signature-settings';
|
||||
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
|
||||
import { AccountSettings } from '@/components/settings/account-settings';
|
||||
import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||
@@ -66,8 +70,11 @@ import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings
|
||||
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||
import { PluginsSettings } from '@/components/settings/plugins-settings';
|
||||
import { AiAssistantSettings } from '@/components/settings/ai-assistant-settings';
|
||||
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||
import { ImportSettings } from '@/components/settings/import-settings';
|
||||
import { SharingSettings } from '@/components/settings/sharing-settings';
|
||||
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
@@ -96,6 +103,7 @@ type Tab =
|
||||
| 'composing'
|
||||
| 'downloads'
|
||||
| 'identities'
|
||||
| 'signatures'
|
||||
| 'vacation'
|
||||
| 'filters'
|
||||
| 'templates'
|
||||
@@ -111,6 +119,9 @@ type Tab =
|
||||
| 'about_data'
|
||||
| 'themes'
|
||||
| 'plugins'
|
||||
| 'import'
|
||||
| 'sharing'
|
||||
| 'ai_assistant'
|
||||
| 'debug';
|
||||
|
||||
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||
@@ -138,6 +149,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
composing: PenLine,
|
||||
downloads: Download,
|
||||
identities: UserPen,
|
||||
signatures: PenLine,
|
||||
vacation: PalmtreeIcon,
|
||||
filters: Filter,
|
||||
templates: FileText,
|
||||
@@ -153,6 +165,9 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
about_data: Info,
|
||||
themes: SwatchBook,
|
||||
plugins: Puzzle,
|
||||
import: Upload,
|
||||
sharing: Share2,
|
||||
ai_assistant: Sparkles,
|
||||
debug: Bug,
|
||||
};
|
||||
|
||||
@@ -215,6 +230,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
],
|
||||
downloads: ['settings.downloads'],
|
||||
identities: ['settings.identities'],
|
||||
signatures: ['signatures'],
|
||||
vacation: ['settings.vacation'],
|
||||
filters: ['settings.filters'],
|
||||
templates: ['settings.templates'],
|
||||
@@ -234,6 +250,9 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
about_data: ['settings.advanced'],
|
||||
themes: [],
|
||||
plugins: [],
|
||||
ai_assistant: [],
|
||||
import: ['settings.importer'],
|
||||
sharing: ['sharing'],
|
||||
debug: ['settings.advanced'],
|
||||
};
|
||||
|
||||
@@ -249,6 +268,7 @@ const tabKeywords: Record<Tab, string> = {
|
||||
composing: 'editor signature plain text reply forward draft compose',
|
||||
downloads: 'download filename template eml attachment save export',
|
||||
identities: 'from address signature email',
|
||||
signatures: 'signature rich text html editor',
|
||||
vacation: 'auto reply away out of office holiday responder',
|
||||
filters: 'sieve rules block junk forward',
|
||||
templates: 'snippet quick reply',
|
||||
@@ -264,6 +284,9 @@ const tabKeywords: Record<Tab, string> = {
|
||||
about_data: 'export import storage quota privacy backup',
|
||||
themes: 'custom theme css skin appearance',
|
||||
plugins: 'extensions addons',
|
||||
ai_assistant: 'assistant ask model llm ollama chatbot',
|
||||
import: 'import email eml zip tgz mbox csv vcard contacts',
|
||||
sharing: 'share shared folder calendar address book permission',
|
||||
debug: 'logs developer console diagnostic',
|
||||
};
|
||||
|
||||
@@ -613,6 +636,7 @@ export default function SettingsPage() {
|
||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
|
||||
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
|
||||
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
||||
{ id: 'sharing', label: t('tabs.sharing'), icon: tabIcons.sharing, group: 'general' },
|
||||
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
|
||||
|
||||
// Appearance
|
||||
@@ -625,10 +649,12 @@ export default function SettingsPage() {
|
||||
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
||||
{ id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' },
|
||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
|
||||
{ id: 'signatures', label: t('tabs.signatures'), icon: tabIcons.signatures, group: 'mail' },
|
||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
|
||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
|
||||
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
|
||||
{ id: 'import', label: t('tabs.import'), icon: tabIcons.import, group: 'mail' },
|
||||
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
|
||||
|
||||
// Privacy & Security
|
||||
@@ -652,6 +678,7 @@ export default function SettingsPage() {
|
||||
// Advanced
|
||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
||||
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('aiAssistantEnabled') ? [{ id: 'ai_assistant' as Tab, label: 'AI Assistant', icon: tabIcons.ai_assistant, group: 'advanced' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
||||
];
|
||||
|
||||
@@ -754,10 +781,13 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
|
||||
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||
{effectiveActiveTab === 'signatures' && <SignatureSettings />}
|
||||
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||
{effectiveActiveTab === 'templates' && <TemplateSettings />}
|
||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||
{effectiveActiveTab === 'import' && <ImportSettings />}
|
||||
{effectiveActiveTab === 'sharing' && <SharingSettings />}
|
||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||
@@ -777,6 +807,7 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
|
||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||
{effectiveActiveTab === 'ai_assistant' && <AiAssistantSettings />}
|
||||
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||
{effectiveActiveTab.startsWith('plugin:') && (
|
||||
<PluginIframeSlot
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Loader2, X, ArrowRight } from 'lucide-react';
|
||||
import type { AiConsoleConfig, AiClass } from '@/lib/ai/types';
|
||||
import { DEFAULT_AI_CONSOLE_CONFIG } from '@/lib/ai/types';
|
||||
import type { AiEntitlementState, MeteringEntry } from '@/lib/ai/entitlement';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { useAdminTabStore } from '@/stores/admin-tab-store';
|
||||
|
||||
type EntitlementResponse = AiEntitlementState & { recentUsage: MeteringEntry[] };
|
||||
|
||||
const CLASS_INFO: Record<AiClass, { name: string; desc: string }> = {
|
||||
local: { name: 'Local', desc: "Ollama on the user's own machine. Free, unmetered, never reaches this server." },
|
||||
server: { name: 'Server', desc: 'VNC-hosted. Entitlement-enforced, seat + usage tracked below.' },
|
||||
opencode: { name: 'OpenCode', desc: 'A locally-running OpenCode agent server. Holds its own provider credentials; nothing metered here.' },
|
||||
public: { name: 'Public (BYOK)', desc: "User's own API key, direct from their browser to the provider." },
|
||||
};
|
||||
|
||||
function AllowlistEditor({
|
||||
values, onChange, placeholder,
|
||||
}: { values: string[] | null; onChange: (next: string[] | null) => void; placeholder: string }) {
|
||||
const [draft, setDraft] = useState('');
|
||||
const restricted = values !== null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-3.5 px-4 pt-2.5 pb-0.5 text-xs">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-muted-foreground">
|
||||
<input type="radio" checked={!restricted} onChange={() => onChange(null)} />
|
||||
Unrestricted (current)
|
||||
</label>
|
||||
<label className={`flex items-center gap-1.5 cursor-pointer ${restricted ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>
|
||||
<input type="radio" checked={restricted} onChange={() => onChange(values ?? [])} />
|
||||
Restrict to selected
|
||||
</label>
|
||||
</div>
|
||||
{restricted && (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1.5 px-4 pt-2.5">
|
||||
{(values ?? []).map((v) => (
|
||||
<span key={v} className="inline-flex items-center gap-1.5 bg-muted border border-border rounded-full py-1 pl-3 pr-1.5 text-xs">
|
||||
{v}
|
||||
<button onClick={() => onChange((values ?? []).filter((x) => x !== v))} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 px-4 py-3">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 h-8 rounded border border-input bg-background px-2.5 text-xs"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && draft.trim()) {
|
||||
onChange([...(values ?? []), draft.trim()]);
|
||||
setDraft('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => { if (draft.trim()) { onChange([...(values ?? []), draft.trim()]); setDraft(''); } }}
|
||||
className="h-8 px-3 rounded border border-border bg-muted text-xs font-medium hover:bg-muted/70"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiPolicyTab() {
|
||||
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
|
||||
const [config, setConfig] = useState<AiConsoleConfig>({ ...DEFAULT_AI_CONSOLE_CONFIG });
|
||||
const [entitlement, setEntitlement] = useState<EntitlementResponse | null>(null);
|
||||
const [serverModels, setServerModels] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [policyRes, entitlementRes, modelsRes] = await Promise.all([
|
||||
apiFetch('/api/admin/ai/policy'),
|
||||
apiFetch('/api/admin/ai/entitlement'),
|
||||
apiFetch('/api/ai/server/models').catch(() => null),
|
||||
]);
|
||||
if (policyRes.ok) setConfig(await policyRes.json());
|
||||
if (entitlementRes.ok) setEntitlement(await entitlementRes.json());
|
||||
if (modelsRes?.ok) {
|
||||
const data = await modelsRes.json();
|
||||
setServerModels(data.models ?? []);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function update(patch: Partial<AiConsoleConfig>) {
|
||||
setConfig((prev) => ({ ...prev, ...patch }));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleClass(cls: AiClass) {
|
||||
const current = config.classesEnabled[cls] !== false;
|
||||
update({ classesEnabled: { ...config.classesEnabled, [cls]: !current } });
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
const res = await apiFetch('/api/admin/ai/policy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
if (res.ok) {
|
||||
setConfig(await res.json());
|
||||
setDirty(false);
|
||||
setMessage({ type: 'success', text: 'Saved.' });
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function setSeatTotal(total: number) {
|
||||
const res = await apiFetch('/api/admin/ai/entitlement', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seatsTotal: total }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEntitlement((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSeat(username: string) {
|
||||
const res = await apiFetch('/api/admin/ai/entitlement', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ revokeUsername: username }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEntitlement((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
const serverInfraAvailable = serverModels.length > 0 || entitlement !== null;
|
||||
const usageToday = (entitlement?.recentUsage ?? []).filter((u) => u.timestamp.slice(0, 10) === new Date().toISOString().slice(0, 10));
|
||||
const tokensToday = usageToday.reduce((sum, u) => sum + u.promptTokens + u.completionTokens, 0);
|
||||
const avgLatency = usageToday.length ? Math.round(usageToday.reduce((sum, u) => sum + u.latencyMs, 0) / usageToday.length) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">AI</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Provider classes, allow-lists, seats, usage, and BYOK consent for the AI Assistant.</p>
|
||||
</div>
|
||||
{dirty && (
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm">
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={() => setActiveTab('policy')}
|
||||
className="w-full flex items-center gap-2 text-xs text-muted-foreground bg-muted border border-border rounded-md px-3.5 py-2.5 hover:bg-muted/70 transition-colors text-left">
|
||||
<span>The master AI Assistant on/off switch lives in</span>
|
||||
<span className="text-primary font-medium inline-flex items-center gap-1">Policy → Feature Gates <ArrowRight className="w-3 h-3" /></span>
|
||||
</button>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Provider classes</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Which of the three AI classes users can reach at all.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 p-4">
|
||||
{(['local', 'server', 'opencode', 'public'] as AiClass[]).map((cls) => {
|
||||
const enabled = config.classesEnabled[cls] !== false;
|
||||
const disabledByInfra = cls === 'server' && !serverInfraAvailable;
|
||||
return (
|
||||
<div key={cls} className={`border border-border rounded-md p-3.5 ${disabledByInfra ? 'opacity-55' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-sm font-semibold">{CLASS_INFO[cls].name}</span>
|
||||
<button
|
||||
onClick={() => !disabledByInfra && toggleClass(cls)}
|
||||
disabled={disabledByInfra}
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled && !disabledByInfra ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'} ${disabledByInfra ? 'cursor-not-allowed' : ''}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled && !disabledByInfra ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{CLASS_INFO[cls].desc}</p>
|
||||
{disabledByInfra && <p className="text-xs text-amber-600 dark:text-amber-400 mt-1.5">Not configured (AI_SERVER_BASE_URL unset)</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Server — model allow-list</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Restrict which Ollama models users may select for the Server class. Also enforced on every chat call, not just the picker.</p>
|
||||
</div>
|
||||
<AllowlistEditor
|
||||
values={config.serverModelAllowlist}
|
||||
onChange={(v) => update({ serverModelAllowlist: v })}
|
||||
placeholder={serverModels.length ? `e.g. ${serverModels[0]}` : 'e.g. qwen2.5:32b'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Public (BYOK) — provider allow-list</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Restrict which base URLs users may point a bring-your-own-key profile at. Checked client-side at save time — advisory, not a network boundary.</p>
|
||||
</div>
|
||||
<AllowlistEditor
|
||||
values={config.publicProviderAllowlist}
|
||||
onChange={(v) => update({ publicProviderAllowlist: v })}
|
||||
placeholder="e.g. https://api.openai.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Entitlement & seats</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Server class only. First successful use auto-assigns a seat.</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center gap-3 border-b border-border">
|
||||
<span className="text-sm flex-1">Seats licensed</span>
|
||||
<input
|
||||
type="number" min={0}
|
||||
value={entitlement?.seatsTotal ?? 0}
|
||||
onChange={(e) => setSeatTotal(Math.max(0, Number.parseInt(e.target.value, 10) || 0))}
|
||||
className="w-20 h-8 rounded border border-input bg-background px-2 text-sm text-center"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{entitlement?.assignedTo.length ?? 0} of {entitlement?.seatsTotal ?? 0} assigned</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{(entitlement?.assignedTo ?? []).length === 0 && (
|
||||
<div className="px-4 py-3 text-xs text-muted-foreground">No seats assigned yet.</div>
|
||||
)}
|
||||
{(entitlement?.assignedTo ?? []).map((username) => (
|
||||
<div key={username} className="px-4 py-2.5 flex items-center justify-between gap-3">
|
||||
<span className="text-sm">{username}</span>
|
||||
<button onClick={() => revokeSeat(username)}
|
||||
className="text-xs font-medium text-destructive border border-border rounded px-2.5 py-1 hover:bg-destructive/10">
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Usage</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Last 200 metered calls. Read-only.</p>
|
||||
</div>
|
||||
<div className="flex gap-6 px-4 py-3 border-b border-border flex-wrap">
|
||||
<div><span className="text-lg font-semibold tabular-nums block">{usageToday.length}</span><span className="text-[11px] uppercase tracking-wide text-muted-foreground">Calls today</span></div>
|
||||
<div><span className="text-lg font-semibold tabular-nums block">{tokensToday.toLocaleString()}</span><span className="text-[11px] uppercase tracking-wide text-muted-foreground">Tokens today</span></div>
|
||||
<div><span className="text-lg font-semibold tabular-nums block">{avgLatency}ms</span><span className="text-[11px] uppercase tracking-wide text-muted-foreground">Avg latency</span></div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground uppercase text-[10px] tracking-wide">
|
||||
<th className="text-left px-4 py-2 font-medium">Time</th>
|
||||
<th className="text-left px-4 py-2 font-medium">User</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Model</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Prompt tok</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Compl. tok</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Latency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{(entitlement?.recentUsage ?? []).length === 0 && (
|
||||
<tr><td colSpan={6} className="px-4 py-3 text-muted-foreground">No usage recorded yet.</td></tr>
|
||||
)}
|
||||
{[...(entitlement?.recentUsage ?? [])].reverse().slice(0, 50).map((u, i) => (
|
||||
<tr key={i} className="tabular-nums">
|
||||
<td className="px-4 py-2">{new Date(u.timestamp).toLocaleTimeString()}</td>
|
||||
<td className="px-4 py-2">{u.username}</td>
|
||||
<td className="px-4 py-2">{u.model}</td>
|
||||
<td className="px-4 py-2">{u.promptTokens}</td>
|
||||
<td className="px-4 py-2">{u.completionTokens}</td>
|
||||
<td className="px-4 py-2">{u.latencyMs}ms</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Retrieval & consent</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Mail-content augmentation and the BYOK consent prompt.</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4 border-b border-border">
|
||||
<div>
|
||||
<div className="text-sm">Retrieval leg</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Send recent mail content to the Server class's embedding model to answer questions grounded in the user's own mail.</p>
|
||||
</div>
|
||||
<button onClick={() => update({ retrievalEnabled: !config.retrievalEnabled })}
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${config.retrievalEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${config.retrievalEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 py-3.5 space-y-2">
|
||||
<label className="text-sm block">Consent text (shown once per version, before first BYOK/Public use)</label>
|
||||
<textarea
|
||||
value={config.consent?.text ?? ''}
|
||||
onChange={(e) => update({ consent: { version: config.consent?.version ?? '1', text: e.target.value } })}
|
||||
className="w-full min-h-20 rounded border border-input bg-background px-2.5 py-2 text-xs"
|
||||
placeholder="Using a bring-your-own-key provider sends your question — and, if retrieval is on, related excerpts from your mail — to that provider's servers, outside this organisation. Continue?"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center gap-2.5 flex-wrap">
|
||||
<span className="text-sm">Version</span>
|
||||
<input
|
||||
value={config.consent?.version ?? ''}
|
||||
onChange={(e) => update({ consent: { version: e.target.value, text: config.consent?.text ?? '' } })}
|
||||
className="w-20 h-8 rounded border border-input bg-background px-2 text-xs text-center"
|
||||
/>
|
||||
<button
|
||||
onClick={() => update({ consent: { version: String(Number.parseInt(config.consent?.version || '0', 10) + 1), text: config.consent?.text ?? '' } })}
|
||||
className="h-8 px-3 rounded border border-border bg-muted text-xs font-medium hover:bg-muted/70">
|
||||
Bump version (re-prompt everyone)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
|
||||
aiAssistantEnabled: { label: 'AI Assistant (preview)', description: 'Show the AI Assistant settings tab. Local (Ollama on the user\'s own machine or this desktop app) is free and unmetered; public (bring-your-own-key) is available too but not yet monitored or metered — see docs/AI-ASSISTANT-CONCEPT.md.' },
|
||||
};
|
||||
|
||||
const RESTRICTABLE_SETTINGS = [
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Loader2, Plus, X } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface VncDirectoryFormData {
|
||||
enabled: boolean;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
samlEnabled: boolean;
|
||||
samlIdpUrl: string;
|
||||
samlSpCert: string;
|
||||
samlIssuer: string;
|
||||
ldapEnabled: boolean;
|
||||
ldapUri: string;
|
||||
ldapBindDn: string;
|
||||
ldapBindPassword: string;
|
||||
ldapSearchBase: string;
|
||||
ldapType: 'openldap' | 'ms-ad';
|
||||
tfaEnabled: boolean;
|
||||
oidcEnabled: boolean;
|
||||
oidcClientId: string;
|
||||
oidcDiscoveryUrl: string;
|
||||
sessionTtl: number;
|
||||
federatedApps: Record<string, string>;
|
||||
}
|
||||
|
||||
const BLANK_FORM: VncDirectoryFormData = {
|
||||
enabled: false,
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
samlEnabled: false,
|
||||
samlIdpUrl: '',
|
||||
samlSpCert: '',
|
||||
samlIssuer: '',
|
||||
ldapEnabled: false,
|
||||
ldapUri: '',
|
||||
ldapBindDn: '',
|
||||
ldapBindPassword: '',
|
||||
ldapSearchBase: '',
|
||||
ldapType: 'openldap',
|
||||
tfaEnabled: false,
|
||||
oidcEnabled: false,
|
||||
oidcClientId: '',
|
||||
oidcDiscoveryUrl: '',
|
||||
sessionTtl: 28800,
|
||||
federatedApps: {},
|
||||
};
|
||||
|
||||
export function VncDirectoryTab() {
|
||||
const [config, setConfig] = useState<VncDirectoryFormData>({ ...BLANK_FORM });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => { fetchConfig(); }, []);
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/vncdirectory');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setConfig(data);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateField<K extends keyof VncDirectoryFormData>(key: K, value: VncDirectoryFormData[K]) {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleBool(key: keyof VncDirectoryFormData) {
|
||||
setConfig((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function setFederatedApp(name: string, url: string) {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
federatedApps: { ...prev.federatedApps, [name]: url },
|
||||
}));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function removeFederatedApp(name: string) {
|
||||
setConfig((prev) => {
|
||||
const next = { ...prev.federatedApps };
|
||||
delete next[name];
|
||||
return { ...prev, federatedApps: next };
|
||||
});
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await apiFetch('/api/admin/vncdirectory', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'VNCdirectory configuration saved.' });
|
||||
setDirty(false);
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const federatedAppsList = Object.entries(config.federatedApps);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">VNCdirectory</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Centralized identity and directory integration (SAML, LDAP, 2FA)
|
||||
</p>
|
||||
</div>
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save configuration
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`text-sm rounded-md px-3 py-2 ${
|
||||
message.type === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Section title="Enable VNCdirectory Integration">
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Enabled</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Turn on VNCdirectory integration for identity management, SSO, and directory services
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleBool('enabled')}
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
config.enabled
|
||||
? 'bg-primary'
|
||||
: 'bg-muted-foreground/25 dark:bg-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${
|
||||
config.enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
<Section title="Connection">
|
||||
<div className="divide-y divide-border">
|
||||
<TextRow
|
||||
label="VNCdirectory URL"
|
||||
value={config.apiUrl}
|
||||
onChange={(v) => updateField('apiUrl', v)}
|
||||
placeholder="https://vncdirectory.example.com"
|
||||
/>
|
||||
<PasswordRow
|
||||
label="API Key"
|
||||
value={config.apiKey}
|
||||
onChange={(v) => updateField('apiKey', v)}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="SAML / Identity Provider">
|
||||
<div className="divide-y divide-border">
|
||||
<ToggleRow
|
||||
label="SAML Enabled"
|
||||
description="Enable SAML single sign-on via VNCdirectory"
|
||||
value={config.samlEnabled}
|
||||
onChange={() => toggleBool('samlEnabled')}
|
||||
/>
|
||||
{config.samlEnabled && (
|
||||
<>
|
||||
<TextRow
|
||||
label="Identity Provider URL"
|
||||
value={config.samlIdpUrl}
|
||||
onChange={(v) => updateField('samlIdpUrl', v)}
|
||||
placeholder="https://idp.example.com/saml2/idp"
|
||||
/>
|
||||
<TextRow
|
||||
label="Issuer Name (Entity ID)"
|
||||
value={config.samlIssuer}
|
||||
onChange={(v) => updateField('samlIssuer', v)}
|
||||
placeholder="urn:example:vncmail"
|
||||
/>
|
||||
<div className="px-4 py-3 flex flex-col gap-2">
|
||||
<label className="text-sm text-foreground">
|
||||
Service Provider Certificate (X.509)
|
||||
</label>
|
||||
<textarea
|
||||
value={config.samlSpCert}
|
||||
onChange={(e) => updateField('samlSpCert', e.target.value)}
|
||||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||
rows={4}
|
||||
className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-vertical"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="LDAP Directory">
|
||||
<div className="divide-y divide-border">
|
||||
<ToggleRow
|
||||
label="LDAP Enabled"
|
||||
description="Query user directory via LDAP for contact lookups and authentication"
|
||||
value={config.ldapEnabled}
|
||||
onChange={() => toggleBool('ldapEnabled')}
|
||||
/>
|
||||
{config.ldapEnabled && (
|
||||
<>
|
||||
<TextRow
|
||||
label="LDAP Server URI"
|
||||
value={config.ldapUri}
|
||||
onChange={(v) => updateField('ldapUri', v)}
|
||||
placeholder="ldaps://ldap.example.com:636"
|
||||
/>
|
||||
<TextRow
|
||||
label="Bind DN"
|
||||
value={config.ldapBindDn}
|
||||
onChange={(v) => updateField('ldapBindDn', v)}
|
||||
placeholder="cn=readonly,dc=example,dc=com"
|
||||
/>
|
||||
<PasswordRow
|
||||
label="Bind Password"
|
||||
value={config.ldapBindPassword}
|
||||
onChange={(v) => updateField('ldapBindPassword', v)}
|
||||
placeholder="Enter LDAP bind password"
|
||||
/>
|
||||
<TextRow
|
||||
label="Search Base"
|
||||
value={config.ldapSearchBase}
|
||||
onChange={(v) => updateField('ldapSearchBase', v)}
|
||||
placeholder="ou=users,dc=example,dc=com"
|
||||
/>
|
||||
<SelectRow
|
||||
label="LDAP Type"
|
||||
value={config.ldapType}
|
||||
options={[
|
||||
{ value: 'openldap', label: 'OpenLDAP' },
|
||||
{ value: 'ms-ad', label: 'Microsoft Active Directory' },
|
||||
]}
|
||||
onChange={(v) => updateField('ldapType', v as 'openldap' | 'ms-ad')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Authentication">
|
||||
<div className="divide-y divide-border">
|
||||
<ToggleRow
|
||||
label="Enforce 2FA/TOTP"
|
||||
description="Require two-factor authentication for all users"
|
||||
value={config.tfaEnabled}
|
||||
onChange={() => toggleBool('tfaEnabled')}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="OpenID Connect (OIDC)"
|
||||
description="Enable OIDC login alongside or instead of SAML"
|
||||
value={config.oidcEnabled}
|
||||
onChange={() => toggleBool('oidcEnabled')}
|
||||
/>
|
||||
{config.oidcEnabled && (
|
||||
<>
|
||||
<TextRow
|
||||
label="OIDC Client ID"
|
||||
value={config.oidcClientId}
|
||||
onChange={(v) => updateField('oidcClientId', v)}
|
||||
placeholder="vncmail-client"
|
||||
/>
|
||||
<TextRow
|
||||
label="OIDC Discovery URL"
|
||||
value={config.oidcDiscoveryUrl}
|
||||
onChange={(v) => updateField('oidcDiscoveryUrl', v)}
|
||||
placeholder="https://idp.example.com/.well-known/openid-configuration"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Session TTL (seconds)</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
How long SSO sessions remain valid. Default: 8 hours (28800)
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={config.sessionTtl}
|
||||
onChange={(e) => updateField('sessionTtl', Number(e.target.value))}
|
||||
className="h-8 w-full sm:w-32 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Federated Applications">
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Configure SSO redirect URLs for other VNC applications. Users signed into one
|
||||
app will be transparently authenticated when navigating to another.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{federatedAppsList.map(([appName, url]) => (
|
||||
<div
|
||||
key={appName}
|
||||
className="flex flex-col sm:flex-row items-start sm:items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={appName}
|
||||
readOnly
|
||||
className="h-8 w-full sm:w-36 rounded-md border border-input bg-muted/50 px-2.5 text-sm text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setFederatedApp(appName, e.target.value)}
|
||||
placeholder="https://vnc.example.com/auth/sso"
|
||||
className="h-8 w-full sm:flex-1 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeFederatedApp(appName)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title={`Remove ${appName}`}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<AddFederatedApp
|
||||
existingKeys={new Set(Object.keys(config.federatedApps))}
|
||||
onAdd={(name, url) => setFederatedApp(name, url)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddFederatedApp({
|
||||
existingKeys,
|
||||
onAdd,
|
||||
}: {
|
||||
existingKeys: Set<string>;
|
||||
onAdd: (name: string, url: string) => void;
|
||||
}) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!adding) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(true)}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add federated app
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
setError('Enter an application name');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||
setError('Name must contain only letters, numbers, hyphens, and underscores');
|
||||
return;
|
||||
}
|
||||
if (existingKeys.has(trimmed)) {
|
||||
setError('An app with this name already exists');
|
||||
return;
|
||||
}
|
||||
if (!url.trim()) {
|
||||
setError('Enter an SSO URL');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onAdd(trimmed, url.trim());
|
||||
setName('');
|
||||
setUrl('');
|
||||
setAdding(false);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setAdding(false);
|
||||
setName('');
|
||||
setUrl('');
|
||||
setError(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
|
||||
placeholder="App name (e.g. vnctalk)"
|
||||
className="h-8 w-full sm:w-36 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => { setUrl(e.target.value); setError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
|
||||
placeholder="https://vnctalk.example.com/auth/sso"
|
||||
className="h-8 w-full sm:flex-1 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="inline-flex items-center h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-8 w-full sm:w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const isMasked = value === '••••••';
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type={isMasked ? 'text' : 'password'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder || (isMasked ? 'Saved - type to replace' : undefined)}
|
||||
className="h-8 w-full sm:w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: boolean;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onChange}
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${
|
||||
value ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectRow({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
options: { value: string; label: string }[];
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
Scale,
|
||||
ScrollText,
|
||||
LogOut,
|
||||
Key,
|
||||
KeyRound,
|
||||
Bot,
|
||||
Puzzle,
|
||||
SwatchBook,
|
||||
Activity,
|
||||
@@ -54,7 +56,9 @@ const NAV_GROUPS: ReadonlyArray<{
|
||||
{ tab: 'settings', label: 'Settings', icon: Settings },
|
||||
{ tab: 'branding', label: 'Branding', icon: Palette },
|
||||
{ tab: 'auth', label: 'Authentication', icon: Shield },
|
||||
{ tab: 'vncdirectory', label: 'VNCdirectory', icon: Key },
|
||||
{ tab: 'policy', label: 'Policy', icon: Scale },
|
||||
{ tab: 'ai-policy', label: 'AI', icon: Bot },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,12 +7,14 @@ import { SettingsTab } from './_tabs/settings';
|
||||
import { BrandingTab } from './_tabs/branding';
|
||||
import { AuthTab } from './_tabs/auth';
|
||||
import { PolicyTab } from './_tabs/policy';
|
||||
import { AiPolicyTab } from './_tabs/ai-policy';
|
||||
import { PluginsTab } from './_tabs/plugins';
|
||||
import { ThemesTab } from './_tabs/themes';
|
||||
import { MarketplaceTab } from './_tabs/marketplace';
|
||||
import { VersionTab } from './_tabs/version';
|
||||
import { TelemetryTab } from './_tabs/telemetry';
|
||||
import { LogsTab } from './_tabs/logs';
|
||||
import { VncDirectoryTab } from './_tabs/vncdirectory';
|
||||
|
||||
export default function AdminPage() {
|
||||
const activeTab = useAdminTabStore((s) => s.activeTab);
|
||||
@@ -39,11 +41,13 @@ export default function AdminPage() {
|
||||
case 'branding': return <BrandingTab />;
|
||||
case 'auth': return <AuthTab />;
|
||||
case 'policy': return <PolicyTab />;
|
||||
case 'ai-policy': return <AiPolicyTab />;
|
||||
case 'plugins': return <PluginsTab />;
|
||||
case 'themes': return <ThemesTab />;
|
||||
case 'marketplace': return <MarketplaceTab />;
|
||||
case 'version': return <VersionTab />;
|
||||
case 'telemetry': return <TelemetryTab />;
|
||||
case 'logs': return <LogsTab />;
|
||||
case 'vncdirectory': return <VncDirectoryTab />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=vncdirectory');
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getEntitlementState, setSeatTotal, revokeSeat, readMeteringLedger } from '@/lib/ai/entitlement';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Admin-only data endpoints for the `server` AI class's real entitlement
|
||||
* enforcement (lib/ai/entitlement.ts). This is the data plumbing only — the
|
||||
* visual admin console (docs/AI-ASSISTANT-CONCEPT.md §6) is a separate,
|
||||
* not-yet-built UI on top of these same endpoints.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
try {
|
||||
const [state, ledger] = await Promise.all([getEntitlementState(), readMeteringLedger()]);
|
||||
return NextResponse.json({ ...state, recentUsage: ledger }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (error) {
|
||||
logger.error('ai entitlement read error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
let body: { seatsTotal?: unknown; revokeUsername?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof body.seatsTotal === 'number') {
|
||||
const state = await setSeatTotal(body.seatsTotal);
|
||||
await auditLog('ai.entitlement.seats_total', { seatsTotal: state.seatsTotal }, ip);
|
||||
return NextResponse.json(state);
|
||||
}
|
||||
if (typeof body.revokeUsername === 'string' && body.revokeUsername) {
|
||||
const state = await revokeSeat(body.revokeUsername);
|
||||
await auditLog('ai.entitlement.revoke_seat', { username: body.revokeUsername }, ip);
|
||||
return NextResponse.json(state);
|
||||
}
|
||||
return NextResponse.json({ error: 'seatsTotal or revokeUsername is required' }, { status: 400 });
|
||||
} catch (error) {
|
||||
logger.error('ai entitlement update error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { AiConsoleConfig, AiClass } from '@/lib/ai/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const VALID_CLASSES: AiClass[] = ['local', 'server', 'public'];
|
||||
|
||||
/**
|
||||
* GET/PUT /api/admin/ai/policy - the admin console's writable config
|
||||
* (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6): per-class enable, model/
|
||||
* provider allow-lists, retrieval on/off, BYOK consent text. Separate from
|
||||
* /api/admin/ai/entitlement (seats/ledger - runtime state) and from the
|
||||
* generic /api/admin/policy (FeatureGates - the master aiAssistantEnabled
|
||||
* toggle stays there, this console only links to it, per spec §6 open
|
||||
* question 3).
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
try {
|
||||
await configManager.ensureLoaded();
|
||||
return NextResponse.json(configManager.getAiConsoleConfig(), { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (error) {
|
||||
logger.error('ai console policy read error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function validate(body: Partial<AiConsoleConfig>): string | null {
|
||||
if (body.classesEnabled !== undefined) {
|
||||
if (typeof body.classesEnabled !== 'object' || body.classesEnabled === null) return 'classesEnabled must be an object';
|
||||
for (const key of Object.keys(body.classesEnabled)) {
|
||||
if (!VALID_CLASSES.includes(key as AiClass)) return `classesEnabled has an unknown class "${key}"`;
|
||||
}
|
||||
}
|
||||
if (body.serverModelAllowlist !== undefined && body.serverModelAllowlist !== null) {
|
||||
if (!Array.isArray(body.serverModelAllowlist) || !body.serverModelAllowlist.every((m) => typeof m === 'string')) {
|
||||
return 'serverModelAllowlist must be an array of strings or null';
|
||||
}
|
||||
}
|
||||
if (body.publicProviderAllowlist !== undefined && body.publicProviderAllowlist !== null) {
|
||||
if (!Array.isArray(body.publicProviderAllowlist) || !body.publicProviderAllowlist.every((m) => typeof m === 'string')) {
|
||||
return 'publicProviderAllowlist must be an array of strings or null';
|
||||
}
|
||||
}
|
||||
if (body.retrievalEnabled !== undefined && typeof body.retrievalEnabled !== 'boolean') {
|
||||
return 'retrievalEnabled must be a boolean';
|
||||
}
|
||||
if (body.consent !== undefined && body.consent !== null) {
|
||||
if (typeof body.consent !== 'object' || typeof body.consent.version !== 'string' || typeof body.consent.text !== 'string') {
|
||||
return 'consent must be { version: string, text: string } or null';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
let body: Partial<AiConsoleConfig>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const validationError = validate(body);
|
||||
if (validationError) return NextResponse.json({ error: validationError }, { status: 400 });
|
||||
|
||||
try {
|
||||
await configManager.ensureLoaded();
|
||||
const next = await configManager.setAiConsoleConfig(body);
|
||||
await auditLog('ai.console_policy.update', {
|
||||
classesEnabled: next.classesEnabled,
|
||||
retrievalEnabled: next.retrievalEnabled,
|
||||
consentVersion: next.consent?.version ?? null,
|
||||
serverModelAllowlistCount: next.serverModelAllowlist?.length ?? null,
|
||||
publicProviderAllowlistCount: next.publicProviderAllowlist?.length ?? null,
|
||||
}, ip);
|
||||
return NextResponse.json(next);
|
||||
} catch (error) {
|
||||
logger.error('ai console policy update error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,146 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
getVncDirectoryConfig,
|
||||
saveVncDirectoryConfig,
|
||||
DEFAULT_VNCDIRECTORY_CONFIG,
|
||||
VNCDIRECTORY_SENSITIVE_KEYS,
|
||||
type VncDirectoryConfig,
|
||||
} from '@/lib/admin/vncdirectory-config';
|
||||
|
||||
const VALID_LDAP_TYPES = new Set(['openldap', 'ms-ad']);
|
||||
const KNOWN_KEYS = new Set(Object.keys(DEFAULT_VNCDIRECTORY_CONFIG));
|
||||
|
||||
function maskConfigForClient(config: VncDirectoryConfig): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (VNCDIRECTORY_SENSITIVE_KEYS.has(key)) {
|
||||
result[key] = typeof value === 'string' && value.length > 0 ? '••••••' : '';
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const config = await getVncDirectoryConfig();
|
||||
return NextResponse.json(maskConfigForClient(config), {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('VNCdirectory config read error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authResult = await requireAdminAuth(request);
|
||||
if ('error' in authResult) return authResult.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const body = await request.json();
|
||||
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return NextResponse.json({ error: 'Request body must be an object' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate known keys only
|
||||
const unknownKeys = Object.keys(body).filter((k) => !KNOWN_KEYS.has(k));
|
||||
if (unknownKeys.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown config keys: ${unknownKeys.join(', ')}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate boolean fields
|
||||
const boolFields = ['enabled', 'samlEnabled', 'ldapEnabled', 'tfaEnabled', 'oidcEnabled'];
|
||||
for (const key of boolFields) {
|
||||
if (key in body && typeof body[key] !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: `${key} must be a boolean` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate sessionTtl
|
||||
if ('sessionTtl' in body) {
|
||||
const ttl = Number(body.sessionTtl);
|
||||
if (!Number.isFinite(ttl) || ttl < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'sessionTtl must be a non-negative number' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
body.sessionTtl = ttl;
|
||||
}
|
||||
|
||||
// Validate ldapType
|
||||
if ('ldapType' in body && !VALID_LDAP_TYPES.has(body.ldapType)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid ldapType: ${body.ldapType}. Must be 'openldap' or 'ms-ad'.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate federatedApps
|
||||
if ('federatedApps' in body) {
|
||||
if (!body.federatedApps || typeof body.federatedApps !== 'object' || Array.isArray(body.federatedApps)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'federatedApps must be an object mapping app names to URLs' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
for (const [appName, url] of Object.entries(body.federatedApps as Record<string, unknown>)) {
|
||||
if (typeof url !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: `federatedApps.${appName} must be a string URL` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If apiKey or ldapBindPassword are "••••••", preserve existing value
|
||||
const currentConfig = await getVncDirectoryConfig();
|
||||
if (body.apiKey === '••••••') {
|
||||
body.apiKey = currentConfig.apiKey;
|
||||
}
|
||||
if (body.ldapBindPassword === '••••••') {
|
||||
body.ldapBindPassword = currentConfig.ldapBindPassword;
|
||||
}
|
||||
|
||||
const changedKeys = Object.keys(body).filter((k) => {
|
||||
const currentVal = currentConfig[k as keyof VncDirectoryConfig];
|
||||
const newVal = body[k];
|
||||
if (k === 'federatedApps') {
|
||||
return JSON.stringify(currentVal) !== JSON.stringify(newVal);
|
||||
}
|
||||
return String(currentVal ?? '') !== String(newVal ?? '');
|
||||
});
|
||||
|
||||
await saveVncDirectoryConfig(body as Partial<VncDirectoryConfig>);
|
||||
|
||||
if (changedKeys.length > 0) {
|
||||
await auditLog('vncdirectory.update', { changedKeys }, ip);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('VNCdirectory config update error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { findOpencodeServer, parseModelRef, opencodePrompt } from '@/lib/ai/opencode';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_BODY_BYTES = 200 * 1024;
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/opencode/chat — one-shot chat against a locally-running
|
||||
* `opencode serve`.
|
||||
*
|
||||
* Deliberately NOT entitlement-metered, unlike /api/ai/server/chat: this runs
|
||||
* on the user's own machine against provider credentials opencode itself
|
||||
* holds, so there is no centrally-borne cost for this app to bill — the same
|
||||
* reasoning that leaves `local` unmetered (lib/ai/entitlement.ts's header).
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await getStalwartCredentials(request);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) {
|
||||
return NextResponse.json({ error: 'the OpenCode class is disabled by admin policy' }, { status: 403 });
|
||||
}
|
||||
|
||||
const rawBody = await request.text();
|
||||
if (rawBody.length > MAX_BODY_BYTES) {
|
||||
return NextResponse.json({ error: 'request too large' }, { status: 413 });
|
||||
}
|
||||
|
||||
let body: { model?: unknown; messages?: unknown };
|
||||
try {
|
||||
body = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const model = typeof body.model === 'string' ? body.model : '';
|
||||
const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null;
|
||||
if (!model || !messages || messages.length === 0) {
|
||||
return NextResponse.json({ error: 'model and messages are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const found = await findOpencodeServer();
|
||||
if (!found) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No local OpenCode server is running. The desktop app starts one automatically when the opencode CLI is installed \u2014 install it from opencode.ai, then restart VNCmail+.' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!found.models.some((m) => m.ref === model)) {
|
||||
// The picker is populated from this same list, so a mismatch means the
|
||||
// saved model was removed/renamed in opencode since it was chosen -
|
||||
// clearer to say so than to forward it and surface opencode's own error.
|
||||
return NextResponse.json(
|
||||
{ error: `OpenCode no longer offers the model "${model}" \u2014 pick another in Settings.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const parsed = parseModelRef(model);
|
||||
if (!parsed) {
|
||||
return NextResponse.json({ error: `Malformed model reference "${model}"` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Flatten our chat-messages shape onto opencode's (system field + text
|
||||
// parts). Every non-system message is already just the built prompt.
|
||||
const system = messages.filter((m) => m.role === 'system').map((m) => m.content).join('\n\n') || undefined;
|
||||
const userText = messages.filter((m) => m.role !== 'system').map((m) => m.content).join('\n\n');
|
||||
if (!userText.trim()) {
|
||||
return NextResponse.json({ error: 'no user content to send' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await opencodePrompt(found.baseUrl, parsed, system, userText);
|
||||
if (!result.ok) {
|
||||
logger.error('opencode prompt failed', { error: result.error });
|
||||
return NextResponse.json({ error: result.error }, { status: 502 });
|
||||
}
|
||||
return NextResponse.json({ answer: result.answer });
|
||||
} catch (cause) {
|
||||
logger.error('opencode chat failed', { error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return NextResponse.json({ error: 'OpenCode server unreachable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { findOpencodeServer } from '@/lib/ai/opencode';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET /api/ai/opencode/models — models a locally-running `opencode serve`
|
||||
* exposes. Proxied rather than fetched directly by the renderer: the desktop
|
||||
* shell's origin is a random localhost port that changes every launch, so a
|
||||
* direct call would need opencode's CORS allowlist updated each time.
|
||||
*
|
||||
* Listing is not a billable action, so a valid session is enough — no seat
|
||||
* check (matching /api/ai/server/models).
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await getStalwartCredentials(request);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) {
|
||||
return NextResponse.json({ error: 'the OpenCode class is disabled by admin policy' }, { status: 403 });
|
||||
}
|
||||
|
||||
const found = await findOpencodeServer();
|
||||
if (!found) {
|
||||
// 503 not 500: "nothing is listening" is a normal state (opencode simply
|
||||
// isn't running), and the client turns it into setup guidance rather than
|
||||
// an error banner.
|
||||
return NextResponse.json(
|
||||
{ error: 'No local OpenCode server is running. The desktop app starts one automatically when the opencode CLI is installed \u2014 install it from opencode.ai, then restart VNCmail+.' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ models: found.models.map((m) => ({ ref: m.ref, label: m.label })) },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import {
|
||||
findOpencodeServer, listOpencodeProviders, setOpencodeProviderKey, removeOpencodeProvider,
|
||||
} from '@/lib/ai/opencode';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const SETUP_ERROR =
|
||||
'No local OpenCode server is running. The desktop app starts one automatically when the opencode CLI is installed — install it from opencode.ai, then restart VNCmail+.';
|
||||
|
||||
async function requireOpencode(request: NextRequest) {
|
||||
const auth = await getStalwartCredentials(request);
|
||||
if (!auth) return { error: NextResponse.json({ error: 'not authenticated' }, { status: 401 }) } as const;
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) {
|
||||
return { error: NextResponse.json({ error: 'the OpenCode class is disabled by admin policy' }, { status: 403 }) } as const;
|
||||
}
|
||||
|
||||
const found = await findOpencodeServer();
|
||||
if (!found) return { error: NextResponse.json({ error: SETUP_ERROR }, { status: 503 }) } as const;
|
||||
return { baseUrl: found.baseUrl } as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET/PUT/DELETE /api/ai/opencode/providers — lets a user add "any LLM
|
||||
* OpenCode supports" from inside this app, rather than only whatever was
|
||||
* already authenticated via its own CLI. See lib/ai/opencode.ts's module
|
||||
* note on why this only covers API-key providers for now, not OAuth ones.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const result = await requireOpencode(request);
|
||||
if ('error' in result) return result.error;
|
||||
try {
|
||||
const providers = await listOpencodeProviders(result.baseUrl);
|
||||
return NextResponse.json({ providers }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (cause) {
|
||||
logger.error('opencode providers list failed', { error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return NextResponse.json({ error: 'Could not list OpenCode providers' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const result = await requireOpencode(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
let body: { providerID?: unknown; key?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
const providerID = typeof body.providerID === 'string' ? body.providerID.trim() : '';
|
||||
const key = typeof body.key === 'string' ? body.key.trim() : '';
|
||||
if (!providerID || !key) {
|
||||
return NextResponse.json({ error: 'providerID and key are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await setOpencodeProviderKey(result.baseUrl, providerID, key);
|
||||
// VERIFY rather than trust the 200: OpenCode accepts a bare API key for
|
||||
// every provider (confirmed live), but does not consider every provider
|
||||
// "connected" from that alone - Snowflake Cortex, for one real example,
|
||||
// needs SNOWFLAKE_ACCOUNT alongside its token, and a single key field
|
||||
// silently leaves it unconnected with no error from the PUT itself. The
|
||||
// provider's own `env` array length does NOT predict this reliably either
|
||||
// (Azure needs two env vars and DOES connect from one key) - the only
|
||||
// honest source of truth is asking OpenCode again.
|
||||
const after = await listOpencodeProviders(result.baseUrl);
|
||||
const nowConnected = after.find((p) => p.id === providerID)?.connected === true;
|
||||
if (!nowConnected) {
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
error: `OpenCode stored the key but does not show ${providerID} as connected — it likely needs more than one credential field (check its requirements with the opencode CLI: opencode auth login ${providerID}).`,
|
||||
}, { status: 200 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (cause) {
|
||||
logger.error('opencode provider auth failed', { providerID, error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return NextResponse.json({ error: cause instanceof Error ? cause.message : 'Could not add the provider' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const result = await requireOpencode(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const providerID = request.nextUrl.searchParams.get('providerID')?.trim();
|
||||
if (!providerID) {
|
||||
return NextResponse.json({ error: 'providerID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await removeOpencodeProvider(result.baseUrl, providerID);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (cause) {
|
||||
logger.error('opencode provider removal failed', { providerID, error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return NextResponse.json({ error: cause instanceof Error ? cause.message : 'Could not remove the provider' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { DEFAULT_AI_ENTITLEMENT, type AiPolicy } from '@/lib/ai/types';
|
||||
|
||||
/**
|
||||
* GET /api/ai/policy - AI Assistant policy (NOT admin-protected - users read this)
|
||||
*
|
||||
* `enabled` mirrors the admin FeatureGates toggle. `entitlement.classes`
|
||||
* reflects real configuration, not a hardcoded guess: `server` only appears
|
||||
* when AI_SERVER_BASE_URL is actually set (app/api/ai/server/* would 503
|
||||
* otherwise) - this is enforcement point 1 (docs §10), cosmetic-only, the
|
||||
* client hiding what it can't use; the real gate is checkAndAssignSeat() on
|
||||
* every /api/ai/server/chat call, not this list.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await configManager.ensureLoaded();
|
||||
const policy = configManager.getPolicy();
|
||||
const consoleConfig = configManager.getAiConsoleConfig();
|
||||
|
||||
// A class must be BOTH infra-available AND not explicitly disabled by
|
||||
// the admin console (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6) to reach
|
||||
// users. Missing classesEnabled entries default to allowed, so this
|
||||
// changes nothing until an admin actually touches the console.
|
||||
const classAllowed = (cls: (typeof DEFAULT_AI_ENTITLEMENT.classes)[number]) => consoleConfig.classesEnabled[cls] !== false;
|
||||
const classes: typeof DEFAULT_AI_ENTITLEMENT.classes = [];
|
||||
if (classAllowed('local')) classes.push('local');
|
||||
if (classAllowed('public')) classes.push('public');
|
||||
if (process.env.AI_SERVER_BASE_URL && classAllowed('server')) classes.push('server');
|
||||
// `opencode` is offered whenever the admin hasn't disabled it — unlike
|
||||
// `server` there is no env var to gate on, because availability is "is a
|
||||
// local `opencode serve` listening right now", which changes minute to
|
||||
// minute and is answered by /api/ai/opencode/models (503 when absent).
|
||||
// Advertising the class and letting that probe report the truth beats
|
||||
// hiding it based on a stale check at policy-fetch time.
|
||||
if (classAllowed('opencode')) classes.push('opencode');
|
||||
|
||||
const aiPolicy: AiPolicy = {
|
||||
enabled: policy.features.aiAssistantEnabled,
|
||||
entitlement: { ...DEFAULT_AI_ENTITLEMENT, classes },
|
||||
publicConsentVersion: consoleConfig.consent?.version ?? null,
|
||||
retrievalEnabled: consoleConfig.retrievalEnabled,
|
||||
consent: consoleConfig.consent,
|
||||
publicProviderAllowlist: consoleConfig.publicProviderAllowlist,
|
||||
};
|
||||
|
||||
return NextResponse.json(aiPolicy, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('AI policy read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { serverSearchMail, hydrateMailRefs } from '@/lib/ai/retrieval/mail-embeddings';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_QUERY_CHARS = 512;
|
||||
const DEFAULT_LIMIT = 6;
|
||||
|
||||
/**
|
||||
* POST /api/ai/retrieve — the server embedding leg (docs/AI-ASSISTANT-CONCEPT.md
|
||||
* §7 step 2). Real JMAP fetch + real Ollama embeddings + real cosine ranking
|
||||
* (lib/ai/retrieval/mail-embeddings.ts), not a mock.
|
||||
*
|
||||
* ACL note (§7 step 2b): this only ever embeds/searches the *authenticated
|
||||
* session's own* JMAP account — there is no shared-mailbox fan-out to
|
||||
* pre-filter yet, since group accounts are still deferred entirely (matches
|
||||
* the doc's own "shared-mailbox retrieval ships server-only" decision, which
|
||||
* itself hasn't been reached because there's no group account to retrieve
|
||||
* from). Nothing here can leak across accounts because nothing crosses the
|
||||
* account boundary in the first place.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await getStalwartCredentials(request);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!process.env.AI_SERVER_BASE_URL) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
// Real enforcement, not cosmetic client hiding (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md
|
||||
// §6): an admin can disable mail-content-to-embeddings augmentation
|
||||
// independent of disabling the `server` chat class outright.
|
||||
await configManager.ensureLoaded();
|
||||
if (!configManager.getAiConsoleConfig().retrievalEnabled) {
|
||||
return NextResponse.json({ error: 'retrieval is disabled by admin policy' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: { query?: unknown; limit?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const query = typeof body.query === 'string' ? body.query.trim() : '';
|
||||
if (!query) {
|
||||
return NextResponse.json({ error: 'query is required' }, { status: 400 });
|
||||
}
|
||||
if (query.length > MAX_QUERY_CHARS) {
|
||||
return NextResponse.json({ error: 'query too long' }, { status: 400 });
|
||||
}
|
||||
const limit = typeof body.limit === 'number' ? Math.min(Math.max(Math.trunc(body.limit), 1), 20) : DEFAULT_LIMIT;
|
||||
|
||||
try {
|
||||
const scored = await serverSearchMail(auth.serverUrl, auth.authHeader, query, limit);
|
||||
const chunks = await hydrateMailRefs(auth.serverUrl, auth.authHeader, scored.map((s) => s.ref));
|
||||
|
||||
const contextBlock = chunks
|
||||
.map((c, i) => `[${i + 1}] Subject: ${c.title}\n${c.text}`)
|
||||
.join('\n\n');
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
hits: chunks.map((c, i) => ({ ref: c.ref, title: c.title, snippet: c.text.slice(0, 200), rank: i + 1 })),
|
||||
contextBlock,
|
||||
}, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (cause) {
|
||||
logger.error('ai retrieve failed', { error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return NextResponse.json({ error: 'retrieval unavailable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { checkAndAssignSeat, recordUsage } from '@/lib/ai/entitlement';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_BODY_BYTES = 200 * 1024;
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface OllamaChatResponse {
|
||||
message?: { content?: string };
|
||||
prompt_eval_count?: number;
|
||||
eval_count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/server/chat — the one real enforcement chokepoint for the
|
||||
* `server` AI class (docs/AI-ASSISTANT-CONCEPT.md §10 point 2: "re-validates
|
||||
* ... entitlement against live state; rejects on mismatch ... never trusts
|
||||
* the client"). Every call re-checks the seat; nothing here is cosmetic.
|
||||
*
|
||||
* Retrieval already happened client-side (the same /api/offline/search leg
|
||||
* `local`/`public` use) — this route receives the already-built prompt
|
||||
* messages and only proxies the model call + records the metering entry
|
||||
* that IS the billing record (lib/ai/entitlement.ts).
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await getStalwartCredentials(request);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Real enforcement, not cosmetic client hiding (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md
|
||||
// §6): the admin console can disable the whole `server` class even when
|
||||
// AI_SERVER_BASE_URL stays configured (e.g. keeping infra up for staging
|
||||
// while turning it off for users).
|
||||
await configManager.ensureLoaded();
|
||||
if (configManager.getAiConsoleConfig().classesEnabled.server === false) {
|
||||
return NextResponse.json({ error: 'the server-hosted AI class is disabled by admin policy' }, { status: 403 });
|
||||
}
|
||||
|
||||
const seat = await checkAndAssignSeat(auth.username);
|
||||
if (!seat.allowed) {
|
||||
return NextResponse.json({ error: seat.reason ?? 'not entitled' }, { status: 402 });
|
||||
}
|
||||
|
||||
const rawBody = await request.text();
|
||||
if (rawBody.length > MAX_BODY_BYTES) {
|
||||
return NextResponse.json({ error: 'request too large' }, { status: 413 });
|
||||
}
|
||||
|
||||
let body: { model?: unknown; messages?: unknown };
|
||||
try {
|
||||
body = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const model = typeof body.model === 'string' ? body.model : '';
|
||||
const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null;
|
||||
if (!model || !messages || messages.length === 0) {
|
||||
return NextResponse.json({ error: 'model and messages are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const allowlist = configManager.getAiConsoleConfig().serverModelAllowlist;
|
||||
if (allowlist && !allowlist.includes(model)) {
|
||||
return NextResponse.json({ error: `model "${model}" is not on the admin allow-list` }, { status: 403 });
|
||||
}
|
||||
|
||||
const baseUrl = process.env.AI_SERVER_BASE_URL;
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 });
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model, messages, stream: false }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: `AI server returned ${res.status}` }, { status: 502 });
|
||||
}
|
||||
const data = (await res.json()) as OllamaChatResponse;
|
||||
const content = data.message?.content;
|
||||
if (!content) {
|
||||
return NextResponse.json({ error: 'AI server returned no message content' }, { status: 502 });
|
||||
}
|
||||
|
||||
await recordUsage({
|
||||
timestamp: new Date().toISOString(),
|
||||
username: auth.username,
|
||||
model,
|
||||
promptTokens: data.prompt_eval_count ?? 0,
|
||||
completionTokens: data.eval_count ?? 0,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
});
|
||||
|
||||
return NextResponse.json({ answer: content, seatJustAssigned: seat.seatJustAssigned === true });
|
||||
} catch (cause) {
|
||||
logger.error('ai server chat failed', { error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return NextResponse.json({ error: 'AI server unreachable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET /api/ai/server/models — list models on the centrally-hosted `server`
|
||||
* class runtime (docs/AI-ASSISTANT-CONCEPT.md §2.1: "the same self-hosted
|
||||
* open-weight model stack as `local`... running on VNC's own infrastructure
|
||||
* instead of the user's laptop"). Tonight, `AI_SERVER_BASE_URL` stands in for
|
||||
* that infra with the Ollama already running on this developer's Mac — see
|
||||
* the module comment in lib/ai/entitlement.ts. Swapping to the real
|
||||
* EU/CH-hosted instance tomorrow is a config change, not a rewrite.
|
||||
*
|
||||
* Listing models is not a billable action (doc §10 point 1 — cosmetic), so
|
||||
* this only requires a valid session, not a seat.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await getStalwartCredentials(request);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const baseUrl = process.env.AI_SERVER_BASE_URL;
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/tags`);
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: `upstream returned ${res.status}` }, { status: 502 });
|
||||
}
|
||||
const body = (await res.json()) as { models?: Array<{ name: string; capabilities?: string[] }> };
|
||||
// Excludes embedding-only models (e.g. nomic-embed-text, used by
|
||||
// lib/ai/retrieval/mail-embeddings.ts) from the *chat* picker — Ollama
|
||||
// lists them in the same /api/tags response, but calling /api/chat with
|
||||
// one fails outright. `capabilities` absent (older Ollama) fails open
|
||||
// rather than hiding every model on an upgrade.
|
||||
let chatModels = (body.models ?? []).filter((m) => !m.capabilities || m.capabilities.includes('completion'));
|
||||
|
||||
// Admin allow-list (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6). null = every
|
||||
// completion-capable model (today's behavior, unchanged).
|
||||
await configManager.ensureLoaded();
|
||||
const allowlist = configManager.getAiConsoleConfig().serverModelAllowlist;
|
||||
if (allowlist) {
|
||||
const allowed = new Set(allowlist);
|
||||
chatModels = chatModels.filter((m) => allowed.has(m.name));
|
||||
}
|
||||
|
||||
return NextResponse.json({ models: chatModels.map((m) => m.name).filter(Boolean) });
|
||||
} catch (cause) {
|
||||
return NextResponse.json(
|
||||
{ error: cause instanceof Error ? cause.message : 'AI server unreachable' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { checkUserAuthRateLimit } from '@/lib/admin/rate-limit';
|
||||
|
||||
function sessionCookieOptions() {
|
||||
return {
|
||||
@@ -48,6 +49,15 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
const rateLimit = checkUserAuthRateLimit(ip, username);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many login attempts', retryAfterMs: rateLimit.retryAfterMs },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Pin the upstream URL to a configured JMAP server so an unauthenticated
|
||||
// caller cannot point this route at internal hosts. We accept the global
|
||||
// `jmapServerUrl` and any entry from `jmapServers`. When neither matches,
|
||||
|
||||
@@ -10,6 +10,17 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
// TODO(P2.13): Wire SAML IDP integration once VNCdirectory is configured.
|
||||
// When VNCdirectory is enabled and SAML is configured (see
|
||||
// lib/admin/vncdirectory-config.ts), the SSO start flow should:
|
||||
// 1. Check isVncDirectoryEnabled() — if false, fall through to existing OAuth flow.
|
||||
// 2. Read getVncDirectoryConfig() for samlIdpUrl, samlIssuer, samlSpCert.
|
||||
// 3. Build a SAML AuthnRequest and redirect to the IdP instead of OAuth.
|
||||
// 4. The /sso/complete handler should process the SAML Response assertion,
|
||||
// validate the signature against the SP certificate, extract the subject,
|
||||
// and create a session.
|
||||
// Reference: docs/admin/VNCDIRECTORY.md in the VNCmail+ plan (P2.13).
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oaut
|
||||
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { checkUserAuthRateLimit } from '@/lib/admin/rate-limit';
|
||||
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
@@ -16,6 +17,15 @@ function getSlot(request: NextRequest): number {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
const rateLimit = checkUserAuthRateLimit(ip, 'oauth-token');
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many token requests', retryAfterMs: rateLimit.retryAfterMs },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||
|
||||
if (!code || !code_verifier || !redirect_uri) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCollaboraEditUrl } from "@/lib/collabora/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.fileId || !body.fileName) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: fileId, fileName" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = await getCollaboraEditUrl(
|
||||
String(body.fileId),
|
||||
String(body.fileName)
|
||||
);
|
||||
|
||||
return NextResponse.json({ url });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error("Collabora edit URL failed", { error: message });
|
||||
|
||||
if (message.includes("not configured")) {
|
||||
return NextResponse.json({ error: message }, { status: 503 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,17 @@ export async function GET(request: NextRequest) {
|
||||
return configManager.get<T>(key, fallback);
|
||||
};
|
||||
|
||||
// Whether a logo field was actually set by an operator (Branding tab,
|
||||
// an env var, or a per-domain override) rather than left at its default -
|
||||
// consumed by resolveThemeLogo() so an explicit choice here wins over the
|
||||
// active theme's own built-in logo, instead of being silently shadowed by
|
||||
// it. See lib/theme-logo.ts.
|
||||
const configSources = configManager.getAllWithSources();
|
||||
const isLogoOverridden = (key: BrandingOverrideKey): boolean =>
|
||||
typeof domainOverrides[key] === 'string' && domainOverrides[key]!.length > 0
|
||||
? true
|
||||
: configSources[key]?.source !== 'default';
|
||||
|
||||
const appName =
|
||||
branded<string>('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
|
||||
@@ -68,8 +79,12 @@ export async function GET(request: NextRequest) {
|
||||
faviconUrl: branded<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
appLogoLightUrl: branded<string>('appLogoLightUrl', ''),
|
||||
appLogoDarkUrl: branded<string>('appLogoDarkUrl', ''),
|
||||
appLogoLightUrlIsCustom: isLogoOverridden('appLogoLightUrl'),
|
||||
appLogoDarkUrlIsCustom: isLogoOverridden('appLogoDarkUrl'),
|
||||
loginLogoLightUrl: branded<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||
loginLogoDarkUrl: branded<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||
loginLogoLightUrlIsCustom: isLogoOverridden('loginLogoLightUrl'),
|
||||
loginLogoDarkUrlIsCustom: isLogoOverridden('loginLogoDarkUrl'),
|
||||
loginCompanyName: branded<string>('loginCompanyName', ''),
|
||||
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
||||
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// GET /api/offline/mail?kind=mailboxes|list|message - the OFFLINE READ SURFACE.
|
||||
//
|
||||
// THIS ROUTE MUST NEVER MAKE A NETWORK CALL. That is the whole feature: it is
|
||||
// consulted precisely when the backend is unreachable, so a JMAP session fetch to
|
||||
// learn the account id would fail for the exact reason the route was called. The
|
||||
// account is resolved from the request's own encrypted `jmap_stalwart_ctx` cookie
|
||||
// (a local decrypt) and from the account ids the store already holds rows for.
|
||||
//
|
||||
// It is a FALLBACK, not a cache in front of the server - see `read.ts`'s header
|
||||
// for the coherence rules that depend on that, and `lib/offline-fallback-client.ts`
|
||||
// for the one place that decides a read has genuinely failed.
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
readEnvelopePage, readMailboxes, readMessage,
|
||||
} from '@/lib/offline-replica/read';
|
||||
import {
|
||||
resolveIndexSession, resolveReadAccountId, withReplica,
|
||||
} from '@/lib/offline-replica/engine';
|
||||
import { gateReplicaRoute, NO_STORE, replicaErrorResponse } from '@/lib/offline-replica/route-gate';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const gated = gateReplicaRoute();
|
||||
if (gated) return gated;
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
const kind = params.get('kind') ?? 'mailboxes';
|
||||
if (kind !== 'mailboxes' && kind !== 'list' && kind !== 'message') {
|
||||
return NextResponse.json({ error: 'kind must be mailboxes, list or message' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await resolveIndexSession(request);
|
||||
const payload = await withReplica(session.accountId, (store) => {
|
||||
const jmapAccountId = resolveReadAccountId(store, params.get('jmapAccountId'));
|
||||
if (!jmapAccountId) {
|
||||
// Nothing synced yet for this account. Not an error - the caller falls
|
||||
// back to whatever it would have shown without a replica.
|
||||
return { empty: true as const };
|
||||
}
|
||||
|
||||
if (kind === 'mailboxes') {
|
||||
return { empty: false as const, jmapAccountId, mailboxes: readMailboxes(store, jmapAccountId) };
|
||||
}
|
||||
|
||||
if (kind === 'list') {
|
||||
const rawLimit = Number(params.get('limit') ?? '50');
|
||||
const limit = Number.isFinite(rawLimit)
|
||||
? Math.min(Math.max(Math.trunc(rawLimit), 1), MAX_LIMIT)
|
||||
: 50;
|
||||
const rawOffset = Number(params.get('offset') ?? '0');
|
||||
const offset = Number.isFinite(rawOffset) ? Math.max(Math.trunc(rawOffset), 0) : 0;
|
||||
// An absent mailboxId means "everything", which is what the unified views
|
||||
// ask for; an empty string is a caller bug and must not silently widen.
|
||||
const mailboxParam = params.get('mailboxId');
|
||||
const mailboxId = mailboxParam === null ? null : mailboxParam;
|
||||
if (mailboxId === '') {
|
||||
return { empty: true as const };
|
||||
}
|
||||
const page = readEnvelopePage(store, jmapAccountId, mailboxId, limit, offset);
|
||||
return { empty: false as const, jmapAccountId, ...page };
|
||||
}
|
||||
|
||||
const id = params.get('id');
|
||||
if (!id || id.length > 256) return { empty: true as const };
|
||||
const message = readMessage(store, jmapAccountId, id);
|
||||
if (!message) return { empty: false as const, jmapAccountId, email: null, hasBody: false };
|
||||
return {
|
||||
empty: false as const,
|
||||
jmapAccountId,
|
||||
email: message.email,
|
||||
hasBody: message.hasBody,
|
||||
};
|
||||
});
|
||||
|
||||
if (payload.empty) {
|
||||
return NextResponse.json({ ok: true, available: false }, { headers: NO_STORE });
|
||||
}
|
||||
return NextResponse.json({ ok: true, available: true, ...payload }, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return replicaErrorResponse(error, 'offline read');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// POST /api/offline/reindex - write mail/calendar/contacts/files into the
|
||||
// encrypted local search index for the calling session's account.
|
||||
//
|
||||
// The PRIMARY caller is the renderer's live JMAP push handler: when a
|
||||
// StateChange arrives it posts the ids that changed, so indexing is reactive to
|
||||
// each delivery rather than periodic. `{ catchUp: true }` (no ids) is the
|
||||
// fallback used at app launch to backfill whatever changed while the app was
|
||||
// closed.
|
||||
//
|
||||
// GATED: returns 404 unless VNCMAIL_DESKTOP_STORE_DIR is set, which only
|
||||
// electron/main.ts does. The same standalone server artifact runs in the
|
||||
// multi-tenant production Docker image, where this feature must not exist at
|
||||
// all - 404 rather than 403 so nothing learns the route is there.
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { isSqlcipherAvailable } from '@/lib/mail-index/binding';
|
||||
import { hasKeyChannel, IndexKeyError } from '@/lib/mail-index/key';
|
||||
import { getStoreDir } from '@/lib/mail-index/paths';
|
||||
import {
|
||||
IndexSessionError, MAX_IDS_PER_CALL, normalizeWindowDays, resolveIndexSession, runIndex,
|
||||
type IndexRequest,
|
||||
} from '@/lib/mail-index/reindex';
|
||||
import { CONTENT_TYPES, isContentType, type ContentType } from '@/lib/mail-index/store';
|
||||
import { JmapIndexError } from '@/lib/mail-index/jmap';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function parseIdMap(raw: unknown): Partial<Record<ContentType, string[]>> | undefined {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
||||
const out: Partial<Record<ContentType, string[]>> = {};
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!isContentType(key) || !Array.isArray(value)) continue;
|
||||
const ids = value
|
||||
.filter((v): v is string => typeof v === 'string' && v.length > 0 && v.length <= 256)
|
||||
.slice(0, MAX_IDS_PER_CALL);
|
||||
if (ids.length > 0) out[key] = ids;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!getStoreDir()) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
if (!hasKeyChannel()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'The local index has no key channel in this process.', code: 'no-key-channel' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!isSqlcipherAvailable()) {
|
||||
// The native binding is an optionalDependency, so "not installed" is a
|
||||
// normal state on platforms without a prebuild - not an error to log loudly.
|
||||
return NextResponse.json(
|
||||
{ error: 'Encrypted local index is unavailable on this platform.', code: 'no-binding' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
const text = await request.text();
|
||||
if (text.trim()) body = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Malformed JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rawTypes = Array.isArray(body.types) ? body.types.filter(isContentType) : [];
|
||||
const req: IndexRequest = {
|
||||
types: rawTypes.length > 0 ? rawTypes : undefined,
|
||||
ids: parseIdMap(body.ids),
|
||||
removed: parseIdMap(body.removed),
|
||||
// Pruning is a catch-up concern; a single-delivery call shouldn't scan.
|
||||
prune: body.catchUp === true,
|
||||
// `undefined` (absent) means "use the default"; an explicit null means
|
||||
// keep everything. normalizeWindowDays() in runIndex clamps anything
|
||||
// unexpected, since this value drives deletion.
|
||||
windowDays: body.windowDays === undefined ? undefined : normalizeWindowDays(body.windowDays),
|
||||
};
|
||||
|
||||
try {
|
||||
const session = await resolveIndexSession(request);
|
||||
const result = await runIndex(session, req);
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: true,
|
||||
written: result.written,
|
||||
skipped: result.skipped,
|
||||
errors: result.errors,
|
||||
durationMs: result.durationMs,
|
||||
types: CONTENT_TYPES,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof IndexSessionError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof JmapIndexError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof IndexKeyError) {
|
||||
// no-secure-storage is the Linux-without-a-keyring refusal: a real,
|
||||
// expected outcome with a user-facing explanation, not a server fault.
|
||||
const status = error.code === 'no-secure-storage' ? 503 : 500;
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status });
|
||||
}
|
||||
logger.error('mail-index reindex failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json({ error: 'Reindex failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// GET /api/offline/search?q=...&types=mail,calendar&limit=20
|
||||
//
|
||||
// THE RETRIEVAL SURFACE. This is what an AI/RAG feature calls to gather
|
||||
// relevant context from the user's own mail, calendar, contacts and files
|
||||
// before prompting a model - hence the `snippet` on every hit and the
|
||||
// `contextBlock` convenience field, which is the same information already
|
||||
// flattened into text a prompt can carry directly.
|
||||
//
|
||||
// Read-only: it never touches the network and never writes. Gated identically
|
||||
// to the reindex route.
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { isSqlcipherAvailable } from '@/lib/mail-index/binding';
|
||||
import { hasKeyChannel, IndexKeyError, withIndexKey } from '@/lib/mail-index/key';
|
||||
import { getStoreDir } from '@/lib/mail-index/paths';
|
||||
import { IndexSessionError, resolveIndexSession } from '@/lib/mail-index/reindex';
|
||||
import {
|
||||
isContentType, MailIndex, MailIndexUnavailableError, type ContentType, type SearchHit,
|
||||
} from '@/lib/mail-index/store';
|
||||
import { detectRecencyIntent } from '@/lib/mail-index/recency';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* One hit as a plain text block, ready to be concatenated into a prompt.
|
||||
* Kept server-side so every caller (a chat feature, a future agent, a test)
|
||||
* formats context the same way rather than each inventing its own.
|
||||
*/
|
||||
function toContextBlock(hit: SearchHit): string {
|
||||
const label: Record<ContentType, string> = {
|
||||
mail: 'EMAIL', calendar: 'CALENDAR EVENT', contact: 'CONTACT', file: 'FILE',
|
||||
};
|
||||
const lines = [`[${label[hit.contentType]}] ${hit.title}`];
|
||||
if (hit.occurredAt) lines.push(`Date: ${hit.occurredAt}`);
|
||||
if (hit.people) lines.push(`People: ${hit.people}`);
|
||||
const path = hit.metadata?.path;
|
||||
if (typeof path === 'string' && path) lines.push(`Path: ${path}`);
|
||||
if (hit.snippet) lines.push(`Excerpt: ${hit.snippet}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!getStoreDir()) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
if (!hasKeyChannel() || !isSqlcipherAvailable()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Encrypted local index is unavailable in this process.', code: 'unavailable' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
const query = (params.get('q') ?? '').trim();
|
||||
const wantStats = params.get('stats') === 'true';
|
||||
|
||||
if (!query && !wantStats) {
|
||||
return NextResponse.json({ error: 'Missing q parameter' }, { status: 400 });
|
||||
}
|
||||
if (query.length > 512) {
|
||||
return NextResponse.json({ error: 'Query too long' }, { status: 400 });
|
||||
}
|
||||
|
||||
const types = (params.get('types') ?? '')
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(isContentType);
|
||||
|
||||
const limitRaw = Number(params.get('limit') ?? '20');
|
||||
const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(Math.trunc(limitRaw), 1), 100) : 20;
|
||||
|
||||
try {
|
||||
const session = await resolveIndexSession(request);
|
||||
const storeDir = getStoreDir();
|
||||
if (!storeDir) return new NextResponse(null, { status: 404 });
|
||||
|
||||
const payload = await withIndexKey(session.accountId, (key) => {
|
||||
const index = MailIndex.open({ storeDir, accountId: session.accountId, key });
|
||||
try {
|
||||
const stats = index.stats();
|
||||
if (!query) return { hits: [] as SearchHit[], stats };
|
||||
// 'any': this route is the AI/RAG retrieval surface (see module
|
||||
// header) - its one real caller sends natural-language questions,
|
||||
// not deliberate search-box keywords, so strict AND-every-token
|
||||
// matching (the default) drops nearly all of them. See
|
||||
// toFtsMatchQueryAny's docstring for the confirmed-live failure.
|
||||
const keywordHits = index.search({ query, types, limit, mode: 'any' });
|
||||
|
||||
// RECENCY leg. Keyword search structurally cannot answer "the last
|
||||
// mail" or "everything from July" (see lib/mail-index/recency.ts), so
|
||||
// when the question is really about time, add a date-ordered slice.
|
||||
// ADDED to the keyword hits rather than replacing them: "what did the
|
||||
// last mail from Anna say" is both a time question and a content one.
|
||||
const intent = detectRecencyIntent(query);
|
||||
if (!intent) {
|
||||
return { hits: keywordHits, stats: wantStats ? stats : undefined };
|
||||
}
|
||||
const recentHits = index.recent({
|
||||
types, limit: Math.min(intent.limit, limit * 3), since: intent.since, until: intent.until,
|
||||
});
|
||||
const seen = new Set(keywordHits.map((h) => `${h.contentType}:${h.id}`));
|
||||
const merged = [...keywordHits];
|
||||
for (const hit of recentHits) {
|
||||
const key = `${hit.contentType}:${hit.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.push(hit);
|
||||
}
|
||||
return { hits: merged, stats: wantStats ? stats : undefined, recency: intent };
|
||||
} finally {
|
||||
index.close();
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: true,
|
||||
query,
|
||||
types: types.length > 0 ? types : 'all',
|
||||
count: payload.hits.length,
|
||||
hits: payload.hits,
|
||||
// Everything a prompt needs, pre-joined in rank order.
|
||||
contextBlock: payload.hits.map(toContextBlock).join('\n\n---\n\n'),
|
||||
...(payload.stats ? { stats: payload.stats } : {}),
|
||||
// Present when the question was read as a time question — lets the
|
||||
// client say "these are the newest N" instead of implying relevance
|
||||
// ranking it did not do.
|
||||
...(payload.recency ? { recency: payload.recency } : {}),
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof IndexSessionError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof IndexKeyError) {
|
||||
const status = error.code === 'no-secure-storage' ? 503 : 500;
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status });
|
||||
}
|
||||
if (error instanceof MailIndexUnavailableError) {
|
||||
return NextResponse.json({ error: error.message, code: 'unavailable' }, { status: 503 });
|
||||
}
|
||||
logger.error('mail-index search failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json({ error: 'Search failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// GET /api/offline/status - size, freshness and retention policy, for Settings.
|
||||
// PUT /api/offline/status - update the retention policy.
|
||||
// DELETE /api/offline/status - purge the replica.
|
||||
//
|
||||
// The POLICY LIVES IN THE ENCRYPTED STORE, not in renderer localStorage. The
|
||||
// design review's H1 was that a server-side engine cannot read a renderer-only
|
||||
// setting; keeping the policy server-side means the retention pass always has the
|
||||
// value it needs, while the DECISION TO SYNC AT ALL stays with the renderer, so
|
||||
// nothing is ever materialised for an account that never opted in.
|
||||
//
|
||||
// Like every read here, GET makes no network call: an offline user must still be
|
||||
// able to see what they have and free the space.
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { clampPolicy, POLICY_LIMITS, type RetentionPolicy } from '@/lib/offline-replica/store';
|
||||
import { resolveIndexSession, resolveReadAccountId, withReplica } from '@/lib/offline-replica/engine';
|
||||
import { gateReplicaRoute, NO_STORE, replicaErrorResponse } from '@/lib/offline-replica/route-gate';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const gated = gateReplicaRoute();
|
||||
if (gated) return gated;
|
||||
try {
|
||||
const session = await resolveIndexSession(request);
|
||||
const payload = await withReplica(session.accountId, (store) => {
|
||||
const jmapAccountId = resolveReadAccountId(store, null);
|
||||
const policy = store.getPolicy();
|
||||
const flags = store.getFlags(Date.now());
|
||||
if (!jmapAccountId) {
|
||||
return {
|
||||
policy,
|
||||
limits: POLICY_LIMITS,
|
||||
synced: false,
|
||||
stats: null,
|
||||
coveragePhase: 'never-run',
|
||||
resyncRequired: flags.resyncRequired,
|
||||
lastCycleAt: flags.lastCycleAt ?? null,
|
||||
lastCycleOk: flags.lastCycleOk ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
policy,
|
||||
limits: POLICY_LIMITS,
|
||||
synced: true,
|
||||
stats: store.stats(jmapAccountId),
|
||||
coveragePhase: store.getCoverage(jmapAccountId)?.phase ?? 'never-run',
|
||||
coveredFrom: store.getCoverage(jmapAccountId)?.coveredFrom ?? null,
|
||||
resyncRequired: flags.resyncRequired,
|
||||
lastCycleAt: flags.lastCycleAt ?? null,
|
||||
lastCycleOk: flags.lastCycleOk ?? null,
|
||||
lastCycleError: flags.lastCycleError ?? null,
|
||||
};
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...payload }, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return replicaErrorResponse(error, 'offline status');
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const gated = gateReplicaRoute();
|
||||
if (gated) return gated;
|
||||
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
const text = await request.text();
|
||||
if (text.trim()) body = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Malformed JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const policy = clampPolicy(body as Partial<RetentionPolicy>);
|
||||
try {
|
||||
const session = await resolveIndexSession(request);
|
||||
await withReplica(session.accountId, (store) => {
|
||||
store.transaction(() => { store.setPolicy(policy); });
|
||||
});
|
||||
// The cycle applies it: a widen re-enters coverage scanning, a narrow evicts,
|
||||
// and the clock guard is told this was INTENT rather than a glitch by the
|
||||
// `lastEnvelopeDays` it compares against.
|
||||
return NextResponse.json({ ok: true, policy }, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return replicaErrorResponse(error, 'offline policy update');
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const gated = gateReplicaRoute();
|
||||
if (gated) return gated;
|
||||
try {
|
||||
const session = await resolveIndexSession(request);
|
||||
await withReplica(session.accountId, (store) => {
|
||||
// ALL OF IT, cursors included. A record wipe that leaves cursors behind is
|
||||
// the one state no amount of syncing repairs: `/changes` structurally cannot
|
||||
// re-deliver mail that already existed when the cursor was captured, so the
|
||||
// next cycle would advance a live cursor over an empty store forever.
|
||||
store.transaction(() => { store.purgeAll(); });
|
||||
});
|
||||
return NextResponse.json({ ok: true, purged: true }, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return replicaErrorResponse(error, 'offline purge');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// POST /api/offline/sync - run ONE bounded delta-sync cycle for the calling
|
||||
// session's account.
|
||||
//
|
||||
// The renderer drives this: once at launch (catch-up for whatever changed while
|
||||
// the app was closed, for which no push event was ever delivered) and on each
|
||||
// JMAP `StateChange` from the live push connection. There is no background worker
|
||||
// and no resident credential - see `lib/offline-replica/sync.ts`'s header for why
|
||||
// that architecture choice keeps most of the original design review's critical
|
||||
// findings out of scope entirely.
|
||||
//
|
||||
// A cycle is BOUNDED. `unfinishedWork: true` means "call again", and the renderer
|
||||
// chains with a cap; it never means an error.
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { clampPolicy, type RetentionPolicy } from '@/lib/offline-replica/store';
|
||||
import { resolveIndexSession, syncAccount } from '@/lib/offline-replica/engine';
|
||||
import { gateReplicaRoute, NO_STORE, replicaErrorResponse } from '@/lib/offline-replica/route-gate';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const gated = gateReplicaRoute();
|
||||
if (gated) return gated;
|
||||
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
const text = await request.text();
|
||||
if (text.trim()) body = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Malformed JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rawPolicy = body.policy;
|
||||
const policy: RetentionPolicy | undefined =
|
||||
rawPolicy && typeof rawPolicy === 'object' && !Array.isArray(rawPolicy)
|
||||
? clampPolicy(rawPolicy as Partial<RetentionPolicy>)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const session = await resolveIndexSession(request);
|
||||
const report = await syncAccount(session, {
|
||||
policy,
|
||||
forceResync: body.forceResync === true,
|
||||
});
|
||||
return NextResponse.json({ ok: report.ok, report }, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return replicaErrorResponse(error, 'sync');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { checkAvailability } from '@/lib/resources/client';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const start = searchParams.get('start');
|
||||
const end = searchParams.get('end');
|
||||
|
||||
if (!start || !end) {
|
||||
return NextResponse.json({ error: 'start and end query parameters are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await checkAvailability(id, start, end);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Resource availability error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { cancelBooking } from '@/lib/resources/client';
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; bookingId: string }> },
|
||||
) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { bookingId } = await params;
|
||||
await cancelBooking(bookingId);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Resource booking cancel error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { bookResource, checkAvailability, getResource } from '@/lib/resources/client';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { start, end, eventId } = body;
|
||||
|
||||
if (!start || !end) {
|
||||
return NextResponse.json({ error: 'start and end are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const resource = await getResource(id);
|
||||
if (!resource) {
|
||||
return NextResponse.json({ error: 'Resource not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const { available, conflicts } = await checkAvailability(id, start, end);
|
||||
if (!available) {
|
||||
return NextResponse.json({ error: 'Resource is not available for the requested time', conflicts }, { status: 409 });
|
||||
}
|
||||
|
||||
const booking = await bookResource(id, start, end, creds.username, eventId);
|
||||
return NextResponse.json({ booking }, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Resource booking error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { getResource } from '@/lib/resources/client';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const resource = await getResource(id);
|
||||
if (!resource) {
|
||||
return NextResponse.json({ error: 'Resource not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ resource });
|
||||
} catch (error) {
|
||||
logger.error('Resource get error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { listResources, createResource, getBookingsForEvent } from '@/lib/resources/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type') || undefined;
|
||||
const eventId = searchParams.get('eventId') || undefined;
|
||||
|
||||
if (eventId) {
|
||||
const bookings = await getBookingsForEvent(eventId);
|
||||
return NextResponse.json({ bookings });
|
||||
}
|
||||
|
||||
const resources = await listResources(creds.username, type);
|
||||
return NextResponse.json({ resources });
|
||||
} catch (error) {
|
||||
logger.error('Resources list error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, type, location, capacity, description, contactEmail, metadata } = body;
|
||||
|
||||
if (!name || !type || !['room', 'vehicle', 'equipment', 'other'].includes(type)) {
|
||||
return NextResponse.json({ error: 'Name and valid type are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const resource = await createResource(creds.username, {
|
||||
name,
|
||||
type,
|
||||
location,
|
||||
capacity: capacity ? Number(capacity) : undefined,
|
||||
description,
|
||||
contactEmail,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return NextResponse.json({ resource }, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Resource create error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
type JmapMethodCall = [string, Record<string, unknown>, string];
|
||||
|
||||
async function jmapRequest(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
methodCalls: JmapMethodCall[],
|
||||
using?: string[],
|
||||
) {
|
||||
const sessionResp = await fetch(`${serverUrl}/.well-known/jmap`, {
|
||||
headers: { Authorization: authHeader },
|
||||
});
|
||||
if (!sessionResp.ok) {
|
||||
return { error: `Session fetch failed: ${sessionResp.status}` };
|
||||
}
|
||||
const session = await sessionResp.json();
|
||||
const apiUrl = session.apiUrl;
|
||||
if (!apiUrl) {
|
||||
return { error: "No API URL in JMAP session" };
|
||||
}
|
||||
|
||||
const body = {
|
||||
using: using || [
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:ietf:params:jmap:mail",
|
||||
"urn:ietf:params:jmap:principals",
|
||||
],
|
||||
methodCalls,
|
||||
};
|
||||
|
||||
const resp = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: authHeader,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return { error: `JMAP request failed: ${resp.status}` };
|
||||
}
|
||||
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get("action");
|
||||
const serverUrl = request.headers.get("X-JMAP-Server-Url");
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
|
||||
if (!serverUrl || !authHeader) {
|
||||
return Response.json(
|
||||
{ error: "Missing server URL or auth header" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action !== "principals") {
|
||||
return Response.json(
|
||||
{ error: "Invalid action" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await jmapRequest(serverUrl, authHeader, [
|
||||
["Principal/query", { accountId: "" }, "0"],
|
||||
["Principal/get", {
|
||||
accountId: "",
|
||||
"#ids": {
|
||||
resultOf: "0",
|
||||
name: "Principal/query",
|
||||
path: "/ids",
|
||||
},
|
||||
}, "1"],
|
||||
]);
|
||||
|
||||
if ("error" in result) {
|
||||
return Response.json(result, { status: 502 });
|
||||
}
|
||||
|
||||
const getResp = (result as Record<string, unknown>).methodResponses as Array<[string, Record<string, unknown>, string]> | undefined;
|
||||
const principals = getResp?.find((r) => r[0] === "Principal/get")?.[1]
|
||||
?.list ?? [];
|
||||
|
||||
return Response.json({ principals });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const serverUrl = request.headers.get("X-JMAP-Server-Url");
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
|
||||
if (!serverUrl || !authHeader) {
|
||||
return Response.json(
|
||||
{ error: "Missing server URL or auth header" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { kind, resourceId, principalId, role } = body;
|
||||
|
||||
if (!kind || !resourceId || !principalId) {
|
||||
return Response.json(
|
||||
{ error: "Missing required fields: kind, resourceId, principalId" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let method: string;
|
||||
let shareProperty: string;
|
||||
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
method = "Mailbox/set";
|
||||
shareProperty = "shareWith";
|
||||
break;
|
||||
case "calendar":
|
||||
method = "Calendar/set";
|
||||
shareProperty = "shareWith";
|
||||
break;
|
||||
case "addressBook":
|
||||
method = "AddressBook/set";
|
||||
shareProperty = "shareWith";
|
||||
break;
|
||||
case "file":
|
||||
method = "FileNode/set";
|
||||
shareProperty = "shareWith";
|
||||
break;
|
||||
default:
|
||||
return Response.json(
|
||||
{ error: `Invalid kind: ${kind}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const patchValue = role === null ? null : buildRights(kind as string, role as string);
|
||||
|
||||
const methodCalls: JmapMethodCall[] = [
|
||||
[
|
||||
method,
|
||||
{
|
||||
accountId: "",
|
||||
update: {
|
||||
[resourceId as string]: {
|
||||
[`${shareProperty}/${principalId}`]: patchValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
"0",
|
||||
],
|
||||
];
|
||||
|
||||
const result = await jmapRequest(
|
||||
serverUrl,
|
||||
authHeader,
|
||||
methodCalls,
|
||||
);
|
||||
|
||||
if ("error" in result) {
|
||||
return Response.json(result, { status: 502 });
|
||||
}
|
||||
|
||||
const responses = (result as Record<string, unknown>).methodResponses as Array<[string, Record<string, unknown>, string]> | undefined;
|
||||
const setResult = responses?.[0]?.[1];
|
||||
|
||||
if (
|
||||
setResult &&
|
||||
typeof setResult === "object" &&
|
||||
"notUpdated" in setResult &&
|
||||
setResult.notUpdated &&
|
||||
typeof setResult.notUpdated === "object" &&
|
||||
(resourceId as string) in setResult.notUpdated
|
||||
) {
|
||||
const err = (setResult.notUpdated as Record<string, Record<string, unknown>>)[resourceId as string];
|
||||
return Response.json(
|
||||
{ error: err.description || "Failed to update share" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
function buildRights(
|
||||
kind: string,
|
||||
role: string,
|
||||
): Record<string, boolean> | null {
|
||||
if (role === null) return null;
|
||||
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
return mailboxRights(role);
|
||||
case "calendar":
|
||||
return calendarRights(role);
|
||||
case "addressBook":
|
||||
return addressBookRights(role);
|
||||
case "file":
|
||||
return fileRights(role);
|
||||
default:
|
||||
return readRights();
|
||||
}
|
||||
}
|
||||
|
||||
function mailboxRights(role: string): Record<string, boolean> {
|
||||
switch (role) {
|
||||
case "read":
|
||||
return {
|
||||
mayReadItems: true,
|
||||
mayAddItems: false,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: false,
|
||||
maySetKeywords: false,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: false,
|
||||
};
|
||||
case "readWrite":
|
||||
return {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: true,
|
||||
};
|
||||
case "manager":
|
||||
return {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
mayShare: true,
|
||||
};
|
||||
default:
|
||||
return mailboxRights("read");
|
||||
}
|
||||
}
|
||||
|
||||
function calendarRights(role: string): Record<string, boolean> {
|
||||
switch (role) {
|
||||
case "read":
|
||||
return {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: false,
|
||||
mayWriteOwn: false,
|
||||
mayUpdatePrivate: false,
|
||||
mayRSVP: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
};
|
||||
case "readWrite":
|
||||
return {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: true,
|
||||
mayWriteOwn: true,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
};
|
||||
case "manager":
|
||||
return {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: true,
|
||||
mayWriteOwn: true,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayShare: true,
|
||||
mayDelete: true,
|
||||
};
|
||||
default:
|
||||
return calendarRights("read");
|
||||
}
|
||||
}
|
||||
|
||||
function addressBookRights(role: string): Record<string, boolean> {
|
||||
switch (role) {
|
||||
case "read":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayWrite: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
};
|
||||
case "readWrite":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayWrite: true,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
};
|
||||
case "manager":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayWrite: true,
|
||||
mayShare: true,
|
||||
mayDelete: true,
|
||||
};
|
||||
default:
|
||||
return addressBookRights("read");
|
||||
}
|
||||
}
|
||||
|
||||
function fileRights(role: string): Record<string, boolean> {
|
||||
switch (role) {
|
||||
case "read":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayAddChildren: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
mayModifyContent: false,
|
||||
mayShare: false,
|
||||
};
|
||||
case "readWrite":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayAddChildren: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
mayModifyContent: true,
|
||||
mayShare: false,
|
||||
};
|
||||
case "manager":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayAddChildren: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
mayModifyContent: true,
|
||||
mayShare: true,
|
||||
};
|
||||
default:
|
||||
return fileRights("read");
|
||||
}
|
||||
}
|
||||
|
||||
function readRights(): Record<string, boolean> {
|
||||
return { mayRead: true };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createVncMeeting } from "@/lib/vnctalk/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
function getClientIP(request: NextRequest): string {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) return forwarded.split(",")[0].trim();
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.name || !body.start || !body.end) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: name, start, end" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const invitees: string[] = Array.isArray(body.invitees) ? body.invitees : [];
|
||||
|
||||
const result = await createVncMeeting({
|
||||
name: String(body.name),
|
||||
start: String(body.start),
|
||||
end: String(body.end),
|
||||
invitees,
|
||||
password: body.password ? String(body.password) : undefined,
|
||||
description: body.description ? String(body.description) : undefined,
|
||||
});
|
||||
|
||||
logger.info("VNCtalk meeting created", {
|
||||
meetingId: result.meetingId,
|
||||
ip: getClientIP(request),
|
||||
});
|
||||
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error("VNCtalk meeting creation failed", { error: message });
|
||||
|
||||
if (message.includes("not configured")) {
|
||||
return NextResponse.json({ error: message }, { status: 503 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -46,7 +46,7 @@ export default async function manifest(): Promise<ExtendedManifest> {
|
||||
const appName =
|
||||
branded<string>("appName", "") ||
|
||||
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||
"Bulwark Webmail";
|
||||
"VNCmail+";
|
||||
|
||||
const shortName = branded<string>("appShortName", "") || appName;
|
||||
const description =
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,264 @@
|
||||
'use client';
|
||||
|
||||
// The AI Assistant's entry point in the MAIN mail view — a Sparkles button in
|
||||
// the search toolbar that opens a compact Ask dialog. Until this existed, the
|
||||
// only way to ask the assistant anything was the "Try it" box buried in
|
||||
// Settings → AI Assistant, which is a configuration screen, not a workflow.
|
||||
//
|
||||
// Deliberately reuses the exact same wire client (lib/ai/local-client's
|
||||
// askMail) and the exact same persisted provider settings as the Settings
|
||||
// pane — this is a second door to the same room, not a second room. When no
|
||||
// provider is configured yet, the dialog deep-links to the Settings pane
|
||||
// (where local-discovery offers the one-click Connect) instead of duplicating
|
||||
// that setup flow here.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, Loader2, Settings2, Sparkles, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
|
||||
import { supportsLocalLlm } from '@/lib/platform-capabilities';
|
||||
import { getAiApiKey } from '@/lib/ai/key-store';
|
||||
import { loadAiSettings, type AiLocalSettings } from '@/lib/ai/local-settings';
|
||||
import { askMail, type AskResult } from '@/lib/ai/local-client';
|
||||
|
||||
function useAiPolicy(): { policy: AiPolicy; loaded: boolean } {
|
||||
const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/api/ai/policy');
|
||||
if (res.ok && !cancelled) setPolicy(await res.json());
|
||||
} catch {
|
||||
/* stays at DEFAULT (disabled) — the button simply doesn't render */
|
||||
} finally {
|
||||
if (!cancelled) setLoaded(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
return { policy, loaded };
|
||||
}
|
||||
|
||||
/** Mirrors the Settings pane's canAsk gating: is any provider actually ready? */
|
||||
function providerConfigured(settings: AiLocalSettings, policy: AiPolicy): boolean {
|
||||
const classes = policy.entitlement.classes;
|
||||
switch (settings.provider) {
|
||||
case 'local':
|
||||
return supportsLocalLlm() && classes.includes('local') && !!settings.localModel;
|
||||
case 'server':
|
||||
return classes.includes('server') && !!settings.serverModel;
|
||||
case 'opencode':
|
||||
return classes.includes('opencode') && !!settings.opencodeModel;
|
||||
case 'public': {
|
||||
const active = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
|
||||
return classes.includes('public') && !!active && settings.publicConsentAccepted;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AiAskButton() {
|
||||
const router = useRouter();
|
||||
const { policy, loaded } = useAiPolicy();
|
||||
// Retrieval must read the SAME account slot the indexer wrote under.
|
||||
const activeSlot = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.cookieSlot);
|
||||
const [open, setOpen] = useState(false);
|
||||
// Re-read on every open: the user may have just configured a provider in
|
||||
// Settings and come straight back here — a mount-time snapshot would still
|
||||
// say "not configured".
|
||||
const [settings, setSettings] = useState<AiLocalSettings>(() => loadAiSettings());
|
||||
|
||||
const [question, setQuestion] = useState('');
|
||||
const [asking, setAsking] = useState(false);
|
||||
const [askResult, setAskResult] = useState<AskResult | null>(null);
|
||||
const [askError, setAskError] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const openDialog = useCallback(() => {
|
||||
setSettings(loadAiSettings());
|
||||
setAskResult(null);
|
||||
setAskError(null);
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
textareaRef.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open]);
|
||||
|
||||
const configured = providerConfigured(settings, policy);
|
||||
const canAsk = configured && question.trim().length > 0 && !asking;
|
||||
|
||||
const runAsk = useCallback(async () => {
|
||||
if (!canAsk) return;
|
||||
setAsking(true);
|
||||
setAskError(null);
|
||||
setAskResult(null);
|
||||
try {
|
||||
const activeProfile = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
|
||||
const key = activeProfile ? getAiApiKey(activeProfile.id) : null;
|
||||
const result = await askMail(question.trim(), {
|
||||
provider: settings.provider as 'local' | 'server' | 'public' | 'opencode',
|
||||
localBaseUrl: settings.localBaseUrl,
|
||||
localModel: settings.localModel,
|
||||
serverModel: settings.serverModel,
|
||||
opencodeModel: settings.opencodeModel,
|
||||
slot: activeSlot,
|
||||
publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null,
|
||||
});
|
||||
setAskResult(result);
|
||||
} catch (err) {
|
||||
setAskError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setAsking(false);
|
||||
}
|
||||
}, [canAsk, question, settings, activeSlot]);
|
||||
|
||||
const goToSettings = useCallback(() => {
|
||||
// The Settings page's one-shot deep-link channel (see readPersistedTab in
|
||||
// app/(main)/[locale]/settings/page.tsx) — lands directly on the AI pane,
|
||||
// where local-discovery's Connect banner does the actual setup.
|
||||
try {
|
||||
sessionStorage.setItem('settings-deep-link-tab', 'ai_assistant');
|
||||
} catch {
|
||||
/* private mode — the settings page just opens on its default tab */
|
||||
}
|
||||
setOpen(false);
|
||||
router.push('/settings');
|
||||
}, [router]);
|
||||
|
||||
// Hidden entirely when the admin gate is off or no provider class is
|
||||
// allowed — same visibility rule as the Settings pane itself.
|
||||
if (!loaded || !policy.enabled || policy.entitlement.classes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDialog}
|
||||
className="flex-shrink-0 p-2 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="AI Assistant"
|
||||
aria-label="AI Assistant"
|
||||
data-tour="ai-assistant"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-[10vh]"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) setOpen(false);
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="AI Assistant"
|
||||
>
|
||||
<div className="w-full max-w-xl rounded-xl border border-border bg-popover text-popover-foreground shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">AI Assistant</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{!configured ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No AI provider is set up yet. Pick one in Settings — if Ollama is running on this machine, a
|
||||
one-click Connect is waiting there.
|
||||
</p>
|
||||
<div>
|
||||
<Button size="sm" onClick={goToSettings}>
|
||||
<Settings2 className="w-3.5 h-3.5 me-1.5" />
|
||||
Open AI settings
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') void runAsk();
|
||||
}}
|
||||
rows={3}
|
||||
placeholder="Ask a question about your mail…"
|
||||
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 resize-y"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" onClick={() => void runAsk()} disabled={!canAsk}>
|
||||
{asking && <Loader2 className="w-3.5 h-3.5 me-1.5 animate-spin" />}
|
||||
Ask
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">⌘⏎ to send</span>
|
||||
</div>
|
||||
|
||||
{askError && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">{askError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{askResult && (
|
||||
<div className={cn('flex flex-col gap-2 rounded-lg border border-border p-4', 'max-h-[45vh] overflow-y-auto')}>
|
||||
{askResult.retrievalState === 'no-index' && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No local mail index available in this session — answered without your mail.
|
||||
</p>
|
||||
)}
|
||||
{askResult.retrievalState === 'no-match' && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Your mail index is available, but nothing in it matched this question — answered
|
||||
without your mail. It matches on keywords, so content questions work better than
|
||||
recency ones.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap">{askResult.answer}</p>
|
||||
{askResult.sources.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border pt-2 mt-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Sources</span>
|
||||
{askResult.sources.map((s, i) => (
|
||||
<span key={s.id} className="text-xs text-muted-foreground truncate">
|
||||
[{i + 1}] {s.subject}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
||||
import { Pencil, Trash2, Copy } from "lucide-react";
|
||||
|
||||
interface CalendarMonthViewProps {
|
||||
selectedDate: Date;
|
||||
@@ -25,6 +27,9 @@ interface CalendarMonthViewProps {
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||
onCreateAtTime?: (date: Date) => void;
|
||||
onEditEvent?: (event: CalendarEvent) => void;
|
||||
onDeleteEvent?: (event: CalendarEvent) => void;
|
||||
onDuplicateEvent?: (event: CalendarEvent) => void;
|
||||
firstDayOfWeek?: number;
|
||||
isMobile?: boolean;
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
@@ -41,6 +46,9 @@ export function CalendarMonthView({
|
||||
onContextMenuEvent,
|
||||
onContextMenuEmpty,
|
||||
onCreateAtTime,
|
||||
onEditEvent,
|
||||
onDeleteEvent,
|
||||
onDuplicateEvent,
|
||||
firstDayOfWeek = 1,
|
||||
isMobile,
|
||||
pendingPreview,
|
||||
@@ -112,6 +120,54 @@ export function CalendarMonthView({
|
||||
|
||||
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
|
||||
|
||||
// Radial menu state
|
||||
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
||||
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
||||
const [radialMenuEvent, setRadialMenuEvent] = useState<CalendarEvent | null>(null);
|
||||
|
||||
const closeRadialMenu = useCallback(() => {
|
||||
setRadialMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
||||
if (!radialMenuEvent) return [];
|
||||
const ev = radialMenuEvent;
|
||||
const items: RadialMenuItem[] = [];
|
||||
if (onEditEvent) {
|
||||
items.push({
|
||||
id: "edit",
|
||||
icon: <Pencil className="w-5 h-5" />,
|
||||
label: t("edit"),
|
||||
onClick: () => { onEditEvent(ev); },
|
||||
});
|
||||
}
|
||||
if (onDeleteEvent) {
|
||||
items.push({
|
||||
id: "delete",
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
label: t("delete"),
|
||||
onClick: () => { onDeleteEvent(ev); },
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
if (onDuplicateEvent) {
|
||||
items.push({
|
||||
id: "duplicate",
|
||||
icon: <Copy className="w-5 h-5" />,
|
||||
label: t("duplicate"),
|
||||
onClick: () => { onDuplicateEvent(ev); },
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [radialMenuEvent, t, onEditEvent, onDeleteEvent, onDuplicateEvent]);
|
||||
|
||||
const handleRadialMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => {
|
||||
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setRadialMenuEvent(event);
|
||||
setRadialMenuOpen(true);
|
||||
onContextMenuEvent?.(e, event);
|
||||
}, [onContextMenuEvent]);
|
||||
|
||||
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
|
||||
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
|
||||
e.preventDefault();
|
||||
@@ -294,7 +350,7 @@ export function CalendarMonthView({
|
||||
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
onContextMenu={handleRadialMenuEvent}
|
||||
draggable
|
||||
className={isMobile ? "text-[10px] px-1" : undefined}
|
||||
/>
|
||||
@@ -306,6 +362,13 @@ export function CalendarMonthView({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<RadialMenu
|
||||
items={radialMenuItems}
|
||||
isOpen={radialMenuOpen}
|
||||
position={radialMenuPos}
|
||||
onClose={closeRadialMenu}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createPortal } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||
Pencil, Trash2, Copy, Send, Check,
|
||||
Pencil, Trash2, Copy, Send, Check, ExternalLink, Globe,
|
||||
} from "lucide-react";
|
||||
import { format, isSameDay } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -107,6 +107,25 @@ function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTransl
|
||||
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
|
||||
}
|
||||
|
||||
const URL_REGEX = /(https?:\/\/[^\s<]+[^\s<.,;:!?'")\]}>])/g;
|
||||
|
||||
function linkifyText(text: string): (string | { url: string })[] {
|
||||
const parts: (string | { url: string })[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = URL_REGEX.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
parts.push({ url: match[1] });
|
||||
lastIndex = match.index + match[1].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function EventDetailPopover({
|
||||
event,
|
||||
calendar,
|
||||
@@ -383,21 +402,34 @@ export function EventDetailPopover({
|
||||
{locationName && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a
|
||||
href={locationName}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline truncate"
|
||||
title={locationName}
|
||||
>
|
||||
{(() => {
|
||||
try { return new URL(locationName).hostname; } catch { return locationName; }
|
||||
})()}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a
|
||||
href={locationName}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline truncate block"
|
||||
title={locationName}
|
||||
>
|
||||
{(() => {
|
||||
try { return new URL(locationName).hostname; } catch { return locationName; }
|
||||
})()}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
<a
|
||||
href={`https://maps.google.com/?q=${encodeURIComponent(locationName)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-primary hover:underline mt-0.5 inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
View on Map
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -413,6 +445,8 @@ export function EventDetailPopover({
|
||||
title={virtualLocation}
|
||||
>
|
||||
{(() => {
|
||||
const isVncMeeting = event.links?.["vnctalk-meeting"];
|
||||
if (isVncMeeting) return "Join VNCtalk Meeting";
|
||||
try {
|
||||
return new URL(virtualLocation).hostname;
|
||||
} catch {
|
||||
@@ -423,6 +457,30 @@ export function EventDetailPopover({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VNCtalk Meeting "Join" button (when meeting via links) */}
|
||||
{!virtualLocation && event.links?.["vnctalk-meeting"] && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<a
|
||||
href={event.links["vnctalk-meeting"].href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
Join VNCtalk Meeting
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timezone */}
|
||||
{!event.showWithoutTime && event.timeZone && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Globe className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">{event.timeZone}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{hasParticipants && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
@@ -476,7 +534,21 @@ export function EventDetailPopover({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3">
|
||||
{event.description}
|
||||
{linkifyText(event.description).map((part, i) =>
|
||||
typeof part === "string" ? (
|
||||
<span key={i}>{part}</span>
|
||||
) : (
|
||||
<a
|
||||
key={i}
|
||||
href={part.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{part.url}
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,13 +4,14 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus, Eye, EyeOff, ExternalLink, Reply, ReplyAll, Globe, Building2 } from "lucide-react";
|
||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||
import { FreeBusyView } from "./free-busy-view";
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
@@ -25,6 +26,10 @@ import { generateUUID } from "@/lib/utils";
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
import { calendarHooks } from "@/lib/plugin-hooks";
|
||||
import type { ConflictWarning } from "@/lib/plugin-types";
|
||||
import { RecipientPopover } from "@/components/email/recipient-popover";
|
||||
import { useProTabStore } from "@/stores/pro-tab-store";
|
||||
import { ResourcePicker } from "./resource-picker";
|
||||
import { useResourceStore } from "@/stores/resource-store";
|
||||
|
||||
export interface PendingEventPreview {
|
||||
start: Date;
|
||||
@@ -49,6 +54,10 @@ interface EventModalProps {
|
||||
onPreviewChange?: (preview: PendingEventPreview | null) => void;
|
||||
currentUserEmails?: string[];
|
||||
isMobile?: boolean;
|
||||
prefillTitle?: string;
|
||||
prefillDescription?: string;
|
||||
prefillParticipants?: { name?: string; email: string }[];
|
||||
prefillDate?: string;
|
||||
}
|
||||
|
||||
function formatDateInput(d: Date): string {
|
||||
@@ -59,6 +68,25 @@ function formatTimeInput(d: Date): string {
|
||||
return format(d, "HH:mm");
|
||||
}
|
||||
|
||||
const URL_REGEX = /(https?:\/\/[^\s<]+[^\s<.,;:!?'")\]}>])/g;
|
||||
|
||||
function linkifyText(text: string): (string | { url: string })[] {
|
||||
const parts: (string | { url: string })[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = URL_REGEX.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
parts.push({ url: match[1] });
|
||||
lastIndex = match.index + match[1].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function buildDuration(startDate: Date, endDate: Date): string {
|
||||
const diffMs = endDate.getTime() - startDate.getTime();
|
||||
const totalMinutes = Math.max(0, Math.floor(diffMs / 60000));
|
||||
@@ -178,6 +206,10 @@ export function EventModal({
|
||||
onPreviewChange,
|
||||
currentUserEmails = [],
|
||||
isMobile = false,
|
||||
prefillTitle,
|
||||
prefillDescription,
|
||||
prefillParticipants,
|
||||
prefillDate,
|
||||
}: EventModalProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const locale = useLocale();
|
||||
@@ -228,6 +260,10 @@ export function EventModal({
|
||||
d.setHours(now.getHours() + 1, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
if (prefillDate) {
|
||||
const d = new Date(prefillDate);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() + 1, 0, 0, 0);
|
||||
return d;
|
||||
@@ -244,8 +280,8 @@ export function EventModal({
|
||||
return addHours(getInitialStart(), 1);
|
||||
};
|
||||
|
||||
const [title, setTitle] = useState(event?.title || "");
|
||||
const [description, setDescription] = useState(event?.description || "");
|
||||
const [title, setTitle] = useState(event?.title || prefillTitle || "");
|
||||
const [description, setDescription] = useState(event?.description || prefillDescription || "");
|
||||
const [location, setLocation] = useState(
|
||||
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
||||
);
|
||||
@@ -328,13 +364,29 @@ export function EventModal({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => {
|
||||
if (!event?.participants) return [];
|
||||
if (!event?.participants) {
|
||||
if (prefillParticipants && prefillParticipants.length > 0) {
|
||||
return prefillParticipants.map(p => ({ name: p.name || "", email: p.email }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return existingParticipants
|
||||
.filter(p => !p.isOrganizer)
|
||||
.map(p => ({ name: p.name, email: p.email }));
|
||||
});
|
||||
const [sendInvitations, setSendInvitations] = useState(true);
|
||||
const [showFreeBusy, setShowFreeBusy] = useState(false);
|
||||
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
||||
const [createVncMeeting, setCreateVncMeeting] = useState(false);
|
||||
const [meetingCreating, setMeetingCreating] = useState(false);
|
||||
const [timezone, setTimezone] = useState(() => {
|
||||
if (event?.timeZone) return event.timeZone;
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return "UTC"; }
|
||||
});
|
||||
const openComposeTab = useProTabStore((s) => s.openComposeTab);
|
||||
|
||||
const resourceStore = useResourceStore();
|
||||
const [showResources, setShowResources] = useState(false);
|
||||
|
||||
// Plugin transform: collect conflict warnings for the current event form.
|
||||
// Re-runs (debounced) whenever fields that affect scheduling change.
|
||||
@@ -361,6 +413,13 @@ export function EventModal({
|
||||
return () => { cancelled = true; clearTimeout(t); };
|
||||
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (event?.id) {
|
||||
resourceStore.fetchEventBookings(event.id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [event?.id]);
|
||||
|
||||
// Report live preview to parent for grid outline
|
||||
useEffect(() => {
|
||||
if (!onPreviewChange || isEdit) return;
|
||||
@@ -416,7 +475,7 @@ export function EventModal({
|
||||
duration = buildDuration(start, end);
|
||||
}
|
||||
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const timeZone = timezone;
|
||||
|
||||
const data: Partial<CalendarEvent> = {
|
||||
title: trimmedTitle,
|
||||
@@ -534,14 +593,72 @@ export function EventModal({
|
||||
data.organizerCalendarAddress = null;
|
||||
}
|
||||
|
||||
// VNCtalk meeting creation
|
||||
if (createVncMeeting && effectiveAttendees.length > 0 && !allDay) {
|
||||
setMeetingCreating(true);
|
||||
try {
|
||||
const vncRes = await fetch("/api/vnctalk/meeting", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: trimmedTitle,
|
||||
start: startStr,
|
||||
end: allDay
|
||||
? `${endDate}T23:59:59`
|
||||
: `${endDate}T${endTime}:00`,
|
||||
invitees: effectiveAttendees.map((a: { email: string }) => a.email),
|
||||
description: description.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
if (vncRes.ok) {
|
||||
const { meetingUrl, meetingId } = await vncRes.json();
|
||||
data.virtualLocations = {
|
||||
vl1: {
|
||||
"@type": "VirtualLocation",
|
||||
name: "VNCtalk Meeting",
|
||||
description: `Meeting ID: ${meetingId}`,
|
||||
uri: meetingUrl,
|
||||
features: null,
|
||||
},
|
||||
};
|
||||
data.links = {
|
||||
"vnctalk-meeting": {
|
||||
"@type": "Link",
|
||||
href: meetingUrl,
|
||||
cid: meetingId,
|
||||
contentType: null,
|
||||
size: null,
|
||||
rel: "vnctalk-meeting",
|
||||
display: null,
|
||||
title: "VNCtalk Meeting",
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create VNCtalk meeting:", err);
|
||||
} finally {
|
||||
setMeetingCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(data, shouldSendScheduling);
|
||||
if (resourceStore.selectedResources.length > 0) {
|
||||
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
|
||||
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
|
||||
const eventRef = event?.id || data.uid;
|
||||
await resourceStore.bookSelectedResources(
|
||||
startStr,
|
||||
endStr,
|
||||
eventRef,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving, createVncMeeting, timezone, resourceStore]);
|
||||
|
||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||
if (!event || !userParticipantId || !onRsvp) return;
|
||||
@@ -575,6 +692,30 @@ export function EventModal({
|
||||
onDuplicate(data);
|
||||
}, [event, onDuplicate]);
|
||||
|
||||
const handleReply = useCallback((replyAll: boolean) => {
|
||||
if (!event) return;
|
||||
const participants = getParticipantList(event);
|
||||
const recipientEmails = replyAll
|
||||
? participants.map((p) => ({ email: p.email, name: p.name }))
|
||||
: (() => {
|
||||
const org = participants.find((p) => p.isOrganizer);
|
||||
return org ? [{ email: org.email, name: org.name }] : [];
|
||||
})();
|
||||
if (recipientEmails.length === 0) return;
|
||||
openComposeTab({
|
||||
sessionId: Date.now(),
|
||||
mode: replyAll ? "replyAll" : "reply",
|
||||
title: `Re: ${event.title}`,
|
||||
replyTo: {
|
||||
subject: `Re: ${event.title}`,
|
||||
to: recipientEmails,
|
||||
},
|
||||
});
|
||||
}, [event, openComposeTab]);
|
||||
|
||||
const handleReplyAll = useCallback(() => handleReply(true), [handleReply]);
|
||||
const handleReplySingle = useCallback(() => handleReply(false), [handleReply]);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -855,22 +996,57 @@ export function EventModal({
|
||||
{locationName && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={locationName}>
|
||||
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate block" title={locationName}>
|
||||
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
<a
|
||||
href={`https://maps.google.com/?q=${encodeURIComponent(locationName)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-primary hover:underline mt-0.5 inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
View on Map
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Virtual Location */}
|
||||
{/* Virtual Location / Meeting Link */}
|
||||
{virtualLoc && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<a href={virtualLoc} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={virtualLoc}>
|
||||
{(() => { try { return new URL(virtualLoc).hostname; } catch { return virtualLoc; } })()}
|
||||
<div className="min-w-0">
|
||||
<a href={virtualLoc} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate block" title={virtualLoc}>
|
||||
{(() => {
|
||||
const isVncMeeting = event.links?.["vnctalk-meeting"];
|
||||
if (isVncMeeting) return "Join VNCtalk Meeting";
|
||||
try { return new URL(virtualLoc).hostname; } catch { return virtualLoc; }
|
||||
})()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VNCtalk Meeting "Join" button (when meeting via links) */}
|
||||
{!virtualLoc && event.links?.["vnctalk-meeting"] && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<a
|
||||
href={event.links["vnctalk-meeting"].href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
Join VNCtalk Meeting
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
@@ -887,7 +1063,7 @@ export function EventModal({
|
||||
{viewParticipants.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate text-foreground">
|
||||
{p.name || p.email}
|
||||
<RecipientPopover name={p.name} email={p.email} />
|
||||
{p.isOrganizer && (
|
||||
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
|
||||
)}
|
||||
@@ -900,6 +1076,34 @@ export function EventModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resources (booked) */}
|
||||
{resourceStore.bookings.length > 0 && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Building2 className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{resourceStore.bookings.map((b) => {
|
||||
const res = resourceStore.resources.find((r) => r.id === b.resourceId);
|
||||
return (
|
||||
<span
|
||||
key={b.id}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs font-medium"
|
||||
>
|
||||
{res?.name || b.resourceId}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timezone */}
|
||||
{!event.showWithoutTime && event.timeZone && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Globe className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">{event.timeZone}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurrence */}
|
||||
{recurrenceLabel && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
@@ -920,7 +1124,23 @@ export function EventModal({
|
||||
{event.description && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line">{event.description}</p>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line">
|
||||
{linkifyText(event.description).map((part, i) =>
|
||||
typeof part === "string" ? (
|
||||
<span key={i}>{part}</span>
|
||||
) : (
|
||||
<a
|
||||
key={i}
|
||||
href={part.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{part.url}
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -933,7 +1153,7 @@ export function EventModal({
|
||||
showDeleteConfirm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-destructive">{t("form.delete_confirm")}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => { onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-destructive border-destructive/30">
|
||||
<Button variant="outline" size="sm" onClick={() => { resourceStore.cancelEventBookings(event.id); onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-destructive border-destructive/30">
|
||||
{t("events.delete")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||
@@ -953,6 +1173,18 @@ export function EventModal({
|
||||
{t("events.duplicate")}
|
||||
</Button>
|
||||
)}
|
||||
{hasParticipants && !showDeleteConfirm && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={handleReplySingle} aria-label="Reply to organizer">
|
||||
<Reply className="w-4 h-4 me-1" />
|
||||
Reply
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleReplyAll} aria-label="Reply All">
|
||||
<ReplyAll className="w-4 h-4 me-1" />
|
||||
Reply All
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!showDeleteConfirm && (
|
||||
<Button onClick={() => setMode("edit")}>
|
||||
@@ -1042,6 +1274,21 @@ export function EventModal({
|
||||
setVirtualLocation,
|
||||
}}
|
||||
/>
|
||||
{attendees.length > 0 && !allDay && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="createVncMeeting"
|
||||
checked={createVncMeeting}
|
||||
onChange={(e) => setCreateVncMeeting(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
disabled={meetingCreating}
|
||||
/>
|
||||
<label htmlFor="createVncMeeting" className="text-sm">
|
||||
{meetingCreating ? "Creating meeting..." : "Create VNCtalk Meeting"}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1057,6 +1304,44 @@ export function EventModal({
|
||||
onAdd={handleAddAttendee}
|
||||
onRemove={handleRemoveAttendee}
|
||||
/>
|
||||
{attendees.length > 0 && !allDay && (
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFreeBusy((prev) => !prev)}
|
||||
className="text-xs"
|
||||
>
|
||||
{showFreeBusy ? (
|
||||
<EyeOff className="w-3.5 h-3.5 me-1" />
|
||||
) : (
|
||||
<Eye className="w-3.5 h-3.5 me-1" />
|
||||
)}
|
||||
{showFreeBusy ? t("freeBusy.hide") : t("freeBusy.check")}
|
||||
</Button>
|
||||
{showFreeBusy && (
|
||||
<div className="mt-3">
|
||||
<FreeBusyView
|
||||
participants={attendees}
|
||||
startDate={(() => {
|
||||
const d = new Date(`${startDate}T${startTime}:00`);
|
||||
return isNaN(d.getTime()) ? new Date() : d;
|
||||
})()}
|
||||
endDate={(() => {
|
||||
const d = new Date(`${endDate}T${endTime}:00`);
|
||||
return isNaN(d.getTime()) ? addHours(new Date(`${startDate}T${startTime}:00`), 8) : d;
|
||||
})()}
|
||||
onTimeSelect={(start, end) => {
|
||||
setStartDate(formatDateInput(start));
|
||||
setStartTime(formatTimeInput(start));
|
||||
setEndDate(formatDateInput(end));
|
||||
setEndTime(formatTimeInput(end));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isEdit && statusCounts && (existingParticipants.length > 0) && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{t("participants.status_summary", {
|
||||
@@ -1067,6 +1352,27 @@ export function EventModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setShowResources((prev) => !prev)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Building2 className="w-3.5 h-3.5 me-1" />
|
||||
{showResources ? t("resources.hide") : t("resources.title")}
|
||||
</Button>
|
||||
{showResources && (
|
||||
<div className="mt-3">
|
||||
<ResourcePicker
|
||||
start={allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`}
|
||||
end={allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1121,6 +1427,32 @@ export function EventModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!allDay && (
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Globe className="w-4 h-4" />
|
||||
Timezone
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
return Intl.supportedValuesOf("timeZone");
|
||||
} catch {
|
||||
return [timezone || "UTC"];
|
||||
}
|
||||
})().map((tz: string) => (
|
||||
<option key={tz} value={tz}>{tz}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pluginConflictWarnings.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{pluginConflictWarnings.map(w => (
|
||||
@@ -1304,7 +1636,7 @@ export function EventModal({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
||||
onClick={() => { resourceStore.cancelEventBookings(event!.id); onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
||||
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
||||
>
|
||||
{t("events.delete")}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { addMinutes, differenceInMinutes, format } from "date-fns";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fetchFreeBusy, type FreeBusySlot, isWorkingHour as isWorkingHourFn } from "@/lib/calendar-freebusy";
|
||||
|
||||
export interface ResourceFreeBusyEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
availabilityMap: Map<number, FreeBusySlot["status"]>;
|
||||
}
|
||||
|
||||
export interface FreeBusyViewProps {
|
||||
participants: { name?: string; email: string }[];
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
onTimeSelect?: (start: Date, end: Date) => void;
|
||||
resources?: ResourceFreeBusyEntry[];
|
||||
}
|
||||
|
||||
const SLOT_MINUTES = 30;
|
||||
const WORK_START_HOUR = 8;
|
||||
const WORK_END_HOUR = 18;
|
||||
|
||||
const statusColors: Record<FreeBusySlot["status"], string> = {
|
||||
free: "bg-emerald-100 dark:bg-emerald-900/40 border-emerald-200 dark:border-emerald-800",
|
||||
busy: "bg-red-100 dark:bg-red-900/40 border-red-200 dark:border-red-800",
|
||||
tentative: "bg-amber-100 dark:bg-amber-900/40 border-amber-200 dark:border-amber-800",
|
||||
unavailable: "bg-purple-100 dark:bg-purple-900/40 border-purple-200 dark:border-purple-800",
|
||||
unknown: "bg-muted border-muted-foreground/20",
|
||||
};
|
||||
|
||||
const statusHoverColors: Record<FreeBusySlot["status"], string> = {
|
||||
free: "hover:bg-emerald-200 dark:hover:bg-emerald-800/60",
|
||||
busy: "hover:bg-red-200 dark:hover:bg-red-800/60",
|
||||
tentative: "hover:bg-amber-200 dark:hover:bg-amber-800/60",
|
||||
unavailable: "hover:bg-purple-200 dark:hover:bg-purple-800/60",
|
||||
unknown: "hover:bg-muted-foreground/20",
|
||||
};
|
||||
|
||||
function clampToSlot(d: Date): Date {
|
||||
const clone = new Date(d);
|
||||
clone.setSeconds(0, 0);
|
||||
const mins = clone.getMinutes();
|
||||
const remainder = mins % SLOT_MINUTES;
|
||||
if (remainder !== 0) {
|
||||
clone.setMinutes(mins - remainder, 0, 0);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
function buildHourSlots(start: Date, end: Date): { label: string; slots: FreeBusySlot[] }[] {
|
||||
const hours: { label: string; slots: FreeBusySlot[] }[] = [];
|
||||
let cursor = clampToSlot(start);
|
||||
while (cursor < end) {
|
||||
const hourEnd = new Date(cursor);
|
||||
hourEnd.setHours(hourEnd.getHours() + 1, 0, 0, 0);
|
||||
const hourSlots: FreeBusySlot[] = [];
|
||||
let slotCursor = new Date(cursor);
|
||||
while (slotCursor < hourEnd && slotCursor < end) {
|
||||
const slotEnd = addMinutes(slotCursor, SLOT_MINUTES);
|
||||
hourSlots.push({
|
||||
start: new Date(slotCursor),
|
||||
end: slotEnd > end ? new Date(end) : slotEnd,
|
||||
status: "unknown",
|
||||
});
|
||||
slotCursor = slotEnd;
|
||||
}
|
||||
hours.push({ label: format(cursor, "HH:mm"), slots: hourSlots });
|
||||
cursor = hourEnd;
|
||||
}
|
||||
return hours;
|
||||
}
|
||||
|
||||
function isWorkingHour(hour: number): boolean {
|
||||
return isWorkingHourFn(hour, WORK_START_HOUR, WORK_END_HOUR);
|
||||
}
|
||||
|
||||
export function FreeBusyView({
|
||||
participants,
|
||||
startDate,
|
||||
endDate,
|
||||
onTimeSelect,
|
||||
resources = [],
|
||||
}: FreeBusyViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const [freeBusyData, setFreeBusyData] = useState<Map<string, FreeBusySlot[]> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hoveredSlot, setHoveredSlot] = useState<{
|
||||
participant: string;
|
||||
slotIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const hourSlots = useMemo(() => buildHourSlots(startDate, endDate), [startDate, endDate]);
|
||||
const totalHalfHourSlots = useMemo(() => {
|
||||
let c = 0;
|
||||
for (const h of hourSlots) c += h.slots.length;
|
||||
return c;
|
||||
}, [hourSlots]);
|
||||
|
||||
const now = new Date();
|
||||
const showNowLine =
|
||||
now >= startDate && now <= endDate;
|
||||
const nowPositionPercent = showNowLine
|
||||
? Math.max(0, Math.min(100, (differenceInMinutes(now, startDate) / differenceInMinutes(endDate, startDate)) * 100))
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || participants.length === 0) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchFreeBusy(client, participants, startDate, endDate)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setFreeBusyData(data);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, participants, startDate, endDate]);
|
||||
|
||||
const handleSlotClick = useCallback(
|
||||
(slot: FreeBusySlot) => {
|
||||
if (slot.status === "free" && onTimeSelect) {
|
||||
onTimeSelect(new Date(slot.start), new Date(slot.end));
|
||||
}
|
||||
},
|
||||
[onTimeSelect]
|
||||
);
|
||||
|
||||
const timezone = useMemo(
|
||||
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
[]
|
||||
);
|
||||
|
||||
if (participants.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("freeBusy.no_participants")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("freeBusy.timezone")}: {timezone}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="text-xs text-muted-foreground animate-pulse">
|
||||
{t("freeBusy.loading")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto border border-border rounded-lg">
|
||||
<div className="min-w-max" style={{ minWidth: totalHalfHourSlots * 24 + 200 }}>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 bg-background border-b border-r border-border px-3 py-2 text-left w-[180px] min-w-[180px]">
|
||||
{t("participants.title")}
|
||||
</th>
|
||||
{hourSlots.map((hour, i) => (
|
||||
<th
|
||||
key={i}
|
||||
colSpan={hour.slots.length}
|
||||
className={cn(
|
||||
"border-b border-r border-border px-1 py-2 text-center font-medium",
|
||||
isWorkingHour(new Date(hour.slots[0]?.start).getHours())
|
||||
? "bg-muted/50"
|
||||
: "bg-muted/20"
|
||||
)}
|
||||
>
|
||||
{hour.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{participants.map((p) => {
|
||||
const key = p.email.toLowerCase();
|
||||
const slots = freeBusyData?.get(key);
|
||||
return (
|
||||
<tr key={key} className="border-b border-border">
|
||||
<td className="sticky left-0 z-10 bg-background border-r border-border px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={p.name}
|
||||
email={p.email}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{p.name || p.email}
|
||||
</div>
|
||||
{p.name && (
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{p.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{hourSlots.map((hour) =>
|
||||
hour.slots.map((hourSlot, si) => {
|
||||
const globalSlotIndex =
|
||||
hourSlots
|
||||
.slice(0, hourSlots.indexOf(hour))
|
||||
.reduce((acc, h) => acc + h.slots.length, 0) + si;
|
||||
|
||||
const slot = slots?.[globalSlotIndex];
|
||||
const status = slot?.status ?? "unknown";
|
||||
const isFree = status === "free";
|
||||
const isHovered =
|
||||
hoveredSlot?.participant === key &&
|
||||
hoveredSlot?.slotIndex === globalSlotIndex;
|
||||
|
||||
return (
|
||||
<td
|
||||
key={si}
|
||||
className={cn(
|
||||
"border-r border-border py-1 text-center relative cursor-default transition-colors",
|
||||
statusColors[status],
|
||||
isFree && statusHoverColors[status],
|
||||
isFree && "cursor-pointer",
|
||||
isHovered && "ring-1 ring-inset ring-primary/50",
|
||||
isWorkingHour(new Date(hourSlot.start).getHours())
|
||||
? ""
|
||||
: "opacity-70"
|
||||
)}
|
||||
title={format(hourSlot.start, "HH:mm")}
|
||||
onClick={() =>
|
||||
isFree ? handleSlotClick(slot!) : undefined
|
||||
}
|
||||
onMouseEnter={() =>
|
||||
setHoveredSlot({
|
||||
participant: key,
|
||||
slotIndex: globalSlotIndex,
|
||||
})
|
||||
}
|
||||
onMouseLeave={() => setHoveredSlot(null)}
|
||||
>
|
||||
{status === "free" && (
|
||||
<span className="block w-full h-full"> </span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{resources.map((res) => (
|
||||
<tr key={`res-${res.id}`} className="border-b border-border">
|
||||
<td className="sticky left-0 z-10 bg-background border-r border-border px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center shrink-0">
|
||||
<span className="text-[10px] font-bold text-blue-600 dark:text-blue-400">
|
||||
R
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate text-sm">
|
||||
{res.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{hourSlots.map((hour) =>
|
||||
hour.slots.map((hourSlot, si) => {
|
||||
const globalSlotIndex =
|
||||
hourSlots
|
||||
.slice(0, hourSlots.indexOf(hour))
|
||||
.reduce((acc, h) => acc + h.slots.length, 0) + si;
|
||||
|
||||
const status = res.availabilityMap.get(globalSlotIndex) ?? "unknown";
|
||||
const isFree = status === "free";
|
||||
|
||||
return (
|
||||
<td
|
||||
key={si}
|
||||
className={cn(
|
||||
"border-r border-border py-1 text-center relative cursor-default transition-colors",
|
||||
statusColors[status],
|
||||
isFree && "cursor-pointer",
|
||||
isWorkingHour(new Date(hourSlot.start).getHours())
|
||||
? ""
|
||||
: "opacity-70"
|
||||
)}
|
||||
title={`${res.name} - ${format(hourSlot.start, "HH:mm")}`}
|
||||
>
|
||||
{status === "free" && (
|
||||
<span className="block w-full h-full"> </span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showNowLine && nowPositionPercent !== null && (
|
||||
<div
|
||||
className="absolute pointer-events-none z-20"
|
||||
style={{
|
||||
left: `calc(180px + ${nowPositionPercent}% * (1 - 180px / ${totalHalfHourSlots * 24 + 200}))`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-emerald-200 dark:border-emerald-800 bg-emerald-100 dark:bg-emerald-900/40" />
|
||||
{t("freeBusy.free")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-red-200 dark:border-red-800 bg-red-100 dark:bg-red-900/40" />
|
||||
{t("freeBusy.busy")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-amber-200 dark:border-amber-800 bg-amber-100 dark:bg-amber-900/40" />
|
||||
{t("freeBusy.tentative")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-purple-200 dark:border-purple-800 bg-purple-100 dark:bg-purple-900/40" />
|
||||
{t("freeBusy.unavailable")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-muted-foreground/20 bg-muted" />
|
||||
{t("freeBusy.unknown")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
eachDayOfInterval,
|
||||
format,
|
||||
isToday,
|
||||
isSameDay,
|
||||
addMonths,
|
||||
subMonths,
|
||||
isSameMonth,
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||
|
||||
interface MiniCalendarDashletProps {
|
||||
events?: { date: string; color?: string }[];
|
||||
onDayClick?: (date: Date) => void;
|
||||
selectedDate?: Date;
|
||||
}
|
||||
|
||||
const ALL_DAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
|
||||
|
||||
export function MiniCalendarDashlet({
|
||||
events: propEvents,
|
||||
onDayClick,
|
||||
selectedDate: propSelectedDate,
|
||||
}: MiniCalendarDashletProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const router = useRouter();
|
||||
const firstDayOfWeek = useSettingsStore((s) => s.firstDayOfWeek);
|
||||
const storeSelectedDate = useCalendarStore((s) => s.selectedDate);
|
||||
const storeEvents = useCalendarStore((s) => s.events);
|
||||
const selectedDate = propSelectedDate ?? storeSelectedDate;
|
||||
const client = useAuthStore((s) => s.client);
|
||||
|
||||
const [displayMonth, setDisplayMonth] = useState(() => new Date());
|
||||
|
||||
const weekStartsOn = useMemo(() => {
|
||||
if (firstDayOfWeek === 0) return 0 as const;
|
||||
if (firstDayOfWeek === 6) return 6 as const;
|
||||
return 1 as const;
|
||||
}, [firstDayOfWeek]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client) return;
|
||||
const start = format(startOfMonth(displayMonth), "yyyy-MM-dd'T'00:00:00");
|
||||
const end = format(endOfMonth(displayMonth), "yyyy-MM-dd'T'23:59:59");
|
||||
const { dateRange } = useCalendarStore.getState();
|
||||
if (dateRange?.start === start && dateRange?.end === end) return;
|
||||
useCalendarStore.getState().fetchEvents(client, start, end);
|
||||
}, [displayMonth, client]);
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(displayMonth);
|
||||
const monthEnd = endOfMonth(displayMonth);
|
||||
const calStart = startOfWeek(monthStart, { weekStartsOn });
|
||||
const calEnd = endOfWeek(monthEnd, { weekStartsOn });
|
||||
return eachDayOfInterval({ start: calStart, end: calEnd });
|
||||
}, [displayMonth, weekStartsOn]);
|
||||
|
||||
const eventDates = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const e of storeEvents) {
|
||||
try {
|
||||
const { startDay, endDay } = getEventDayBounds(e);
|
||||
const cursor = new Date(startDay);
|
||||
while (cursor <= endDay) {
|
||||
set.add(format(cursor, "yyyy-MM-dd"));
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
if (propEvents) {
|
||||
for (const e of propEvents) {
|
||||
set.add(e.date);
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}, [storeEvents, propEvents]);
|
||||
|
||||
const dayHeaders = useMemo(
|
||||
() => [...ALL_DAY_KEYS.slice(weekStartsOn), ...ALL_DAY_KEYS.slice(0, weekStartsOn)],
|
||||
[weekStartsOn],
|
||||
);
|
||||
|
||||
const handlePrevMonth = useCallback(() => {
|
||||
setDisplayMonth((prev) => subMonths(prev, 1));
|
||||
}, []);
|
||||
|
||||
const handleNextMonth = useCallback(() => {
|
||||
setDisplayMonth((prev) => addMonths(prev, 1));
|
||||
}, []);
|
||||
|
||||
const handleGoToToday = useCallback(() => {
|
||||
setDisplayMonth(new Date());
|
||||
}, []);
|
||||
|
||||
const handleDayClick = useCallback(
|
||||
(day: Date) => {
|
||||
useCalendarStore.getState().setSelectedDate(day);
|
||||
if (onDayClick) {
|
||||
onDayClick(day);
|
||||
} else {
|
||||
router.push("/calendar");
|
||||
}
|
||||
},
|
||||
[onDayClick, router],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="select-none px-2 py-1.5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<button
|
||||
onClick={handlePrevMonth}
|
||||
className="p-0.5 rounded hover:bg-muted transition-colors"
|
||||
aria-label={t("nav_prev")}
|
||||
>
|
||||
<ChevronLeft className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGoToToday}
|
||||
className="text-xs font-medium hover:bg-muted px-1.5 py-0.5 rounded transition-colors"
|
||||
title={t("views.today")}
|
||||
>
|
||||
{format(displayMonth, "MMM yyyy")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextMonth}
|
||||
className="p-0.5 rounded hover:bg-muted transition-colors"
|
||||
aria-label={t("nav_next")}
|
||||
>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 mb-0.5">
|
||||
{dayHeaders.map((dh) => (
|
||||
<div
|
||||
key={dh}
|
||||
className="text-center text-[9px] font-medium text-muted-foreground py-0.5"
|
||||
>
|
||||
{t(`days.${dh}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0">
|
||||
{days.map((day) => {
|
||||
const inMonth = isSameMonth(day, displayMonth);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
const today = isToday(day);
|
||||
const dateStr = format(day, "yyyy-MM-dd");
|
||||
const hasEvent = eventDates.has(dateStr);
|
||||
const dotColor =
|
||||
propEvents?.find((e) => e.date === dateStr && e.color)?.color ??
|
||||
undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => handleDayClick(day)}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-6 h-6 text-[11px] rounded-full transition-colors mx-auto",
|
||||
!inMonth && "text-muted-foreground/30",
|
||||
inMonth && !selected && "hover:bg-muted",
|
||||
today && !selected && "font-bold text-primary",
|
||||
selected && "bg-primary text-primary-foreground",
|
||||
)}
|
||||
>
|
||||
{day.getDate()}
|
||||
{hasEvent && !selected && (
|
||||
<span
|
||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary"
|
||||
style={dotColor ? { backgroundColor: dotColor } : undefined}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useResourceStore } from "@/stores/resource-store";
|
||||
import type { Resource } from "@/lib/resources/client";
|
||||
import {
|
||||
Building2,
|
||||
Car,
|
||||
Wrench,
|
||||
Box,
|
||||
MapPin,
|
||||
Users,
|
||||
Search,
|
||||
X,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ResourcePickerProps {
|
||||
start?: string;
|
||||
end?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const typeIcons: Record<Resource["type"], typeof Building2> = {
|
||||
room: Building2,
|
||||
vehicle: Car,
|
||||
equipment: Wrench,
|
||||
other: Box,
|
||||
};
|
||||
|
||||
type TypeFilter = "all" | Resource["type"];
|
||||
|
||||
export function ResourcePicker({ start, end, compact = false }: ResourcePickerProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const {
|
||||
resources,
|
||||
selectedResources,
|
||||
isLoading,
|
||||
fetchResources,
|
||||
searchResources,
|
||||
toggleResource,
|
||||
deselectResource,
|
||||
clearSelection,
|
||||
} = useResourceStore();
|
||||
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [availabilityMap, setAvailabilityMap] = useState<Record<string, "available" | "conflict" | "unknown">>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchResources();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = typeFilter === "all" ? resources : resources.filter((r) => r.type === typeFilter);
|
||||
if (query.trim()) {
|
||||
list = searchResources(query).filter((r) => typeFilter === "all" || r.type === typeFilter);
|
||||
}
|
||||
return list;
|
||||
}, [resources, typeFilter, query, searchResources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!start || !end) return;
|
||||
let cancelled = false;
|
||||
|
||||
const checkAll = async () => {
|
||||
const map: Record<string, "available" | "conflict" | "unknown"> = {};
|
||||
for (const resource of filtered) {
|
||||
try {
|
||||
const params = new URLSearchParams({ start, end });
|
||||
const { apiFetch } = await import("@/lib/browser-navigation");
|
||||
const res = await apiFetch(
|
||||
`/api/resources/${resource.id}/availability?${params.toString()}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
map[resource.id] = data.available ? "available" : "conflict";
|
||||
} else {
|
||||
map[resource.id] = "unknown";
|
||||
}
|
||||
} catch {
|
||||
map[resource.id] = "unknown";
|
||||
}
|
||||
}
|
||||
if (!cancelled) setAvailabilityMap(map);
|
||||
};
|
||||
|
||||
checkAll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filtered, start, end]);
|
||||
|
||||
const filters: { key: TypeFilter; label: string }[] = [
|
||||
{ key: "all", label: t("resources.filter_all") },
|
||||
{ key: "room", label: t("resources.type_room") },
|
||||
{ key: "vehicle", label: t("resources.type_vehicle") },
|
||||
{ key: "equipment", label: t("resources.type_equipment") },
|
||||
{ key: "other", label: t("resources.type_other") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => setTypeFilter(f.key)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
|
||||
typeFilter === f.key
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("resources.search_placeholder")}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("resources.no_resources")}
|
||||
</p>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"border border-border rounded-lg divide-y divide-border",
|
||||
!compact && "max-h-64 overflow-y-auto"
|
||||
)}>
|
||||
{filtered.map((resource) => {
|
||||
const TypeIcon = typeIcons[resource.type];
|
||||
const isSelected = selectedResources.some((r) => r.id === resource.id);
|
||||
const avail = availabilityMap[resource.id] || "unknown";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={resource.id}
|
||||
type="button"
|
||||
onClick={() => toggleResource(resource)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/10 hover:bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<span className="relative flex-shrink-0">
|
||||
<TypeIcon className="w-5 h-5 text-muted-foreground" />
|
||||
{start && end && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-background",
|
||||
avail === "available" && "bg-emerald-500",
|
||||
avail === "conflict" && "bg-red-500",
|
||||
avail === "unknown" && "bg-muted-foreground/40"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">{resource.name}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{resource.location && (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{resource.location}
|
||||
</span>
|
||||
)}
|
||||
{resource.capacity != null && resource.capacity > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Users className="w-3 h-3" />
|
||||
{resource.capacity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors",
|
||||
isSelected
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "border-muted-foreground/40"
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="w-3.5 h-3.5" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedResources.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{selectedResources.map((resource) => (
|
||||
<span
|
||||
key={resource.id}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary px-2.5 py-1 text-xs font-medium"
|
||||
>
|
||||
{resource.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deselectResource(resource.id)}
|
||||
className="ml-0.5 rounded-full p-0.5 hover:bg-primary/20 transition-colors"
|
||||
aria-label={t("resources.remove", { name: resource.name })}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{selectedResources.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
className="text-xs text-muted-foreground hover:text-foreground ml-1"
|
||||
>
|
||||
{t("resources.clear_all")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,26 +2,39 @@
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, FileText, AlertTriangle, X, Check } from "lucide-react";
|
||||
import { Upload, FileText, AlertTriangle, X, Check, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseVCard, detectDuplicates } from "@/lib/vcard";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import {
|
||||
parseCSV,
|
||||
autoMapColumns,
|
||||
mapRowToContact,
|
||||
detectDuplicatesByEmail,
|
||||
type CsvColumnMapping,
|
||||
type CsvParseResult,
|
||||
} from "@/lib/contact-csv-import";
|
||||
|
||||
type FileType = "vcf" | "csv" | null;
|
||||
|
||||
interface ContactImportDialogProps {
|
||||
existingContacts: ContactCard[];
|
||||
addressBooks?: AddressBook[];
|
||||
onImport: (contacts: ContactCard[]) => Promise<number>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ContactImportDialog({
|
||||
existingContacts,
|
||||
addressBooks,
|
||||
onImport,
|
||||
onClose,
|
||||
}: ContactImportDialogProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [fileType, setFileType] = useState<FileType>(null);
|
||||
const [parsed, setParsed] = useState<ContactCard[]>([]);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [duplicates, setDuplicates] = useState<Map<number, string>>(new Map());
|
||||
@@ -29,41 +42,111 @@ export function ContactImportDialog({
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [csvData, setCsvData] = useState<CsvParseResult | null>(null);
|
||||
const [mapping, setMapping] = useState<CsvColumnMapping | null>(null);
|
||||
const [targetBookId, setTargetBookId] = useState("");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const ALLOWED_ACCEPT = ".vcf,.vcard,.csv,text/csv,text/vcard";
|
||||
|
||||
const books = addressBooks || [];
|
||||
const defaultBookId =
|
||||
books.find((b) => b.isDefault)?.id || books[0]?.id || "";
|
||||
const effectiveBookId = targetBookId || defaultBookId;
|
||||
const bookOptions = books.map((b) => ({
|
||||
value: b.id,
|
||||
label: b.name,
|
||||
}));
|
||||
|
||||
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setFileType(null);
|
||||
setParsed([]);
|
||||
setSelected(new Set());
|
||||
setDuplicates(new Map());
|
||||
setCsvData(null);
|
||||
setMapping(null);
|
||||
setShowPreview(false);
|
||||
setTargetBookId("");
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError(t("import.file_too_large"));
|
||||
return;
|
||||
}
|
||||
|
||||
const name = file.name.toLowerCase();
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const contacts = parseVCard(text);
|
||||
if (name.endsWith(".csv") || file.type === "text/csv") {
|
||||
setFileType("csv");
|
||||
const text = await file.text();
|
||||
const result = parseCSV(text);
|
||||
|
||||
if (contacts.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
if (result.rows.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
}
|
||||
|
||||
setCsvData(result);
|
||||
setMapping(autoMapColumns(result.headers));
|
||||
setTargetBookId(defaultBookId);
|
||||
} else {
|
||||
setFileType("vcf");
|
||||
const text = await file.text();
|
||||
const contacts = parseVCard(text);
|
||||
|
||||
if (contacts.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
}
|
||||
|
||||
const dupes = detectDuplicates(existingContacts, contacts);
|
||||
setParsed(contacts);
|
||||
setDuplicates(dupes);
|
||||
|
||||
const initialSelected = new Set<number>();
|
||||
contacts.forEach((_, idx) => {
|
||||
if (!dupes.has(idx)) initialSelected.add(idx);
|
||||
});
|
||||
setSelected(initialSelected);
|
||||
}
|
||||
|
||||
const dupes = detectDuplicates(existingContacts, contacts);
|
||||
setParsed(contacts);
|
||||
setDuplicates(dupes);
|
||||
|
||||
const initialSelected = new Set<number>();
|
||||
contacts.forEach((_, idx) => {
|
||||
if (!dupes.has(idx)) initialSelected.add(idx);
|
||||
});
|
||||
setSelected(initialSelected);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse vCard:', error);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse file:", err);
|
||||
setError(t("import.parse_error"));
|
||||
}
|
||||
}, [existingContacts, t]);
|
||||
}, [existingContacts, t, defaultBookId]);
|
||||
|
||||
const applyCsvMapping = useCallback(() => {
|
||||
if (!csvData || !mapping) return;
|
||||
|
||||
const bookIds = effectiveBookId ? { [effectiveBookId]: true } : {};
|
||||
const contacts: ContactCard[] = [];
|
||||
|
||||
for (const row of csvData.rows) {
|
||||
const contact = mapRowToContact(row, mapping, bookIds);
|
||||
if (contact) contacts.push(contact);
|
||||
}
|
||||
|
||||
if (contacts.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
}
|
||||
|
||||
const dupes = detectDuplicatesByEmail(existingContacts, contacts);
|
||||
setParsed(contacts);
|
||||
setDuplicates(dupes);
|
||||
|
||||
const initialSelected = new Set<number>();
|
||||
contacts.forEach((_, idx) => {
|
||||
if (!dupes.has(idx)) initialSelected.add(idx);
|
||||
});
|
||||
setSelected(initialSelected);
|
||||
setShowPreview(true);
|
||||
}, [csvData, mapping, effectiveBookId, existingContacts, t]);
|
||||
|
||||
const toggleSelect = (idx: number) => {
|
||||
const next = new Set(selected);
|
||||
@@ -91,14 +174,160 @@ export function ContactImportDialog({
|
||||
try {
|
||||
const count = await onImport(toImport);
|
||||
setResult(count);
|
||||
} catch (error) {
|
||||
console.error('Failed to import contacts:', error);
|
||||
} catch (err) {
|
||||
console.error("Failed to import contacts:", err);
|
||||
setError(t("import.failed"));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCsvMapping = () => {
|
||||
if (!csvData || !mapping) return null;
|
||||
|
||||
const fields: Array<{ key: keyof CsvColumnMapping; label: string }> = [
|
||||
{ key: "firstName", label: t("import.csv_first_name") },
|
||||
{ key: "lastName", label: t("import.csv_last_name") },
|
||||
{ key: "email", label: t("import.csv_email") },
|
||||
{ key: "phone", label: t("import.csv_phone") },
|
||||
{ key: "company", label: t("import.csv_company") },
|
||||
{ key: "jobTitle", label: t("import.csv_job_title") },
|
||||
{ key: "address", label: t("import.csv_address") },
|
||||
{ key: "city", label: t("import.csv_city") },
|
||||
{ key: "region", label: t("import.csv_region") },
|
||||
{ key: "postcode", label: t("import.csv_postcode") },
|
||||
{ key: "country", label: t("import.csv_country") },
|
||||
{ key: "website", label: t("import.csv_website") },
|
||||
{ key: "note", label: t("import.csv_note") },
|
||||
{ key: "nickname", label: t("import.csv_nickname") },
|
||||
];
|
||||
|
||||
const headerOptions = csvData.headers.map((h, i) => ({
|
||||
value: String(i),
|
||||
label: h,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">{t("import.csv_map_columns")}</p>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-64 overflow-y-auto">
|
||||
{fields.map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground w-24 flex-shrink-0 truncate">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
value={mapping[key] >= 0 ? String(mapping[key]) : "-1"}
|
||||
onChange={(e) => {
|
||||
setMapping((prev) => prev ? {
|
||||
...prev,
|
||||
[key]: parseInt(e.target.value, 10),
|
||||
} : null);
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-xs rounded border border-border bg-muted text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
dir="auto"
|
||||
>
|
||||
<option value="-1">{t("import.csv_ignore")}</option>
|
||||
{headerOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{books.length > 0 && (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<label className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{t("import.csv_address_book")}
|
||||
</label>
|
||||
<select
|
||||
value={effectiveBookId}
|
||||
onChange={(e) => setTargetBookId(e.target.value)}
|
||||
className="px-2 py-1 text-xs rounded border border-border bg-muted text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
dir="auto"
|
||||
>
|
||||
{bookOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button size="sm" onClick={applyCsvMapping}>
|
||||
{t("import.csv_preview")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setFileType(null);
|
||||
setCsvData(null);
|
||||
setMapping(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}}
|
||||
>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCsvPreview = () => {
|
||||
if (!csvData || !mapping || !showPreview) return null;
|
||||
const previewRows = csvData.rows.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">{t("import.csv_preview_title", { count: parsed.length })}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(false)}
|
||||
>
|
||||
{t("import.csv_back")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-md overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted">
|
||||
{csvData.headers.map((h, i) => (
|
||||
<th key={i} className="px-2 py-1.5 text-start font-medium text-muted-foreground whitespace-nowrap">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewRows.map((row, ri) => (
|
||||
<tr key={ri} className="border-t border-border">
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="px-2 py-1.5 truncate max-w-[150px]">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={applyCsvMapping}>
|
||||
{t("import.csv_load_all")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
||||
@@ -119,12 +348,12 @@ export function ContactImportDialog({
|
||||
{t("import.close")}
|
||||
</Button>
|
||||
</div>
|
||||
) : parsed.length === 0 ? (
|
||||
) : fileType === null ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".vcf,.vcard"
|
||||
accept={ALLOWED_ACCEPT}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
@@ -141,7 +370,7 @@ export function ContactImportDialog({
|
||||
>
|
||||
<Upload className="w-8 h-8" />
|
||||
<p className="text-sm font-medium">{t("import.drop_hint")}</p>
|
||||
<p className="text-xs">{t("import.file_types")}</p>
|
||||
<p className="text-xs">{t("import.file_types_csv")}</p>
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
@@ -151,6 +380,10 @@ export function ContactImportDialog({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : fileType === "csv" && csvData && !showPreview ? (
|
||||
renderCsvMapping()
|
||||
) : fileType === "csv" && csvData && showPreview ? (
|
||||
renderCsvPreview()
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
@@ -217,7 +450,23 @@ export function ContactImportDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parsed.length > 0 && result === null && (
|
||||
{parsed.length > 0 && result === null && fileType !== "csv" && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("import.selected", { count: selected.size })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={isImporting}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting || selected.size === 0}>
|
||||
{isImporting ? t("import.importing") : t("import.import_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileType === "csv" && showPreview && parsed.length > 0 && result === null && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("import.selected", { count: selected.size })}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, useCallback } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu } from "lucide-react";
|
||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, Pencil } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContactListItem } from "./contact-list-item";
|
||||
import { ContactContextMenu } from "./contact-context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
|
||||
@@ -142,6 +143,63 @@ export function ContactList({
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
|
||||
|
||||
// Radial menu state
|
||||
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
||||
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
||||
const [radialMenuContact, setRadialMenuContact] = useState<ContactCard | null>(null);
|
||||
|
||||
const openRadialMenu = useCallback((e: React.MouseEvent, contact: ContactCard) => {
|
||||
e.preventDefault();
|
||||
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setRadialMenuContact(contact);
|
||||
setRadialMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeRadialMenu = useCallback(() => {
|
||||
setRadialMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
||||
if (!radialMenuContact) return [];
|
||||
const c = radialMenuContact;
|
||||
const items: RadialMenuItem[] = [];
|
||||
items.push({
|
||||
id: "edit",
|
||||
icon: <Pencil className="w-5 h-5" />,
|
||||
label: t("edit"),
|
||||
onClick: () => { onEditContact(c.id); },
|
||||
});
|
||||
items.push({
|
||||
id: "delete",
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
label: t("delete"),
|
||||
onClick: () => { onDeleteContact(c); },
|
||||
destructive: true,
|
||||
});
|
||||
if (c.emails && Object.keys(c.emails).length > 0) {
|
||||
const contactEmails = c.emails;
|
||||
items.push({
|
||||
id: "send-email",
|
||||
icon: <Mail className="w-5 h-5" />,
|
||||
label: t("send_email"),
|
||||
onClick: () => {
|
||||
const values = Object.values(contactEmails);
|
||||
if (values[0]?.address) {
|
||||
window.location.href = `mailto:${values[0].address}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
id: "export",
|
||||
icon: <Download className="w-5 h-5" />,
|
||||
label: t("export"),
|
||||
onClick: () => { onBulkExport(); },
|
||||
});
|
||||
return items;
|
||||
}, [radialMenuContact, t, onEditContact, onDeleteContact, onBulkExport]);
|
||||
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
|
||||
const activeFilters = countActiveFilters(filters);
|
||||
@@ -571,7 +629,7 @@ export function ContactList({
|
||||
e.stopPropagation();
|
||||
onToggleSelection(contact.id);
|
||||
}}
|
||||
onContextMenu={(e, c) => openContextMenu(e, c)}
|
||||
onContextMenu={(e, c) => { openContextMenu(e, c); openRadialMenu(e, c); }}
|
||||
/>
|
||||
);
|
||||
return groupByLetter ? (
|
||||
@@ -592,6 +650,14 @@ export function ContactList({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Radial Action Menu */}
|
||||
<RadialMenu
|
||||
items={radialMenuItems}
|
||||
isOpen={radialMenuOpen}
|
||||
position={radialMenuPos}
|
||||
onClose={closeRadialMenu}
|
||||
/>
|
||||
|
||||
{contextMenu.data && (
|
||||
<ContactContextMenu
|
||||
contact={contextMenu.data}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users } from "lucide-react";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users, PenLine } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -24,6 +24,7 @@ import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useSignatureStore } from "@/stores/signature-store";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
@@ -298,6 +299,34 @@ export function EmailComposer({
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const templatesEnabled = isFeatureEnabled('templatesEnabled');
|
||||
|
||||
const {
|
||||
signatures,
|
||||
defaultSignatureId,
|
||||
replySignatureId,
|
||||
getSignatureById,
|
||||
getIdentityDefaultSignatureId,
|
||||
getIdentityReplySignatureId,
|
||||
} = useSignatureStore();
|
||||
|
||||
const resolveStoreSignatureId = (): string | null => {
|
||||
const perIdentityId = selectedIdentityId || initialData?.selectedIdentityId || null;
|
||||
if (mode === 'compose') {
|
||||
if (perIdentityId) {
|
||||
const id = getIdentityDefaultSignatureId(perIdentityId);
|
||||
if (id) return id;
|
||||
}
|
||||
return defaultSignatureId;
|
||||
}
|
||||
if (perIdentityId) {
|
||||
const id = getIdentityReplySignatureId(perIdentityId);
|
||||
if (id) return id;
|
||||
}
|
||||
return replySignatureId ?? defaultSignatureId;
|
||||
};
|
||||
|
||||
const [selectedSignatureId, setSelectedSignatureId] = useState<string | null>(resolveStoreSignatureId);
|
||||
const selectedSignature = selectedSignatureId ? getSignatureById(selectedSignatureId) ?? null : null;
|
||||
|
||||
// The signature identity used when embedding the signature into the initial
|
||||
// body for "above quote" mode. Mirrors the signatureIdentity derivation
|
||||
// below, but uses initialData (or primary) since selectedIdentityId state
|
||||
@@ -509,17 +538,19 @@ export function EmailComposer({
|
||||
// requests with the same draftId. See bug #303.
|
||||
const inflightSaveRef = useRef<Promise<string | null> | null>(null);
|
||||
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
|
||||
if (mode === 'forward' && replyTo?.attachments?.length) {
|
||||
return replyTo.attachments
|
||||
if (replyTo?.attachments?.length) {
|
||||
let atts = replyTo.attachments;
|
||||
if (mode === 'forward') {
|
||||
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
|
||||
// (matches the viewer's hideInlineImageAttachments logic).
|
||||
.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
|
||||
.map(att => ({
|
||||
name: att.name || 'attachment',
|
||||
type: att.type || 'application/octet-stream',
|
||||
size: att.size,
|
||||
blobId: att.blobId,
|
||||
}));
|
||||
atts = atts.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')));
|
||||
}
|
||||
return atts.map(att => ({
|
||||
name: att.name || 'attachment',
|
||||
type: att.type || 'application/octet-stream',
|
||||
size: att.size,
|
||||
blobId: att.blobId,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
@@ -592,6 +623,7 @@ export function EmailComposer({
|
||||
// when the user switches identity in "above quote" mode without rebuilding
|
||||
// the whole body (which would lose user edits to the surrounding draft).
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const [editorReady, setEditorReady] = useState(false);
|
||||
const prevSignatureIdentityIdRef = useRef<string | null | undefined>(signatureIdentity?.id);
|
||||
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
|
||||
|
||||
@@ -668,6 +700,28 @@ export function EmailComposer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
|
||||
|
||||
const sigInsertedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (plainTextMode) return;
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
if (!selectedSignatureId) return;
|
||||
if (sigInsertedRef.current) return;
|
||||
const sig = getSignatureById(selectedSignatureId);
|
||||
if (!sig) return;
|
||||
const currentHtml = serializeEditorContent(editor);
|
||||
if (currentHtml.includes(sig.body)) {
|
||||
sigInsertedRef.current = true;
|
||||
return;
|
||||
}
|
||||
sigInsertedRef.current = true;
|
||||
if (mode === 'compose') {
|
||||
editor.chain().focus('end').insertContent(`<p></p>${sig.body}`).run();
|
||||
} else if ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') {
|
||||
editor.chain().focus('start').insertContent(sig.body).run();
|
||||
}
|
||||
}, [selectedSignatureId, plainTextMode, mode, signaturePosition, getSignatureById, editorReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutsideSendMenu = (event: MouseEvent) => {
|
||||
if (!sendMenuRef.current?.contains(event.target as Node)) {
|
||||
@@ -2498,7 +2552,7 @@ export function EmailComposer({
|
||||
onImageUpload={handleImageUpload}
|
||||
placeholder={t('body_placeholder')}
|
||||
hasError={validationErrors.body}
|
||||
onEditorReady={(ed) => { editorRef.current = ed; }}
|
||||
onEditorReady={(ed) => { editorRef.current = ed; setEditorReady(true); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -2657,8 +2711,53 @@ export function EmailComposer({
|
||||
<PluginSlot name="composer-toolbar" />
|
||||
</div>
|
||||
|
||||
{/* Right side - Discard + Send (desktop) */}
|
||||
{/* Right side - Signature selector + Discard + Send (desktop) */}
|
||||
<div className="flex items-center gap-2">
|
||||
{signatures.length > 0 && (
|
||||
<div className="relative hidden md:inline-flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!editorRef.current) return;
|
||||
const sig = selectedSignature;
|
||||
if (sig) {
|
||||
editorRef.current.chain().focus().insertContent(sig.body).run();
|
||||
}
|
||||
}}
|
||||
title={t('insert_signature')}
|
||||
className="h-8 px-2 text-xs gap-1"
|
||||
disabled={!selectedSignature}
|
||||
>
|
||||
<PenLine className="w-4 h-4" />
|
||||
{selectedSignature?.name ?? t('no_signature')}
|
||||
</Button>
|
||||
<select
|
||||
value={selectedSignatureId ?? ''}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
setSelectedSignatureId(id || null);
|
||||
if (id && editorRef.current) {
|
||||
const sig = getSignatureById(id);
|
||||
if (sig) {
|
||||
editorRef.current.chain().focus().insertContent(sig.body).run();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
title={t('select_signature')}
|
||||
aria-label={t('select_signature')}
|
||||
>
|
||||
<option value="">{t('no_signature')}</option>
|
||||
{signatures.map((sig) => (
|
||||
<option key={sig.id} value={sig.id}>
|
||||
{sig.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { useUIStore } from "@/stores/ui-store";
|
||||
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
||||
import { Reply, ReplyAll, Forward, Star, Archive, FolderOpen } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
@@ -141,6 +143,101 @@ export function EmailList({
|
||||
const contextMenuEmail = contextMenu.data
|
||||
? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data
|
||||
: null;
|
||||
|
||||
// Radial menu state
|
||||
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
||||
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
||||
const [radialMenuEmail, setRadialMenuEmail] = useState<Email | null>(null);
|
||||
|
||||
const openRadialMenu = useCallback((e: React.MouseEvent, email: Email) => {
|
||||
e.preventDefault();
|
||||
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setRadialMenuEmail(email);
|
||||
setRadialMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeRadialMenu = useCallback(() => {
|
||||
setRadialMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
||||
if (!radialMenuEmail) return [];
|
||||
const email = radialMenuEmail;
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
|
||||
const act = (fn?: (email: Email) => void) => fn ? () => { fn(email); } : undefined;
|
||||
|
||||
const items: RadialMenuItem[] = [];
|
||||
|
||||
if (onReply) {
|
||||
items.push({
|
||||
id: "reply",
|
||||
icon: <Reply className="w-5 h-5" />,
|
||||
label: t("../context_menu.reply"),
|
||||
onClick: () => { act(onReply)!(); },
|
||||
});
|
||||
}
|
||||
if (onReplyAll) {
|
||||
items.push({
|
||||
id: "reply-all",
|
||||
icon: <ReplyAll className="w-5 h-5" />,
|
||||
label: t("../context_menu.reply_all"),
|
||||
onClick: () => { act(onReplyAll)!(); },
|
||||
});
|
||||
}
|
||||
if (onForward) {
|
||||
items.push({
|
||||
id: "forward",
|
||||
icon: <Forward className="w-5 h-5" />,
|
||||
label: t("../context_menu.forward"),
|
||||
onClick: () => { act(onForward)!(); },
|
||||
});
|
||||
}
|
||||
if (onToggleStar) {
|
||||
items.push({
|
||||
id: "star",
|
||||
icon: <Star className="w-5 h-5" fill={isStarred ? "currentColor" : "none"} />,
|
||||
label: isStarred ? t("../context_menu.unstar") : t("../context_menu.star"),
|
||||
onClick: () => { act(onToggleStar)!(); },
|
||||
});
|
||||
}
|
||||
if (onMarkAsRead) {
|
||||
items.push({
|
||||
id: "mark-read",
|
||||
icon: isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />,
|
||||
label: isUnread ? t("../context_menu.mark_read") : t("../context_menu.mark_unread"),
|
||||
onClick: () => { onMarkAsRead(email, !isUnread); },
|
||||
});
|
||||
}
|
||||
if (onArchive) {
|
||||
items.push({
|
||||
id: "archive",
|
||||
icon: <Archive className="w-5 h-5" />,
|
||||
label: t("../context_menu.archive"),
|
||||
onClick: () => { act(onArchive)!(); },
|
||||
});
|
||||
}
|
||||
if (onDelete) {
|
||||
items.push({
|
||||
id: "delete",
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
label: t("../context_menu.delete"),
|
||||
onClick: () => { act(onDelete)!(); },
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
if (onMoveToMailbox) {
|
||||
items.push({
|
||||
id: "move",
|
||||
icon: <FolderOpen className="w-5 h-5" />,
|
||||
label: t("../context_menu.move_to"),
|
||||
onClick: () => { openContextMenu({ preventDefault: () => {}, stopPropagation: () => {}, clientX: radialMenuPos.x, clientY: radialMenuPos.y } as React.MouseEvent, email); },
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [radialMenuEmail, radialMenuPos, t, onReply, onReplyAll, onForward, onToggleStar, onMarkAsRead, onArchive, onDelete, onMoveToMailbox, openContextMenu]);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -549,7 +646,7 @@ export function EmailList({
|
||||
onEmailSelect?.(email);
|
||||
}}
|
||||
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
||||
onContextMenu={openContextMenu}
|
||||
onContextMenu={(e, email) => { openContextMenu(e, email); openRadialMenu(e, email); }}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
||||
@@ -581,6 +678,14 @@ export function EmailList({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Radial Action Menu */}
|
||||
<RadialMenu
|
||||
items={radialMenuItems}
|
||||
isOpen={radialMenuOpen}
|
||||
position={radialMenuPos}
|
||||
onClose={closeRadialMenu}
|
||||
/>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenuEmail && (
|
||||
<EmailContextMenu
|
||||
|
||||
@@ -9,6 +9,7 @@ import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourc
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { resolveThemeLogo } from "@/lib/theme-logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
@@ -75,6 +76,7 @@ import {
|
||||
PlayCircle,
|
||||
PenSquare,
|
||||
CalendarClock,
|
||||
CalendarPlus,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
@@ -87,6 +89,8 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
@@ -702,6 +706,9 @@ export function EmailViewer({
|
||||
const isScheduled = email?.isScheduled === true;
|
||||
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
|
||||
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||
const createAppointmentVisible = !isScheduled && !isDraft && calendarEnabled && !!email;
|
||||
|
||||
|
||||
// Tablet list visibility
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
@@ -766,6 +773,8 @@ export function EmailViewer({
|
||||
return new Date(time).toISOString();
|
||||
}, [client, t, tComposer]);
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const activeThemeId = useThemeStore((state) => state.activeThemeId);
|
||||
const installedThemes = useThemeStore((state) => state.installedThemes);
|
||||
const { startTour } = useTour();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||
@@ -1026,6 +1035,34 @@ export function EmailViewer({
|
||||
const { isMobile: isMobileDevice } = useDeviceDetection();
|
||||
const router = useRouter();
|
||||
|
||||
const handleCreateAppointment = useCallback(() => {
|
||||
if (!email) return;
|
||||
const subject = email.subject ? `Re: ${email.subject}` : "";
|
||||
const body = email.htmlBody?.[0]?.partId
|
||||
? email.bodyValues?.[email.htmlBody[0].partId]?.value || ""
|
||||
: "";
|
||||
const participants: { name?: string; email: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addParticipant = (p?: { name?: string; email?: string }) => {
|
||||
if (!p?.email) return;
|
||||
const normalized = p.email.toLowerCase();
|
||||
if (!seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
participants.push({ name: p.name, email: p.email });
|
||||
}
|
||||
};
|
||||
if (email.from) email.from.forEach(addParticipant);
|
||||
if (email.to) email.to.forEach(addParticipant);
|
||||
if (email.cc) email.cc.forEach(addParticipant);
|
||||
useCalendarStore.getState().setNewEventPrefill({
|
||||
title: subject,
|
||||
description: body,
|
||||
participants,
|
||||
date: email.receivedAt,
|
||||
});
|
||||
router.push('/calendar');
|
||||
}, [email, router]);
|
||||
|
||||
const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => {
|
||||
if (isMobileDevice) {
|
||||
// No room for a sidebar on mobile - send the user to the contacts page
|
||||
@@ -2729,15 +2766,20 @@ export function EmailViewer({
|
||||
|
||||
if (!email) {
|
||||
if (isDemoMode) {
|
||||
const logoSrc = withBasePath(resolvedTheme === 'dark'
|
||||
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
|
||||
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg');
|
||||
// Same resolution as navigation-rail.tsx/login: active theme's own
|
||||
// brand logo (SRC mark / VNClagoon wordmark), falling back to the SRC
|
||||
// mark rather than a hardcoded brand image - this demo empty state has
|
||||
// no admin-override concept of its own, so there's no global override
|
||||
// to check here.
|
||||
const logoSrc = withBasePath(
|
||||
resolveThemeLogo(installedThemes, activeThemeId, resolvedTheme === 'dark', '/branding/SRC_Symbol.png', '/branding/SRC_Symbol.png'),
|
||||
);
|
||||
return (
|
||||
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
||||
<div className="text-center p-8 max-w-md">
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="Bulwark Mail"
|
||||
alt="VNCmail+"
|
||||
className="h-12 mx-auto mb-6"
|
||||
/>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-3">{tDemoWelcome('title')}</h3>
|
||||
@@ -2901,6 +2943,20 @@ export function EmailViewer({
|
||||
<Forward className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
|
||||
</Button>
|
||||
{createAppointmentVisible && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCreateAppointment}
|
||||
data-overflow-item
|
||||
data-overflow-priority="3.5"
|
||||
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
title={t('create_appointment')}
|
||||
>
|
||||
<CalendarPlus className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('create_appointment')}</span>}
|
||||
</Button>
|
||||
)}
|
||||
</>)}
|
||||
<PluginSlot name="toolbar-actions" />
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
AlertCircle, Star, Clock, FolderUp,
|
||||
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
||||
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
||||
Menu, Users, Share2,
|
||||
Menu, Users, Share2, MailPlus, Paperclip, ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -27,6 +27,7 @@ import { Avatar } from "@/components/ui/avatar";
|
||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNodeRights } from "@/lib/jmap/types";
|
||||
|
||||
@@ -106,6 +107,8 @@ interface FileBrowserProps {
|
||||
sharingEnabled?: boolean;
|
||||
/** Add/update/remove a principal's share on a node. Set null rights to revoke. */
|
||||
onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
|
||||
/** Send selected files as email attachments - opens the composer with files pre-attached. */
|
||||
onSendAsAttachment?: (names: string[]) => void;
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
|
||||
@@ -205,6 +208,14 @@ function isDatabaseFile(name: string): boolean {
|
||||
return DATABASE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const OFFICE_EXTENSIONS = new Set([
|
||||
"docx", "xlsx", "pptx", "odt", "ods", "odp", "doc", "xls", "ppt",
|
||||
]);
|
||||
function isOfficeFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return OFFICE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
function isPreviewable(name: string): boolean {
|
||||
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
|
||||
}
|
||||
@@ -384,6 +395,7 @@ export function FileBrowser({
|
||||
ownAccountId,
|
||||
sharingEnabled,
|
||||
onShare,
|
||||
onSendAsAttachment,
|
||||
}: FileBrowserProps) {
|
||||
const t = useTranslations("files");
|
||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||
@@ -401,6 +413,60 @@ export function FileBrowser({
|
||||
[sharingEnabled, onShare, client]);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
|
||||
const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Radial menu state
|
||||
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
||||
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
||||
const [radialMenuResourceName, setRadialMenuResourceName] = useState<string | null>(null);
|
||||
|
||||
const closeRadialMenu = useCallback(() => {
|
||||
setRadialMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
||||
if (!radialMenuResourceName) return [];
|
||||
const name = radialMenuResourceName;
|
||||
const resource = resources.find((r) => r.name === name);
|
||||
const items: RadialMenuItem[] = [];
|
||||
items.push({
|
||||
id: "rename",
|
||||
icon: <Pencil className="w-5 h-5" />,
|
||||
label: t("rename"),
|
||||
onClick: () => { setRenameTarget(name); },
|
||||
});
|
||||
items.push({
|
||||
id: "delete",
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
label: t("delete"),
|
||||
onClick: () => { onDelete(name); },
|
||||
destructive: true,
|
||||
});
|
||||
if (resource && !resource.isDirectory) {
|
||||
items.push({
|
||||
id: "download",
|
||||
icon: <Download className="w-5 h-5" />,
|
||||
label: t("download"),
|
||||
onClick: () => { onDownload(name); },
|
||||
});
|
||||
}
|
||||
if (canShare(resource)) {
|
||||
items.push({
|
||||
id: "share",
|
||||
icon: <Share2 className="w-5 h-5" />,
|
||||
label: t("share"),
|
||||
onClick: () => { if (resource?.id) setShareTargetId(resource.id); },
|
||||
});
|
||||
}
|
||||
if (resource && !resource.isDirectory) {
|
||||
items.push({
|
||||
id: "send-as-attachment",
|
||||
icon: <Paperclip className="w-5 h-5" />,
|
||||
label: t("send_as_attachment"),
|
||||
onClick: () => {},
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [radialMenuResourceName, resources, t, onDelete, onDownload, canShare]);
|
||||
const [showNewTextFile, setShowNewTextFile] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -765,6 +831,9 @@ export function FileBrowser({
|
||||
const handleContextMenu = (e: React.MouseEvent, name: string) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, name });
|
||||
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setRadialMenuResourceName(name);
|
||||
setRadialMenuOpen(true);
|
||||
};
|
||||
|
||||
// Adjust context menu position to stay within viewport
|
||||
@@ -974,28 +1043,76 @@ export function FileBrowser({
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{selectedResources.size > 1 && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => onBatchDownload([...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory))}
|
||||
>
|
||||
<Download className="w-4 h-4 me-1" />
|
||||
{t("download")} ({[...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory).length})
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:text-destructive"
|
||||
onClick={() => onBatchDelete([...selectedResources])}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 me-1" />
|
||||
{t("delete")} ({selectedResources.size})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{selectedResources.size > 0 && (() => {
|
||||
const fileNames = [...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory);
|
||||
const hasFiles = fileNames.length > 0;
|
||||
const showBatch = selectedResources.size > 1;
|
||||
if (!showBatch && !hasFiles) return null;
|
||||
return (
|
||||
<>
|
||||
{showBatch && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => onBatchDownload(fileNames)}
|
||||
>
|
||||
<Download className="w-4 h-4 me-1" />
|
||||
{t("download")} ({fileNames.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:text-destructive"
|
||||
onClick={() => onBatchDelete([...selectedResources])}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 me-1" />
|
||||
{t("delete")} ({selectedResources.size})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{hasFiles && onSendAsAttachment && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => onSendAsAttachment(fileNames)}
|
||||
>
|
||||
<MailPlus className="w-4 h-4 me-1" />
|
||||
{t("send_as_attachment")} {fileNames.length > 1 && `(${fileNames.length})`}
|
||||
</Button>
|
||||
)}
|
||||
{!showBatch && hasFiles && fileNames.length === 1 && isOfficeFile(fileNames[0]) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={async () => {
|
||||
const file = resources.find((r) => r.name === fileNames[0]);
|
||||
if (!file) return;
|
||||
try {
|
||||
const res = await fetch("/api/collabora/edit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fileId: file.id, fileName: file.name }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const { url } = await res.json();
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Collabora edit failed:", err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-4 h-4 me-1" />
|
||||
Edit with Collabora
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{clipboard && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1669,6 +1786,14 @@ export function FileBrowser({
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Radial Action Menu */}
|
||||
<RadialMenu
|
||||
items={radialMenuItems}
|
||||
isOpen={radialMenuOpen}
|
||||
position={radialMenuPos}
|
||||
onClose={closeRadialMenu}
|
||||
/>
|
||||
|
||||
{/* Context menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
@@ -1707,6 +1832,32 @@ export function FileBrowser({
|
||||
{t("download")}
|
||||
</button>
|
||||
)}
|
||||
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && isOfficeFile(contextMenu.name) && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={async () => {
|
||||
const file = resources.find((r) => r.name === contextMenu.name);
|
||||
if (!file) { setContextMenu(null); return; }
|
||||
try {
|
||||
const res = await fetch("/api/collabora/edit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fileId: file.id, fileName: file.name }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const { url } = await res.json();
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Collabora edit failed:", err);
|
||||
}
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Edit with Collabora
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input';
|
||||
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
|
||||
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
|
||||
import { useSignatureStore } from '@/stores/signature-store';
|
||||
|
||||
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
|
||||
const SIGNATURE_MAX_BYTES = 2047;
|
||||
@@ -73,6 +74,15 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const {
|
||||
signatures,
|
||||
identitySignatureMap,
|
||||
setIdentitySignature,
|
||||
} = useSignatureStore();
|
||||
const identitySigMapping = identity?.id ? (identitySignatureMap[identity.id] ?? {}) : {};
|
||||
const [sigDefaultId, setSigDefaultId] = useState<string>(identitySigMapping.defaultId ?? '');
|
||||
const [sigReplyId, setSigReplyId] = useState<string>(identitySigMapping.replyId ?? '');
|
||||
|
||||
const parseEmailList = (input: string): EmailAddress[] | undefined => {
|
||||
if (!input.trim()) return undefined;
|
||||
|
||||
@@ -134,6 +144,10 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
};
|
||||
|
||||
await onSave(sanitizedData);
|
||||
if (identity?.id) {
|
||||
setIdentitySignature(identity.id, 'default', sigDefaultId || null);
|
||||
setIdentitySignature(identity.id, 'reply', sigReplyId || null);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -266,6 +280,57 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Signature Store Mapping (per-identity) */}
|
||||
{isEditing && signatures.length > 0 && (
|
||||
<div className="border border-border rounded-md p-4 space-y-3 bg-muted/30">
|
||||
<p className="text-sm font-medium text-foreground">{t('signature_store_mapping')}</p>
|
||||
<div>
|
||||
<label htmlFor="identity-sig-default" className="block text-xs text-muted-foreground mb-1">
|
||||
{t('signature_store_default')}
|
||||
</label>
|
||||
<select
|
||||
id="identity-sig-default"
|
||||
value={sigDefaultId}
|
||||
onChange={(e) => {
|
||||
setSigDefaultId(e.target.value);
|
||||
if (identity?.id) {
|
||||
setIdentitySignature(identity.id, 'default', e.target.value || null);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="w-full px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t('use_global_default')}</option>
|
||||
{signatures.map((sig) => (
|
||||
<option key={sig.id} value={sig.id}>{sig.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="identity-sig-reply" className="block text-xs text-muted-foreground mb-1">
|
||||
{t('signature_store_reply')}
|
||||
</label>
|
||||
<select
|
||||
id="identity-sig-reply"
|
||||
value={sigReplyId}
|
||||
onChange={(e) => {
|
||||
setSigReplyId(e.target.value);
|
||||
if (identity?.id) {
|
||||
setIdentitySignature(identity.id, 'reply', e.target.value || null);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="w-full px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t('use_global_default')}</option>
|
||||
{signatures.map((sig) => (
|
||||
<option key={sig.id} value={sig.id}>{sig.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Signature */}
|
||||
<div>
|
||||
<label htmlFor="identity-text-sig" className="block text-sm font-medium mb-1">
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FolderX,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Position {
|
||||
@@ -86,6 +87,7 @@ interface MailboxContextMenuProps {
|
||||
onRenameFolder?: (mailboxId: string) => void;
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onShareFolder?: (mailboxId: string) => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
@@ -105,6 +107,7 @@ export function MailboxContextMenu({
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onImportEmail,
|
||||
onShareFolder,
|
||||
onRefresh,
|
||||
}: MailboxContextMenuProps) {
|
||||
const t = useTranslations("mailbox_context_menu");
|
||||
@@ -191,6 +194,12 @@ export function MailboxContextMenu({
|
||||
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
|
||||
disabled={!onRenameFolder || !canRename}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Share2}
|
||||
label={t("share_folder")}
|
||||
onClick={() => handleAction(() => onShareFolder?.(mailbox.id))}
|
||||
disabled={!onShareFolder || mailbox.isShared}
|
||||
/>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ export function NavigationRail({
|
||||
const t = useTranslations("sidebar");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
const { appLogoLightUrl, appLogoDarkUrl, appLogoLightUrlIsCustom, appLogoDarkUrlIsCustom } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const activeThemeId = useThemeStore((s) => s.activeThemeId);
|
||||
const installedThemes = useThemeStore((s) => s.installedThemes);
|
||||
@@ -465,7 +465,7 @@ export function NavigationRail({
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
const logoUrl = withBasePath(resolveThemeLogo(installedThemes, activeThemeId, resolvedTheme === 'dark', appLogoLightUrl, appLogoDarkUrl));
|
||||
const logoUrl = withBasePath(resolveThemeLogo(installedThemes, activeThemeId, resolvedTheme === 'dark', appLogoLightUrl, appLogoDarkUrl, appLogoLightUrlIsCustom || appLogoDarkUrlIsCustom));
|
||||
return logoUrl ? (
|
||||
<div className="flex items-center justify-center py-3 px-1">
|
||||
<img
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo, ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { MiniCalendarDashlet } from "@/components/calendar/mini-calendar-dashlet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Inbox,
|
||||
@@ -62,6 +63,7 @@ import { useUIStore } from "@/stores/ui-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useVacationStore } from "@/stores/vacation-store";
|
||||
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
@@ -88,6 +90,7 @@ interface SidebarProps {
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onRefreshMailboxes?: () => void;
|
||||
onShareFolder?: (mailboxId: string) => void;
|
||||
scheduledTotal?: number;
|
||||
showScheduledMailbox?: boolean;
|
||||
/** True when the unified view spans multiple login accounts (cross-account).
|
||||
@@ -777,6 +780,7 @@ export function Sidebar({
|
||||
onDeleteFolder,
|
||||
onImportEmail,
|
||||
onRefreshMailboxes,
|
||||
onShareFolder,
|
||||
scheduledTotal = 0,
|
||||
showScheduledMailbox = false,
|
||||
crossAccountActive = false,
|
||||
@@ -808,6 +812,12 @@ export function Sidebar({
|
||||
return stored !== null ? JSON.parse(stored) : true;
|
||||
} catch { return true; }
|
||||
});
|
||||
const [calendarDashletExpanded, setCalendarDashletExpanded] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('sidebarCalendarDashletExpanded');
|
||||
return stored !== null ? JSON.parse(stored) : true;
|
||||
} catch { return true; }
|
||||
});
|
||||
const [unifiedExpanded, setUnifiedExpanded] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('sidebarUnifiedExpanded');
|
||||
@@ -840,6 +850,7 @@ export function Sidebar({
|
||||
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||
const nestedTags = useSettingsStore(s => s.nestedTags);
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
|
||||
// own AccountSwitcher would be a redundant second account UI in the same
|
||||
// pane.
|
||||
@@ -1054,6 +1065,13 @@ export function Sidebar({
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const toggleCalendarDashlet = () => {
|
||||
setCalendarDashletExpanded((prev: boolean) => {
|
||||
const next = !prev;
|
||||
try { localStorage.setItem('sidebarCalendarDashletExpanded', JSON.stringify(next)); } catch { /* */ }
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const toggleShared = () => {
|
||||
setSharedExpanded((prev: boolean) => {
|
||||
const next = !prev;
|
||||
@@ -1405,6 +1423,18 @@ export function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCollapsed && calendarEnabled && (
|
||||
<div>
|
||||
<SidebarSectionHeader
|
||||
label={t("calendar")}
|
||||
expanded={calendarDashletExpanded}
|
||||
onToggle={toggleCalendarDashlet}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{calendarDashletExpanded && <MiniCalendarDashlet />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
||||
</div>
|
||||
|
||||
@@ -1424,6 +1454,7 @@ export function Sidebar({
|
||||
onRenameFolder={onRenameFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onImportEmail={onImportEmail}
|
||||
onShareFolder={onShareFolder}
|
||||
onRefresh={onRefreshMailboxes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { cn } from '@/lib/utils';
|
||||
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { clearCachedData } from '@/lib/clear-cached-data';
|
||||
import { SpamSiegeGame } from './spam-siege-game';
|
||||
import { LocalIndexSettings } from './local-index-settings';
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||
@@ -218,6 +219,9 @@ export function AboutDataSettings() {
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Desktop shell only - renders nothing in the browser/PWA build. */}
|
||||
<LocalIndexSettings />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,776 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { RefreshCw, CheckCircle, AlertTriangle, Loader2, Plus, Trash2, Sparkles, X } from 'lucide-react';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup, Select } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
|
||||
import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities';
|
||||
import { getAiApiKey, setAiApiKey, clearAiApiKey } from '@/lib/ai/key-store';
|
||||
import { loadAiSettings, saveAiSettings, createProfile, type AiLocalSettings } from '@/lib/ai/local-settings';
|
||||
import {
|
||||
discoverLocalOllama,
|
||||
recommendDefaultModel,
|
||||
largestModel,
|
||||
isLocalDiscoveryDismissed,
|
||||
dismissLocalDiscovery,
|
||||
type LocalDiscoveryResult,
|
||||
} from '@/lib/ai/local-discovery';
|
||||
import {
|
||||
askMail,
|
||||
listLocalModels,
|
||||
listServerModels,
|
||||
listOpencodeModels,
|
||||
listOpencodeProviders,
|
||||
addOpencodeProvider,
|
||||
removeOpencodeProvider,
|
||||
type OpencodeProviderOption,
|
||||
type OpencodeModelOption,
|
||||
testLocalConnection,
|
||||
type AskResult,
|
||||
} from '@/lib/ai/local-client';
|
||||
|
||||
const inputClass =
|
||||
'px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 flex-1 min-w-[220px]';
|
||||
|
||||
/**
|
||||
* Decisions recorded 2026-08-05 (see lib/ai/types.ts, lib/ai/entitlement.ts):
|
||||
* `local` (loopback Ollama) ships free, no entitlement check. `server`
|
||||
* (centrally-hosted, proxied through this app's own backend) is real and
|
||||
* entitlement-enforced — every call re-checks a licensed seat server-side.
|
||||
* `public` (BYOK) supports several named provider profiles, picked case by
|
||||
* case per question, and is explicitly unmonitored for now.
|
||||
*/
|
||||
export function AiAssistantSettings() {
|
||||
const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY);
|
||||
const [policyLoading, setPolicyLoading] = useState(true);
|
||||
const [settings, setSettings] = useState<AiLocalSettings>(() => loadAiSettings());
|
||||
// The index is written under the ACTIVE account's cookie slot, so retrieval
|
||||
// must read the same one — see fetchLocalLeg in lib/ai/local-client.ts.
|
||||
const activeSlot = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.cookieSlot);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/api/ai/policy');
|
||||
if (res.ok && !cancelled) setPolicy(await res.json());
|
||||
} finally {
|
||||
if (!cancelled) setPolicyLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const update = useCallback(<K extends keyof AiLocalSettings>(key: K, value: AiLocalSettings[K]) => {
|
||||
setSettings((prev) => {
|
||||
const next = { ...prev, [key]: value };
|
||||
saveAiSettings(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const canUseLocal = supportsLocalLlm() && policy.entitlement.classes.includes('local');
|
||||
const canUseServer = policy.entitlement.classes.includes('server');
|
||||
const canUsePublic = policy.entitlement.classes.includes('public');
|
||||
const canUseOpencode = policy.entitlement.classes.includes('opencode');
|
||||
|
||||
// ── Local provider ──
|
||||
const [localModels, setLocalModels] = useState<string[]>([]);
|
||||
const [refreshingLocal, setRefreshingLocal] = useState(false);
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
|
||||
const refreshLocalModels = useCallback(async () => {
|
||||
setRefreshingLocal(true);
|
||||
try {
|
||||
const models = await listLocalModels(settings.localBaseUrl);
|
||||
setLocalModels(models);
|
||||
if (!settings.localModel && models[0]) update('localModel', models[0]);
|
||||
} catch {
|
||||
setLocalModels([]);
|
||||
} finally {
|
||||
setRefreshingLocal(false);
|
||||
}
|
||||
}, [settings.localBaseUrl, settings.localModel, update]);
|
||||
|
||||
const runTestConnection = useCallback(async () => {
|
||||
setTestStatus('testing');
|
||||
setTestError(null);
|
||||
const result = await testLocalConnection(settings.localBaseUrl);
|
||||
if (result.ok) {
|
||||
setTestStatus('ok');
|
||||
} else {
|
||||
setTestStatus('error');
|
||||
setTestError(result.error ?? 'Connection failed');
|
||||
}
|
||||
}, [settings.localBaseUrl]);
|
||||
|
||||
// ── Local discovery — proactively find an already-running Ollama and
|
||||
// offer a one-click connect, rather than making the user hunt down and
|
||||
// type a base URL + model name by hand. ──
|
||||
const [discovery, setDiscovery] = useState<LocalDiscoveryResult | null>(null);
|
||||
const [discoveryDismissed, setDiscoveryDismissed] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setDiscoveryDismissed(isLocalDiscoveryDismissed());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canUseLocal || discoveryDismissed || settings.localModel) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const result = await discoverLocalOllama();
|
||||
if (!cancelled) setDiscovery(result);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canUseLocal, discoveryDismissed, settings.localModel]);
|
||||
|
||||
const connectDiscoveredLocal = useCallback(() => {
|
||||
if (!discovery) return;
|
||||
const recommended = recommendDefaultModel(discovery.models) ?? discovery.models[0]?.name ?? null;
|
||||
if (!recommended) return;
|
||||
setSettings((prev) => {
|
||||
const next: AiLocalSettings = { ...prev, provider: 'local', localBaseUrl: discovery.baseUrl, localModel: recommended };
|
||||
saveAiSettings(next);
|
||||
return next;
|
||||
});
|
||||
setLocalModels(discovery.models.filter((m) => m.capabilities.includes('completion')).map((m) => m.name));
|
||||
setDiscovery(null);
|
||||
}, [discovery]);
|
||||
|
||||
const dismissDiscoveryBanner = useCallback(() => {
|
||||
dismissLocalDiscovery();
|
||||
setDiscoveryDismissed(true);
|
||||
setDiscovery(null);
|
||||
}, []);
|
||||
|
||||
// ── OpenCode provider — a locally-running `opencode serve`. No key to
|
||||
// manage (opencode holds provider auth itself) and a real model list, which
|
||||
// is why this is its own class rather than another BYOK profile. ──
|
||||
const [opencodeModels, setOpencodeModels] = useState<OpencodeModelOption[]>([]);
|
||||
const [refreshingOpencode, setRefreshingOpencode] = useState(false);
|
||||
const [opencodeError, setOpencodeError] = useState<string | null>(null);
|
||||
|
||||
// ── OpenCode provider management — "add any LLM OpenCode supports" from
|
||||
// inside this app, not only whatever its own CLI already authenticated. ──
|
||||
const [opencodeProviders, setOpencodeProviders] = useState<OpencodeProviderOption[]>([]);
|
||||
const [loadingProviders, setLoadingProviders] = useState(false);
|
||||
const [providerSearch, setProviderSearch] = useState('');
|
||||
const [addingProviderId, setAddingProviderId] = useState<string | null>(null);
|
||||
const [newProviderKey, setNewProviderKey] = useState('');
|
||||
const [providerBusyId, setProviderBusyId] = useState<string | null>(null);
|
||||
const [providerActionError, setProviderActionError] = useState<string | null>(null);
|
||||
const [showProviderManager, setShowProviderManager] = useState(false);
|
||||
|
||||
const refreshOpencodeProviders = useCallback(async () => {
|
||||
setLoadingProviders(true);
|
||||
setProviderActionError(null);
|
||||
try {
|
||||
setOpencodeProviders(await listOpencodeProviders());
|
||||
} catch (err) {
|
||||
setProviderActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoadingProviders(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAddProvider = useCallback(async (providerId: string) => {
|
||||
if (!newProviderKey.trim()) return;
|
||||
setProviderBusyId(providerId);
|
||||
setProviderActionError(null);
|
||||
try {
|
||||
await addOpencodeProvider(providerId, newProviderKey.trim());
|
||||
setAddingProviderId(null);
|
||||
setNewProviderKey('');
|
||||
await refreshOpencodeProviders();
|
||||
} catch (err) {
|
||||
setProviderActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setProviderBusyId(null);
|
||||
}
|
||||
}, [newProviderKey, refreshOpencodeProviders]);
|
||||
|
||||
const handleRemoveProvider = useCallback(async (providerId: string) => {
|
||||
setProviderBusyId(providerId);
|
||||
setProviderActionError(null);
|
||||
try {
|
||||
await removeOpencodeProvider(providerId);
|
||||
await refreshOpencodeProviders();
|
||||
} catch (err) {
|
||||
setProviderActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setProviderBusyId(null);
|
||||
}
|
||||
}, [refreshOpencodeProviders]);
|
||||
|
||||
const refreshOpencodeModels = useCallback(async () => {
|
||||
setRefreshingOpencode(true);
|
||||
setOpencodeError(null);
|
||||
try {
|
||||
const models = await listOpencodeModels();
|
||||
setOpencodeModels(models);
|
||||
if (!settings.opencodeModel && models[0]) update('opencodeModel', models[0].ref);
|
||||
} catch (err) {
|
||||
setOpencodeModels([]);
|
||||
setOpencodeError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setRefreshingOpencode(false);
|
||||
}
|
||||
}, [settings.opencodeModel, update]);
|
||||
|
||||
// ── Server provider ──
|
||||
const [serverModels, setServerModels] = useState<string[]>([]);
|
||||
const [refreshingServer, setRefreshingServer] = useState(false);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const [seatNotice, setSeatNotice] = useState<string | null>(null);
|
||||
|
||||
const refreshServerModels = useCallback(async () => {
|
||||
setRefreshingServer(true);
|
||||
setServerError(null);
|
||||
try {
|
||||
const models = await listServerModels();
|
||||
setServerModels(models);
|
||||
if (!settings.serverModel && models[0]) update('serverModel', models[0]);
|
||||
} catch (err) {
|
||||
setServerModels([]);
|
||||
setServerError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setRefreshingServer(false);
|
||||
}
|
||||
}, [settings.serverModel, update]);
|
||||
|
||||
// ── Public provider — several named profiles, one picked per question ──
|
||||
const [newProfileName, setNewProfileName] = useState('');
|
||||
const [newProfileBaseUrl, setNewProfileBaseUrl] = useState('https://openrouter.ai/api/v1');
|
||||
const [newProfileModel, setNewProfileModel] = useState('');
|
||||
const [newProfileKey, setNewProfileKey] = useState('');
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
|
||||
const addProfile = useCallback(() => {
|
||||
if (!newProfileName || !newProfileBaseUrl || !newProfileModel || !newProfileKey) return;
|
||||
setProfileError(null);
|
||||
// Admin allow-list (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6.1) — advisory,
|
||||
// client-side only, checked here at save time.
|
||||
const allowlist = policy.publicProviderAllowlist;
|
||||
if (allowlist && !allowlist.some((prefix) => newProfileBaseUrl.startsWith(prefix))) {
|
||||
setProfileError(`This base URL isn't on the admin-approved list (${allowlist.join(', ')}).`);
|
||||
return;
|
||||
}
|
||||
const profile = createProfile(newProfileName, newProfileBaseUrl, newProfileModel);
|
||||
setAiApiKey(profile.id, newProfileKey);
|
||||
update('publicProfiles', [...settings.publicProfiles, profile]);
|
||||
if (!settings.activeProfileId) update('activeProfileId', profile.id);
|
||||
setNewProfileName('');
|
||||
setNewProfileBaseUrl('https://openrouter.ai/api/v1');
|
||||
setNewProfileModel('');
|
||||
setNewProfileKey('');
|
||||
}, [newProfileName, newProfileBaseUrl, newProfileModel, newProfileKey, settings.publicProfiles, settings.activeProfileId, update, policy.publicProviderAllowlist]);
|
||||
|
||||
const removeProfile = useCallback(
|
||||
(id: string) => {
|
||||
clearAiApiKey(id);
|
||||
const remaining = settings.publicProfiles.filter((p) => p.id !== id);
|
||||
update('publicProfiles', remaining);
|
||||
if (settings.activeProfileId === id) update('activeProfileId', remaining[0]?.id ?? null);
|
||||
},
|
||||
[settings.publicProfiles, settings.activeProfileId, update],
|
||||
);
|
||||
|
||||
// ── Ask ──
|
||||
const [question, setQuestion] = useState('');
|
||||
const [asking, setAsking] = useState(false);
|
||||
const [askResult, setAskResult] = useState<AskResult | null>(null);
|
||||
const [askError, setAskError] = useState<string | null>(null);
|
||||
|
||||
const activeProfile = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
|
||||
|
||||
const canAsk =
|
||||
question.trim().length > 0 &&
|
||||
(settings.provider === 'local'
|
||||
? canUseLocal && !!settings.localModel
|
||||
: settings.provider === 'server'
|
||||
? canUseServer && !!settings.serverModel
|
||||
: settings.provider === 'opencode'
|
||||
? canUseOpencode && !!settings.opencodeModel
|
||||
: settings.provider === 'public'
|
||||
? canUsePublic && !!activeProfile && settings.publicConsentAccepted
|
||||
: false);
|
||||
|
||||
const runAsk = useCallback(async () => {
|
||||
setAsking(true);
|
||||
setAskError(null);
|
||||
setAskResult(null);
|
||||
setSeatNotice(null);
|
||||
try {
|
||||
const key = activeProfile ? getAiApiKey(activeProfile.id) : null;
|
||||
const result = await askMail(question.trim(), {
|
||||
provider: settings.provider as 'local' | 'server' | 'public' | 'opencode',
|
||||
localBaseUrl: settings.localBaseUrl,
|
||||
localModel: settings.localModel,
|
||||
serverModel: settings.serverModel,
|
||||
opencodeModel: settings.opencodeModel,
|
||||
slot: activeSlot,
|
||||
publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null,
|
||||
});
|
||||
setAskResult(result);
|
||||
if (result.seatJustAssigned) {
|
||||
setSeatNotice('A licensed seat on the server-hosted class was just assigned to your account.');
|
||||
}
|
||||
} catch (err) {
|
||||
setAskError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setAsking(false);
|
||||
}
|
||||
}, [question, settings, activeProfile, activeSlot]);
|
||||
|
||||
const providerOptions = useMemo(
|
||||
() => [
|
||||
...(canUseLocal ? [{ value: 'local', label: 'Local (Ollama)' }] : []),
|
||||
...(canUseServer ? [{ value: 'server', label: 'Server (VNC-hosted)' }] : []),
|
||||
...(canUseOpencode ? [{ value: 'opencode', label: 'OpenCode (local agent)' }] : []),
|
||||
...(canUsePublic ? [{ value: 'public', label: 'Public (your API keys)' }] : []),
|
||||
],
|
||||
[canUseLocal, canUseServer, canUsePublic, canUseOpencode],
|
||||
);
|
||||
|
||||
if (policyLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const discoveryRecommended = discovery ? recommendDefaultModel(discovery.models) : null;
|
||||
const discoveryLargest = discovery ? largestModel(discovery.models) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{discovery && discoveryRecommended && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-primary/30 bg-primary/5 p-4">
|
||||
<Sparkles className="w-5 h-5 mt-0.5 text-primary shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">Local AI found on this machine</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ollama is running at {discovery.baseUrl} with {discovery.models.length} model{discovery.models.length === 1 ? '' : 's'} installed.
|
||||
Recommended for quick answers: <span className="font-medium text-foreground">{discoveryRecommended}</span>.
|
||||
{discoveryLargest && discoveryLargest !== discoveryRecommended && (
|
||||
<> Also available for higher-quality answers: <span className="font-medium text-foreground">{discoveryLargest}</span>.</>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={connectDiscoveredLocal}>
|
||||
<Sparkles className="w-3.5 h-3.5 me-1.5" /> Connect
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={dismissDiscoveryBanner}>
|
||||
<X className="w-3.5 h-3.5 me-1.5" /> Not now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsSection
|
||||
title="AI Assistant"
|
||||
description="Ask questions about your synced mail. Local runs entirely on this machine's own model runtime; server is centrally hosted and licensed per seat; public sends your question to a provider you choose, using your own API key."
|
||||
>
|
||||
<SettingItem label="Provider">
|
||||
{providerOptions.length > 0 ? (
|
||||
<RadioGroup
|
||||
value={settings.provider ?? ''}
|
||||
onChange={(v) => update('provider', v as 'local' | 'server' | 'public' | 'opencode')}
|
||||
options={providerOptions}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">No provider class available.</span>
|
||||
)}
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
{settings.provider === 'local' && canUseLocal && (
|
||||
<SettingsSection
|
||||
title="Local runtime"
|
||||
description={
|
||||
localLlmNeedsCorsSetup()
|
||||
? "Reaches Ollama on this machine directly from the browser. If the test below fails, Ollama's OLLAMA_ORIGINS setting likely doesn't allow this page's origin yet."
|
||||
: 'Reaches Ollama on this machine directly — no extra setup needed in the desktop app.'
|
||||
}
|
||||
>
|
||||
<SettingItem label="Base URL">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.localBaseUrl}
|
||||
onChange={(e) => update('localBaseUrl', e.target.value)}
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="Model" description={localModels.length === 0 ? 'Refresh to list installed models.' : undefined}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{localModels.length > 0 ? (
|
||||
<Select
|
||||
value={settings.localModel ?? ''}
|
||||
onChange={(v) => update('localModel', v)}
|
||||
options={localModels.map((m) => ({ value: m, label: m }))}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{settings.localModel || 'None selected'}</span>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={refreshLocalModels} disabled={refreshingLocal}>
|
||||
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshingLocal ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem label="Connection">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button variant="outline" size="sm" onClick={runTestConnection} disabled={testStatus === 'testing'}>
|
||||
{testStatus === 'testing' && <Loader2 className="w-3.5 h-3.5 me-1.5 animate-spin" />}
|
||||
Test connection
|
||||
</Button>
|
||||
{testStatus === 'ok' && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-500">
|
||||
<CheckCircle className="w-3.5 h-3.5" /> Reachable
|
||||
</span>
|
||||
)}
|
||||
{testStatus === 'error' && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-destructive">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" /> {testError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{settings.provider === 'opencode' && canUseOpencode && (
|
||||
<SettingsSection
|
||||
title="OpenCode (local agent)"
|
||||
description="Uses a locally-running OpenCode server on this machine. OpenCode holds its own provider credentials, so there is no API key to enter here — and it reports the exact models it can reach, so there is nothing to type by hand."
|
||||
>
|
||||
<SettingItem label="Model" description={opencodeModels.length === 0 ? 'Refresh to list the models OpenCode can reach.' : undefined}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{opencodeModels.length > 0 ? (
|
||||
<Select
|
||||
value={settings.opencodeModel ?? ''}
|
||||
onChange={(v) => update('opencodeModel', v)}
|
||||
options={opencodeModels.map((m) => ({ value: m.ref, label: m.label }))}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{settings.opencodeModel || 'None selected'}</span>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={refreshOpencodeModels} disabled={refreshingOpencode}>
|
||||
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshingOpencode ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
{opencodeError && (
|
||||
<SettingItem label="Status">
|
||||
<span className="flex items-start gap-1.5 text-sm text-destructive">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5" /> {opencodeError}
|
||||
</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem
|
||||
label="Providers"
|
||||
description="Add credentials for any provider OpenCode supports — a key entered here is stored by OpenCode itself, not by this app. Providers that only offer a browser sign-in (OAuth) aren't manageable here yet; use the opencode CLI for those."
|
||||
>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => {
|
||||
const next = !showProviderManager;
|
||||
setShowProviderManager(next);
|
||||
if (next && opencodeProviders.length === 0) void refreshOpencodeProviders();
|
||||
}}
|
||||
>
|
||||
{showProviderManager ? 'Hide' : 'Manage providers'}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
{showProviderManager && (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{providerActionError && (
|
||||
<p className="flex items-start gap-1.5 text-sm text-destructive">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5" /> {providerActionError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={providerSearch}
|
||||
onChange={(e) => setProviderSearch(e.target.value)}
|
||||
placeholder="Search providers (e.g. anthropic, openai, groq)…"
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={refreshOpencodeProviders} disabled={loadingProviders}>
|
||||
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${loadingProviders ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{opencodeProviders.length === 0 && !loadingProviders && (
|
||||
<p className="text-xs text-muted-foreground">No providers loaded yet — click Refresh.</p>
|
||||
)}
|
||||
|
||||
<div className="max-h-72 overflow-y-auto space-y-1.5">
|
||||
{opencodeProviders
|
||||
.filter((p) => {
|
||||
const q = providerSearch.trim().toLowerCase();
|
||||
return !q || p.id.toLowerCase().includes(q) || p.name.toLowerCase().includes(q);
|
||||
})
|
||||
// Connected first (already sorted server-side), then cap what
|
||||
// renders — 180 providers in one scroll box is noise, not choice.
|
||||
.slice(0, providerSearch.trim() ? 40 : 20)
|
||||
.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-2 rounded-md border border-border px-3 py-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm">{p.name}</span>
|
||||
<span className="ms-1.5 text-xs text-muted-foreground">{p.id}</span>
|
||||
</div>
|
||||
{p.connected ? (
|
||||
<>
|
||||
<span className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-500">
|
||||
<CheckCircle className="w-3.5 h-3.5" /> Connected
|
||||
</span>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => handleRemoveProvider(p.id)}
|
||||
disabled={providerBusyId === p.id}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
) : p.supportsApiKey ? (
|
||||
addingProviderId === p.id ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="password"
|
||||
value={newProviderKey}
|
||||
onChange={(e) => setNewProviderKey(e.target.value)}
|
||||
placeholder="API key"
|
||||
autoFocus
|
||||
className="px-2 py-1 text-xs rounded-md bg-muted border border-border w-36"
|
||||
/>
|
||||
<Button size="sm" onClick={() => handleAddProvider(p.id)} disabled={providerBusyId === p.id || !newProviderKey.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setAddingProviderId(null); setNewProviderKey(''); }}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => { setAddingProviderId(p.id); setNewProviderKey(''); }}>
|
||||
<Plus className="w-3.5 h-3.5 me-1" /> Add key
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Browser sign-in only</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{settings.provider === 'server' && canUseServer && (
|
||||
<SettingsSection
|
||||
title="Server (VNC-hosted)"
|
||||
description="Centrally hosted — no setup needed on your side. Licensed per seat; using this for the first time consumes one automatically if seats remain."
|
||||
>
|
||||
<SettingItem label="Model" description={serverModels.length === 0 ? 'Refresh to list available models.' : undefined}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{serverModels.length > 0 ? (
|
||||
<Select
|
||||
value={settings.serverModel ?? ''}
|
||||
onChange={(v) => update('serverModel', v)}
|
||||
options={serverModels.map((m) => ({ value: m, label: m }))}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{settings.serverModel || 'None selected'}</span>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={refreshServerModels} disabled={refreshingServer}>
|
||||
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${refreshingServer ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
{serverError && (
|
||||
<SettingItem label="Status">
|
||||
<span className="flex items-center gap-1.5 text-sm text-destructive">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" /> {serverError}
|
||||
</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{settings.provider === 'public' && canUsePublic && (
|
||||
<SettingsSection
|
||||
title="Public providers"
|
||||
description="Save several — different models for different questions. Any OpenAI-compatible endpoint works. Keys are stored only in this browser and, for now, use of this class is not monitored or metered by VNC."
|
||||
>
|
||||
{settings.publicProfiles.length > 0 && (
|
||||
<SettingItem label="Saved profiles">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{settings.publicProfiles.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-2 rounded-md border border-border px-3 py-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{p.model} · {p.baseUrl}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeProfile(p.id)} aria-label={`Remove ${p.name}`}>
|
||||
<Trash2 className="w-3.5 h-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
<SettingItem label="Add a provider">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
value={newProfileName}
|
||||
onChange={(e) => setNewProfileName(e.target.value)}
|
||||
placeholder="Name, e.g. Claude via OpenRouter"
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newProfileModel}
|
||||
onChange={(e) => setNewProfileModel(e.target.value)}
|
||||
placeholder="Model, e.g. anthropic/claude-sonnet-4.5"
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
value={newProfileBaseUrl}
|
||||
onChange={(e) => setNewProfileBaseUrl(e.target.value)}
|
||||
placeholder="Base URL"
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newProfileKey}
|
||||
onChange={(e) => setNewProfileKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addProfile}
|
||||
disabled={!newProfileName || !newProfileBaseUrl || !newProfileModel || !newProfileKey}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 me-1.5" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{profileError && <p className="text-xs text-destructive">{profileError}</p>}
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label="I understand this leaves the organisation"
|
||||
description="Your question and any retrieved mail excerpts are sent to the provider you pick below, outside this organisation."
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={settings.publicConsentAccepted}
|
||||
onChange={(v) => update('publicConsentAccepted', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{settings.provider && (
|
||||
<SettingsSection title="Try it" description="Ask a question against your synced mail.">
|
||||
<div className="flex flex-col gap-3">
|
||||
{settings.provider === 'public' && settings.publicProfiles.length > 0 && (
|
||||
<SettingItem label="Answer with">
|
||||
<Select
|
||||
value={settings.activeProfileId ?? ''}
|
||||
onChange={(v) => update('activeProfileId', v)}
|
||||
options={settings.publicProfiles.map((p) => ({ value: p.id, label: p.name }))}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
<textarea
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
placeholder="What did legal say about the Meier contract deadline?"
|
||||
rows={3}
|
||||
className="px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 resize-y"
|
||||
/>
|
||||
<Button onClick={runAsk} disabled={!canAsk || asking} className="self-start">
|
||||
{asking && <Loader2 className="w-3.5 h-3.5 me-1.5 animate-spin" />}
|
||||
Ask
|
||||
</Button>
|
||||
|
||||
{seatNotice && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/40 p-3">
|
||||
<CheckCircle className="w-4 h-4 mt-0.5 text-green-600 dark:text-green-500 shrink-0" />
|
||||
<p className="text-sm text-muted-foreground">{seatNotice}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{askError && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">{askError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{askResult && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-4">
|
||||
{askResult.retrievalState === 'no-index' && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No local mail index available in this session — answered without your mail. The index is
|
||||
desktop-only; build it under Settings → About & Data.
|
||||
</p>
|
||||
)}
|
||||
{askResult.retrievalState === 'no-match' && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Your mail index is available, but nothing in it matched this question — answered without
|
||||
your mail. It matches on keywords, so questions about <em>content</em> (“what did
|
||||
Anna say about the invoice?”) work better than ones about recency
|
||||
(“the last mail”).
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap">{askResult.answer}</p>
|
||||
{askResult.sources.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border pt-2 mt-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Sources</span>
|
||||
{askResult.sources.map((s, i) => (
|
||||
<span key={s.id} className="text-xs text-muted-foreground truncate">
|
||||
[{i + 1}] {s.subject}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export function ContactsSettings() {
|
||||
const { client } = useAuthStore();
|
||||
const {
|
||||
contacts,
|
||||
addressBooks,
|
||||
supportsSync,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
@@ -46,6 +47,7 @@ export function ContactsSettings() {
|
||||
<div className="border border-border rounded-lg overflow-hidden" style={{ minHeight: 400 }}>
|
||||
<ContactImportDialog
|
||||
existingContacts={contacts}
|
||||
addressBooks={addressBooks}
|
||||
onImport={handleImport}
|
||||
onClose={() => setShowImport(false)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, FolderOpen, Download, AlertTriangle, Check, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection, SettingItem, RadioGroup, Select } from "./settings-section";
|
||||
import { importEmails, type ConflictResolution, type ImportProgress, type ImportResult } from "@/lib/email-import";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { EML_IMPORT_ACCEPT } from "@/lib/eml-import";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ImportSettings() {
|
||||
const t = useTranslations("settings.importer");
|
||||
const { client } = useAuthStore();
|
||||
const { mailboxes } = useEmailStore();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [destination, setDestination] = useState("");
|
||||
const [conflict, setConflict] = useState<ConflictResolution>("skip");
|
||||
const [progress, setProgress] = useState<ImportProgress | null>(null);
|
||||
const [result, setResult] = useState<ImportResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (mailboxes.length > 0 && !destination) {
|
||||
const inbox = mailboxes.find((m) => m.role === "inbox") || mailboxes[0];
|
||||
if (inbox) setDestination(inbox.id);
|
||||
}
|
||||
}, [mailboxes, destination]);
|
||||
|
||||
const folderOptions = mailboxes.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.name,
|
||||
}));
|
||||
|
||||
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files;
|
||||
if (!selected || selected.length === 0) return;
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setProgress(null);
|
||||
setFiles(Array.from(selected));
|
||||
}, []);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!client || files.length === 0 || !destination) return;
|
||||
|
||||
setImporting(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const res = await importEmails({
|
||||
client,
|
||||
files,
|
||||
destinationMailboxId: destination,
|
||||
conflictResolution: conflict,
|
||||
onProgress: (p) => setProgress({ ...p }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
setResult(res);
|
||||
if (res.imported > 0) {
|
||||
toast.success(t("success", { count: res.imported }));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) {
|
||||
const msg = err instanceof Error ? err.message : t("fail");
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [client, files, destination, conflict, t]);
|
||||
|
||||
const handleCancel = () => {
|
||||
abortRef.current?.abort();
|
||||
setImporting(false);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setFiles([]);
|
||||
setResult(null);
|
||||
setProgress(null);
|
||||
setError(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
};
|
||||
|
||||
const progressPercent = progress && progress.total > 0
|
||||
? Math.round((progress.processed / progress.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
>
|
||||
<SettingItem
|
||||
label={t("file_label")}
|
||||
description={t("file_description")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept={EML_IMPORT_ACCEPT}
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={importing}
|
||||
>
|
||||
<Upload className="w-4 h-4 me-2" />
|
||||
{files.length > 0
|
||||
? t("files_selected", { count: files.length })
|
||||
: t("choose_files")}
|
||||
</Button>
|
||||
{files.length > 0 && !importing && (
|
||||
<Button variant="ghost" size="sm" onClick={reset}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t("folder_label")}
|
||||
description={t("folder_description")}
|
||||
>
|
||||
<Select
|
||||
value={destination}
|
||||
onChange={setDestination}
|
||||
options={folderOptions}
|
||||
disabled={importing || folderOptions.length === 0}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t("conflict_label")}
|
||||
description={t("conflict_description")}
|
||||
>
|
||||
<RadioGroup
|
||||
value={conflict}
|
||||
onChange={(v) => setConflict(v as ConflictResolution)}
|
||||
options={[
|
||||
{ value: "skip", label: t("conflict_skip") },
|
||||
{ value: "replace", label: t("conflict_replace") },
|
||||
{ value: "copy", label: t("conflict_copy") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{files.length > 0 && !result && (
|
||||
<SettingItem label={t("action_label")} description="">
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !destination}
|
||||
>
|
||||
{importing ? t("importing") : t("start_import", { count: files.length })}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && importing && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{progress.currentFile}</span>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{t("progress_imported", { count: progress.imported })}</span>
|
||||
<span>{t("progress_skipped", { count: progress.skipped })}</span>
|
||||
<span>{t("progress_failed", { count: progress.failed })}</span>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" onClick={handleCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !importing && (
|
||||
<div className={cn(
|
||||
"rounded-lg p-4 space-y-3",
|
||||
result.failed > 0
|
||||
? "bg-warning/10 border border-warning/30"
|
||||
: "bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800"
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
<span className="font-medium text-sm">{t("import_complete")}</span>
|
||||
</div>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>{t("summary_imported", { count: result.imported })}</p>
|
||||
<p>{t("summary_skipped", { count: result.skipped })}</p>
|
||||
<p>{t("summary_failed", { count: result.failed })}</p>
|
||||
</div>
|
||||
{result.errors.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
{t("error_details", { count: result.errors.length })}
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-1 ps-4 list-disc">
|
||||
{result.errors.map((e, i) => (
|
||||
<li key={i} className="text-red-600 dark:text-red-400">
|
||||
<span className="font-medium">{e.file}</span>: {e.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={reset}>
|
||||
{t("import_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
// Settings panel for the desktop shell's encrypted local search index.
|
||||
//
|
||||
// Deliberately small: the index's PRIMARY trigger is the live push connection
|
||||
// (see lib/mail-index-client.ts's indexOnStateChange, wired into
|
||||
// stores/email-store.ts's handleStateChange), so this panel is a status readout
|
||||
// plus a manual catch-up button - not the mechanism.
|
||||
//
|
||||
// Renders nothing at all outside the Electron shell, where the routes 404.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import { isElectronShell } from '@/lib/electron-bridge';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import {
|
||||
catchUpIndex, fetchIndexStats, getRetentionDays, setRetentionDays, type IndexStats,
|
||||
} from '@/lib/mail-index-client';
|
||||
import {
|
||||
chainSync, fetchReplicaStatus, purgeReplica, updateRetentionPolicy,
|
||||
type ReplicaStatus, type RetentionPolicy,
|
||||
} from '@/lib/offline-replica-client';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
mail: 'Mail',
|
||||
calendar: 'Calendar',
|
||||
contact: 'Contacts',
|
||||
file: 'Files',
|
||||
};
|
||||
|
||||
export function LocalIndexSettings() {
|
||||
const slot = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.cookieSlot);
|
||||
const [stats, setStats] = useState<IndexStats[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
// `null` until the first probe resolves, so we don't flash a panel that then
|
||||
// vanishes on a non-desktop build.
|
||||
const [available, setAvailable] = useState<boolean | null>(null);
|
||||
// `null` = keep everything. Read once on mount; the setter writes through.
|
||||
const [retentionDays, setRetentionDaysState] = useState<number | null>(365);
|
||||
|
||||
useEffect(() => { setRetentionDaysState(getRetentionDays()); }, []);
|
||||
|
||||
const refreshStats = useCallback(async () => {
|
||||
const next = await fetchIndexStats(slot);
|
||||
setStats(next);
|
||||
setAvailable(next !== null);
|
||||
}, [slot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectronShell()) {
|
||||
setAvailable(false);
|
||||
return;
|
||||
}
|
||||
void refreshStats();
|
||||
}, [refreshStats]);
|
||||
|
||||
const handleRebuild = async () => {
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const result = await catchUpIndex(slot, retentionDays);
|
||||
if (result.unavailable) {
|
||||
setAvailable(false);
|
||||
setMessage(result.error ?? 'The encrypted index is unavailable on this system.');
|
||||
return;
|
||||
}
|
||||
if (!result.ok) {
|
||||
setMessage(result.error ?? 'Indexing failed.');
|
||||
return;
|
||||
}
|
||||
const written = Object.entries(result.written ?? {})
|
||||
.map(([type, n]) => `${TYPE_LABELS[type] ?? type}: ${n}`)
|
||||
.join(', ');
|
||||
const failed = (result.errors ?? []).map((e) => `${e.contentType} (${e.message})`).join('; ');
|
||||
setMessage(
|
||||
[
|
||||
written ? `Indexed ${written}.` : 'Nothing to index.',
|
||||
result.skipped?.length ? `Not supported: ${result.skipped.join(', ')}.` : '',
|
||||
failed ? `Problems: ${failed}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
);
|
||||
await refreshStats();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (available === false || available === null) return null;
|
||||
|
||||
const total = (stats ?? []).reduce((sum, s) => sum + s.count, 0);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Local search index"
|
||||
description={
|
||||
'An encrypted index of your recent mail, calendar events, contacts and file names, ' +
|
||||
'stored on this device only. It updates automatically as items arrive, and powers ' +
|
||||
'local search and AI answers about your own data. Files are indexed by name and ' +
|
||||
'location, not by their contents.'
|
||||
}
|
||||
>
|
||||
<SettingItem
|
||||
label="Indexed items"
|
||||
description={
|
||||
total > 0
|
||||
? (stats ?? [])
|
||||
.map((s) => `${TYPE_LABELS[s.contentType] ?? s.contentType}: ${s.count}`)
|
||||
.join(' · ')
|
||||
: 'Nothing indexed yet.'
|
||||
}
|
||||
>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">{total}</span>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Keep AI search history for"
|
||||
description={
|
||||
'How far back the AI assistant can search your mail. This also PRUNES: mail older ' +
|
||||
'than the window is removed from the local index on the next update, so a short ' +
|
||||
'window means questions about older mail cannot be answered. Only ever covers the ' +
|
||||
'mailbox you are signed in to.'
|
||||
}
|
||||
>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
|
||||
value={retentionDays === null ? 'forever' : String(retentionDays)}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value === 'forever' ? null : Number.parseInt(e.target.value, 10);
|
||||
setRetentionDaysState(next);
|
||||
setRetentionDays(next);
|
||||
setMessage('Saved. Choose "Update index" to apply it now.');
|
||||
}}
|
||||
>
|
||||
<option value="30">30 days</option>
|
||||
<option value="90">3 months</option>
|
||||
<option value="365">1 year</option>
|
||||
<option value="forever">Everything</option>
|
||||
</select>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Update now"
|
||||
description={
|
||||
message ??
|
||||
'Catches up on anything that changed while the app was closed. Normally not needed - ' +
|
||||
'the index updates itself when mail, events, contacts or files change.'
|
||||
}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={handleRebuild} disabled={busy}>
|
||||
{busy ? 'Indexing…' : 'Update index'}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
<OfflineMailSettings slot={slot} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ['KB', 'MB', 'GB'];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; }
|
||||
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
const PHASE_LABELS: Record<string, string> = {
|
||||
'never-run': 'not started',
|
||||
scanning: 'downloading history',
|
||||
reconciling: 'rebuilding',
|
||||
complete: 'up to date',
|
||||
};
|
||||
|
||||
/**
|
||||
* Controls for the offline mail replica (lib/offline-replica/**).
|
||||
*
|
||||
* Lives inside the same panel as the search index because they share one
|
||||
* encrypted file, one key and one purge - presenting them as two unrelated
|
||||
* features would misrepresent what "delete" deletes.
|
||||
*/
|
||||
function OfflineMailSettings({ slot }: { slot: number | undefined }) {
|
||||
const [status, setStatus] = useState<ReplicaStatus | null>(null);
|
||||
const [busy, setBusy] = useState<null | 'sync' | 'purge' | 'policy'>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setStatus(await fetchReplicaStatus(slot));
|
||||
}, [slot]);
|
||||
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
|
||||
const savePolicy = async (patch: Partial<RetentionPolicy>) => {
|
||||
if (!status) return;
|
||||
const next: RetentionPolicy = { ...status.policy, ...patch };
|
||||
setBusy('policy');
|
||||
setMessage(null);
|
||||
try {
|
||||
const ok = await updateRetentionPolicy(next, slot);
|
||||
if (!ok) { setMessage('Could not save the retention setting.'); return; }
|
||||
// The change is applied by the next cycle - a widen re-scans, a narrow
|
||||
// evicts - so run one now rather than leaving the number looking wrong.
|
||||
await chainSync({ slot, max: 2 });
|
||||
await refresh();
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
setBusy('sync');
|
||||
setMessage(null);
|
||||
try {
|
||||
const report = await chainSync({ slot });
|
||||
if (!report) { setMessage('Offline mail is unavailable on this system.'); return; }
|
||||
setMessage(
|
||||
report.ok
|
||||
? `Synced ${report.envelopesWritten} messages and ${report.bodiesWritten} bodies.` +
|
||||
(report.unfinishedWork ? ' More will download in the background.' : '') +
|
||||
(report.warnings.length > 0 ? ` Notes: ${report.warnings.join('; ')}` : '')
|
||||
: `Sync failed: ${report.error ?? 'unknown error'}`,
|
||||
);
|
||||
await refresh();
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurge = async () => {
|
||||
setBusy('purge');
|
||||
setMessage(null);
|
||||
try {
|
||||
const ok = await purgeReplica(slot);
|
||||
setMessage(ok ? 'Offline mail deleted from this device.' : 'Could not delete offline mail.');
|
||||
await refresh();
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
const stats = status.stats;
|
||||
const total = stats ? stats.fileBytes : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingItem
|
||||
label="Offline mail"
|
||||
description={
|
||||
stats
|
||||
? `${stats.envelopes} messages listed, ${stats.bodies} readable offline · ` +
|
||||
`${formatBytes(stats.bodyBytes)} of message content · ` +
|
||||
`status: ${PHASE_LABELS[status.coveragePhase] ?? status.coveragePhase}` +
|
||||
(status.resyncRequired ? ' (a rebuild is queued)' : '') +
|
||||
(stats.wantedBodies > 0 ? ` · ${stats.wantedBodies} still downloading` : '')
|
||||
: 'Nothing stored yet. Mail downloads automatically as it arrives.'
|
||||
}
|
||||
>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">{formatBytes(total)}</span>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Keep message list for"
|
||||
description={
|
||||
'How far back the offline message list goes. Listed messages are tiny (about a ' +
|
||||
'kilobyte each), so a wide window here costs very little and means a message never ' +
|
||||
'disappears from the offline list just because its content was removed to save space.'
|
||||
}
|
||||
>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
|
||||
value={status.policy.envelopeDays}
|
||||
disabled={busy !== null}
|
||||
onChange={(e) => void savePolicy({ envelopeDays: Number(e.target.value) })}
|
||||
>
|
||||
{[30, 90, 180, 365, 730, 1825].map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d >= 365 ? `${Math.round(d / 365)} year${d >= 730 ? 's' : ''}` : `${d} days`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Keep full messages for"
|
||||
description={
|
||||
'How far back complete messages - including formatted content - are stored so they ' +
|
||||
'can be read with no network. Attachments are not downloaded; they still need a ' +
|
||||
'connection.'
|
||||
}
|
||||
>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
|
||||
value={status.policy.bodyDays}
|
||||
disabled={busy !== null}
|
||||
onChange={(e) => void savePolicy({ bodyDays: Number(e.target.value) })}
|
||||
>
|
||||
{[7, 14, 30, 90, 180, 365].map((d) => (
|
||||
<option key={d} value={d}>{d >= 365 ? '1 year' : `${d} days`}</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Storage limit for message content"
|
||||
description={
|
||||
'The oldest stored content is removed first when this is reached. Messages stay in ' +
|
||||
'the offline list either way - only their content is removed.'
|
||||
}
|
||||
>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
|
||||
value={status.policy.maxBodyMB}
|
||||
disabled={busy !== null}
|
||||
onChange={(e) => void savePolicy({ maxBodyMB: Number(e.target.value) })}
|
||||
>
|
||||
{[100, 250, 500, 1000, 2000, 5000].map((mb) => (
|
||||
<option key={mb} value={mb}>{mb >= 1000 ? `${mb / 1000} GB` : `${mb} MB`}</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Offline mail actions"
|
||||
description={message ?? 'Download now, or delete everything stored offline on this device.'}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleSync} disabled={busy !== null}>
|
||||
{busy === 'sync' ? 'Downloading…' : 'Download now'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handlePurge} disabled={busy !== null}>
|
||||
{busy === 'purge' ? 'Deleting…' : 'Delete offline mail'}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Check,
|
||||
X,
|
||||
Folder,
|
||||
Calendar,
|
||||
BookUser,
|
||||
HardDrive,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSharingStore, type SharedResourceKind, type SharedFolder } from "@/stores/sharing-store";
|
||||
|
||||
const ICON_CLASS = "w-4 h-4 shrink-0";
|
||||
|
||||
function KindIcon({ kind }: { kind: SharedResourceKind }) {
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
return <Folder className={cn(ICON_CLASS, "text-blue-600/80")} />;
|
||||
case "calendar":
|
||||
return <Calendar className={cn(ICON_CLASS, "text-emerald-600/80")} />;
|
||||
case "addressBook":
|
||||
return <BookUser className={cn(ICON_CLASS, "text-violet-600/80")} />;
|
||||
case "file":
|
||||
return <HardDrive className={cn(ICON_CLASS, "text-amber-600/80")} />;
|
||||
}
|
||||
}
|
||||
|
||||
function KindLabel({ kind }: { kind: SharedResourceKind }) {
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
return "Mail";
|
||||
case "calendar":
|
||||
return "Calendar";
|
||||
case "addressBook":
|
||||
return "Contacts";
|
||||
case "file":
|
||||
return "Files";
|
||||
}
|
||||
}
|
||||
|
||||
export function SharingSettings() {
|
||||
const t = useTranslations("settings");
|
||||
const tSharing = useTranslations("sharing");
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const {
|
||||
sharedByMe,
|
||||
sharedWithMe,
|
||||
loading,
|
||||
fetchShares,
|
||||
revokeShare,
|
||||
changeRole,
|
||||
acceptShare,
|
||||
declineShare,
|
||||
} = useSharingStore();
|
||||
const [activeTab, setActiveTab] = useState<"byMe" | "withMe">("byMe");
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (client) fetchShares(client);
|
||||
}, [client, fetchShares]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client) handleRefresh();
|
||||
}, [client, handleRefresh]);
|
||||
|
||||
const handleRevoke = async (share: SharedFolder) => {
|
||||
if (!client) return;
|
||||
await revokeShare(
|
||||
client,
|
||||
share.resourceId,
|
||||
share.resourceKind,
|
||||
share.principalId,
|
||||
share.accountId,
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangeRole = async (share: SharedFolder, role: string) => {
|
||||
if (!client) return;
|
||||
await changeRole(
|
||||
client,
|
||||
share.resourceId,
|
||||
share.resourceKind,
|
||||
share.principalId,
|
||||
role,
|
||||
share.accountId,
|
||||
);
|
||||
};
|
||||
|
||||
const handleAccept = async (share: SharedFolder) => {
|
||||
if (!client) return;
|
||||
await acceptShare(client, share);
|
||||
};
|
||||
|
||||
const handleDecline = async (share: SharedFolder) => {
|
||||
if (!client) return;
|
||||
await declineShare(client, share);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-1 border-b border-border mb-4">
|
||||
<button
|
||||
onClick={() => setActiveTab("byMe")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
activeTab === "byMe"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tSharing("tab_shared_by_me")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("withMe")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
activeTab === "withMe"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tSharing("tab_shared_with_me")}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="p-2 rounded-md hover:bg-muted text-muted-foreground disabled:opacity-50 transition-colors"
|
||||
title={t("refresh")}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("w-4 h-4", loading && "animate-spin")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin me-2" />
|
||||
{t("loading")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === "byMe" && (
|
||||
<>
|
||||
{sharedByMe.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||
{tSharing("no_shares_by_me")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{sharedByMe.map((share) => (
|
||||
<div
|
||||
key={share.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-md border border-border bg-card"
|
||||
>
|
||||
<KindIcon kind={share.resourceKind} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{share.resourceName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<KindLabel kind={share.resourceKind} />
|
||||
<span className="mx-1 opacity-40">|</span>
|
||||
<Avatar
|
||||
name={share.principalName}
|
||||
email={share.principalEmail ?? undefined}
|
||||
size="sm"
|
||||
className="shrink-0 me-1"
|
||||
/>
|
||||
<span className="truncate">{share.principalName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={share.role}
|
||||
onChange={(e) => handleChangeRole(share, e.target.value)}
|
||||
className="appearance-none rounded-md border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="read">
|
||||
{tSharing("preset.read")}
|
||||
</option>
|
||||
<option value="readWrite">
|
||||
{tSharing("preset.readWrite")}
|
||||
</option>
|
||||
<option value="manager">
|
||||
{tSharing("preset.manager")}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleRevoke(share)}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title={tSharing("remove")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === "withMe" && (
|
||||
<>
|
||||
{sharedWithMe.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||
{tSharing("no_shares_with_me")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{sharedWithMe.map((share) => (
|
||||
<div
|
||||
key={share.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-md border border-border bg-card"
|
||||
>
|
||||
<KindIcon kind={share.resourceKind} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{share.resourceName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<KindLabel kind={share.resourceKind} />
|
||||
<span className="mx-1 opacity-40">|</span>
|
||||
<span className="truncate">
|
||||
{tSharing("shared_by")}: {share.principalName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs bg-muted rounded px-2 py-0.5 text-muted-foreground">
|
||||
{tSharing(`preset.${share.role}`)}
|
||||
</span>
|
||||
{share.pending ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => handleAccept(share)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Check className="w-3 h-3 me-1" />
|
||||
{tSharing("accept")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDecline(share)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<X className="w-3 h-3 me-1" />
|
||||
{tSharing("decline")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDecline(share)}
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
{tSharing("remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import Color from '@tiptap/extension-color';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { htmlToPlainText } from '@/lib/html-to-text';
|
||||
import type { Signature } from '@/stores/signature-store';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline as UnderlineIcon,
|
||||
Strikethrough,
|
||||
List,
|
||||
ListOrdered,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
Link as LinkIcon,
|
||||
Baseline,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SignatureEditorModalProps {
|
||||
signature?: Signature | null;
|
||||
onSave: (data: { name: string; body: string; plainText: string }) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StyledParagraph = Paragraph.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
style: {
|
||||
default: null as string | null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute('style'),
|
||||
renderHTML: (attrs: Record<string, string | null>) =>
|
||||
attrs.style ? { style: attrs.style } : {},
|
||||
},
|
||||
class: {
|
||||
default: null as string | null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute('class'),
|
||||
renderHTML: (attrs: Record<string, string | null>) =>
|
||||
attrs.class ? { class: attrs.class } : {},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const TEXT_COLORS = [
|
||||
'#000000', '#5f6368', '#9aa0a6', '#c5221f', '#e8710a', '#f9ab00', '#188038', '#1967d2',
|
||||
'#7627bb', '#c2185b', '#795548', '#fa5252', '#fd7e14', '#40c057', '#4dabf7', '#e64980',
|
||||
];
|
||||
|
||||
function ToolbarButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
title,
|
||||
disabled,
|
||||
}: {
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={cn(
|
||||
'p-1.5 rounded hover:bg-accent transition-colors',
|
||||
active && 'bg-accent text-accent-foreground',
|
||||
disabled && 'opacity-40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarSeparator() {
|
||||
return <div className="w-px h-5 bg-border mx-0.5" />;
|
||||
}
|
||||
|
||||
export function SignatureEditorModal({
|
||||
signature,
|
||||
onSave,
|
||||
onClose,
|
||||
}: SignatureEditorModalProps) {
|
||||
const t = useTranslations('signatures');
|
||||
const tCommon = useTranslations('common');
|
||||
const isEditing = !!signature;
|
||||
|
||||
const [name, setName] = useState(signature?.name ?? '');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [colorMenuOpen, setColorMenuOpen] = useState(false);
|
||||
|
||||
const dialogRef = useFocusTrap({
|
||||
isActive: true,
|
||||
onEscape: onClose,
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
paragraph: false,
|
||||
link: false,
|
||||
underline: false,
|
||||
codeBlock: false,
|
||||
}),
|
||||
StyledParagraph,
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { rel: 'noopener noreferrer nofollow' },
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['paragraph'],
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
],
|
||||
content: signature?.body ?? '<p></p>',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'tiptap min-h-[120px] px-3 py-2 text-sm text-foreground focus:outline-none',
|
||||
},
|
||||
},
|
||||
immediatelyRender: false,
|
||||
});
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const previousUrl = editor.getAttributes('link').href;
|
||||
const url = window.prompt('URL', previousUrl);
|
||||
if (url === null) return;
|
||||
if (url === '') {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
}, [editor]);
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setNameError(t('name_required'));
|
||||
return;
|
||||
}
|
||||
const html = editor?.getHTML() ?? '<p></p>';
|
||||
const plainText = htmlToPlainText(html);
|
||||
onSave({ name: trimmedName, body: html, plainText });
|
||||
};
|
||||
|
||||
const bodyHtml = editor?.getHTML() ?? '';
|
||||
const bodyPlainText = htmlToPlainText(bodyHtml);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-start justify-center z-[60] p-4 pt-[10vh] animate-in fade-in duration-150">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-2xl animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{isEditing ? t('edit_signature') : t('new_signature')}
|
||||
</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
<div>
|
||||
<label htmlFor="sig-name" className="block text-sm font-medium mb-1">
|
||||
{t('name_label')}
|
||||
</label>
|
||||
<Input
|
||||
id="sig-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
if (nameError) setNameError('');
|
||||
}}
|
||||
placeholder={t('name_placeholder')}
|
||||
className={cn(nameError && 'border-destructive')}
|
||||
aria-invalid={!!nameError}
|
||||
aria-describedby={nameError ? 'sig-name-error' : undefined}
|
||||
/>
|
||||
{nameError && (
|
||||
<p id="sig-name-error" className="text-sm text-destructive mt-1" role="alert">
|
||||
{nameError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium">{t('editor_label')}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{showPreview ? t('show_editor') : t('show_preview')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showPreview ? (
|
||||
<div className="border border-border rounded-md bg-muted/30 p-4 min-h-[200px]">
|
||||
<div className="text-xs text-muted-foreground mb-2 font-medium">
|
||||
{t('html_preview_label')}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm text-foreground [&_a]:text-primary [&_a]:underline-offset-2"
|
||||
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
||||
/>
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<div className="text-xs text-muted-foreground mb-2 font-medium">
|
||||
{t('plain_text_preview_label')}
|
||||
</div>
|
||||
<pre className="text-sm text-foreground whitespace-pre-wrap font-sans">
|
||||
{bodyPlainText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('flex flex-col border border-border rounded-md overflow-hidden')}>
|
||||
<div className="flex flex-wrap items-center gap-0.5 px-3 py-1.5 border-b border-border/50 bg-muted/30">
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('bold')}
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
title={t('toolbar.bold')}
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('italic')}
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
title={t('toolbar.italic')}
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('underline')}
|
||||
onClick={() => editor?.chain().focus().toggleUnderline().run()}
|
||||
title={t('toolbar.underline')}
|
||||
>
|
||||
<UnderlineIcon className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('strike')}
|
||||
onClick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
title={t('toolbar.strikethrough')}
|
||||
>
|
||||
<Strikethrough className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="relative">
|
||||
<ToolbarButton
|
||||
active={!!editor?.getAttributes('textStyle').color}
|
||||
onClick={() => setColorMenuOpen((v) => !v)}
|
||||
title={t('toolbar.text_color')}
|
||||
>
|
||||
<Baseline
|
||||
className="w-4 h-4"
|
||||
style={{ color: editor?.getAttributes('textStyle').color || undefined }}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
{colorMenuOpen && (
|
||||
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2">
|
||||
<div
|
||||
className="grid gap-0.5"
|
||||
style={{ gridTemplateColumns: 'repeat(8, 1fr)' }}
|
||||
>
|
||||
{TEXT_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
title={color}
|
||||
onClick={() => {
|
||||
editor?.chain().focus().setColor(color).run();
|
||||
setColorMenuOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-4 h-4 border border-border/60 rounded-[2px] transition-transform hover:scale-110',
|
||||
editor?.getAttributes('textStyle').color === color &&
|
||||
'ring-1 ring-ring ring-offset-1'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-px bg-border my-1.5" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1 text-sm rounded hover:bg-accent text-start w-full"
|
||||
onClick={() => {
|
||||
editor?.chain().focus().unsetColor().run();
|
||||
setColorMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('toolbar.remove_color')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('bulletList')}
|
||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
title={t('toolbar.bullet_list')}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('orderedList')}
|
||||
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||
title={t('toolbar.ordered_list')}
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButton
|
||||
active={editor?.isActive({ textAlign: 'left' })}
|
||||
onClick={() => editor?.chain().focus().setTextAlign('left').run()}
|
||||
title={t('toolbar.align_left')}
|
||||
>
|
||||
<AlignLeft className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive({ textAlign: 'center' })}
|
||||
onClick={() => editor?.chain().focus().setTextAlign('center').run()}
|
||||
title={t('toolbar.align_center')}
|
||||
>
|
||||
<AlignCenter className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive({ textAlign: 'right' })}
|
||||
onClick={() => editor?.chain().focus().setTextAlign('right').run()}
|
||||
title={t('toolbar.align_right')}
|
||||
>
|
||||
<AlignRight className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('link')}
|
||||
onClick={addLink}
|
||||
title={t('toolbar.link')}
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{tCommon('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { SettingsSection, SettingItem, Select } from './settings-section';
|
||||
import { SignatureEditorModal } from './signature-editor-modal';
|
||||
import { useSignatureStore, type Signature } from '@/stores/signature-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { truncateText } from '@/lib/utils';
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Copy,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function SignatureSettings() {
|
||||
const t = useTranslations('signatures');
|
||||
const tCommon = useTranslations('common');
|
||||
const {
|
||||
signatures,
|
||||
defaultSignatureId,
|
||||
replySignatureId,
|
||||
identitySignatureMap,
|
||||
addSignature,
|
||||
updateSignature,
|
||||
deleteSignature,
|
||||
duplicateSignature,
|
||||
setDefaultSignatureId,
|
||||
setReplySignatureId,
|
||||
setIdentitySignature,
|
||||
} = useSignatureStore();
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
|
||||
const [editingSignature, setEditingSignature] = useState<Signature | null>(null);
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Signature | null>(null);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingSignature(null);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const handleEdit = (sig: Signature) => {
|
||||
setEditingSignature(sig);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const handleDuplicate = (id: string) => {
|
||||
duplicateSignature(id);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (deleteTarget) {
|
||||
deleteSignature(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = (data: { name: string; body: string; plainText: string }) => {
|
||||
if (editingSignature) {
|
||||
updateSignature(editingSignature.id, data);
|
||||
} else {
|
||||
addSignature(data);
|
||||
}
|
||||
setShowEditor(false);
|
||||
setEditingSignature(null);
|
||||
};
|
||||
|
||||
const signatureOptions = [
|
||||
{ value: '', label: t('no_signature') },
|
||||
...signatures.map((sig) => ({ value: sig.id, label: sig.name })),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<SettingItem
|
||||
label={t('default_signature.label')}
|
||||
description={t('default_signature.description')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={defaultSignatureId ?? ''}
|
||||
onChange={(value) => setDefaultSignatureId(value || null)}
|
||||
options={signatureOptions}
|
||||
ariaLabel={t('default_signature.label')}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('reply_signature.label')}
|
||||
description={t('reply_signature.description')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={replySignatureId ?? ''}
|
||||
onChange={(value) => setReplySignatureId(value || null)}
|
||||
options={signatureOptions}
|
||||
ariaLabel={t('reply_signature.label')}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{identities.length > 0 && (
|
||||
<SettingItem
|
||||
label={t('per_identity_signatures.label')}
|
||||
description={t('per_identity_signatures.description')}
|
||||
>
|
||||
<div className="space-y-2 max-w-xs">
|
||||
{identities.map((identity) => {
|
||||
const mapping = identitySignatureMap[identity.id] ?? {};
|
||||
const identitySigOptions = [
|
||||
{ value: '', label: t('use_global_default') },
|
||||
...signatures.map((sig) => ({ value: sig.id, label: sig.name })),
|
||||
];
|
||||
return (
|
||||
<div key={identity.id} className="border border-border rounded-md p-3 space-y-2">
|
||||
<span className="text-sm font-medium block truncate">
|
||||
{identity.name ? `${identity.name} <${identity.email}>` : identity.email}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-16 shrink-0">
|
||||
{t('default')}
|
||||
</span>
|
||||
<select
|
||||
value={mapping.defaultId ?? ''}
|
||||
onChange={(e) =>
|
||||
setIdentitySignature(identity.id, 'default', e.target.value || null)
|
||||
}
|
||||
className="flex-1 px-2 py-1 text-xs rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{identitySigOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-16 shrink-0">
|
||||
{t('reply')}
|
||||
</span>
|
||||
<select
|
||||
value={mapping.replyId ?? ''}
|
||||
onChange={(e) =>
|
||||
setIdentitySignature(identity.id, 'reply', e.target.value || null)
|
||||
}
|
||||
className="flex-1 px-2 py-1 text-xs rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{identitySigOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{t('your_signatures', { count: signatures.length })}
|
||||
</h4>
|
||||
<Button size="sm" onClick={handleAdd}>
|
||||
<Plus className="w-4 h-4 me-1" />
|
||||
{t('add_signature')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{signatures.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t('no_signatures')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-md divide-y divide-border">
|
||||
{signatures.map((sig) => (
|
||||
<div
|
||||
key={sig.id}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center gap-3 min-w-0 text-start"
|
||||
onClick={() => handleEdit(sig)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{sig.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{truncateText(sig.plainText, 80)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5 ml-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDuplicate(sig.id);
|
||||
}}
|
||||
title={t('duplicate')}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(sig);
|
||||
}}
|
||||
title={tCommon('edit')}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(sig);
|
||||
}}
|
||||
title={tCommon('delete')}
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{showEditor && (
|
||||
<SignatureEditorModal
|
||||
signature={editingSignature}
|
||||
onSave={handleSave}
|
||||
onClose={() => {
|
||||
setShowEditor(false);
|
||||
setEditingSignature(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title={t('delete_title')}
|
||||
message={t('delete_message', { name: deleteTarget?.name ?? '' })}
|
||||
variant="destructive"
|
||||
confirmText={tCommon('delete')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -72,21 +72,11 @@ export function ThemesSettings() {
|
||||
|
||||
{/* Theme Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{/* Default theme card */}
|
||||
<ThemeCard
|
||||
name="Default"
|
||||
author="Bulwark"
|
||||
isDefaultTheme
|
||||
variants={['light', 'dark']}
|
||||
isDark={isDark}
|
||||
isActive={activeThemeId === null}
|
||||
isBuiltIn
|
||||
isDefault={!themePolicy.defaultThemeId}
|
||||
disabled={Boolean(forcedThemeId)}
|
||||
onActivate={() => handleActivate(null)}
|
||||
/>
|
||||
|
||||
{/* Installed themes */}
|
||||
{/* No "Default/Bulwark" card: product decision 2026-08-05 ships exactly
|
||||
two themes (SRC default, VNClagoon) - see DEFAULT_THEME_POLICY in
|
||||
lib/admin/types.ts. The underlying activateTheme(null) capability
|
||||
stays reachable programmatically (e.g. an admin clearing
|
||||
defaultThemeId), just not offered as a selectable card here. */}
|
||||
{visibleThemes.map(theme => {
|
||||
const isForceEnabled = theme.id === forcedThemeId || theme.forceEnabled || isThemeForceEnabled(theme.id);
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import {
|
||||
X,
|
||||
Loader2,
|
||||
UserPlus,
|
||||
Trash2,
|
||||
Users,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { Principal } from "@/lib/jmap/types";
|
||||
import { useSharingStore, type SharedResourceKind } from "@/stores/sharing-store";
|
||||
|
||||
export interface ShareFolderDialogProps {
|
||||
client: IJMAPClient;
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
resourceKind: SharedResourceKind;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PRESET_OPTIONS: Record<SharedResourceKind, readonly string[]> = {
|
||||
mailbox: ["read", "readWrite", "manager"],
|
||||
calendar: ["read", "readWrite", "manager"],
|
||||
addressBook: ["read", "readWrite", "manager"],
|
||||
file: ["read", "readWrite", "manager"],
|
||||
};
|
||||
|
||||
export function ShareFolderDialog({
|
||||
client,
|
||||
resourceId,
|
||||
resourceName,
|
||||
resourceKind,
|
||||
onClose,
|
||||
}: ShareFolderDialogProps) {
|
||||
const t = useTranslations("sharing");
|
||||
const tCommon = useTranslations("common");
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sharedByMe = useSharingStore((s) => s.sharedByMe);
|
||||
const loadPrincipals = useSharingStore((s) => s.loadPrincipals);
|
||||
const shareFolder = useSharingStore((s) => s.shareFolder);
|
||||
const revokeShare = useSharingStore((s) => s.revokeShare);
|
||||
const changeRole = useSharingStore((s) => s.changeRole);
|
||||
|
||||
const [allPrincipals, setAllPrincipals] = useState<Principal[]>([]);
|
||||
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingPrincipals(true);
|
||||
loadPrincipals(client)
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
setAllPrincipals(list);
|
||||
setLoadingPrincipals(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoadingPrincipals(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, loadPrincipals]);
|
||||
|
||||
const ownAccountId = client.getAccountId();
|
||||
|
||||
const allPrincipalsById = useMemo(() => {
|
||||
const map = new Map<string, Principal>();
|
||||
for (const p of allPrincipals) map.set(p.id, p);
|
||||
return map;
|
||||
}, [allPrincipals]);
|
||||
|
||||
const currentShares = sharedByMe.filter(
|
||||
(f) => f.resourceId === resourceId && f.resourceKind === resourceKind,
|
||||
);
|
||||
|
||||
const principals = useMemo(() => {
|
||||
const existing = new Set(currentShares.map((s) => s.principalId));
|
||||
return allPrincipals.filter(
|
||||
(p) => p.id !== ownAccountId && !existing.has(p.id),
|
||||
);
|
||||
}, [allPrincipals, ownAccountId, currentShares]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleRemove = async (principalId: string) => {
|
||||
setSavingId(principalId);
|
||||
try {
|
||||
await revokeShare(client, resourceId, resourceKind, principalId);
|
||||
} catch {
|
||||
/* error toast comes from store */
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeRole = async (principalId: string, role: string) => {
|
||||
setSavingId(principalId);
|
||||
try {
|
||||
await changeRole(client, resourceId, resourceKind, principalId, role);
|
||||
} catch {
|
||||
/* error toast comes from store */
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (principal: Principal) => {
|
||||
setSavingId(principal.id);
|
||||
try {
|
||||
await shareFolder(
|
||||
client,
|
||||
resourceId,
|
||||
resourceName,
|
||||
resourceKind,
|
||||
principal.id,
|
||||
"read",
|
||||
message || undefined,
|
||||
);
|
||||
setShowAdd(false);
|
||||
setSearch("");
|
||||
setMessage("");
|
||||
} catch {
|
||||
/* error toast comes from store */
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPrincipals = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return principals;
|
||||
return principals.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.email?.toLowerCase().includes(q) ||
|
||||
p.description?.toLowerCase().includes(q),
|
||||
);
|
||||
}, [principals, search]);
|
||||
|
||||
const presetOptions = PRESET_OPTIONS[resourceKind];
|
||||
|
||||
const kindLabels: Record<SharedResourceKind, string> = {
|
||||
mailbox: "Mail folder",
|
||||
calendar: "Calendar",
|
||||
addressBook: "Address book",
|
||||
file: "File folder",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-[1px]"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("title", { name: resourceName })}
|
||||
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 animate-in zoom-in-95 duration-200 max-h-[85vh] flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("title", { name: resourceName })}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{kindLabels[resourceKind]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
aria-label={tCommon("close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-4 overflow-y-auto">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("description")}
|
||||
</p>
|
||||
|
||||
{currentShares.length === 0 && !showAdd && (
|
||||
<div className="text-sm text-muted-foreground italic py-4 text-center">
|
||||
{t("no_shares")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentShares.length > 0 && (
|
||||
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
|
||||
{currentShares.map((share) => {
|
||||
const principal = allPrincipalsById.get(share.principalId);
|
||||
return (
|
||||
<li
|
||||
key={share.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5"
|
||||
>
|
||||
<Avatar
|
||||
name={principal?.name}
|
||||
email={principal?.email ?? undefined}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{principal?.name ||
|
||||
principal?.email ||
|
||||
share.principalId}
|
||||
</div>
|
||||
{principal?.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{principal.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={share.role}
|
||||
onChange={(e) =>
|
||||
handleChangeRole(share.principalId, e.target.value)
|
||||
}
|
||||
disabled={savingId === share.principalId}
|
||||
className="appearance-none rounded-md border border-input bg-background ps-3 pe-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
||||
>
|
||||
{presetOptions.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(`preset.${p}`)}
|
||||
</option>
|
||||
))}
|
||||
{share.role === "custom" && (
|
||||
<option value="custom">
|
||||
{t("preset.custom")}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
<ChevronDown className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemove(share.principalId)}
|
||||
disabled={savingId === share.principalId}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||
aria-label={t("remove")}
|
||||
title={t("remove")}
|
||||
>
|
||||
{savingId === share.principalId ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!showAdd && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<UserPlus className="w-4 h-4 me-2" />
|
||||
{t("add_person")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<div className="space-y-2 border border-border rounded-md p-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("search_placeholder")}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto -mx-1">
|
||||
{loadingPrincipals && (
|
||||
<div className="flex items-center justify-center py-4 text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||
{t("loading_principals")}
|
||||
</div>
|
||||
)}
|
||||
{!loadingPrincipals &&
|
||||
filteredPrincipals.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground text-center py-3">
|
||||
{search.trim()
|
||||
? t("no_match")
|
||||
: t("no_principals")}
|
||||
</div>
|
||||
)}
|
||||
{!loadingPrincipals &&
|
||||
filteredPrincipals.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => handleAdd(p)}
|
||||
disabled={savingId === p.id}
|
||||
className="w-full text-start px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={p.name}
|
||||
email={p.email ?? undefined}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate flex items-center gap-2">
|
||||
{p.name}
|
||||
{p.type === "group" && (
|
||||
<span className="text-[10px] uppercase font-normal text-muted-foreground bg-muted rounded px-1 py-0.5">
|
||||
{t("group")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{p.email && p.email !== p.name && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{p.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{savingId === p.id && (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Optional message…"
|
||||
rows={2}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
|
||||
/>
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowAdd(false);
|
||||
setSearch("");
|
||||
setMessage("");
|
||||
}}
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<Button onClick={onClose}>{tCommon("close")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface RadialMenuItem {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
interface RadialMenuProps {
|
||||
items: RadialMenuItem[];
|
||||
isOpen: boolean;
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function RadialMenu({
|
||||
items,
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
size = 200,
|
||||
}: RadialMenuProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const [animatingIn, setAnimatingIn] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => setAnimatingIn(true)));
|
||||
} else {
|
||||
setAnimatingIn(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setActiveIndex(-1);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && activeIndex >= 0 && activeIndex < items.length) {
|
||||
e.preventDefault();
|
||||
const item = items[activeIndex];
|
||||
if (!item.disabled) {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => {
|
||||
let next = prev + 1;
|
||||
if (next >= items.length) next = 0;
|
||||
let loops = 0;
|
||||
while (items[next]?.disabled && loops < items.length) {
|
||||
next = next + 1 >= items.length ? 0 : next + 1;
|
||||
loops++;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => {
|
||||
let next = prev - 1;
|
||||
if (next < 0) next = items.length - 1;
|
||||
let loops = 0;
|
||||
while (items[next]?.disabled && loops < items.length) {
|
||||
next = next - 1 < 0 ? items.length - 1 : next - 1;
|
||||
loops++;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, activeIndex, items, onClose]);
|
||||
|
||||
const radius = size / 2 - 28;
|
||||
const center = size / 2;
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-[9998] bg-black/20 cursor-pointer transition-opacity duration-200",
|
||||
animatingIn ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[9999]"
|
||||
style={{
|
||||
left: position.x - center,
|
||||
top: position.y - center,
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
role="menu"
|
||||
aria-label="Action menu"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute rounded-full flex items-center justify-center transition-all duration-200 ease-out will-change-transform",
|
||||
animatingIn ? "scale-100 opacity-100" : "scale-0 opacity-0"
|
||||
)}
|
||||
style={{
|
||||
left: center - 24,
|
||||
top: center - 24,
|
||||
width: 48,
|
||||
height: 48,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="w-12 h-12 rounded-full bg-background border border-border shadow-lg flex items-center justify-center hover:bg-muted transition-colors cursor-pointer"
|
||||
onClick={onClose}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{items.map((item, index) => {
|
||||
const angle = (index / items.length) * 2 * Math.PI - Math.PI / 2;
|
||||
const x = center + radius * Math.cos(angle);
|
||||
const y = center + radius * Math.sin(angle);
|
||||
const itemSize = 40;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"absolute transition-all duration-200 ease-out will-change-transform",
|
||||
animatingIn ? "scale-100 opacity-100" : "scale-0 opacity-0"
|
||||
)}
|
||||
style={{
|
||||
left: x - itemSize / 2,
|
||||
top: y - itemSize / 2,
|
||||
width: itemSize,
|
||||
height: itemSize,
|
||||
transitionDelay: animatingIn ? `${index * 35}ms` : "0ms",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"group relative flex items-center justify-center w-full h-full rounded-full shadow-lg border border-border transition-all duration-150 cursor-pointer focus:outline-none",
|
||||
item.disabled
|
||||
? "opacity-30 cursor-not-allowed bg-muted"
|
||||
: item.destructive
|
||||
? "bg-destructive/10 text-destructive hover:scale-125 hover:bg-destructive hover:text-destructive-foreground hover:border-destructive"
|
||||
: "bg-background text-foreground hover:scale-125 hover:bg-primary hover:text-primary-foreground hover:border-primary",
|
||||
activeIndex === index && !item.disabled && "scale-125 ring-2 ring-primary"
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (item.disabled) return;
|
||||
item.onClick();
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onMouseLeave={() => setActiveIndex(-1)}
|
||||
onFocus={() => setActiveIndex(index)}
|
||||
onBlur={() => setActiveIndex(-1)}
|
||||
role="menuitem"
|
||||
aria-label={item.label}
|
||||
tabIndex={activeIndex === index ? 0 : -1}
|
||||
>
|
||||
<span className="w-5 h-5 flex items-center justify-center [&>svg]:w-full [&>svg]:h-full">
|
||||
{item.icon}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -bottom-7 left-1/2 -translate-x-1/2 whitespace-nowrap text-[11px] font-medium leading-tight text-foreground bg-background/95 px-1.5 py-0.5 rounded shadow-sm border border-border/50",
|
||||
"opacity-0 group-hover:opacity-100 transition-opacity duration-100 pointer-events-none",
|
||||
activeIndex === index && !item.disabled && "opacity-100"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Registered on the dev-k8s-1/2/3 cluster (where ArgoCD already lives) via
|
||||
# `kubectl apply` directly to the argocd namespace — this file is the
|
||||
# version-controlled record of that, not something ArgoCD itself syncs
|
||||
# (no app-of-apps here, deliberately kept simple for two Applications).
|
||||
#
|
||||
# syncPolicy starts WITHOUT automated — manual sync until the one-time
|
||||
# per-namespace bootstrap (vncmail-env secret, image-pull secret — see
|
||||
# deploy/k8s/README.md §3) is done by hand once. Flip to automated (see
|
||||
# commented block below) only after a first manual sync succeeds cleanly.
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: vncmail-dev
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: git@gitlab.vnc.biz:gitlab-instance-b9b5cf2f/vncmail-plus.git
|
||||
targetRevision: dev
|
||||
path: deploy/k8s/overlays/dev
|
||||
destination:
|
||||
server: https://kubernetes.default.svc # in-cluster — ArgoCD and vncmail-dev share this cluster
|
||||
namespace: vncmail
|
||||
syncPolicy:
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
# automated:
|
||||
# prune: true
|
||||
# selfHeal: true
|
||||
@@ -0,0 +1,30 @@
|
||||
# NOT YET APPLIED to any cluster. Scaffolding only, matching
|
||||
# deploy/k8s/overlays/prod's own "inert until Phase D" status.
|
||||
#
|
||||
# Unlike vncmail-dev-app.yaml, this targets a DIFFERENT cluster (node1-3,
|
||||
# the HA "prod" cluster) than the one ArgoCD itself runs on (dev-k8s).
|
||||
# That means before this can be applied, node1-3 needs to be registered as
|
||||
# an ArgoCD-managed cluster (`argocd cluster add`, or an equivalent
|
||||
# ServiceAccount+kubeconfig secret) — deliberately not done yet: there's no
|
||||
# reason to wire cross-cluster RBAC into the prod HA cluster before prod
|
||||
# hostname/Stalwart/secrets are real and someone's actually promoting.
|
||||
#
|
||||
# syncPolicy has no automated block at all, and won't get one even later —
|
||||
# prod stays manual-sync-only permanently. That's the promotion gate.
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: vncmail-prod
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: git@gitlab.vnc.biz:gitlab-instance-b9b5cf2f/vncmail-plus.git
|
||||
targetRevision: main
|
||||
path: deploy/k8s/overlays/prod
|
||||
destination:
|
||||
server: CHANGEME # the node1-3 cluster's registered ArgoCD server URL, once added
|
||||
namespace: vncmail-prod
|
||||
syncPolicy:
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
+91
-43
@@ -1,29 +1,67 @@
|
||||
# VNCmail+ — Admin Deployment Guide (microk8s)
|
||||
|
||||
Deploy VNCmail+ (VNC's Bulwark fork) as a container at **`vncmail.sandbox.vnc.de`**,
|
||||
**alongside** the existing `bulwark.sandbox.vnc.de`. Plain `kubectl apply` — no
|
||||
GitOps needed.
|
||||
**alongside** the existing `bulwark.sandbox.vnc.de`.
|
||||
|
||||
> Why a container (not Vercel): Bulwark is stateful — it writes settings/admin/
|
||||
> telemetry to `/app/data`, which needs persistent volumes.
|
||||
|
||||
## Structure — base + overlays
|
||||
|
||||
```
|
||||
deploy/k8s/
|
||||
base/ # shared manifest shapes (namespace-agnostic)
|
||||
overlays/
|
||||
dev/ # the live sandbox — vncmail.sandbox.vnc.de, namespace vncmail
|
||||
prod/ # scaffolded, NOT YET LIVE — see "Production status" below
|
||||
ca/ # separate, isolated EJBCA internal CA — see ca/README.md.
|
||||
# Never composed with base/ or either overlay above.
|
||||
```
|
||||
|
||||
`kubectl apply -k overlays/dev` (or `overlays/prod`, once real) instead of
|
||||
applying `base/` directly — `base/` alone has no namespace and won't apply
|
||||
meaningfully on its own.
|
||||
|
||||
## Routine deploys go through CI + ArgoCD now
|
||||
|
||||
As of the GitLab CI/CD pipeline (`.gitlab-ci.yml`, see `../../VNCMAIL-SETUP.md`
|
||||
§ CI/CD), **pushing to `dev` auto-builds and bumps the deploy tag; ArgoCD's
|
||||
`vncmail-dev` Application applies it** — you should not normally need to run
|
||||
`kubectl apply` for the sandbox by hand anymore, and CI never touches the
|
||||
cluster directly (it only ever talks to the registry and to this git repo).
|
||||
This guide's manual steps below are for first-time setup, the one-time
|
||||
secret creation CI/ArgoCD deliberately never automate, and troubleshooting.
|
||||
|
||||
## Production status
|
||||
|
||||
**There is no production VNCmail+ deployment yet.** `overlays/prod/` exists
|
||||
in the repo but is inert: its ingress hostname and its secret's
|
||||
`JMAP_SERVER_URL` are both obvious placeholders (`vncmail.CHANGEME.invalid` /
|
||||
`https://REPLACE-ME-prod-stalwart-not-yet-deployed.invalid`) that will fail
|
||||
loudly rather than silently deploy against the wrong backend. Applying it
|
||||
requires, in order: a real prod Stalwart instance to exist, a real hostname
|
||||
decision, DNS, a real `secret.yaml`, and the `.gitlab-ci.yml` `promote` job's
|
||||
`kubectl apply` step (currently a TODO placeholder) filled in. None of that
|
||||
is CI's job to decide — it's an explicit, human-triggered event.
|
||||
|
||||
---
|
||||
|
||||
## 1. What you are deploying
|
||||
## 1. What you are deploying (per overlay)
|
||||
|
||||
| # | Object | File | Purpose |
|
||||
|---|--------|------|---------|
|
||||
| 1 | Namespace `vncmail` | `namespace.yaml` | Isolates the app |
|
||||
| 2 | 4× PersistentVolumeClaim | `pvc.yaml` | `/app/data/{settings,admin,admin-state,telemetry}` |
|
||||
| 3 | Secret `vncmail-env` | `secret.yaml` *(you create it)* | App config (JMAP URL, session secret, branding) |
|
||||
| 4 | Secret `ghcr-pull` | *(you create it — command below)* | Pull the private image from GHCR |
|
||||
| 5 | Deployment `vncmail-plus` | `deployment.yaml` | The app pod |
|
||||
| 6 | Service `vncmail-plus` | `service.yaml` | ClusterIP :80 → pod :3000 |
|
||||
| 7 | Ingress `vncmail-plus` | `ingress.yaml` | TLS host `vncmail.sandbox.vnc.de` |
|
||||
| 1 | Namespace | `overlays/<env>/namespace.yaml` | Isolates the app (`vncmail` for dev, `vncmail-prod` for prod) |
|
||||
| 2 | 4× PersistentVolumeClaim | `base/pvc.yaml` | `/app/data/{settings,admin,admin-state,telemetry}` |
|
||||
| 3 | Secret `vncmail-env` | `overlays/<env>/secret.yaml` *(you create it)* | App config (JMAP URL, session secret, branding) |
|
||||
| 4 | Image-pull secret | *(you create it — command below)* | Pull the (currently private) image |
|
||||
| 5 | Deployment `vncmail-plus` | `base/deployment.yaml` (+ overlay patches) | The app pod |
|
||||
| 6 | Service `vncmail-plus` | `base/service.yaml` | ClusterIP :80 → pod :3000 |
|
||||
| 7 | Ingress `vncmail-plus` | `base/ingress.yaml` (+ overlay patches for prod) | TLS host |
|
||||
|
||||
**Image:** `ghcr.io/brvncde-dotcom/vncmail-plus-dev:latest`
|
||||
(built automatically by CI from the `dev` branch). For anything beyond the
|
||||
sandbox, pin a digest — see §5.
|
||||
**Image:** CI builds and pushes to `registry.gitlab.vnc.biz/gitlab-instance-b9b5cf2f/vncmail-plus`
|
||||
(tag `sha-<sha>` per deploy, moving pointer `dev-latest`). The generic `vncmail-plus`
|
||||
image name in `base/deployment.yaml` is a placeholder — kustomize's image-tag
|
||||
Component replaces it with the real registry path on every deploy.
|
||||
|
||||
---
|
||||
|
||||
@@ -43,45 +81,48 @@ kubectl get ingressclass
|
||||
kubectl get clusterissuer # cert-manager issuers (if used)
|
||||
```
|
||||
|
||||
Then edit if they differ from the defaults below:
|
||||
Then edit if they differ from the defaults below (in `base/`, so both overlays
|
||||
pick up the fix):
|
||||
|
||||
| Value | Default in manifests | File to edit |
|
||||
|-------|----------------------|--------------|
|
||||
| StorageClass | `microk8s-hostpath` | `pvc.yaml` (all 4) |
|
||||
| IngressClass | `public` | `ingress.yaml` |
|
||||
| cert-manager issuer | `letsencrypt-prod` | `ingress.yaml` |
|
||||
| StorageClass | `microk8s-hostpath` | `base/pvc.yaml` (all 4) |
|
||||
| IngressClass | `public` | `base/ingress.yaml` |
|
||||
| cert-manager issuer | `letsencrypt-prod` | `base/ingress.yaml` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Deploy (copy-paste, in order)
|
||||
## 3. First-time setup (one-time, per environment — CI never does this)
|
||||
|
||||
```bash
|
||||
cd deploy/k8s
|
||||
cd deploy/k8s/overlays/dev # or overlays/prod, once real
|
||||
|
||||
# a) Namespace
|
||||
kubectl apply -f namespace.yaml
|
||||
|
||||
# b) Image-pull secret — the GHCR package is private.
|
||||
# Use a GitHub PAT (classic) with the read:packages scope.
|
||||
kubectl create secret docker-registry ghcr-pull \
|
||||
# a) Image-pull secret — the GitLab registry requires authentication.
|
||||
# Use a project deploy token with `read_registry` scope, or the CI job
|
||||
# token (short-lived — better for CI, not for long-running clusters).
|
||||
kubectl create secret docker-registry gitlab-registry \
|
||||
--namespace vncmail \
|
||||
--docker-server=ghcr.io \
|
||||
--docker-username=brvncde-dotcom \
|
||||
--docker-password='<GITHUB_PAT_read:packages>' \
|
||||
--docker-email=br@vnc.biz
|
||||
--docker-server=registry.gitlab.vnc.biz \
|
||||
--docker-username=<deploy-token-name> \
|
||||
--docker-password='<deploy-token-secret>' \
|
||||
--docker-email=ci@vnc.biz
|
||||
|
||||
# c) App config secret — copy the template, set a real SESSION_SECRET, apply.
|
||||
# b) App config secret — copy the template, set a real SESSION_SECRET, apply.
|
||||
cp secret.example.yaml secret.yaml
|
||||
# edit secret.yaml: SESSION_SECRET: "$(openssl rand -base64 32)"
|
||||
kubectl apply -f secret.yaml
|
||||
|
||||
# d) Everything else (PVCs, Deployment, Service, Ingress)
|
||||
# c) Everything else (namespace, PVCs, Deployment, Service, Ingress)
|
||||
kubectl apply -k .
|
||||
```
|
||||
|
||||
> Alternative to (b): make the GHCR package public
|
||||
> (GitHub → Packages → vncmail-plus-dev → Package settings → Change visibility),
|
||||
> then delete the `imagePullSecrets:` block from `deployment.yaml`.
|
||||
> Alternative to (a): make the GitLab container registry public for this
|
||||
> project, then delete the `imagePullSecrets:` block from `base/deployment.yaml`.
|
||||
|
||||
After this one-time setup, routine deploys to `dev` happen automatically via
|
||||
CI on every push — see "Routine deploys go through CI now" above. This
|
||||
section is for first-time bring-up (or `overlays/prod`, once it's real) and
|
||||
troubleshooting, not the everyday path.
|
||||
|
||||
---
|
||||
|
||||
@@ -104,16 +145,23 @@ a bare username.
|
||||
|
||||
## 5. Update to a new build
|
||||
|
||||
```bash
|
||||
# CI rebuilds ghcr.io/brvncde-dotcom/vncmail-plus-dev on every push to `dev`.
|
||||
kubectl -n vncmail rollout restart deploy/vncmail-plus # pulls :latest (imagePullPolicy: Always)
|
||||
Normally you don't — CI's `bump-dev` job + ArgoCD's automated sync do this
|
||||
on every push to `dev`. To do it by hand (e.g. troubleshooting, before
|
||||
automated sync is turned on):
|
||||
|
||||
# Production: pin a digest instead of :latest so rollouts are deterministic.
|
||||
```bash
|
||||
kubectl -n vncmail set image deploy/vncmail-plus \
|
||||
vncmail-plus=ghcr.io/brvncde-dotcom/vncmail-plus-dev@sha256:<digest>
|
||||
vncmail-plus=registry.gitlab.vnc.biz/gitlab-instance-b9b5cf2f/vncmail-plus:sha-<sha>
|
||||
```
|
||||
|
||||
Rollback: `kubectl -n vncmail rollout undo deploy/vncmail-plus`
|
||||
ArgoCD will overwrite this on its next sync unless you also update
|
||||
`deploy/k8s/overlays/dev/image-tag/kustomization.yaml` to match — that file
|
||||
is CI-owned (see its header comment), so a by-hand `set image` is only ever
|
||||
a temporary override, not a real fix.
|
||||
|
||||
Rollback (bypassing ArgoCD temporarily): `kubectl -n vncmail rollout undo deploy/vncmail-plus`.
|
||||
The real rollback is reverting the commit that bumped the tag and letting
|
||||
ArgoCD re-sync.
|
||||
|
||||
---
|
||||
|
||||
@@ -121,9 +169,9 @@ Rollback: `kubectl -n vncmail rollout undo deploy/vncmail-plus`
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
|---------|-------------|
|
||||
| Pod `ImagePullBackOff` | `ghcr-pull` secret missing/expired, or package still private. Recreate the secret (§3b) or make the package public. |
|
||||
| Pod `CrashLoopBackOff`, logs show `EACCES`/permission on `/app/data` | Volume not writable by uid 1001. `securityContext.fsGroup: 1001` is set in `deployment.yaml` — keep it; some storage drivers also need it on the PVC. |
|
||||
| PVC stuck `Pending` | Wrong `storageClassName` in `pvc.yaml`. Set it to one from `kubectl get sc`. |
|
||||
| Pod `ImagePullBackOff` | `gitlab-registry` secret missing/expired, or token lacks `read_registry`. Recreate the secret (§3a) or make the registry public. |
|
||||
| Pod `CrashLoopBackOff`, logs show `EACCES`/permission on `/app/data` | Volume not writable by uid 1001. `securityContext.fsGroup: 1001` is set in `base/deployment.yaml` — keep it; some storage drivers also need it on the PVC. |
|
||||
| PVC stuck `Pending` | Wrong `storageClassName` in `base/pvc.yaml`. Set it to one from `kubectl get sc`. |
|
||||
| Ingress has no address / no cert | Wrong `ingressClassName` or cert issuer. Match bulwark's (§2). Check `kubectl -n vncmail describe ingress vncmail-plus`. |
|
||||
| Login shows "Ein Fehler ist aufgetreten" | Use the **full** email (`user@sandbox.vnc.de`), not a bare username. |
|
||||
| Can't reach Stalwart | Check `JMAP_SERVER_URL` in the secret = `https://stalwart.sandbox.vnc.de`. |
|
||||
|
||||
@@ -2,7 +2,6 @@ apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: vncmail-plus
|
||||
namespace: vncmail
|
||||
labels:
|
||||
app: vncmail-plus
|
||||
spec:
|
||||
@@ -24,15 +23,18 @@ spec:
|
||||
fsGroup: 1001
|
||||
runAsUser: 1001
|
||||
runAsGroup: 1001
|
||||
# ghcr package is private by default — see deploy/k8s/README.md to create
|
||||
# this pull secret. Delete this block if you make the package public.
|
||||
# The GitLab container registry is private by default. Nodes need a
|
||||
# docker-registry secret named `gitlab-registry` in the target namespace.
|
||||
# Create it once per environment during first-time setup
|
||||
# (see deploy/k8s/README.md §3a).
|
||||
imagePullSecrets:
|
||||
- name: ghcr-pull
|
||||
- name: gitlab-registry
|
||||
containers:
|
||||
- name: vncmail-plus
|
||||
# dev image (built from the `dev` branch by CI). For production pin a
|
||||
# digest: ghcr.io/brvncde-dotcom/vncmail-plus-dev@sha256:<digest>
|
||||
image: ghcr.io/brvncde-dotcom/vncmail-plus-dev:latest
|
||||
# Generic placeholder — the real image name + tag are injected by the
|
||||
# image-tag kustomize Component on every deploy (see
|
||||
# overlays/*/image-tag/kustomization.yaml, rewritten by CI).
|
||||
image: vncmail-plus:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
@@ -0,0 +1,35 @@
|
||||
# Both real clusters (node1-3 "prod", dev-k8s-1-3 "dev") run Traefik, not
|
||||
# nginx — confirmed via `kubectl get ingressclass` (class is literally named
|
||||
# `traefik`). Unlike nginx's restrictive 1MB default, Traefik has no default
|
||||
# request-body-size cap, so there's no equivalent needed for mail attachment
|
||||
# uploads (the old nginx.ingress.kubernetes.io/proxy-body-size annotation
|
||||
# this file used to carry is simply not applicable here).
|
||||
#
|
||||
# Host, TLS secretName, and cert-manager issuer are ALL overlay-specific now
|
||||
# (dev-k8s only has a `letsencrypt-staging` issuer; node1-3/prod has none
|
||||
# configured yet) — every overlay's patch-ingress.yaml must override the
|
||||
# CHANGEME placeholders below.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: vncmail-plus
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: CHANGEME
|
||||
traefik.ingress.kubernetes.io/router.middlewares: traefik-redirect-to-https@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- CHANGEME.invalid
|
||||
secretName: vncmail-plus-tls
|
||||
rules:
|
||||
- host: CHANGEME.invalid
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: vncmail-plus
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,14 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- pvc.yaml
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
# - secret.yaml # create from an overlay's secret.example.yaml; not committed
|
||||
|
||||
# Namespace is intentionally NOT set here. Kustomize's `namespace:` transformer
|
||||
# doesn't rename cluster-scoped Namespace objects, so each overlay ships its own
|
||||
# namespace.yaml (the actual object) and its own `namespace:` field (which
|
||||
# injects metadata.namespace into every namespaced resource below). Applying
|
||||
# this base directly is meaningless — always go through an overlay.
|
||||
@@ -5,7 +5,6 @@ apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: vncmail-settings
|
||||
namespace: vncmail
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: microk8s-hostpath
|
||||
@@ -17,7 +16,6 @@ apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: vncmail-admin
|
||||
namespace: vncmail
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: microk8s-hostpath
|
||||
@@ -29,7 +27,6 @@ apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: vncmail-admin-state
|
||||
namespace: vncmail
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: microk8s-hostpath
|
||||
@@ -41,7 +38,6 @@ apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: vncmail-telemetry
|
||||
namespace: vncmail
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: microk8s-hostpath
|
||||
@@ -2,7 +2,6 @@ apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: vncmail-plus
|
||||
namespace: vncmail
|
||||
labels:
|
||||
app: vncmail-plus
|
||||
spec:
|
||||
@@ -1,34 +0,0 @@
|
||||
# Exposes VNCmail+ at vncmail.sandbox.vnc.de, alongside bulwark.sandbox.vnc.de.
|
||||
# MATCH YOUR CLUSTER — inspect the existing Bulwark ingress and copy its
|
||||
# ingressClassName + TLS/cert-manager annotations:
|
||||
# kubectl get ingress -A | grep bulwark
|
||||
# kubectl get ingress <bulwark-ingress> -n <ns> -o yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: vncmail-plus
|
||||
namespace: vncmail
|
||||
annotations:
|
||||
# cert-manager issuer — set to whatever bulwark.sandbox.vnc.de uses.
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
# Mail attachments can be large; raise the nginx body limit.
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
|
||||
spec:
|
||||
# microk8s ingress addon class is usually "public" (nginx). Confirm with
|
||||
# `kubectl get ingressclass` and match bulwark's.
|
||||
ingressClassName: public
|
||||
tls:
|
||||
- hosts:
|
||||
- vncmail.sandbox.vnc.de
|
||||
secretName: vncmail-plus-tls
|
||||
rules:
|
||||
- host: vncmail.sandbox.vnc.de
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: vncmail-plus
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,8 @@
|
||||
# Owned by CI (bump-dev job in .gitlab-ci.yml) - regenerated every
|
||||
# push to dev. Do not hand-edit; edits here get overwritten.
|
||||
apiVersion: kustomize.config.k8s.io/v1alpha1
|
||||
kind: Component
|
||||
images:
|
||||
- name: vncmail-plus
|
||||
newName: registry.gitlab.vnc.biz/gitlab-instance-b9b5cf2f/vncmail-plus
|
||||
newTag: sha-35ed6a28
|
||||
@@ -0,0 +1,19 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: vncmail
|
||||
resources:
|
||||
- ../../base
|
||||
- namespace.yaml
|
||||
# - secret.yaml # create from secret.example.yaml; not committed
|
||||
|
||||
patches:
|
||||
- path: patch-ingress.yaml
|
||||
- path: patch-image-pull-policy.yaml
|
||||
|
||||
components:
|
||||
- image-tag
|
||||
|
||||
# Targets the dev-k8s-1/2/3 cluster (confirmed via direct access: this is
|
||||
# where ArgoCD already lives). The image tag lives in image-tag/ (a separate
|
||||
# Component CI owns — see .gitlab-ci.yml's bump-dev job) rather than here, so
|
||||
# CI never needs to touch this file.
|
||||
@@ -0,0 +1,16 @@
|
||||
# base/deployment.yaml sets imagePullPolicy: Always, which is the right
|
||||
# default for a mutable tag like :latest. The dev overlay pins an immutable
|
||||
# sha-<commit> tag instead (see image-tag/), and for an immutable tag Always
|
||||
# is pure waste - the content behind that tag can never change, so re-pulling
|
||||
# it on every pod start only adds a registry round-trip and a hard dependency
|
||||
# on the registry being reachable at scheduling time.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: vncmail-plus
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: vncmail-plus
|
||||
imagePullPolicy: IfNotPresent
|
||||
@@ -0,0 +1,25 @@
|
||||
# Dev now uses letsencrypt-prod so the sandbox certificate is trusted by
|
||||
# browsers. Both letsencrypt-staging and letsencrypt-prod ClusterIssuers
|
||||
# exist on the dev-k8s cluster (see VNCiAC-dev-cluster-runbook.md §7).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: vncmail-plus
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- vncmail.sandbox.vnc.de
|
||||
secretName: vncmail-plus-tls
|
||||
rules:
|
||||
- host: vncmail.sandbox.vnc.de
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: vncmail-plus
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,12 @@
|
||||
# Owned by CI (the bump-prod job in .gitlab-ci.yml), not by hand — same
|
||||
# reasoning as overlays/dev/image-tag/. Starts pointed at an obviously-fake
|
||||
# tag on purpose: nothing has been promoted yet, and vncmail-prod's ArgoCD
|
||||
# Application has manual sync anyway, so this being "wrong" doesn't deploy
|
||||
# anything wrong — it just means there's nothing to sync until a real
|
||||
# `git push` to main updates it.
|
||||
apiVersion: kustomize.config.k8s.io/v1alpha1
|
||||
kind: Component
|
||||
images:
|
||||
- name: vncmail-plus
|
||||
newName: registry.gitlab.vnc.biz/gitlab-instance-b9b5cf2f/vncmail-plus
|
||||
newTag: not-yet-promoted
|
||||
@@ -0,0 +1,21 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: vncmail-prod
|
||||
resources:
|
||||
- ../../base
|
||||
- namespace.yaml
|
||||
# - secret.yaml # create from secret.example.yaml; not committed
|
||||
|
||||
patches:
|
||||
- path: patch-ingress.yaml
|
||||
- path: patch-deployment.yaml
|
||||
|
||||
components:
|
||||
- image-tag
|
||||
|
||||
# NOT MEANT TO BE SYNCED AS COMMITTED. Scaffolding only (see the pipeline
|
||||
# plan's Phase C/D) — image-tag/'s placeholder tag is obviously-invalid on
|
||||
# purpose. The bump-prod job in .gitlab-ci.yml keeps that tag pointed at
|
||||
# whatever's already on dev once main advances, but vncmail-prod's ArgoCD
|
||||
# Application has manual sync — a human still has to click Sync (or
|
||||
# `argocd app sync vncmail-prod`) for any of this to actually apply.
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: vncmail-prod
|
||||
labels:
|
||||
app.kubernetes.io/part-of: vnclagoon-suite
|
||||
@@ -0,0 +1,9 @@
|
||||
# Basic HA. Still `strategy: Recreate` (inherited from base) since the PVCs
|
||||
# are RWO — 2 replicas doesn't buy zero-downtime rollouts by itself, only
|
||||
# tolerance for a node loss between deploys. Revisit if that's not enough.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: vncmail-plus
|
||||
spec:
|
||||
replicas: 2
|
||||
@@ -0,0 +1,31 @@
|
||||
# PLACEHOLDER — the real production hostname has not been decided yet (see
|
||||
# VNCMAIL-SETUP.md / the pipeline plan). vncmail.CHANGEME.invalid is
|
||||
# deliberately unresolvable: applying this overlay as committed will not
|
||||
# issue a cert or route traffic anywhere. Replace both occurrences below,
|
||||
# and the matching TLS secretName, before Phase D (first real prod deploy).
|
||||
#
|
||||
# Targets node1-3 (the HA "prod" cluster). Deliberately does NOT override
|
||||
# base's `cert-manager.io/cluster-issuer: CHANGEME` — node1-3 has ZERO
|
||||
# ClusterIssuers configured today (confirmed via direct access). A human
|
||||
# needs to create a real one there (ACME account, DNS-01 or HTTP-01 solver)
|
||||
# before this can be anything but a placeholder.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: vncmail-plus
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- vncmail.CHANGEME.invalid
|
||||
secretName: vncmail-plus-prod-tls
|
||||
rules:
|
||||
- host: vncmail.CHANGEME.invalid
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: vncmail-plus
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,28 @@
|
||||
# Copy to secret.yaml, fill in real values, and apply. DO NOT commit secret.yaml
|
||||
# (it is gitignored). Generate SESSION_SECRET with: openssl rand -base64 32
|
||||
#
|
||||
# JMAP_SERVER_URL is a PLACEHOLDER — there is no production Stalwart instance
|
||||
# yet. This overlay cannot go live (Phase D) until one exists and this value
|
||||
# points at it for real.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: vncmail-env
|
||||
namespace: vncmail-prod
|
||||
type: Opaque
|
||||
stringData:
|
||||
# Core — connect to Stalwart over JMAP
|
||||
JMAP_SERVER_URL: "https://REPLACE-ME-prod-stalwart-not-yet-deployed.invalid"
|
||||
SESSION_SECRET: "REPLACE_ME__openssl_rand_base64_32"
|
||||
# Branding (theme defaults to VNClagoon in code; these set name + logo)
|
||||
APP_NAME: "VNCmail+"
|
||||
APP_SHORT_NAME: "VNCmail+"
|
||||
LOGIN_COMPANY_NAME: "VNClagoon"
|
||||
LOGIN_LOGO_DARK_URL: "/branding/vncmail-wordmark-on-dark.svg"
|
||||
LOGIN_LOGO_LIGHT_URL: "/branding/vncmail-wordmark-on-light.svg"
|
||||
APP_LOGO_DARK_URL: "/branding/vncmail-wordmark-on-dark.svg"
|
||||
APP_LOGO_LIGHT_URL: "/branding/vncmail-wordmark-on-light.svg"
|
||||
LOGIN_LOGO_MAX_HEIGHT: "52"
|
||||
# Housekeeping
|
||||
BULWARK_UPDATE_CHECK: "off"
|
||||
# Data dirs default to /app/data/* (mounted to the PVCs) — no need to set them.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user