Compare commits
145
Commits
vnc-v0.3.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2116798281 | ||
|
|
a457c1770e | ||
|
|
88bca86c1e | ||
|
|
60fde141b5 | ||
|
|
e693072862 | ||
|
|
7c127d8183 | ||
|
|
cf93b24abc | ||
|
|
d103c47cda | ||
|
|
20dadea2dd | ||
|
|
e696c65f75 | ||
|
|
33ca4bae37 | ||
|
|
424dba39f7 | ||
|
|
16853a364c | ||
|
|
8f2c89f9a8 | ||
|
|
54c508e8df | ||
|
|
d021a0a87c | ||
|
|
410aa52217 | ||
|
|
a227396e54 | ||
|
|
cfdd091d22 | ||
|
|
0ac429fe36 | ||
|
|
a58d9d8cda | ||
|
|
b98ab59f0d | ||
|
|
2e29af50d6 | ||
|
|
a0bffa9467 | ||
|
|
42a7b67eb2 | ||
|
|
80107d3b32 | ||
|
|
f121678e2a | ||
|
|
fb3f1a35b3 | ||
|
|
4fcd37650d | ||
|
|
13ec05da83 | ||
|
|
e7acf56753 | ||
|
|
83e29b3ef1 | ||
|
|
1aa0a4686b | ||
|
|
67f61f18d0 | ||
|
|
395fcc27a8 | ||
|
|
fbfaf528ab | ||
|
|
f34537adcf | ||
|
|
931d1fa06a | ||
|
|
47b9ab4398 | ||
|
|
7e3034da8d | ||
|
|
a622e3755b | ||
|
|
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.example
|
||||||
!.env.dev.example
|
!.env.dev.example
|
||||||
scripts/
|
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
|
TODO.md
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
|
|||||||
+10
-4
@@ -15,9 +15,15 @@
|
|||||||
DEV_MOCK_JMAP=true
|
DEV_MOCK_JMAP=true
|
||||||
|
|
||||||
# Point the app at its own mock endpoint.
|
# Point the app at its own mock endpoint.
|
||||||
# IMPORTANT: This must match the origin the app runs on (default: port 3000).
|
# IMPORTANT: must be an ABSOLUTE URL matching the origin the app runs on
|
||||||
# Using a different port (e.g. 3001) will cause CORS errors.
|
# (default: port 3000) - NOT a relative path. A relative path here makes
|
||||||
JMAP_SERVER_URL=/api/dev-jmap
|
# /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
|
# App
|
||||||
@@ -29,7 +35,7 @@ APP_NAME=Bulwark Webmail (Dev)
|
|||||||
# Session & Settings Sync (optional for 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
|
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
|
||||||
.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
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
@@ -51,13 +59,18 @@ next-env.d.ts
|
|||||||
# Sibling repos
|
# Sibling repos
|
||||||
/repos/
|
/repos/
|
||||||
|
|
||||||
# k8s deploy secret (create from deploy/k8s/secret.example.yaml)
|
# k8s deploy secrets (create from the matching overlay's secret.example.yaml)
|
||||||
/deploy/k8s/secret.yaml
|
/deploy/k8s/overlays/*/secret.yaml
|
||||||
|
|
||||||
# S/MIME plugin build output (rebuild with: cd vnc/plugins/smime && npm run build)
|
# First-party plugin build output (rebuild with: npm run build:plugins).
|
||||||
vnc/plugins/smime/node_modules/
|
# vnc/plugins/build/ is the staging dir the server installs from at startup
|
||||||
vnc/plugins/smime/dist/
|
# (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-vnc.zip
|
||||||
|
vnc/plugins/smime/smime.zip
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.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
|
# 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)
|
## 1.7.8 (2026-07-22)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+17
@@ -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.
|
# `git rev-parse` inside the build can't find it - CI must pass it in.
|
||||||
ARG GIT_COMMIT=unknown
|
ARG GIT_COMMIT=unknown
|
||||||
ENV GIT_COMMIT=$GIT_COMMIT
|
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
|
RUN npx next build --webpack
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
@@ -43,6 +49,17 @@ RUN apk upgrade --no-cache && \
|
|||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
# next/dist/lib/metadata/** (get-metadata-route.js and its neighbours). A
|
||||||
|
# plain top-level require in router-utils/filesystem.js, yet Next's own
|
||||||
|
# output file tracing for `output: "standalone"` + `next build --webpack`
|
||||||
|
# drops the whole directory - the server crashes on its first line with
|
||||||
|
# "Cannot find module '../../../lib/metadata/get-metadata-route'" without
|
||||||
|
# this. Same tracing-gap class as the plugins copy below.
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/next/dist/lib/metadata ./node_modules/next/dist/lib/metadata
|
||||||
|
# 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
|
RUN mkdir -p /app/data/settings /app/data/admin /app/data/admin-state /app/data/telemetry && chown -R nextjs:nodejs /app/data
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
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`).
|
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,
|
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.
|
not URLs). Bulwark's native model is a container + persistent volumes.
|
||||||
- So VNCmail+ runs as a Docker image (`ghcr.io/brvncde-dotcom/vncmail-plus-*`)
|
- So VNCmail+ runs as a Docker image with **4 persistent volumes**, exactly
|
||||||
with **4 persistent volumes**, exactly like the existing `bulwark.sandbox.vnc.de`.
|
like the existing `bulwark.sandbox.vnc.de`.
|
||||||
- JMAP calls go through **server-side `/api/*` routes** (`proxy.ts`) → server-to-
|
- JMAP calls go through **server-side `/api/*` routes** (`proxy.ts`) → server-to-
|
||||||
server to Stalwart, **no browser CORS**. Config is **runtime-read**.
|
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 |
|
| Branch | Role |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
| `main` | **Production** — CI builds `…/vncmail-plus-beta`. Only updated by an explicit promote. |
|
| `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 — CI builds `…/vncmail-plus-dev` on push. Default working branch. |
|
| `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`, PR into `dev`). |
|
| `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`).
|
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)
|
## Deploy (Kubernetes / microk8s)
|
||||||
|
|
||||||
Full runbook: **[deploy/k8s/README.md](deploy/k8s/README.md)**. In short:
|
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`).
|
1. CI (above) builds and pushes the image, one name/many tags, to GitLab's
|
||||||
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.
|
registry.
|
||||||
3. Point `vncmail.sandbox.vnc.de` DNS at the ingress; cert-manager issues TLS.
|
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
|
Runs alongside the existing `bulwark.sandbox.vnc.de`. Match your cluster's
|
||||||
StorageClass / IngressClass / cert issuer to bulwark's (see the runbook).
|
StorageClass / IngressClass / cert issuer to bulwark's (see the runbook).
|
||||||
|
|
||||||
## Deploy workflow (dev-first — ALWAYS)
|
## 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`:
|
2. **Promote to production only on explicit go-live** — merge `dev` → `main`:
|
||||||
```bash
|
```bash
|
||||||
git log dev..main # MUST be empty — main must have nothing dev lacks (else prod would revert)
|
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 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
|
git checkout dev
|
||||||
```
|
```
|
||||||
Then roll the production deployment to the new image (pin its digest — see deploy/k8s/README.md).
|
Then click `promote` in the GitLab pipeline UI (protected `production`
|
||||||
Never push straight to `main`. Never let a dev→main merge silently revert prod.
|
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)
|
## Syncing upstream (Bulwark releases)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import { Loader2, AlertCircle } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
|
// Module-level guard so a Suspense/search-params remount of this client
|
||||||
|
// component can't exchange the same OAuth code twice — Keycloak rejects a
|
||||||
|
// reused code with `invalid_grant` ("Code not valid") and the login fails.
|
||||||
|
const processedAuthCodes = new Set<string>();
|
||||||
|
|
||||||
function OAuthCallbackInner() {
|
function OAuthCallbackInner() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -32,6 +37,11 @@ function OAuthCallbackInner() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent a second token exchange for the same code (remount / double
|
||||||
|
// effect). Without this, the second exchange fails with "Code not valid".
|
||||||
|
if (processedAuthCodes.has(code)) return;
|
||||||
|
processedAuthCodes.add(code);
|
||||||
|
|
||||||
// Step-up re-auth for device pairing: the QR generator sent the user here
|
// Step-up re-auth for device pairing: the QR generator sent the user here
|
||||||
// via prompt=login. Don't create a login session — just confirm the fresh
|
// via prompt=login. Don't create a login session — just confirm the fresh
|
||||||
// auth (sets the short-lived pairing proof cookie) and bounce back to the
|
// auth (sets the short-lived pairing proof cookie) and bounce back to the
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ type PendingScopeAction =
|
|||||||
| { type: "delete"; event: CalendarEvent; sendScheduling?: boolean };
|
| { type: "delete"; event: CalendarEvent; sendScheduling?: boolean };
|
||||||
|
|
||||||
function isRecurringEvent(event: CalendarEvent): boolean {
|
function isRecurringEvent(event: CalendarEvent): boolean {
|
||||||
return (event.recurrenceRules?.length ?? 0) > 0 || event.recurrenceId != null;
|
// Stalwart may return an empty-string `recurrenceId` for non-recurring events
|
||||||
|
// rather than null; treat that as non-recurring so editing doesn't route
|
||||||
|
// through the recurrence-scope flow for a plain single event.
|
||||||
|
return (event.recurrenceRules?.length ?? 0) > 0 || Boolean(event.recurrenceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
@@ -97,9 +100,12 @@ export default function CalendarPage() {
|
|||||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
|
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
|
||||||
removeCalendar, clearCalendarEvents,
|
removeCalendar, clearCalendarEvents,
|
||||||
refreshAllSubscriptions, icalSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
|
newEventPrefill, setNewEventPrefill,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
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 sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||||
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||||
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
|
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
|
||||||
@@ -462,6 +468,19 @@ export default function CalendarPage() {
|
|||||||
setShowEventModal(true);
|
setShowEventModal(true);
|
||||||
}, [selectedDate, setSelectedDate]);
|
}, [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) => {
|
const openEditModal = useCallback((event: CalendarEvent) => {
|
||||||
setEditEvent(event);
|
setEditEvent(event);
|
||||||
setDefaultModalDate(undefined);
|
setDefaultModalDate(undefined);
|
||||||
@@ -1196,6 +1215,9 @@ export default function CalendarPage() {
|
|||||||
onContextMenuEvent={handleContextMenuEvent}
|
onContextMenuEvent={handleContextMenuEvent}
|
||||||
onContextMenuEmpty={handleContextMenuEmpty}
|
onContextMenuEmpty={handleContextMenuEmpty}
|
||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
|
onEditEvent={openEditModal}
|
||||||
|
onDeleteEvent={handleDeleteContextMenu}
|
||||||
|
onDuplicateEvent={handleDuplicateContextMenu}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
pendingPreview={pendingPreview}
|
pendingPreview={pendingPreview}
|
||||||
@@ -1488,10 +1510,14 @@ export default function CalendarPage() {
|
|||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
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}
|
onPreviewChange={setPendingPreview}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
|
prefillTitle={editEvent ? undefined : newEventPrefill?.title}
|
||||||
|
prefillDescription={editEvent ? undefined : newEventPrefill?.description}
|
||||||
|
prefillParticipants={editEvent ? undefined : newEventPrefill?.participants}
|
||||||
|
prefillDate={editEvent ? undefined : newEventPrefill?.date}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1618,9 +1644,13 @@ export default function CalendarPage() {
|
|||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); setNewEventPrefill(null); }}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={true}
|
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 { useAccountStore } from "@/stores/account-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
import { useProTabStore } from "@/stores/pro-tab-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { cn, formatFileSize } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||||
@@ -411,6 +412,31 @@ export default function FilesPage() {
|
|||||||
await shareResource(id, principalId, rights);
|
await shareResource(id, principalId, rights);
|
||||||
}, [shareResource]);
|
}, [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
|
// Pro shell only: all connected accounts are equal top-level entries at
|
||||||
// the root. The root path "/" itself is a cross-account picker - no
|
// the root. The root path "/" itself is a cross-account picker - no
|
||||||
// account's files are shown until the user enters one.
|
// account's files are shown until the user enters one.
|
||||||
@@ -554,6 +580,7 @@ export default function FilesPage() {
|
|||||||
ownAccountId={filesAccountId}
|
ownAccountId={filesAccountId}
|
||||||
sharingEnabled={sharingEnabled}
|
sharingEnabled={sharingEnabled}
|
||||||
onShare={handleShare}
|
onShare={handleShare}
|
||||||
|
onSendAsAttachment={handleSendAsAttachment}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -134,12 +134,20 @@ export default function LoginPage() {
|
|||||||
const isMobileHandoff = Boolean(mobileRedirectUri);
|
const isMobileHandoff = Boolean(mobileRedirectUri);
|
||||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
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 { 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 resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
const { activeThemeId, installedThemes } = useThemeStore(useShallow((s) => ({ activeThemeId: s.activeThemeId, installedThemes: s.installedThemes })));
|
const { activeThemeId, installedThemes } = useThemeStore(useShallow((s) => ({ activeThemeId: s.activeThemeId, installedThemes: s.installedThemes })));
|
||||||
// Active theme may carry its own brand logo (VNClagoon wordmark, SRC mark);
|
// Active theme may carry its own brand logo (VNClagoon wordmark, SRC mark);
|
||||||
// fall back to the globally configured login logo.
|
// an explicitly-configured logo (Branding tab / LOGIN_LOGO_*_URL) wins
|
||||||
const effLoginLogo = resolveThemeLogo(installedThemes, activeThemeId, resolvedTheme === 'dark', loginLogoLightUrl, loginLogoDarkUrl);
|
// 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
|
// 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.
|
// 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 { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { playNotificationSound } from "@/lib/notification-sound";
|
import { playNotificationSound } from "@/lib/notification-sound";
|
||||||
|
import { isElectronShell, showElectronNotification } from "@/lib/electron-bridge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||||
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
||||||
@@ -60,12 +61,14 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils";
|
import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils";
|
||||||
|
import { useSignatureStore } from "@/stores/signature-store";
|
||||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||||
import { findDraftIdentityId, resolveReplyFrom, type ReplyFromResolution } from "@/lib/reply-identity";
|
import { findDraftIdentityId, resolveReplyFrom, type ReplyFromResolution } from "@/lib/reply-identity";
|
||||||
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
|
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
|
||||||
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
|
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 { 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 { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
@@ -1062,7 +1065,67 @@ export default function Home() {
|
|||||||
debug.log('push', `[Push] Push notifications enabled for ${cleanups.length} account(s)`);
|
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 () => {
|
return () => {
|
||||||
|
catchUpCancelled = true;
|
||||||
|
clearTimeout(catchUpTimer);
|
||||||
cleanups.forEach((fn) => fn());
|
cleanups.forEach((fn) => fn());
|
||||||
};
|
};
|
||||||
}, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, handleStateChange, setPushConnected, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts]);
|
}, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, handleStateChange, setPushConnected, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts]);
|
||||||
@@ -1186,13 +1249,34 @@ export default function Home() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedEmail?.id, isScheduledView]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (newEmailNotification) {
|
if (newEmailNotification) {
|
||||||
const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState();
|
const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState();
|
||||||
if (emailNotificationsEnabled && emailNotificationSound) {
|
if (emailNotificationsEnabled && emailNotificationSound) {
|
||||||
playNotificationSound(notificationSoundChoice);
|
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);
|
debug.log('email', 'New email received:', newEmailNotification.subject);
|
||||||
clearNewEmailNotification();
|
clearNewEmailNotification();
|
||||||
}
|
}
|
||||||
@@ -2532,8 +2616,16 @@ export default function Home() {
|
|||||||
|
|
||||||
// Append signature from the sending identity (fall back to primary
|
// Append signature from the sending identity (fall back to primary
|
||||||
// when the reply-from lives on the same identity but a different alias).
|
// when the reply-from lives on the same identity but a different alias).
|
||||||
|
// The signature store's reply signature takes precedence over the legacy
|
||||||
|
// identity signature, matching the composer's send path.
|
||||||
|
const signatureStore = useSignatureStore.getState();
|
||||||
|
const replySigId = signatureStore.getIdentityReplySignatureId(sendingIdentity?.id ?? '');
|
||||||
|
const replySig = replySigId ? signatureStore.getSignatureById(replySigId) : undefined;
|
||||||
|
const signatureSource = replySig
|
||||||
|
? { htmlSignature: replySig.body, textSignature: replySig.plainText }
|
||||||
|
: sendingIdentity;
|
||||||
const separator = useSettingsStore.getState().signatureSeparatorEnabled;
|
const separator = useSettingsStore.getState().signatureSeparatorEnabled;
|
||||||
const finalBody = appendPlainTextSignature(body, sendingIdentity, { separator });
|
const finalBody = appendPlainTextSignature(body, signatureSource, { separator });
|
||||||
|
|
||||||
// When the identity has an HTML signature, send a matching HTML body so the
|
// When the identity has an HTML signature, send a matching HTML body so the
|
||||||
// signature keeps its formatting; appendPlainTextSignature would otherwise
|
// signature keeps its formatting; appendPlainTextSignature would otherwise
|
||||||
@@ -2544,8 +2636,8 @@ export default function Home() {
|
|||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
.replace(/\n/g, '<br>');
|
.replace(/\n/g, '<br>');
|
||||||
const finalHtmlBody = sendingIdentity?.htmlSignature?.trim()
|
const finalHtmlBody = signatureSource?.htmlSignature?.trim()
|
||||||
? appendHtmlSignature(`<div>${escapedBody}</div>`, sendingIdentity, { separator })
|
? appendHtmlSignature(`<div>${escapedBody}</div>`, signatureSource, { separator })
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const originalEmailId = selectedEmail.id;
|
const originalEmailId = selectedEmail.id;
|
||||||
@@ -3104,6 +3196,7 @@ export default function Home() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<AiAskButton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ import {
|
|||||||
Bug,
|
Bug,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
Download,
|
Download,
|
||||||
|
Sparkles,
|
||||||
|
Upload,
|
||||||
|
Share2,
|
||||||
X,
|
X,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -45,6 +48,7 @@ import { LayoutSettings } from '@/components/settings/layout-settings';
|
|||||||
import { LanguageSettings } from '@/components/settings/language-settings';
|
import { LanguageSettings } from '@/components/settings/language-settings';
|
||||||
import { ReadingSettings } from '@/components/settings/reading-settings';
|
import { ReadingSettings } from '@/components/settings/reading-settings';
|
||||||
import { ComposingSettings } from '@/components/settings/composing-settings';
|
import { ComposingSettings } from '@/components/settings/composing-settings';
|
||||||
|
import { SignatureSettings } from '@/components/settings/signature-settings';
|
||||||
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
|
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
|
||||||
import { AccountSettings } from '@/components/settings/account-settings';
|
import { AccountSettings } from '@/components/settings/account-settings';
|
||||||
import { IdentitySettings } from '@/components/settings/identity-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 { NotificationSettings } from '@/components/settings/notification-settings';
|
||||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||||
import { PluginsSettings } from '@/components/settings/plugins-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 { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||||
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
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 { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||||
import { useEmailStore } from '@/stores/email-store';
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
@@ -96,6 +103,7 @@ type Tab =
|
|||||||
| 'composing'
|
| 'composing'
|
||||||
| 'downloads'
|
| 'downloads'
|
||||||
| 'identities'
|
| 'identities'
|
||||||
|
| 'signatures'
|
||||||
| 'vacation'
|
| 'vacation'
|
||||||
| 'filters'
|
| 'filters'
|
||||||
| 'templates'
|
| 'templates'
|
||||||
@@ -111,6 +119,9 @@ type Tab =
|
|||||||
| 'about_data'
|
| 'about_data'
|
||||||
| 'themes'
|
| 'themes'
|
||||||
| 'plugins'
|
| 'plugins'
|
||||||
|
| 'import'
|
||||||
|
| 'sharing'
|
||||||
|
| 'ai_assistant'
|
||||||
| 'debug';
|
| 'debug';
|
||||||
|
|
||||||
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||||
@@ -138,6 +149,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
|||||||
composing: PenLine,
|
composing: PenLine,
|
||||||
downloads: Download,
|
downloads: Download,
|
||||||
identities: UserPen,
|
identities: UserPen,
|
||||||
|
signatures: PenLine,
|
||||||
vacation: PalmtreeIcon,
|
vacation: PalmtreeIcon,
|
||||||
filters: Filter,
|
filters: Filter,
|
||||||
templates: FileText,
|
templates: FileText,
|
||||||
@@ -153,6 +165,9 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
|||||||
about_data: Info,
|
about_data: Info,
|
||||||
themes: SwatchBook,
|
themes: SwatchBook,
|
||||||
plugins: Puzzle,
|
plugins: Puzzle,
|
||||||
|
import: Upload,
|
||||||
|
sharing: Share2,
|
||||||
|
ai_assistant: Sparkles,
|
||||||
debug: Bug,
|
debug: Bug,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,6 +230,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
],
|
],
|
||||||
downloads: ['settings.downloads'],
|
downloads: ['settings.downloads'],
|
||||||
identities: ['settings.identities'],
|
identities: ['settings.identities'],
|
||||||
|
signatures: ['signatures'],
|
||||||
vacation: ['settings.vacation'],
|
vacation: ['settings.vacation'],
|
||||||
filters: ['settings.filters'],
|
filters: ['settings.filters'],
|
||||||
templates: ['settings.templates'],
|
templates: ['settings.templates'],
|
||||||
@@ -234,6 +250,9 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
about_data: ['settings.advanced'],
|
about_data: ['settings.advanced'],
|
||||||
themes: [],
|
themes: [],
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
ai_assistant: [],
|
||||||
|
import: ['settings.importer'],
|
||||||
|
sharing: ['sharing'],
|
||||||
debug: ['settings.advanced'],
|
debug: ['settings.advanced'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -249,6 +268,7 @@ const tabKeywords: Record<Tab, string> = {
|
|||||||
composing: 'editor signature plain text reply forward draft compose',
|
composing: 'editor signature plain text reply forward draft compose',
|
||||||
downloads: 'download filename template eml attachment save export',
|
downloads: 'download filename template eml attachment save export',
|
||||||
identities: 'from address signature email',
|
identities: 'from address signature email',
|
||||||
|
signatures: 'signature rich text html editor',
|
||||||
vacation: 'auto reply away out of office holiday responder',
|
vacation: 'auto reply away out of office holiday responder',
|
||||||
filters: 'sieve rules block junk forward',
|
filters: 'sieve rules block junk forward',
|
||||||
templates: 'snippet quick reply',
|
templates: 'snippet quick reply',
|
||||||
@@ -264,6 +284,9 @@ const tabKeywords: Record<Tab, string> = {
|
|||||||
about_data: 'export import storage quota privacy backup',
|
about_data: 'export import storage quota privacy backup',
|
||||||
themes: 'custom theme css skin appearance',
|
themes: 'custom theme css skin appearance',
|
||||||
plugins: 'extensions addons',
|
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',
|
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: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
|
||||||
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, 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: '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' },
|
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
|
||||||
|
|
||||||
// Appearance
|
// Appearance
|
||||||
@@ -625,10 +649,12 @@ export default function SettingsPage() {
|
|||||||
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
||||||
{ id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, 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: '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 }] : []),
|
...(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 }] : []),
|
...(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 }] : []),
|
...(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: '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 }] : []),
|
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
|
||||||
|
|
||||||
// Privacy & Security
|
// Privacy & Security
|
||||||
@@ -652,6 +678,7 @@ export default function SettingsPage() {
|
|||||||
// Advanced
|
// Advanced
|
||||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: '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('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 }] : []),
|
...(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 === 'composing' && <ComposingSettings />}
|
||||||
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
|
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
|
||||||
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||||
|
{effectiveActiveTab === 'signatures' && <SignatureSettings />}
|
||||||
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||||
{effectiveActiveTab === 'templates' && <TemplateSettings />}
|
{effectiveActiveTab === 'templates' && <TemplateSettings />}
|
||||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||||
|
{effectiveActiveTab === 'import' && <ImportSettings />}
|
||||||
|
{effectiveActiveTab === 'sharing' && <SharingSettings />}
|
||||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||||
@@ -777,6 +807,7 @@ export default function SettingsPage() {
|
|||||||
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
|
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
|
||||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||||
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||||
|
{effectiveActiveTab === 'ai_assistant' && <AiAssistantSettings />}
|
||||||
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||||
{effectiveActiveTab.startsWith('plugin:') && (
|
{effectiveActiveTab.startsWith('plugin:') && (
|
||||||
<PluginIframeSlot
|
<PluginIframeSlot
|
||||||
|
|||||||
@@ -0,0 +1,452 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Save, Loader2, X, ArrowRight, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import type { AiConsoleConfig, AiClass, PublicAiPreset } 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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newPresetId(): string {
|
||||||
|
return `preset-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Paperclip-style env-var-key picker (decision 2026-08-07): an admin
|
||||||
|
* names a preset and an env var; the actual secret value is never entered
|
||||||
|
* here — it's whatever ops has set in the server's real environment. This is
|
||||||
|
* what lets a user in Settings pick a provider from a dropdown instead of
|
||||||
|
* pasting a key.
|
||||||
|
*/
|
||||||
|
function PublicPresetsEditor({
|
||||||
|
presets, onChange,
|
||||||
|
}: { presets: PublicAiPreset[]; onChange: (next: PublicAiPreset[]) => void }) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [baseUrl, setBaseUrl] = useState('https://api.deepseek.com');
|
||||||
|
const [model, setModel] = useState('');
|
||||||
|
const [envVar, setEnvVar] = useState('');
|
||||||
|
|
||||||
|
const canAdd = name.trim() && baseUrl.trim() && model.trim() && envVar.trim();
|
||||||
|
|
||||||
|
function addPreset() {
|
||||||
|
if (!canAdd) return;
|
||||||
|
onChange([...presets, { id: newPresetId(), name: name.trim(), baseUrl: baseUrl.trim(), model: model.trim(), apiKeyEnvVar: envVar.trim() }]);
|
||||||
|
setName('');
|
||||||
|
setBaseUrl('https://api.deepseek.com');
|
||||||
|
setModel('');
|
||||||
|
setEnvVar('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{presets.length > 0 && (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{presets.map((p) => (
|
||||||
|
<div key={p.id} className="px-4 py-2.5 flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-sm font-medium">{p.name}</span>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{p.model} · {p.baseUrl} · reads <code className="text-[11px]">{p.apiKeyEnvVar}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(presets.filter((x) => x.id !== p.id))}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
aria-label={`Remove ${p.name}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="px-4 py-3 flex flex-col gap-2 border-t border-border">
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name, e.g. DeepSeek (org)"
|
||||||
|
className="flex-1 min-w-[160px] h-8 rounded border border-input bg-background px-2.5 text-xs" />
|
||||||
|
<input value={model} onChange={(e) => setModel(e.target.value)} placeholder="Model, e.g. deepseek-chat"
|
||||||
|
className="flex-1 min-w-[160px] h-8 rounded border border-input bg-background px-2.5 text-xs" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="API base URL"
|
||||||
|
className="flex-1 min-w-[200px] h-8 rounded border border-input bg-background px-2.5 text-xs" />
|
||||||
|
<input value={envVar} onChange={(e) => setEnvVar(e.target.value)} placeholder="Env var, e.g. DEEPSEEK_API_KEY"
|
||||||
|
className="flex-1 min-w-[200px] h-8 rounded border border-input bg-background px-2.5 text-xs" />
|
||||||
|
<button onClick={addPreset} disabled={!canAdd}
|
||||||
|
className="h-8 px-3 rounded border border-border bg-muted text-xs font-medium hover:bg-muted/70 disabled:opacity-50 inline-flex items-center gap-1.5">
|
||||||
|
<Plus className="w-3 h-3" /> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Only the env var <em>name</em> is stored here — provision the actual key as a real environment variable on
|
||||||
|
the server (k8s secret, .env, Electron packaging). This app never sees or stores the value.
|
||||||
|
</p>
|
||||||
|
</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">Public — org-managed presets</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Paperclip-style: publish a provider by name instead of making every user paste their own key. Users pick
|
||||||
|
one of these in Settings with no key field at all — the server resolves the named env var at request time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PublicPresetsEditor presets={config.publicPresets} onChange={(v) => update({ publicPresets: v })} />
|
||||||
|
</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.' },
|
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.' },
|
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.' },
|
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 = [
|
const RESTRICTABLE_SETTINGS = [
|
||||||
|
|||||||
@@ -0,0 +1,638 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Save, Loader2, Plus, X } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { toast } from '@/stores/toast-store';
|
||||||
|
|
||||||
|
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 t = useTranslations('admin.vncdirectory');
|
||||||
|
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);
|
||||||
|
|
||||||
|
try {
|
||||||
|
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: t('saved') });
|
||||||
|
setDirty(false);
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || t('save_error') });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : t('save_error');
|
||||||
|
setMessage({ type: 'error', text: msg });
|
||||||
|
toast.error(msg);
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
|
{t('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">{t('title')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{t('description')}
|
||||||
|
</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" />}
|
||||||
|
{t('save')}
|
||||||
|
</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={t('enable_section')}>
|
||||||
|
<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">{t('enabled')}</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{t('enabled_description')}
|
||||||
|
</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={t('connection')}>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<TextRow
|
||||||
|
label={t('url')}
|
||||||
|
value={config.apiUrl}
|
||||||
|
onChange={(v) => updateField('apiUrl', v)}
|
||||||
|
placeholder={t('url_placeholder')}
|
||||||
|
/>
|
||||||
|
<PasswordRow
|
||||||
|
label={t('api_key')}
|
||||||
|
value={config.apiKey}
|
||||||
|
onChange={(v) => updateField('apiKey', v)}
|
||||||
|
placeholder={t('api_key_placeholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t('saml')}>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<ToggleRow
|
||||||
|
label={t('saml_enabled')}
|
||||||
|
description={t('saml_enabled_description')}
|
||||||
|
value={config.samlEnabled}
|
||||||
|
onChange={() => toggleBool('samlEnabled')}
|
||||||
|
/>
|
||||||
|
{config.samlEnabled && (
|
||||||
|
<>
|
||||||
|
<TextRow
|
||||||
|
label={t('idp_url')}
|
||||||
|
value={config.samlIdpUrl}
|
||||||
|
onChange={(v) => updateField('samlIdpUrl', v)}
|
||||||
|
placeholder={t('idp_url_placeholder')}
|
||||||
|
/>
|
||||||
|
<TextRow
|
||||||
|
label={t('issuer')}
|
||||||
|
value={config.samlIssuer}
|
||||||
|
onChange={(v) => updateField('samlIssuer', v)}
|
||||||
|
placeholder={t('issuer_placeholder')}
|
||||||
|
/>
|
||||||
|
<div className="px-4 py-3 flex flex-col gap-2">
|
||||||
|
<label className="text-sm text-foreground">
|
||||||
|
{t('sp_cert')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={config.samlSpCert}
|
||||||
|
onChange={(e) => updateField('samlSpCert', e.target.value)}
|
||||||
|
placeholder={t('sp_cert_placeholder')}
|
||||||
|
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={t('ldap')}>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<ToggleRow
|
||||||
|
label={t('ldap_enabled')}
|
||||||
|
description={t('ldap_enabled_description')}
|
||||||
|
value={config.ldapEnabled}
|
||||||
|
onChange={() => toggleBool('ldapEnabled')}
|
||||||
|
/>
|
||||||
|
{config.ldapEnabled && (
|
||||||
|
<>
|
||||||
|
<TextRow
|
||||||
|
label={t('ldap_uri')}
|
||||||
|
value={config.ldapUri}
|
||||||
|
onChange={(v) => updateField('ldapUri', v)}
|
||||||
|
placeholder={t('ldap_uri_placeholder')}
|
||||||
|
/>
|
||||||
|
<TextRow
|
||||||
|
label={t('bind_dn')}
|
||||||
|
value={config.ldapBindDn}
|
||||||
|
onChange={(v) => updateField('ldapBindDn', v)}
|
||||||
|
placeholder={t('bind_dn_placeholder')}
|
||||||
|
/>
|
||||||
|
<PasswordRow
|
||||||
|
label={t('bind_password')}
|
||||||
|
value={config.ldapBindPassword}
|
||||||
|
onChange={(v) => updateField('ldapBindPassword', v)}
|
||||||
|
placeholder={t('bind_password_placeholder')}
|
||||||
|
/>
|
||||||
|
<TextRow
|
||||||
|
label={t('search_base')}
|
||||||
|
value={config.ldapSearchBase}
|
||||||
|
onChange={(v) => updateField('ldapSearchBase', v)}
|
||||||
|
placeholder={t('search_base_placeholder')}
|
||||||
|
/>
|
||||||
|
<SelectRow
|
||||||
|
label={t('ldap_type')}
|
||||||
|
value={config.ldapType}
|
||||||
|
options={[
|
||||||
|
{ value: 'openldap', label: t('ldap_type_openldap') },
|
||||||
|
{ value: 'ms-ad', label: t('ldap_type_msad') },
|
||||||
|
]}
|
||||||
|
onChange={(v) => updateField('ldapType', v as 'openldap' | 'ms-ad')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t('auth_section')}>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<ToggleRow
|
||||||
|
label={t('require_2fa')}
|
||||||
|
description={t('require_2fa_description')}
|
||||||
|
value={config.tfaEnabled}
|
||||||
|
onChange={() => toggleBool('tfaEnabled')}
|
||||||
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label={t('oidc_section')}
|
||||||
|
description={t('oidc_section_description')}
|
||||||
|
value={config.oidcEnabled}
|
||||||
|
onChange={() => toggleBool('oidcEnabled')}
|
||||||
|
/>
|
||||||
|
{config.oidcEnabled && (
|
||||||
|
<>
|
||||||
|
<TextRow
|
||||||
|
label={t('oidc_client_id')}
|
||||||
|
value={config.oidcClientId}
|
||||||
|
onChange={(v) => updateField('oidcClientId', v)}
|
||||||
|
placeholder={t('oidc_client_id_placeholder')}
|
||||||
|
/>
|
||||||
|
<TextRow
|
||||||
|
label={t('oidc_discovery_url')}
|
||||||
|
value={config.oidcDiscoveryUrl}
|
||||||
|
onChange={(v) => updateField('oidcDiscoveryUrl', v)}
|
||||||
|
placeholder={t('oidc_discovery_url_placeholder')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<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">{t('session_ttl')}</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{t('session_ttl_description')}
|
||||||
|
</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={t('federated')}>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
|
{t('federated_description')}
|
||||||
|
</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={t('app_url_placeholder')}
|
||||||
|
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={t('remove_app', { name: 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 t = useTranslations('admin.vncdirectory');
|
||||||
|
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" />
|
||||||
|
{t('add_app')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setError(t('app_name_error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||||
|
setError(t('app_name_format_error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existingKeys.has(trimmed)) {
|
||||||
|
setError(t('app_exists_error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!url.trim()) {
|
||||||
|
setError(t('app_url_error'));
|
||||||
|
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={t('app_name_placeholder')}
|
||||||
|
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={t('app_url_placeholder')}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{t('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"
|
||||||
|
>
|
||||||
|
{t('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, setIsMasked] = useState(value === '••••••');
|
||||||
|
|
||||||
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
if (isMasked) {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setIsMasked(false);
|
||||||
|
} else {
|
||||||
|
onChange(e.target.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={handleChange}
|
||||||
|
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,
|
Scale,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Key,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
Bot,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
Activity,
|
Activity,
|
||||||
@@ -54,7 +56,9 @@ const NAV_GROUPS: ReadonlyArray<{
|
|||||||
{ tab: 'settings', label: 'Settings', icon: Settings },
|
{ tab: 'settings', label: 'Settings', icon: Settings },
|
||||||
{ tab: 'branding', label: 'Branding', icon: Palette },
|
{ tab: 'branding', label: 'Branding', icon: Palette },
|
||||||
{ tab: 'auth', label: 'Authentication', icon: Shield },
|
{ tab: 'auth', label: 'Authentication', icon: Shield },
|
||||||
|
{ tab: 'vncdirectory', label: 'VNCdirectory', icon: Key },
|
||||||
{ tab: 'policy', label: 'Policy', icon: Scale },
|
{ 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 { BrandingTab } from './_tabs/branding';
|
||||||
import { AuthTab } from './_tabs/auth';
|
import { AuthTab } from './_tabs/auth';
|
||||||
import { PolicyTab } from './_tabs/policy';
|
import { PolicyTab } from './_tabs/policy';
|
||||||
|
import { AiPolicyTab } from './_tabs/ai-policy';
|
||||||
import { PluginsTab } from './_tabs/plugins';
|
import { PluginsTab } from './_tabs/plugins';
|
||||||
import { ThemesTab } from './_tabs/themes';
|
import { ThemesTab } from './_tabs/themes';
|
||||||
import { MarketplaceTab } from './_tabs/marketplace';
|
import { MarketplaceTab } from './_tabs/marketplace';
|
||||||
import { VersionTab } from './_tabs/version';
|
import { VersionTab } from './_tabs/version';
|
||||||
import { TelemetryTab } from './_tabs/telemetry';
|
import { TelemetryTab } from './_tabs/telemetry';
|
||||||
import { LogsTab } from './_tabs/logs';
|
import { LogsTab } from './_tabs/logs';
|
||||||
|
import { VncDirectoryTab } from './_tabs/vncdirectory';
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const activeTab = useAdminTabStore((s) => s.activeTab);
|
const activeTab = useAdminTabStore((s) => s.activeTab);
|
||||||
@@ -39,11 +41,13 @@ export default function AdminPage() {
|
|||||||
case 'branding': return <BrandingTab />;
|
case 'branding': return <BrandingTab />;
|
||||||
case 'auth': return <AuthTab />;
|
case 'auth': return <AuthTab />;
|
||||||
case 'policy': return <PolicyTab />;
|
case 'policy': return <PolicyTab />;
|
||||||
|
case 'ai-policy': return <AiPolicyTab />;
|
||||||
case 'plugins': return <PluginsTab />;
|
case 'plugins': return <PluginsTab />;
|
||||||
case 'themes': return <ThemesTab />;
|
case 'themes': return <ThemesTab />;
|
||||||
case 'marketplace': return <MarketplaceTab />;
|
case 'marketplace': return <MarketplaceTab />;
|
||||||
case 'version': return <VersionTab />;
|
case 'version': return <VersionTab />;
|
||||||
case 'telemetry': return <TelemetryTab />;
|
case 'telemetry': return <TelemetryTab />;
|
||||||
case 'logs': return <LogsTab />;
|
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,111 @@
|
|||||||
|
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.publicPresets !== undefined) {
|
||||||
|
if (!Array.isArray(body.publicPresets)) return 'publicPresets must be an array';
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const preset of body.publicPresets) {
|
||||||
|
if (
|
||||||
|
typeof preset !== 'object' || preset === null ||
|
||||||
|
typeof preset.id !== 'string' || !preset.id ||
|
||||||
|
typeof preset.name !== 'string' || !preset.name ||
|
||||||
|
typeof preset.baseUrl !== 'string' || !preset.baseUrl ||
|
||||||
|
typeof preset.model !== 'string' || !preset.model ||
|
||||||
|
typeof preset.apiKeyEnvVar !== 'string' || !preset.apiKeyEnvVar
|
||||||
|
) {
|
||||||
|
return 'each publicPresets entry needs non-empty id, name, baseUrl, model, apiKeyEnvVar';
|
||||||
|
}
|
||||||
|
if (ids.has(preset.id)) return `duplicate publicPresets id "${preset.id}"`;
|
||||||
|
ids.add(preset.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
publicPresetsCount: next.publicPresets.length,
|
||||||
|
}, 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,60 @@
|
|||||||
|
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,
|
||||||
|
// Sanitized: {id,name,model} only. baseUrl/apiKeyEnvVar stay server-side —
|
||||||
|
// the client only ever refers to a preset by id (app/api/ai/public/chat
|
||||||
|
// resolves the rest), so there's no reason to hand a browser tab even
|
||||||
|
// an internal env var *name*, let alone a provider base URL.
|
||||||
|
publicPresets: consoleConfig.publicPresets.map((p) => ({ id: p.id, name: p.name, model: p.model })),
|
||||||
|
};
|
||||||
|
|
||||||
|
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,97 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
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 OpenAiChatResponse {
|
||||||
|
choices?: Array<{ message?: { content?: string } }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/public/chat — the Paperclip-style, admin-managed alternative
|
||||||
|
* to the personal-key `chatPublic` path (lib/ai/local-client.ts): the client
|
||||||
|
* sends a `presetId`, never a key. The preset (name/baseUrl/model/
|
||||||
|
* apiKeyEnvVar) lives in admin config (lib/ai/types.ts's PublicAiPreset);
|
||||||
|
* the actual secret value is read from THIS PROCESS's real environment at
|
||||||
|
* request time and never leaves this route — same custody model as
|
||||||
|
* AI_SERVER_BASE_URL, just admin-nameable per preset instead of one fixed var.
|
||||||
|
*
|
||||||
|
* Deliberately NOT entitlement-metered, same reasoning as `local`/`opencode`
|
||||||
|
* (lib/ai/entitlement.ts's header): this is still the `public` class, just
|
||||||
|
* with the org supplying the key instead of the user — no centrally-borne
|
||||||
|
* inference cost this app is billing for.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await getStalwartCredentials(request);
|
||||||
|
if (!auth) {
|
||||||
|
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const consoleConfig = configManager.getAiConsoleConfig();
|
||||||
|
if (consoleConfig.classesEnabled.public === false) {
|
||||||
|
return NextResponse.json({ error: 'the Public AI 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: { presetId?: unknown; messages?: unknown };
|
||||||
|
try {
|
||||||
|
body = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetId = typeof body.presetId === 'string' ? body.presetId : '';
|
||||||
|
const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null;
|
||||||
|
if (!presetId || !messages || messages.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'presetId and messages are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = consoleConfig.publicPresets.find((p) => p.id === presetId);
|
||||||
|
if (!preset) {
|
||||||
|
return NextResponse.json({ error: `No such preset "${presetId}" — it may have been removed by an admin.` }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env[preset.apiKeyEnvVar];
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Env var "${preset.apiKeyEnvVar}" is not set on the server for preset "${preset.name}" — ask an admin to provision it.` },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${preset.baseUrl.replace(/\/+$/, '')}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||||
|
body: JSON.stringify({ model: preset.model, messages }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json({ error: `Provider returned ${res.status}` }, { status: 502 });
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as OpenAiChatResponse;
|
||||||
|
const content = data.choices?.[0]?.message?.content;
|
||||||
|
if (!content) {
|
||||||
|
return NextResponse.json({ error: 'Provider returned no message content' }, { status: 502 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ answer: content });
|
||||||
|
} catch (cause) {
|
||||||
|
logger.error('public ai preset chat failed', {
|
||||||
|
presetId, error: cause instanceof Error ? cause.message : String(cause),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: `Could not reach ${preset.baseUrl}` }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
import { checkUserAuthRateLimit } from '@/lib/admin/rate-limit';
|
||||||
|
|
||||||
function sessionCookieOptions() {
|
function sessionCookieOptions() {
|
||||||
return {
|
return {
|
||||||
@@ -48,6 +49,15 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
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
|
// Pin the upstream URL to a configured JMAP server so an unauthenticated
|
||||||
// caller cannot point this route at internal hosts. We accept the global
|
// caller cannot point this route at internal hosts. We accept the global
|
||||||
// `jmapServerUrl` and any entry from `jmapServers`. When neither matches,
|
// `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 { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
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_COOKIE = 'sso_pending';
|
||||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
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 { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
import { checkUserAuthRateLimit } from '@/lib/admin/rate-limit';
|
||||||
|
|
||||||
function getSlot(request: NextRequest): number {
|
function getSlot(request: NextRequest): number {
|
||||||
const raw = request.nextUrl.searchParams.get('slot');
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
@@ -16,6 +17,15 @@ function getSlot(request: NextRequest): number {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
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();
|
const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||||
|
|
||||||
if (!code || !code_verifier || !redirect_uri) {
|
if (!code || !code_verifier || !redirect_uri) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
|
|||||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||||
|
import { isFeatureEnabledServer } from '@/lib/admin/feature-gate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/calendar-agenda
|
* POST /api/calendar-agenda
|
||||||
@@ -100,6 +101,10 @@ function firstCalendarId(event: Partial<CalendarEvent>): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!isFeatureEnabledServer('calendarEnabled')) {
|
||||||
|
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const creds = await getStalwartCredentials(request);
|
const creds = await getStalwartCredentials(request);
|
||||||
if (!creds) {
|
if (!creds) {
|
||||||
|
|||||||
@@ -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);
|
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 =
|
const appName =
|
||||||
branded<string>('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
branded<string>('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||||
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
|
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'),
|
faviconUrl: branded<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||||
appLogoLightUrl: branded<string>('appLogoLightUrl', ''),
|
appLogoLightUrl: branded<string>('appLogoLightUrl', ''),
|
||||||
appLogoDarkUrl: branded<string>('appLogoDarkUrl', ''),
|
appLogoDarkUrl: branded<string>('appLogoDarkUrl', ''),
|
||||||
|
appLogoLightUrlIsCustom: isLogoOverridden('appLogoLightUrl'),
|
||||||
|
appLogoDarkUrlIsCustom: isLogoOverridden('appLogoDarkUrl'),
|
||||||
loginLogoLightUrl: branded<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
loginLogoLightUrl: branded<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||||
loginLogoDarkUrl: branded<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
loginLogoDarkUrl: branded<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||||
|
loginLogoLightUrlIsCustom: isLogoOverridden('loginLogoLightUrl'),
|
||||||
|
loginLogoDarkUrlIsCustom: isLogoOverridden('loginLogoDarkUrl'),
|
||||||
loginCompanyName: branded<string>('loginCompanyName', ''),
|
loginCompanyName: branded<string>('loginCompanyName', ''),
|
||||||
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
||||||
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { decryptSession } from '@/lib/auth/crypto';
|
||||||
|
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
const JITSI_URL = (process.env.JITSI_URL || 'https://meet.src-advisory.com').replace(/\/+$/, '');
|
||||||
|
|
||||||
|
function base64url(input: Buffer): string {
|
||||||
|
return input.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64u(input: string): string {
|
||||||
|
return Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const appId = process.env.JITSI_APP_ID;
|
||||||
|
const appSecret = process.env.JITSI_APP_SECRET;
|
||||||
|
if (!appId || !appSecret) {
|
||||||
|
return NextResponse.json({ error: 'Jitsi is not configured' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionToken = cookieStore.get(sessionCookieName(0))?.value;
|
||||||
|
if (!sessionToken) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const session = decryptSession(sessionToken);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const email = session.username;
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const room = typeof body.room === 'string' ? body.room.trim() : '';
|
||||||
|
if (!room || !/^[a-z0-9-]{1,100}$/i.test(room)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid room name' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = new URL(JITSI_URL).hostname;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||||||
|
const payload = {
|
||||||
|
iss: 'bulwark-webmail',
|
||||||
|
sub: domain,
|
||||||
|
aud: appId,
|
||||||
|
room,
|
||||||
|
iat: now,
|
||||||
|
exp: now + 86400,
|
||||||
|
context: {
|
||||||
|
user: {
|
||||||
|
email,
|
||||||
|
name: email.split('@')[0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const signingInput = `${b64u(JSON.stringify(header))}.${b64u(JSON.stringify(payload))}`;
|
||||||
|
const signature = createHmac('sha256', appSecret).update(signingInput).digest();
|
||||||
|
const token = `${signingInput}.${base64url(signature)}`;
|
||||||
|
|
||||||
|
logger.info('Jitsi token issued', { room, email });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
token,
|
||||||
|
room,
|
||||||
|
url: `${JITSI_URL}/${encodeURIComponent(room)}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('Jitsi token issuance failed', { error: message });
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,119 @@
|
|||||||
|
// 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';
|
||||||
|
import { isFeatureEnabledServer } from '@/lib/admin/feature-gate';
|
||||||
|
|
||||||
|
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 (!isFeatureEnabledServer('aiAssistantEnabled')) {
|
||||||
|
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry
|
|||||||
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { isFeatureEnabledServer } from '@/lib/admin/feature-gate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
|
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
|
||||||
@@ -11,6 +12,10 @@ import { logger } from '@/lib/logger';
|
|||||||
* No admin auth required - this is how regular users receive plugins/themes.
|
* No admin auth required - this is how regular users receive plugins/themes.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
if (!isFeatureEnabledServer('pluginsEnabled')) {
|
||||||
|
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const policy = configManager.getPolicy();
|
const policy = configManager.getPolicy();
|
||||||
|
|||||||
@@ -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,194 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { resolveRights, type SharedResourceKind } from "@/lib/sharing-rights";
|
||||||
|
|
||||||
|
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 : resolveRights(kind as SharedResourceKind, 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -15,12 +15,17 @@ import { NextResponse } from 'next/server';
|
|||||||
import { readStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
import { readStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||||
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||||
import { CaError, getCaProvider } from '@/lib/smime-ca';
|
import { CaError, getCaProvider } from '@/lib/smime-ca';
|
||||||
|
import { isFeatureEnabledServer } from '@/lib/admin/feature-gate';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
const MAX_CSR_BYTES = 8 * 1024;
|
const MAX_CSR_BYTES = 8 * 1024;
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
if (!isFeatureEnabledServer('smimeEnabled')) {
|
||||||
|
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const provider = getCaProvider();
|
const provider = getCaProvider();
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -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 =
|
const appName =
|
||||||
branded<string>("appName", "") ||
|
branded<string>("appName", "") ||
|
||||||
process.env.NEXT_PUBLIC_APP_NAME ||
|
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||||
"Bulwark Webmail";
|
"VNCmail+";
|
||||||
|
|
||||||
const shortName = branded<string>("appShortName", "") || appName;
|
const shortName = branded<string>("appShortName", "") || appName;
|
||||||
const description =
|
const description =
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,282 @@
|
|||||||
|
'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, isPresetActiveId, presetIdFromActiveId, type AiLocalSettings } from '@/lib/ai/local-settings';
|
||||||
|
import { askMail, type AskResult } from '@/lib/ai/local-client';
|
||||||
|
import { ensureDefaultProvider } from '@/lib/ai/auto-provision';
|
||||||
|
|
||||||
|
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': {
|
||||||
|
if (isPresetActiveId(settings.activeProfileId)) {
|
||||||
|
return classes.includes('public') && !!presetIdFromActiveId(settings.activeProfileId) && settings.publicConsentAccepted;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Zero-config default (see lib/ai/auto-provision.ts): resolves as soon as
|
||||||
|
// policy loads, so a user who never visits Settings still finds AI
|
||||||
|
// already on the first time they open this dialog, if OpenCode or Ollama
|
||||||
|
// is available.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded) return;
|
||||||
|
(async () => {
|
||||||
|
const next = await ensureDefaultProvider(policy);
|
||||||
|
setSettings(next);
|
||||||
|
})();
|
||||||
|
}, [loaded, policy]);
|
||||||
|
|
||||||
|
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 managedPresetId = presetIdFromActiveId(settings.activeProfileId);
|
||||||
|
const activeProfile = managedPresetId ? null : 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,
|
||||||
|
publicPresetId: managedPresetId,
|
||||||
|
});
|
||||||
|
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 type { PendingEventPreview } from "./event-modal";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
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 {
|
interface CalendarMonthViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -25,6 +27,9 @@ interface CalendarMonthViewProps {
|
|||||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||||
onCreateAtTime?: (date: Date) => void;
|
onCreateAtTime?: (date: Date) => void;
|
||||||
|
onEditEvent?: (event: CalendarEvent) => void;
|
||||||
|
onDeleteEvent?: (event: CalendarEvent) => void;
|
||||||
|
onDuplicateEvent?: (event: CalendarEvent) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
pendingPreview?: PendingEventPreview | null;
|
pendingPreview?: PendingEventPreview | null;
|
||||||
@@ -41,6 +46,9 @@ export function CalendarMonthView({
|
|||||||
onContextMenuEvent,
|
onContextMenuEvent,
|
||||||
onContextMenuEmpty,
|
onContextMenuEmpty,
|
||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
|
onEditEvent,
|
||||||
|
onDeleteEvent,
|
||||||
|
onDuplicateEvent,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
isMobile,
|
isMobile,
|
||||||
pendingPreview,
|
pendingPreview,
|
||||||
@@ -112,6 +120,54 @@ export function CalendarMonthView({
|
|||||||
|
|
||||||
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
|
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) => {
|
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
|
||||||
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
|
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -294,7 +350,7 @@ export function CalendarMonthView({
|
|||||||
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
onContextMenu={onContextMenuEvent}
|
onContextMenu={handleRadialMenuEvent}
|
||||||
draggable
|
draggable
|
||||||
className={isMobile ? "text-[10px] px-1" : undefined}
|
className={isMobile ? "text-[10px] px-1" : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -306,6 +362,13 @@ export function CalendarMonthView({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RadialMenu
|
||||||
|
items={radialMenuItems}
|
||||||
|
isOpen={radialMenuOpen}
|
||||||
|
position={radialMenuPos}
|
||||||
|
onClose={closeRadialMenu}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createPortal } from "react-dom";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||||
Pencil, Trash2, Copy, Send, Check,
|
Pencil, Trash2, Copy, Send, Check, ExternalLink, Globe,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format, isSameDay } from "date-fns";
|
import { format, isSameDay } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -107,6 +107,25 @@ function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTransl
|
|||||||
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
|
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({
|
export function EventDetailPopover({
|
||||||
event,
|
event,
|
||||||
calendar,
|
calendar,
|
||||||
@@ -163,7 +182,8 @@ export function EventDetailPopover({
|
|||||||
|
|
||||||
const isAttendeeMode = useMemo(() => {
|
const isAttendeeMode = useMemo(() => {
|
||||||
if (!event.participants) return false;
|
if (!event.participants) return false;
|
||||||
return !event.isOrigin && !userIsOrganizer;
|
if (userIsOrganizer) return false;
|
||||||
|
return event.isOrigin === false;
|
||||||
}, [event, userIsOrganizer]);
|
}, [event, userIsOrganizer]);
|
||||||
|
|
||||||
const userParticipantId = useMemo(
|
const userParticipantId = useMemo(
|
||||||
@@ -383,21 +403,34 @@ export function EventDetailPopover({
|
|||||||
{locationName && (
|
{locationName && (
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
{/^https?:\/\//i.test(locationName) ? (
|
<div className="min-w-0">
|
||||||
<a
|
{/^https?:\/\//i.test(locationName) ? (
|
||||||
href={locationName}
|
<a
|
||||||
target="_blank"
|
href={locationName}
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
className="text-sm text-primary hover:underline truncate"
|
rel="noreferrer"
|
||||||
title={locationName}
|
className="text-sm text-primary hover:underline truncate block"
|
||||||
>
|
title={locationName}
|
||||||
{(() => {
|
>
|
||||||
try { return new URL(locationName).hostname; } catch { return locationName; }
|
{(() => {
|
||||||
})()}
|
try { return new URL(locationName).hostname; } catch { return locationName; }
|
||||||
</a>
|
})()}
|
||||||
) : (
|
</a>
|
||||||
<span className="text-sm text-foreground">{locationName}</span>
|
) : (
|
||||||
)}
|
<>
|
||||||
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -413,6 +446,8 @@ export function EventDetailPopover({
|
|||||||
title={virtualLocation}
|
title={virtualLocation}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
const isVncMeeting = event.links?.["vnctalk-meeting"];
|
||||||
|
if (isVncMeeting) return "Join VNCtalk Meeting";
|
||||||
try {
|
try {
|
||||||
return new URL(virtualLocation).hostname;
|
return new URL(virtualLocation).hostname;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -423,6 +458,30 @@ export function EventDetailPopover({
|
|||||||
</div>
|
</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 */}
|
{/* Participants */}
|
||||||
{hasParticipants && (
|
{hasParticipants && (
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
@@ -476,7 +535,21 @@ export function EventDetailPopover({
|
|||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|||||||
import { useTranslations, useLocale } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { 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 { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||||
|
import { FreeBusyView } from "./free-busy-view";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
getUserParticipantId,
|
getUserParticipantId,
|
||||||
@@ -25,6 +26,10 @@ import { generateUUID } from "@/lib/utils";
|
|||||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||||
import { calendarHooks } from "@/lib/plugin-hooks";
|
import { calendarHooks } from "@/lib/plugin-hooks";
|
||||||
import type { ConflictWarning } from "@/lib/plugin-types";
|
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 {
|
export interface PendingEventPreview {
|
||||||
start: Date;
|
start: Date;
|
||||||
@@ -49,6 +54,10 @@ interface EventModalProps {
|
|||||||
onPreviewChange?: (preview: PendingEventPreview | null) => void;
|
onPreviewChange?: (preview: PendingEventPreview | null) => void;
|
||||||
currentUserEmails?: string[];
|
currentUserEmails?: string[];
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
prefillTitle?: string;
|
||||||
|
prefillDescription?: string;
|
||||||
|
prefillParticipants?: { name?: string; email: string }[];
|
||||||
|
prefillDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateInput(d: Date): string {
|
function formatDateInput(d: Date): string {
|
||||||
@@ -59,6 +68,25 @@ function formatTimeInput(d: Date): string {
|
|||||||
return format(d, "HH:mm");
|
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 {
|
function buildDuration(startDate: Date, endDate: Date): string {
|
||||||
const diffMs = endDate.getTime() - startDate.getTime();
|
const diffMs = endDate.getTime() - startDate.getTime();
|
||||||
const totalMinutes = Math.max(0, Math.floor(diffMs / 60000));
|
const totalMinutes = Math.max(0, Math.floor(diffMs / 60000));
|
||||||
@@ -178,6 +206,10 @@ export function EventModal({
|
|||||||
onPreviewChange,
|
onPreviewChange,
|
||||||
currentUserEmails = [],
|
currentUserEmails = [],
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
|
prefillTitle,
|
||||||
|
prefillDescription,
|
||||||
|
prefillParticipants,
|
||||||
|
prefillDate,
|
||||||
}: EventModalProps) {
|
}: EventModalProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
@@ -185,7 +217,9 @@ export function EventModal({
|
|||||||
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
const isEdit = !!event;
|
const isEdit = !!event;
|
||||||
const formatEventDate = useFormatEventDate();
|
const formatEventDate = useFormatEventDate();
|
||||||
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
|
// Open directly in edit mode so the fields are immediately editable. The
|
||||||
|
// read-only summary (view mode) is still reachable via the Cancel button.
|
||||||
|
const [mode, setMode] = useState<"view" | "edit">("edit");
|
||||||
|
|
||||||
const userIsOrganizer = useMemo(() => {
|
const userIsOrganizer = useMemo(() => {
|
||||||
if (!event) return true;
|
if (!event) return true;
|
||||||
@@ -195,7 +229,12 @@ export function EventModal({
|
|||||||
|
|
||||||
const isAttendeeMode = useMemo(() => {
|
const isAttendeeMode = useMemo(() => {
|
||||||
if (!event || !event.participants) return false;
|
if (!event || !event.participants) return false;
|
||||||
return !event.isOrigin && !userIsOrganizer;
|
// Only enter attendee (read-only + RSVP) mode when we are definitively NOT
|
||||||
|
// the organizer AND the event explicitly did not originate from this
|
||||||
|
// account. Stalwart may omit `isOrigin`, so treat a missing value as "ours"
|
||||||
|
// (editable) rather than locking the user out of their own events.
|
||||||
|
if (userIsOrganizer) return false;
|
||||||
|
return event.isOrigin === false;
|
||||||
}, [event, userIsOrganizer]);
|
}, [event, userIsOrganizer]);
|
||||||
|
|
||||||
const userParticipantId = useMemo(() => {
|
const userParticipantId = useMemo(() => {
|
||||||
@@ -228,6 +267,10 @@ export function EventModal({
|
|||||||
d.setHours(now.getHours() + 1, 0, 0, 0);
|
d.setHours(now.getHours() + 1, 0, 0, 0);
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
if (prefillDate) {
|
||||||
|
const d = new Date(prefillDate);
|
||||||
|
if (!isNaN(d.getTime())) return d;
|
||||||
|
}
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setHours(d.getHours() + 1, 0, 0, 0);
|
d.setHours(d.getHours() + 1, 0, 0, 0);
|
||||||
return d;
|
return d;
|
||||||
@@ -244,8 +287,8 @@ export function EventModal({
|
|||||||
return addHours(getInitialStart(), 1);
|
return addHours(getInitialStart(), 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [title, setTitle] = useState(event?.title || "");
|
const [title, setTitle] = useState(event?.title || prefillTitle || "");
|
||||||
const [description, setDescription] = useState(event?.description || "");
|
const [description, setDescription] = useState(event?.description || prefillDescription || "");
|
||||||
const [location, setLocation] = useState(
|
const [location, setLocation] = useState(
|
||||||
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
||||||
);
|
);
|
||||||
@@ -328,13 +371,29 @@ export function EventModal({
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => {
|
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
|
return existingParticipants
|
||||||
.filter(p => !p.isOrganizer)
|
.filter(p => !p.isOrganizer)
|
||||||
.map(p => ({ name: p.name, email: p.email }));
|
.map(p => ({ name: p.name, email: p.email }));
|
||||||
});
|
});
|
||||||
const [sendInvitations, setSendInvitations] = useState(true);
|
const [sendInvitations, setSendInvitations] = useState(true);
|
||||||
|
const [showFreeBusy, setShowFreeBusy] = useState(false);
|
||||||
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
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.
|
// Plugin transform: collect conflict warnings for the current event form.
|
||||||
// Re-runs (debounced) whenever fields that affect scheduling change.
|
// Re-runs (debounced) whenever fields that affect scheduling change.
|
||||||
@@ -361,6 +420,13 @@ export function EventModal({
|
|||||||
return () => { cancelled = true; clearTimeout(t); };
|
return () => { cancelled = true; clearTimeout(t); };
|
||||||
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId]);
|
}, [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
|
// Report live preview to parent for grid outline
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onPreviewChange || isEdit) return;
|
if (!onPreviewChange || isEdit) return;
|
||||||
@@ -416,7 +482,7 @@ export function EventModal({
|
|||||||
duration = buildDuration(start, end);
|
duration = buildDuration(start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
const timeZone = timezone;
|
||||||
|
|
||||||
const data: Partial<CalendarEvent> = {
|
const data: Partial<CalendarEvent> = {
|
||||||
title: trimmedTitle,
|
title: trimmedTitle,
|
||||||
@@ -534,14 +600,72 @@ export function EventModal({
|
|||||||
data.organizerCalendarAddress = null;
|
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;
|
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await onSave(data, shouldSendScheduling);
|
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 {
|
} finally {
|
||||||
setIsSaving(false);
|
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']) => {
|
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||||
if (!event || !userParticipantId || !onRsvp) return;
|
if (!event || !userParticipantId || !onRsvp) return;
|
||||||
@@ -575,6 +699,30 @@ export function EventModal({
|
|||||||
onDuplicate(data);
|
onDuplicate(data);
|
||||||
}, [event, onDuplicate]);
|
}, [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);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -855,22 +1003,57 @@ export function EventModal({
|
|||||||
{locationName && (
|
{locationName && (
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
{/^https?:\/\//i.test(locationName) ? (
|
<div className="min-w-0">
|
||||||
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={locationName}>
|
{/^https?:\/\//i.test(locationName) ? (
|
||||||
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
|
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate block" title={locationName}>
|
||||||
</a>
|
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
|
||||||
) : (
|
</a>
|
||||||
<span className="text-sm text-foreground">{locationName}</span>
|
) : (
|
||||||
)}
|
<>
|
||||||
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Virtual Location */}
|
{/* Virtual Location / Meeting Link */}
|
||||||
{virtualLoc && (
|
{virtualLoc && (
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
<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}>
|
<div className="min-w-0">
|
||||||
{(() => { try { return new URL(virtualLoc).hostname; } catch { return virtualLoc; } })()}
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -887,7 +1070,7 @@ export function EventModal({
|
|||||||
{viewParticipants.map((p) => (
|
{viewParticipants.map((p) => (
|
||||||
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
|
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
|
||||||
<span className="truncate text-foreground">
|
<span className="truncate text-foreground">
|
||||||
{p.name || p.email}
|
<RecipientPopover name={p.name} email={p.email} />
|
||||||
{p.isOrganizer && (
|
{p.isOrganizer && (
|
||||||
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
|
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
|
||||||
)}
|
)}
|
||||||
@@ -900,6 +1083,34 @@ export function EventModal({
|
|||||||
</div>
|
</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 */}
|
{/* Recurrence */}
|
||||||
{recurrenceLabel && (
|
{recurrenceLabel && (
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
@@ -920,20 +1131,36 @@ export function EventModal({
|
|||||||
{event.description && (
|
{event.description && (
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Bar */}
|
{/* Action Bar */}
|
||||||
<div className="px-6 py-3 border-t border-border flex-shrink-0 flex items-center justify-between">
|
<div className="px-6 py-3 border-t border-border flex-shrink-0 flex flex-wrap items-center gap-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1 min-w-0">
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
showDeleteConfirm ? (
|
showDeleteConfirm ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-destructive">{t("form.delete_confirm")}</span>
|
<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")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||||
@@ -953,9 +1180,21 @@ export function EventModal({
|
|||||||
{t("events.duplicate")}
|
{t("events.duplicate")}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
{!showDeleteConfirm && (
|
{!showDeleteConfirm && (
|
||||||
<Button onClick={() => setMode("edit")}>
|
<Button onClick={() => setMode("edit")} className="ml-auto shrink-0">
|
||||||
<Pencil className="w-4 h-4 me-1" />
|
<Pencil className="w-4 h-4 me-1" />
|
||||||
{t("events.edit")}
|
{t("events.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1042,6 +1281,21 @@ export function EventModal({
|
|||||||
setVirtualLocation,
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -1057,6 +1311,44 @@ export function EventModal({
|
|||||||
onAdd={handleAddAttendee}
|
onAdd={handleAddAttendee}
|
||||||
onRemove={handleRemoveAttendee}
|
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) && (
|
{isEdit && statusCounts && (existingParticipants.length > 0) && (
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
{t("participants.status_summary", {
|
{t("participants.status_summary", {
|
||||||
@@ -1067,6 +1359,27 @@ export function EventModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -1121,6 +1434,32 @@ export function EventModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{pluginConflictWarnings.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{pluginConflictWarnings.map(w => (
|
{pluginConflictWarnings.map(w => (
|
||||||
@@ -1286,8 +1625,8 @@ export function EventModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0">
|
<div className="flex flex-wrap items-center gap-2 px-6 py-4 border-t border-border flex-shrink-0">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1 min-w-0">
|
||||||
{isEdit && onDelete && (
|
{isEdit && onDelete && (
|
||||||
showDeleteConfirm ? (
|
showDeleteConfirm ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1304,7 +1643,7 @@ export function EventModal({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
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"
|
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
||||||
>
|
>
|
||||||
{t("events.delete")}
|
{t("events.delete")}
|
||||||
@@ -1338,7 +1677,7 @@ export function EventModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 ml-auto shrink-0">
|
||||||
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
|
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
|
||||||
{t("form.cancel")}
|
{t("form.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
"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 activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
|
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, activeAccountId ?? undefined)
|
||||||
|
.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="relative">
|
||||||
|
<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={() => {
|
||||||
|
if (!isFree) return;
|
||||||
|
const s = slot;
|
||||||
|
if (!s) return;
|
||||||
|
handleSlotClick(s);
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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,199 @@
|
|||||||
|
"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;
|
||||||
|
// Imperative fetch via getState() is intentional: we only need to
|
||||||
|
// trigger a data fetch, not react to its completion directly within
|
||||||
|
// this component. The store handles loading / error states internally.
|
||||||
|
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,244 @@
|
|||||||
|
"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 { apiFetch } from "@/lib/browser-navigation";
|
||||||
|
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 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"
|
||||||
|
aria-label="Search resources"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8" role="status" aria-label="Loading resources">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,22 +6,35 @@ import { Upload, FileText, AlertTriangle, X, Check } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { parseVCard, detectDuplicates } from "@/lib/vcard";
|
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 { 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 {
|
interface ContactImportDialogProps {
|
||||||
existingContacts: ContactCard[];
|
existingContacts: ContactCard[];
|
||||||
|
addressBooks?: AddressBook[];
|
||||||
onImport: (contacts: ContactCard[]) => Promise<number>;
|
onImport: (contacts: ContactCard[]) => Promise<number>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactImportDialog({
|
export function ContactImportDialog({
|
||||||
existingContacts,
|
existingContacts,
|
||||||
|
addressBooks,
|
||||||
onImport,
|
onImport,
|
||||||
onClose,
|
onClose,
|
||||||
}: ContactImportDialogProps) {
|
}: ContactImportDialogProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [fileType, setFileType] = useState<FileType>(null);
|
||||||
const [parsed, setParsed] = useState<ContactCard[]>([]);
|
const [parsed, setParsed] = useState<ContactCard[]>([]);
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const [duplicates, setDuplicates] = useState<Map<number, string>>(new Map());
|
const [duplicates, setDuplicates] = useState<Map<number, string>>(new Map());
|
||||||
@@ -29,41 +42,111 @@ export function ContactImportDialog({
|
|||||||
const [result, setResult] = useState<number | null>(null);
|
const [result, setResult] = useState<number | null>(null);
|
||||||
const [error, setError] = useState<string | 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 handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setResult(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"));
|
setError(t("import.file_too_large"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
if (name.endsWith(".csv") || file.type === "text/csv") {
|
||||||
const contacts = parseVCard(text);
|
setFileType("csv");
|
||||||
|
const text = await file.text();
|
||||||
|
const result = parseCSV(text);
|
||||||
|
|
||||||
if (contacts.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
setError(t("import.no_contacts"));
|
setError(t("import.no_contacts"));
|
||||||
return;
|
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);
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
const dupes = detectDuplicates(existingContacts, contacts);
|
console.error("Failed to parse file:", err);
|
||||||
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);
|
|
||||||
setError(t("import.parse_error"));
|
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 toggleSelect = (idx: number) => {
|
||||||
const next = new Set(selected);
|
const next = new Set(selected);
|
||||||
@@ -91,14 +174,160 @@ export function ContactImportDialog({
|
|||||||
try {
|
try {
|
||||||
const count = await onImport(toImport);
|
const count = await onImport(toImport);
|
||||||
setResult(count);
|
setResult(count);
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error('Failed to import contacts:', error);
|
console.error("Failed to import contacts:", err);
|
||||||
setError(t("import.failed"));
|
setError(t("import.failed"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsImporting(false);
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
<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")}
|
{t("import.close")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : parsed.length === 0 ? (
|
) : fileType === null ? (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".vcf,.vcard"
|
accept={ALLOWED_ACCEPT}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
@@ -141,7 +370,7 @@ export function ContactImportDialog({
|
|||||||
>
|
>
|
||||||
<Upload className="w-8 h-8" />
|
<Upload className="w-8 h-8" />
|
||||||
<p className="text-sm font-medium">{t("import.drop_hint")}</p>
|
<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>
|
</button>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -151,6 +380,10 @@ export function ContactImportDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
) : fileType === "csv" && csvData && !showPreview ? (
|
||||||
|
renderCsvMapping()
|
||||||
|
) : fileType === "csv" && csvData && showPreview ? (
|
||||||
|
renderCsvPreview()
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -217,7 +450,23 @@ export function ContactImportDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("import.selected", { count: selected.size })}
|
{t("import.selected", { count: selected.size })}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState, useCallback } from "react";
|
||||||
import { useTranslations, useLocale } from "next-intl";
|
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 { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ContactListItem } from "./contact-list-item";
|
import { ContactListItem } from "./contact-list-item";
|
||||||
import { ContactContextMenu } from "./contact-context-menu";
|
import { ContactContextMenu } from "./contact-context-menu";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
|
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
|
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
|
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
|
||||||
@@ -142,6 +143,63 @@ export function ContactList({
|
|||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
|
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
|
||||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
|
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 [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
|
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
|
||||||
const activeFilters = countActiveFilters(filters);
|
const activeFilters = countActiveFilters(filters);
|
||||||
@@ -571,7 +629,7 @@ export function ContactList({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggleSelection(contact.id);
|
onToggleSelection(contact.id);
|
||||||
}}
|
}}
|
||||||
onContextMenu={(e, c) => openContextMenu(e, c)}
|
onContextMenu={(e, c) => { openContextMenu(e, c); openRadialMenu(e, c); }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return groupByLetter ? (
|
return groupByLetter ? (
|
||||||
@@ -592,6 +650,14 @@ export function ContactList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Radial Action Menu */}
|
||||||
|
<RadialMenu
|
||||||
|
items={radialMenuItems}
|
||||||
|
isOpen={radialMenuOpen}
|
||||||
|
position={radialMenuPos}
|
||||||
|
onClose={closeRadialMenu}
|
||||||
|
/>
|
||||||
|
|
||||||
{contextMenu.data && (
|
{contextMenu.data && (
|
||||||
<ContactContextMenu
|
<ContactContextMenu
|
||||||
contact={contextMenu.data}
|
contact={contextMenu.data}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { 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 { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { toast } from "@/stores/toast-store";
|
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 { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useSignatureStore } from "@/stores/signature-store";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
@@ -298,6 +299,40 @@ export function EmailComposer({
|
|||||||
const { isFeatureEnabled } = usePolicyStore();
|
const { isFeatureEnabled } = usePolicyStore();
|
||||||
const templatesEnabled = isFeatureEnabled('templatesEnabled');
|
const templatesEnabled = isFeatureEnabled('templatesEnabled');
|
||||||
|
|
||||||
|
const {
|
||||||
|
signatures,
|
||||||
|
defaultSignatureId,
|
||||||
|
replySignatureId,
|
||||||
|
getSignatureById,
|
||||||
|
getIdentityDefaultSignatureId,
|
||||||
|
getIdentityReplySignatureId,
|
||||||
|
} = useSignatureStore();
|
||||||
|
|
||||||
|
// Lazy useState initializer (below) — runs during the FIRST render, before
|
||||||
|
// the selectedIdentityId state declared further down exists yet (same TDZ
|
||||||
|
// constraint the initialCurrentIdentityForSig comment a few lines down
|
||||||
|
// already documents). On that first render selectedIdentityId can only be
|
||||||
|
// unset anyway (nothing has called setSelectedIdentityId yet), so reading
|
||||||
|
// initialData directly is equivalent, not a workaround.
|
||||||
|
const resolveStoreSignatureId = (): string | null => {
|
||||||
|
const perIdentityId = 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
|
// The signature identity used when embedding the signature into the initial
|
||||||
// body for "above quote" mode. Mirrors the signatureIdentity derivation
|
// body for "above quote" mode. Mirrors the signatureIdentity derivation
|
||||||
// below, but uses initialData (or primary) since selectedIdentityId state
|
// below, but uses initialData (or primary) since selectedIdentityId state
|
||||||
@@ -509,17 +544,19 @@ export function EmailComposer({
|
|||||||
// requests with the same draftId. See bug #303.
|
// requests with the same draftId. See bug #303.
|
||||||
const inflightSaveRef = useRef<Promise<string | null> | null>(null);
|
const inflightSaveRef = useRef<Promise<string | null> | null>(null);
|
||||||
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
|
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
|
||||||
if (mode === 'forward' && replyTo?.attachments?.length) {
|
if (replyTo?.attachments?.length) {
|
||||||
return replyTo.attachments
|
let atts = replyTo.attachments;
|
||||||
|
if (mode === 'forward') {
|
||||||
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
|
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
|
||||||
// (matches the viewer's hideInlineImageAttachments logic).
|
// (matches the viewer's hideInlineImageAttachments logic).
|
||||||
.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
|
atts = atts.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')));
|
||||||
.map(att => ({
|
}
|
||||||
name: att.name || 'attachment',
|
return atts.map(att => ({
|
||||||
type: att.type || 'application/octet-stream',
|
name: att.name || 'attachment',
|
||||||
size: att.size,
|
type: att.type || 'application/octet-stream',
|
||||||
blobId: att.blobId,
|
size: att.size,
|
||||||
}));
|
blobId: att.blobId,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
@@ -532,6 +569,34 @@ export function EmailComposer({
|
|||||||
const [fromOverrideEnabled, setFromOverrideEnabled] = useState<boolean>(initialData?.fromOverrideEnabled ?? false);
|
const [fromOverrideEnabled, setFromOverrideEnabled] = useState<boolean>(initialData?.fromOverrideEnabled ?? false);
|
||||||
const [fromOverrideEmail, setFromOverrideEmail] = useState<string>(initialData?.fromOverrideEmail ?? '');
|
const [fromOverrideEmail, setFromOverrideEmail] = useState<string>(initialData?.fromOverrideEmail ?? '');
|
||||||
const [fromOverrideName, setFromOverrideName] = useState<string>(initialData?.fromOverrideName ?? '');
|
const [fromOverrideName, setFromOverrideName] = useState<string>(initialData?.fromOverrideName ?? '');
|
||||||
|
const [fromOverrideWarning, setFromOverrideWarning] = useState<string>('');
|
||||||
|
|
||||||
|
// Validate that from override domain matches at least one of the user's identities
|
||||||
|
const ownIdentityDomains = useMemo(() => new Set(
|
||||||
|
identities.map(i => i.email).filter(Boolean).map(email => {
|
||||||
|
const atPos = email.indexOf('@');
|
||||||
|
return atPos >= 0 ? email.slice(atPos + 1).toLowerCase() : '';
|
||||||
|
}).filter(d => d.length > 0),
|
||||||
|
), [identities]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fromOverrideEnabled || !fromOverrideEmail.trim()) {
|
||||||
|
setFromOverrideWarning('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const email = fromOverrideEmail.trim();
|
||||||
|
const atPos = email.indexOf('@');
|
||||||
|
if (atPos < 0) {
|
||||||
|
setFromOverrideWarning('Invalid email address');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const domain = email.slice(atPos + 1).toLowerCase();
|
||||||
|
if (!ownIdentityDomains.has(domain)) {
|
||||||
|
setFromOverrideWarning(`This email's domain (${domain}) does not match any of your verified identities`);
|
||||||
|
} else {
|
||||||
|
setFromOverrideWarning('');
|
||||||
|
}
|
||||||
|
}, [fromOverrideEnabled, fromOverrideEmail, ownIdentityDomains]);
|
||||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||||
@@ -588,10 +653,20 @@ export function EmailComposer({
|
|||||||
? currentIdentity
|
? currentIdentity
|
||||||
: primaryIdentity;
|
: primaryIdentity;
|
||||||
|
|
||||||
|
// The signature store (default/reply/per-identity) takes precedence over the
|
||||||
|
// legacy per-identity html/text signature. `selectedSignature` is resolved in
|
||||||
|
// resolveStoreSignatureId for the current mode (compose → default; reply/
|
||||||
|
// forward → reply), so replies and forwards pick up the reply signature.
|
||||||
|
// Falls back to the legacy identity signature when no store signature is set.
|
||||||
|
const effectiveSignature = selectedSignature
|
||||||
|
? { htmlSignature: selectedSignature.body, textSignature: selectedSignature.plainText }
|
||||||
|
: signatureIdentity;
|
||||||
|
|
||||||
// Hold the TipTap editor instance so we can swap the embedded signature
|
// Hold the TipTap editor instance so we can swap the embedded signature
|
||||||
// when the user switches identity in "above quote" mode without rebuilding
|
// when the user switches identity in "above quote" mode without rebuilding
|
||||||
// the whole body (which would lose user edits to the surrounding draft).
|
// the whole body (which would lose user edits to the surrounding draft).
|
||||||
const editorRef = useRef<Editor | null>(null);
|
const editorRef = useRef<Editor | null>(null);
|
||||||
|
const [editorReady, setEditorReady] = useState(false);
|
||||||
const prevSignatureIdentityIdRef = useRef<string | null | undefined>(signatureIdentity?.id);
|
const prevSignatureIdentityIdRef = useRef<string | null | undefined>(signatureIdentity?.id);
|
||||||
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
|
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
|
||||||
|
|
||||||
@@ -668,6 +743,32 @@ export function EmailComposer({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
|
}, [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();
|
||||||
|
// Place the caret in the empty paragraph above the signature so the user
|
||||||
|
// starts typing at the top of the new email.
|
||||||
|
editor.chain().focus('start').run();
|
||||||
|
} else if ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') {
|
||||||
|
editor.chain().focus('start').insertContent(`<p></p>${sig.body}`).run();
|
||||||
|
editor.chain().focus('start').run();
|
||||||
|
}
|
||||||
|
}, [selectedSignatureId, plainTextMode, mode, signaturePosition, getSignatureById, editorReady]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutsideSendMenu = (event: MouseEvent) => {
|
const handleClickOutsideSendMenu = (event: MouseEvent) => {
|
||||||
if (!sendMenuRef.current?.contains(event.target as Node)) {
|
if (!sendMenuRef.current?.contains(event.target as Node)) {
|
||||||
@@ -1795,6 +1896,7 @@ export function EmailComposer({
|
|||||||
// duplicate it.
|
// duplicate it.
|
||||||
const signatureAlreadyInBody =
|
const signatureAlreadyInBody =
|
||||||
shouldEmbedSignatureInNewMail ||
|
shouldEmbedSignatureInNewMail ||
|
||||||
|
(!plainTextMode && !!selectedSignature && mode === 'compose') ||
|
||||||
((mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
((mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||||
signaturePosition === 'above_quote');
|
signaturePosition === 'above_quote');
|
||||||
|
|
||||||
@@ -1802,11 +1904,11 @@ export function EmailComposer({
|
|||||||
const buildSignatureHtml = (): string => {
|
const buildSignatureHtml = (): string => {
|
||||||
if (signatureAlreadyInBody) return '';
|
if (signatureAlreadyInBody) return '';
|
||||||
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
|
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
|
||||||
if (signatureIdentity?.htmlSignature) {
|
if (effectiveSignature?.htmlSignature) {
|
||||||
return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`;
|
return `${sep}${sanitizeSignatureHtml(effectiveSignature.htmlSignature)}`;
|
||||||
}
|
}
|
||||||
if (signatureIdentity?.textSignature) {
|
if (effectiveSignature?.textSignature) {
|
||||||
return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
return `${sep}${effectiveSignature.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
@@ -1819,8 +1921,8 @@ export function EmailComposer({
|
|||||||
// In plain text mode, send text/plain only (no HTML body)
|
// In plain text mode, send text/plain only (no HTML body)
|
||||||
const signatureOpts = { separator: signatureSeparatorEnabled };
|
const signatureOpts = { separator: signatureSeparatorEnabled };
|
||||||
const finalBody = plainTextMode
|
const finalBody = plainTextMode
|
||||||
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
|
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, effectiveSignature, signatureOpts))
|
||||||
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts));
|
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), effectiveSignature, signatureOpts));
|
||||||
|
|
||||||
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
|
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
|
||||||
const finalHtmlBody = plainTextMode
|
const finalHtmlBody = plainTextMode
|
||||||
@@ -2298,6 +2400,11 @@ export function EmailComposer({
|
|||||||
>
|
>
|
||||||
{fromOverrideEnabled ? t('from_override.toggle_on') : t('from_override.toggle_off')}
|
{fromOverrideEnabled ? t('from_override.toggle_on') : t('from_override.toggle_off')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{fromOverrideWarning && (
|
||||||
|
<span className="text-xs text-amber-600 dark:text-amber-400 ml-2" role="alert">
|
||||||
|
{fromOverrideWarning}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2498,7 +2605,7 @@ export function EmailComposer({
|
|||||||
onImageUpload={handleImageUpload}
|
onImageUpload={handleImageUpload}
|
||||||
placeholder={t('body_placeholder')}
|
placeholder={t('body_placeholder')}
|
||||||
hasError={validationErrors.body}
|
hasError={validationErrors.body}
|
||||||
onEditorReady={(ed) => { editorRef.current = ed; }}
|
onEditorReady={(ed) => { editorRef.current = ed; setEditorReady(true); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2657,8 +2764,53 @@ export function EmailComposer({
|
|||||||
<PluginSlot name="composer-toolbar" />
|
<PluginSlot name="composer-toolbar" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Discard + Send (desktop) */}
|
{/* Right side - Signature selector + Discard + Send (desktop) */}
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { useUIStore } from "@/stores/ui-store";
|
|||||||
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
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 { useTranslations } from "next-intl";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||||
@@ -141,6 +143,101 @@ export function EmailList({
|
|||||||
const contextMenuEmail = contextMenu.data
|
const contextMenuEmail = contextMenu.data
|
||||||
? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data
|
? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data
|
||||||
: null;
|
: 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 { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
@@ -549,7 +646,7 @@ export function EmailList({
|
|||||||
onEmailSelect?.(email);
|
onEmailSelect?.(email);
|
||||||
}}
|
}}
|
||||||
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
||||||
onContextMenu={openContextMenu}
|
onContextMenu={(e, email) => { openContextMenu(e, email); openRadialMenu(e, email); }}
|
||||||
onOpenConversation={onOpenConversation}
|
onOpenConversation={onOpenConversation}
|
||||||
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
||||||
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
||||||
@@ -581,6 +678,14 @@ export function EmailList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Radial Action Menu */}
|
||||||
|
<RadialMenu
|
||||||
|
items={radialMenuItems}
|
||||||
|
isOpen={radialMenuOpen}
|
||||||
|
position={radialMenuPos}
|
||||||
|
onClose={closeRadialMenu}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Context Menu */}
|
{/* Context Menu */}
|
||||||
{contextMenuEmail && (
|
{contextMenuEmail && (
|
||||||
<EmailContextMenu
|
<EmailContextMenu
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourc
|
|||||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||||
import { withBasePath } from "@/lib/browser-navigation";
|
import { withBasePath } from "@/lib/browser-navigation";
|
||||||
|
import { resolveThemeLogo } from "@/lib/theme-logo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||||
@@ -75,6 +76,7 @@ import {
|
|||||||
PlayCircle,
|
PlayCircle,
|
||||||
PenSquare,
|
PenSquare,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
CalendarPlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
@@ -87,6 +89,8 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
|
|||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useEmailStore } from "@/stores/email-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 { useThemeStore } from "@/stores/theme-store";
|
||||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||||
@@ -702,6 +706,9 @@ export function EmailViewer({
|
|||||||
const isScheduled = email?.isScheduled === true;
|
const isScheduled = email?.isScheduled === true;
|
||||||
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
|
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
|
||||||
|
|
||||||
|
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||||
|
const createAppointmentVisible = !isScheduled && !isDraft && calendarEnabled && !!email;
|
||||||
|
|
||||||
|
|
||||||
// Tablet list visibility
|
// Tablet list visibility
|
||||||
const { isTablet, isMobile } = useDeviceDetection();
|
const { isTablet, isMobile } = useDeviceDetection();
|
||||||
@@ -766,6 +773,8 @@ export function EmailViewer({
|
|||||||
return new Date(time).toISOString();
|
return new Date(time).toISOString();
|
||||||
}, [client, t, tComposer]);
|
}, [client, t, tComposer]);
|
||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
|
const activeThemeId = useThemeStore((state) => state.activeThemeId);
|
||||||
|
const installedThemes = useThemeStore((state) => state.installedThemes);
|
||||||
const { startTour } = useTour();
|
const { startTour } = useTour();
|
||||||
const isEmbedded = useIsEmbedded();
|
const isEmbedded = useIsEmbedded();
|
||||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||||
@@ -1026,6 +1035,34 @@ export function EmailViewer({
|
|||||||
const { isMobile: isMobileDevice } = useDeviceDetection();
|
const { isMobile: isMobileDevice } = useDeviceDetection();
|
||||||
const router = useRouter();
|
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) => {
|
const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => {
|
||||||
if (isMobileDevice) {
|
if (isMobileDevice) {
|
||||||
// No room for a sidebar on mobile - send the user to the contacts page
|
// No room for a sidebar on mobile - send the user to the contacts page
|
||||||
@@ -2729,15 +2766,20 @@ export function EmailViewer({
|
|||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
if (isDemoMode) {
|
if (isDemoMode) {
|
||||||
const logoSrc = withBasePath(resolvedTheme === 'dark'
|
// Same resolution as navigation-rail.tsx/login: active theme's own
|
||||||
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
|
// brand logo (SRC mark / VNClagoon wordmark), falling back to the SRC
|
||||||
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg');
|
// 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 (
|
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={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">
|
<div className="text-center p-8 max-w-md">
|
||||||
<img
|
<img
|
||||||
src={logoSrc}
|
src={logoSrc}
|
||||||
alt="Bulwark Mail"
|
alt="VNCmail+"
|
||||||
className="h-12 mx-auto mb-6"
|
className="h-12 mx-auto mb-6"
|
||||||
/>
|
/>
|
||||||
<h3 className="text-xl font-semibold text-foreground mb-3">{tDemoWelcome('title')}</h3>
|
<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" />
|
<Forward className="w-4 h-4" />
|
||||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
|
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
|
||||||
</Button>
|
</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" />
|
<PluginSlot name="toolbar-actions" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { Mail, Phone, Building, ExternalLink, Copy, Send, UserPlus } from "lucide-react";
|
import { Mail, Phone, Building, ExternalLink, Copy, Send, UserPlus } from "lucide-react";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import { savePendingMailto, notifyPendingMailto } from "@/lib/protocol-handlers/session";
|
||||||
|
import { formatRecipient } from "@/lib/email-composer-utils";
|
||||||
import type { ContactCard } from "@/lib/jmap/types";
|
import type { ContactCard } from "@/lib/jmap/types";
|
||||||
|
|
||||||
interface RecipientPopoverProps {
|
interface RecipientPopoverProps {
|
||||||
@@ -125,6 +128,21 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleCompose = () => {
|
||||||
|
savePendingMailto({
|
||||||
|
to: [formatRecipient(contactName, email)],
|
||||||
|
cc: [],
|
||||||
|
bcc: [],
|
||||||
|
subject: "",
|
||||||
|
body: "",
|
||||||
|
});
|
||||||
|
notifyPendingMailto();
|
||||||
|
router.push("/");
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -210,14 +228,14 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
|||||||
<Copy className="w-3.5 h-3.5" />
|
<Copy className="w-3.5 h-3.5" />
|
||||||
Copy
|
Copy
|
||||||
</button>
|
</button>
|
||||||
<a
|
<button
|
||||||
href={`mailto:${email}`}
|
onClick={handleCompose}
|
||||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors"
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors"
|
||||||
title="Send email"
|
title="Send email"
|
||||||
>
|
>
|
||||||
<Send className="w-3.5 h-3.5" />
|
<Send className="w-3.5 h-3.5" />
|
||||||
Email
|
Email
|
||||||
</a>
|
</button>
|
||||||
{onViewContact && (
|
{onViewContact && (
|
||||||
<button
|
<button
|
||||||
onClick={handleViewContact}
|
onClick={handleViewContact}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
AlertCircle, Star, Clock, FolderUp,
|
AlertCircle, Star, Clock, FolderUp,
|
||||||
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
||||||
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
||||||
Menu, Users, Share2,
|
Menu, Users, Share2, MailPlus, Paperclip, ExternalLink,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -27,6 +27,7 @@ import { Avatar } from "@/components/ui/avatar";
|
|||||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||||
import type { FileResource } from "@/stores/file-store";
|
import type { FileResource } from "@/stores/file-store";
|
||||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
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 { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
import type { FileNodeRights } from "@/lib/jmap/types";
|
import type { FileNodeRights } from "@/lib/jmap/types";
|
||||||
|
|
||||||
@@ -106,6 +107,8 @@ interface FileBrowserProps {
|
|||||||
sharingEnabled?: boolean;
|
sharingEnabled?: boolean;
|
||||||
/** Add/update/remove a principal's share on a node. Set null rights to revoke. */
|
/** 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>;
|
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"]);
|
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);
|
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 {
|
function isPreviewable(name: string): boolean {
|
||||||
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
|
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
|
||||||
}
|
}
|
||||||
@@ -384,6 +395,7 @@ export function FileBrowser({
|
|||||||
ownAccountId,
|
ownAccountId,
|
||||||
sharingEnabled,
|
sharingEnabled,
|
||||||
onShare,
|
onShare,
|
||||||
|
onSendAsAttachment,
|
||||||
}: FileBrowserProps) {
|
}: FileBrowserProps) {
|
||||||
const t = useTranslations("files");
|
const t = useTranslations("files");
|
||||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||||
@@ -401,6 +413,60 @@ export function FileBrowser({
|
|||||||
[sharingEnabled, onShare, client]);
|
[sharingEnabled, onShare, client]);
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
|
||||||
const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | 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 [showNewTextFile, setShowNewTextFile] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
@@ -765,6 +831,9 @@ export function FileBrowser({
|
|||||||
const handleContextMenu = (e: React.MouseEvent, name: string) => {
|
const handleContextMenu = (e: React.MouseEvent, name: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, name });
|
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
|
// Adjust context menu position to stay within viewport
|
||||||
@@ -974,28 +1043,76 @@ export function FileBrowser({
|
|||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{selectedResources.size > 1 && (
|
{selectedResources.size > 0 && (() => {
|
||||||
<>
|
const fileNames = [...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory);
|
||||||
<Button
|
const hasFiles = fileNames.length > 0;
|
||||||
variant="ghost"
|
const showBatch = selectedResources.size > 1;
|
||||||
size="sm"
|
if (!showBatch && !hasFiles) return null;
|
||||||
className="h-8"
|
return (
|
||||||
onClick={() => onBatchDownload([...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory))}
|
<>
|
||||||
>
|
{showBatch && (
|
||||||
<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"
|
||||||
<Button
|
size="sm"
|
||||||
variant="ghost"
|
className="h-8"
|
||||||
size="sm"
|
onClick={() => onBatchDownload(fileNames)}
|
||||||
className="h-8 text-destructive hover:text-destructive"
|
>
|
||||||
onClick={() => onBatchDelete([...selectedResources])}
|
<Download className="w-4 h-4 me-1" />
|
||||||
>
|
{t("download")} ({fileNames.length})
|
||||||
<Trash2 className="w-4 h-4 me-1" />
|
</Button>
|
||||||
{t("delete")} ({selectedResources.size})
|
<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 && (
|
{clipboard && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1669,6 +1786,14 @@ export function FileBrowser({
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Radial Action Menu */}
|
||||||
|
<RadialMenu
|
||||||
|
items={radialMenuItems}
|
||||||
|
isOpen={radialMenuOpen}
|
||||||
|
position={radialMenuPos}
|
||||||
|
onClose={closeRadialMenu}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Context menu */}
|
{/* Context menu */}
|
||||||
{contextMenu && (
|
{contextMenu && (
|
||||||
<div
|
<div
|
||||||
@@ -1707,6 +1832,32 @@ export function FileBrowser({
|
|||||||
{t("download")}
|
{t("download")}
|
||||||
</button>
|
</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
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||||
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
|
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
|
||||||
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
|
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
|
||||||
|
import { useSignatureStore } from '@/stores/signature-store';
|
||||||
|
|
||||||
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
|
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
|
||||||
const SIGNATURE_MAX_BYTES = 2047;
|
const SIGNATURE_MAX_BYTES = 2047;
|
||||||
@@ -73,6 +74,15 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
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 => {
|
const parseEmailList = (input: string): EmailAddress[] | undefined => {
|
||||||
if (!input.trim()) return undefined;
|
if (!input.trim()) return undefined;
|
||||||
|
|
||||||
@@ -134,6 +144,10 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
await onSave(sanitizedData);
|
await onSave(sanitizedData);
|
||||||
|
if (identity?.id) {
|
||||||
|
setIdentitySignature(identity.id, 'default', sigDefaultId || null);
|
||||||
|
setIdentitySignature(identity.id, 'reply', sigReplyId || null);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -266,6 +280,57 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Text Signature */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="identity-text-sig" className="block text-sm font-medium mb-1">
|
<label htmlFor="identity-text-sig" className="block text-sm font-medium mb-1">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
FolderX,
|
FolderX,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Upload,
|
Upload,
|
||||||
|
Share2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface Position {
|
interface Position {
|
||||||
@@ -86,6 +87,7 @@ interface MailboxContextMenuProps {
|
|||||||
onRenameFolder?: (mailboxId: string) => void;
|
onRenameFolder?: (mailboxId: string) => void;
|
||||||
onDeleteFolder?: (mailboxId: string) => void;
|
onDeleteFolder?: (mailboxId: string) => void;
|
||||||
onImportEmail?: (mailboxId: string) => void;
|
onImportEmail?: (mailboxId: string) => void;
|
||||||
|
onShareFolder?: (mailboxId: string) => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +107,7 @@ export function MailboxContextMenu({
|
|||||||
onRenameFolder,
|
onRenameFolder,
|
||||||
onDeleteFolder,
|
onDeleteFolder,
|
||||||
onImportEmail,
|
onImportEmail,
|
||||||
|
onShareFolder,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: MailboxContextMenuProps) {
|
}: MailboxContextMenuProps) {
|
||||||
const t = useTranslations("mailbox_context_menu");
|
const t = useTranslations("mailbox_context_menu");
|
||||||
@@ -191,6 +194,12 @@ export function MailboxContextMenu({
|
|||||||
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
|
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
|
||||||
disabled={!onRenameFolder || !canRename}
|
disabled={!onRenameFolder || !canRename}
|
||||||
/>
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Share2}
|
||||||
|
label={t("share_folder")}
|
||||||
|
onClick={() => handleAction(() => onShareFolder?.(mailbox.id))}
|
||||||
|
disabled={!onShareFolder || mailbox.isShared}
|
||||||
|
/>
|
||||||
|
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export function NavigationRail({
|
|||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
const { appLogoLightUrl, appLogoDarkUrl, appLogoLightUrlIsCustom, appLogoDarkUrlIsCustom } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
const activeThemeId = useThemeStore((s) => s.activeThemeId);
|
const activeThemeId = useThemeStore((s) => s.activeThemeId);
|
||||||
const installedThemes = useThemeStore((s) => s.installedThemes);
|
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 ? (
|
return logoUrl ? (
|
||||||
<div className="flex items-center justify-center py-3 px-1">
|
<div className="flex items-center justify-center py-3 px-1">
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useAccountStore } from '@/stores/account-store';
|
||||||
|
import {
|
||||||
|
getPendingOperationsCount,
|
||||||
|
onPendingCountChange,
|
||||||
|
processQueue,
|
||||||
|
} from '@/lib/offline-write-queue';
|
||||||
|
|
||||||
|
export function OfflineQueueIndicator() {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const client = useAuthStore((s) => s.client);
|
||||||
|
const activeAccountId = useAccountStore((s) => s.activeAccountId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCount(getPendingOperationsCount());
|
||||||
|
return onPendingCountChange(setCount);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRetry = useCallback(async () => {
|
||||||
|
if (!client || !activeAccountId) return;
|
||||||
|
setProcessing(true);
|
||||||
|
try {
|
||||||
|
await processQueue(client, activeAccountId);
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
}, [client, activeAccountId]);
|
||||||
|
|
||||||
|
if (count === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2 bg-amber-50 border-b border-amber-200 px-4 py-1.5 text-sm dark:bg-amber-950 dark:border-amber-800">
|
||||||
|
<span className="text-amber-800 dark:text-amber-200">
|
||||||
|
{count} pending {count === 1 ? 'operation' : 'operations'} (offline)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
disabled={processing || !client}
|
||||||
|
className="rounded bg-amber-200 px-2 py-0.5 text-xs font-medium text-amber-900 hover:bg-amber-300 disabled:opacity-50 dark:bg-amber-800 dark:text-amber-100 dark:hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
{processing ? 'Retrying...' : 'Retry now'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo, ReactNode } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
|
import { MiniCalendarDashlet } from "@/components/calendar/mini-calendar-dashlet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Inbox,
|
Inbox,
|
||||||
@@ -62,6 +63,7 @@ import { useUIStore } from "@/stores/ui-store";
|
|||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useVacationStore } from "@/stores/vacation-store";
|
import { useVacationStore } from "@/stores/vacation-store";
|
||||||
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
|
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
|
||||||
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
@@ -88,6 +90,7 @@ interface SidebarProps {
|
|||||||
onDeleteFolder?: (mailboxId: string) => void;
|
onDeleteFolder?: (mailboxId: string) => void;
|
||||||
onImportEmail?: (mailboxId: string) => void;
|
onImportEmail?: (mailboxId: string) => void;
|
||||||
onRefreshMailboxes?: () => void;
|
onRefreshMailboxes?: () => void;
|
||||||
|
onShareFolder?: (mailboxId: string) => void;
|
||||||
scheduledTotal?: number;
|
scheduledTotal?: number;
|
||||||
showScheduledMailbox?: boolean;
|
showScheduledMailbox?: boolean;
|
||||||
/** True when the unified view spans multiple login accounts (cross-account).
|
/** True when the unified view spans multiple login accounts (cross-account).
|
||||||
@@ -777,6 +780,7 @@ export function Sidebar({
|
|||||||
onDeleteFolder,
|
onDeleteFolder,
|
||||||
onImportEmail,
|
onImportEmail,
|
||||||
onRefreshMailboxes,
|
onRefreshMailboxes,
|
||||||
|
onShareFolder,
|
||||||
scheduledTotal = 0,
|
scheduledTotal = 0,
|
||||||
showScheduledMailbox = false,
|
showScheduledMailbox = false,
|
||||||
crossAccountActive = false,
|
crossAccountActive = false,
|
||||||
@@ -808,6 +812,12 @@ export function Sidebar({
|
|||||||
return stored !== null ? JSON.parse(stored) : true;
|
return stored !== null ? JSON.parse(stored) : true;
|
||||||
} catch { return 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(() => {
|
const [unifiedExpanded, setUnifiedExpanded] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('sidebarUnifiedExpanded');
|
const stored = localStorage.getItem('sidebarUnifiedExpanded');
|
||||||
@@ -840,6 +850,7 @@ export function Sidebar({
|
|||||||
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||||
const nestedTags = useSettingsStore(s => s.nestedTags);
|
const nestedTags = useSettingsStore(s => s.nestedTags);
|
||||||
const isEmbedded = useIsEmbedded();
|
const isEmbedded = useIsEmbedded();
|
||||||
|
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||||
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
|
// 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
|
// own AccountSwitcher would be a redundant second account UI in the same
|
||||||
// pane.
|
// pane.
|
||||||
@@ -1054,6 +1065,13 @@ export function Sidebar({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const toggleCalendarDashlet = () => {
|
||||||
|
setCalendarDashletExpanded((prev: boolean) => {
|
||||||
|
const next = !prev;
|
||||||
|
try { localStorage.setItem('sidebarCalendarDashletExpanded', JSON.stringify(next)); } catch { /* */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
const toggleShared = () => {
|
const toggleShared = () => {
|
||||||
setSharedExpanded((prev: boolean) => {
|
setSharedExpanded((prev: boolean) => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
@@ -1405,6 +1423,18 @@ export function Sidebar({
|
|||||||
</div>
|
</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" />}
|
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1424,6 +1454,7 @@ export function Sidebar({
|
|||||||
onRenameFolder={onRenameFolder}
|
onRenameFolder={onRenameFolder}
|
||||||
onDeleteFolder={onDeleteFolder}
|
onDeleteFolder={onDeleteFolder}
|
||||||
onImportEmail={onImportEmail}
|
onImportEmail={onImportEmail}
|
||||||
|
onShareFolder={onShareFolder}
|
||||||
onRefresh={onRefreshMailboxes}
|
onRefresh={onRefreshMailboxes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { getPathPrefix } from '@/lib/browser-navigation';
|
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||||
import { clearCachedData } from '@/lib/clear-cached-data';
|
import { clearCachedData } from '@/lib/clear-cached-data';
|
||||||
import { SpamSiegeGame } from './spam-siege-game';
|
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 APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||||
@@ -218,6 +219,9 @@ export function AboutDataSettings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Desktop shell only - renders nothing in the browser/PWA build. */}
|
||||||
|
<LocalIndexSettings />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,819 @@
|
|||||||
|
'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, presetActiveId, presetIdFromActiveId,
|
||||||
|
type AiLocalSettings,
|
||||||
|
} from '@/lib/ai/local-settings';
|
||||||
|
import { ensureDefaultProvider } from '@/lib/ai/auto-provision';
|
||||||
|
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) {
|
||||||
|
const loadedPolicy = (await res.json()) as AiPolicy;
|
||||||
|
setPolicy(loadedPolicy);
|
||||||
|
// Zero-config default (lib/ai/auto-provision.ts) — a no-op once a
|
||||||
|
// provider is already chosen, so this is safe to run on every
|
||||||
|
// visit to this pane, not just first-run.
|
||||||
|
const next = await ensureDefaultProvider(loadedPolicy);
|
||||||
|
// Separately, once an admin has published at least one org-managed
|
||||||
|
// preset, make IT the default "Answer with" pick too — pasting a
|
||||||
|
// personal key should be the fallback a user reaches for, not the
|
||||||
|
// thing they have to do to get any answer at all.
|
||||||
|
if (!next.activeProfileId && loadedPolicy.publicPresets[0]) {
|
||||||
|
next.activeProfileId = presetActiveId(loadedPolicy.publicPresets[0].id);
|
||||||
|
saveAiSettings(next);
|
||||||
|
}
|
||||||
|
if (!cancelled) setSettings(next);
|
||||||
|
}
|
||||||
|
} 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 activePresetId = presetIdFromActiveId(settings.activeProfileId);
|
||||||
|
const activeProfile = activePresetId ? null : settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
|
||||||
|
const activePublicSelection = !!activeProfile || (!!activePresetId && policy.publicPresets.some((p) => p.id === activePresetId));
|
||||||
|
|
||||||
|
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 && activePublicSelection && 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,
|
||||||
|
publicPresetId: activePresetId,
|
||||||
|
});
|
||||||
|
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, activePresetId, 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."
|
||||||
|
>
|
||||||
|
{policy.publicPresets.length > 0 && (
|
||||||
|
<SettingItem
|
||||||
|
label="Org-managed providers"
|
||||||
|
description="Set up by your admin. Pick one below in “Answer with” — no key to paste, it's resolved on the server."
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
{policy.publicPresets.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} · managed by admin</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
|
{settings.publicProfiles.length > 0 && (
|
||||||
|
<SettingItem label="Your own keys">
|
||||||
|
<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 your own key" description="Prefer to bring your own instead of an org-managed provider above.">
|
||||||
|
<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 || policy.publicPresets.length > 0) && (
|
||||||
|
<SettingItem label="Answer with">
|
||||||
|
<Select
|
||||||
|
value={settings.activeProfileId ?? ''}
|
||||||
|
onChange={(v) => update('activeProfileId', v)}
|
||||||
|
options={[
|
||||||
|
...policy.publicPresets.map((p) => ({ value: presetActiveId(p.id), label: `${p.name} (org)` })),
|
||||||
|
...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 { client } = useAuthStore();
|
||||||
const {
|
const {
|
||||||
contacts,
|
contacts,
|
||||||
|
addressBooks,
|
||||||
supportsSync,
|
supportsSync,
|
||||||
importContacts,
|
importContacts,
|
||||||
} = useContactStore();
|
} = useContactStore();
|
||||||
@@ -46,6 +47,7 @@ export function ContactsSettings() {
|
|||||||
<div className="border border-border rounded-lg overflow-hidden" style={{ minHeight: 400 }}>
|
<div className="border border-border rounded-lg overflow-hidden" style={{ minHeight: 400 }}>
|
||||||
<ContactImportDialog
|
<ContactImportDialog
|
||||||
existingContacts={contacts}
|
existingContacts={contacts}
|
||||||
|
addressBooks={addressBooks}
|
||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
onClose={() => setShowImport(false)}
|
onClose={() => setShowImport(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Upload, 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} aria-label="Clear selection">
|
||||||
|
<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" aria-label="Close">
|
||||||
|
<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 */}
|
{/* Theme Grid */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
{/* Default theme card */}
|
{/* No "Default/Bulwark" card: product decision 2026-08-05 ships exactly
|
||||||
<ThemeCard
|
two themes (SRC default, VNClagoon) - see DEFAULT_THEME_POLICY in
|
||||||
name="Default"
|
lib/admin/types.ts. The underlying activateTheme(null) capability
|
||||||
author="Bulwark"
|
stays reachable programmatically (e.g. an admin clearing
|
||||||
isDefaultTheme
|
defaultThemeId), just not offered as a selectable card here. */}
|
||||||
variants={['light', 'dark']}
|
|
||||||
isDark={isDark}
|
|
||||||
isActive={activeThemeId === null}
|
|
||||||
isBuiltIn
|
|
||||||
isDefault={!themePolicy.defaultThemeId}
|
|
||||||
disabled={Boolean(forcedThemeId)}
|
|
||||||
onActivate={() => handleActivate(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Installed themes */}
|
|
||||||
{visibleThemes.map(theme => {
|
{visibleThemes.map(theme => {
|
||||||
const isForceEnabled = theme.id === forcedThemeId || theme.forceEnabled || isThemeForceEnabled(theme.id);
|
const isForceEnabled = theme.id === forcedThemeId || theme.forceEnabled || isThemeForceEnabled(theme.id);
|
||||||
return (
|
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,232 @@
|
|||||||
|
"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);
|
||||||
|
const activeIndexRef = useRef(activeIndex);
|
||||||
|
const itemsRef = useRef(items);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
|
||||||
|
activeIndexRef.current = activeIndex;
|
||||||
|
itemsRef.current = items;
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => setAnimatingIn(true)));
|
||||||
|
} else {
|
||||||
|
setAnimatingIn(false);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
setActiveIndex(-1);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const items = itemsRef.current;
|
||||||
|
const currentIndex = activeIndexRef.current;
|
||||||
|
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onCloseRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (currentIndex >= 0 && currentIndex < items.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
const item = items[currentIndex];
|
||||||
|
if (!item.disabled) {
|
||||||
|
item.onClick();
|
||||||
|
onCloseRef.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((prev) => {
|
||||||
|
const hasEnabledItem = items.some((item) => !item.disabled);
|
||||||
|
if (!hasEnabledItem) return -1;
|
||||||
|
|
||||||
|
let next = prev;
|
||||||
|
let loops = 0;
|
||||||
|
do {
|
||||||
|
next = next + 1 >= items.length ? 0 : next + 1;
|
||||||
|
loops++;
|
||||||
|
} while (items[next]?.disabled && loops < items.length);
|
||||||
|
return items[next]?.disabled ? -1 : next;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((prev) => {
|
||||||
|
const hasEnabledItem = items.some((item) => !item.disabled);
|
||||||
|
if (!hasEnabledItem) return -1;
|
||||||
|
|
||||||
|
let next = prev;
|
||||||
|
let loops = 0;
|
||||||
|
do {
|
||||||
|
next = next - 1 < 0 ? items.length - 1 : next - 1;
|
||||||
|
loops++;
|
||||||
|
} while (items[next]?.disabled && loops < items.length);
|
||||||
|
return items[next]?.disabled ? -1 : next;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
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)
|
# VNCmail+ — Admin Deployment Guide (microk8s)
|
||||||
|
|
||||||
Deploy VNCmail+ (VNC's Bulwark fork) as a container at **`vncmail.sandbox.vnc.de`**,
|
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
|
**alongside** the existing `bulwark.sandbox.vnc.de`.
|
||||||
GitOps needed.
|
|
||||||
|
|
||||||
> Why a container (not Vercel): Bulwark is stateful — it writes settings/admin/
|
> Why a container (not Vercel): Bulwark is stateful — it writes settings/admin/
|
||||||
> telemetry to `/app/data`, which needs persistent volumes.
|
> 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 |
|
| # | Object | File | Purpose |
|
||||||
|---|--------|------|---------|
|
|---|--------|------|---------|
|
||||||
| 1 | Namespace `vncmail` | `namespace.yaml` | Isolates the app |
|
| 1 | Namespace | `overlays/<env>/namespace.yaml` | Isolates the app (`vncmail` for dev, `vncmail-prod` for prod) |
|
||||||
| 2 | 4× PersistentVolumeClaim | `pvc.yaml` | `/app/data/{settings,admin,admin-state,telemetry}` |
|
| 2 | 4× PersistentVolumeClaim | `base/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) |
|
| 3 | Secret `vncmail-env` | `overlays/<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 |
|
| 4 | Image-pull secret | *(you create it — command below)* | Pull the (currently private) image |
|
||||||
| 5 | Deployment `vncmail-plus` | `deployment.yaml` | The app pod |
|
| 5 | Deployment `vncmail-plus` | `base/deployment.yaml` (+ overlay patches) | The app pod |
|
||||||
| 6 | Service `vncmail-plus` | `service.yaml` | ClusterIP :80 → pod :3000 |
|
| 6 | Service `vncmail-plus` | `base/service.yaml` | ClusterIP :80 → pod :3000 |
|
||||||
| 7 | Ingress `vncmail-plus` | `ingress.yaml` | TLS host `vncmail.sandbox.vnc.de` |
|
| 7 | Ingress `vncmail-plus` | `base/ingress.yaml` (+ overlay patches for prod) | TLS host |
|
||||||
|
|
||||||
**Image:** `ghcr.io/brvncde-dotcom/vncmail-plus-dev:latest`
|
**Image:** CI builds and pushes to `registry.gitlab.vnc.biz/gitlab-instance-b9b5cf2f/vncmail-plus`
|
||||||
(built automatically by CI from the `dev` branch). For anything beyond the
|
(tag `sha-<sha>` per deploy, moving pointer `dev-latest`). The generic `vncmail-plus`
|
||||||
sandbox, pin a digest — see §5.
|
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)
|
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 |
|
| Value | Default in manifests | File to edit |
|
||||||
|-------|----------------------|--------------|
|
|-------|----------------------|--------------|
|
||||||
| StorageClass | `microk8s-hostpath` | `pvc.yaml` (all 4) |
|
| StorageClass | `microk8s-hostpath` | `base/pvc.yaml` (all 4) |
|
||||||
| IngressClass | `public` | `ingress.yaml` |
|
| IngressClass | `public` | `base/ingress.yaml` |
|
||||||
| cert-manager issuer | `letsencrypt-prod` | `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
|
```bash
|
||||||
cd deploy/k8s
|
cd deploy/k8s/overlays/dev # or overlays/prod, once real
|
||||||
|
|
||||||
# a) Namespace
|
# a) Image-pull secret — the GitLab registry requires authentication.
|
||||||
kubectl apply -f namespace.yaml
|
# Use a project deploy token with `read_registry` scope, or the CI job
|
||||||
|
# token (short-lived — better for CI, not for long-running clusters).
|
||||||
# b) Image-pull secret — the GHCR package is private.
|
kubectl create secret docker-registry gitlab-registry \
|
||||||
# Use a GitHub PAT (classic) with the read:packages scope.
|
|
||||||
kubectl create secret docker-registry ghcr-pull \
|
|
||||||
--namespace vncmail \
|
--namespace vncmail \
|
||||||
--docker-server=ghcr.io \
|
--docker-server=registry.gitlab.vnc.biz \
|
||||||
--docker-username=brvncde-dotcom \
|
--docker-username=<deploy-token-name> \
|
||||||
--docker-password='<GITHUB_PAT_read:packages>' \
|
--docker-password='<deploy-token-secret>' \
|
||||||
--docker-email=br@vnc.biz
|
--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
|
cp secret.example.yaml secret.yaml
|
||||||
# edit secret.yaml: SESSION_SECRET: "$(openssl rand -base64 32)"
|
# edit secret.yaml: SESSION_SECRET: "$(openssl rand -base64 32)"
|
||||||
kubectl apply -f secret.yaml
|
kubectl apply -f secret.yaml
|
||||||
|
|
||||||
# d) Everything else (PVCs, Deployment, Service, Ingress)
|
# c) Everything else (namespace, PVCs, Deployment, Service, Ingress)
|
||||||
kubectl apply -k .
|
kubectl apply -k .
|
||||||
```
|
```
|
||||||
|
|
||||||
> Alternative to (b): make the GHCR package public
|
> Alternative to (a): make the GitLab container registry public for this
|
||||||
> (GitHub → Packages → vncmail-plus-dev → Package settings → Change visibility),
|
> project, then delete the `imagePullSecrets:` block from `base/deployment.yaml`.
|
||||||
> then delete the `imagePullSecrets:` block from `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
|
## 5. Update to a new build
|
||||||
|
|
||||||
```bash
|
Normally you don't — CI's `bump-dev` job + ArgoCD's automated sync do this
|
||||||
# CI rebuilds ghcr.io/brvncde-dotcom/vncmail-plus-dev on every push to `dev`.
|
on every push to `dev`. To do it by hand (e.g. troubleshooting, before
|
||||||
kubectl -n vncmail rollout restart deploy/vncmail-plus # pulls :latest (imagePullPolicy: Always)
|
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 \
|
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 |
|
| Symptom | Cause / fix |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| Pod `ImagePullBackOff` | `ghcr-pull` secret missing/expired, or package still private. Recreate the secret (§3b) or make the package public. |
|
| 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 `deployment.yaml` — keep it; some storage drivers also need it on the PVC. |
|
| 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 `pvc.yaml`. Set it to one from `kubectl get sc`. |
|
| 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`. |
|
| 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. |
|
| 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`. |
|
| 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
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: vncmail-plus
|
name: vncmail-plus
|
||||||
namespace: vncmail
|
|
||||||
labels:
|
labels:
|
||||||
app: vncmail-plus
|
app: vncmail-plus
|
||||||
spec:
|
spec:
|
||||||
@@ -24,15 +23,18 @@ spec:
|
|||||||
fsGroup: 1001
|
fsGroup: 1001
|
||||||
runAsUser: 1001
|
runAsUser: 1001
|
||||||
runAsGroup: 1001
|
runAsGroup: 1001
|
||||||
# ghcr package is private by default — see deploy/k8s/README.md to create
|
# The GitLab container registry is private by default. Nodes need a
|
||||||
# this pull secret. Delete this block if you make the package public.
|
# 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:
|
imagePullSecrets:
|
||||||
- name: ghcr-pull
|
- name: gitlab-registry
|
||||||
containers:
|
containers:
|
||||||
- name: vncmail-plus
|
- name: vncmail-plus
|
||||||
# dev image (built from the `dev` branch by CI). For production pin a
|
# Generic placeholder — the real image name + tag are injected by the
|
||||||
# digest: ghcr.io/brvncde-dotcom/vncmail-plus-dev@sha256:<digest>
|
# image-tag kustomize Component on every deploy (see
|
||||||
image: ghcr.io/brvncde-dotcom/vncmail-plus-dev:latest
|
# overlays/*/image-tag/kustomization.yaml, rewritten by CI).
|
||||||
|
image: vncmail-plus:latest
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 3000
|
- 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
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
name: vncmail-settings
|
name: vncmail-settings
|
||||||
namespace: vncmail
|
|
||||||
spec:
|
spec:
|
||||||
accessModes: [ReadWriteOnce]
|
accessModes: [ReadWriteOnce]
|
||||||
storageClassName: microk8s-hostpath
|
storageClassName: microk8s-hostpath
|
||||||
@@ -17,7 +16,6 @@ apiVersion: v1
|
|||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
name: vncmail-admin
|
name: vncmail-admin
|
||||||
namespace: vncmail
|
|
||||||
spec:
|
spec:
|
||||||
accessModes: [ReadWriteOnce]
|
accessModes: [ReadWriteOnce]
|
||||||
storageClassName: microk8s-hostpath
|
storageClassName: microk8s-hostpath
|
||||||
@@ -29,7 +27,6 @@ apiVersion: v1
|
|||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
name: vncmail-admin-state
|
name: vncmail-admin-state
|
||||||
namespace: vncmail
|
|
||||||
spec:
|
spec:
|
||||||
accessModes: [ReadWriteOnce]
|
accessModes: [ReadWriteOnce]
|
||||||
storageClassName: microk8s-hostpath
|
storageClassName: microk8s-hostpath
|
||||||
@@ -41,7 +38,6 @@ apiVersion: v1
|
|||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
name: vncmail-telemetry
|
name: vncmail-telemetry
|
||||||
namespace: vncmail
|
|
||||||
spec:
|
spec:
|
||||||
accessModes: [ReadWriteOnce]
|
accessModes: [ReadWriteOnce]
|
||||||
storageClassName: microk8s-hostpath
|
storageClassName: microk8s-hostpath
|
||||||
@@ -2,7 +2,6 @@ apiVersion: v1
|
|||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: vncmail-plus
|
name: vncmail-plus
|
||||||
namespace: vncmail
|
|
||||||
labels:
|
labels:
|
||||||
app: vncmail-plus
|
app: vncmail-plus
|
||||||
spec:
|
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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user