Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79b99ed4f8 | ||
|
|
68214c3e91 | ||
|
|
5da0e2bdf1 | ||
|
|
92ada0460a | ||
|
|
1cce5c3c8a | ||
|
|
40fd7799e9 | ||
|
|
aaa283357e | ||
|
|
066ab1bc32 | ||
|
|
92fb9bf132 | ||
|
|
4c804d5d2b | ||
|
|
63b464641b | ||
|
|
67210c9924 | ||
|
|
8937777bcd | ||
|
|
789d211a71 | ||
|
|
c512352f77 | ||
|
|
c859230862 | ||
|
|
0fb8b81acd | ||
|
|
1eebec292a | ||
|
|
2d983c9853 | ||
|
|
a453f7fa67 | ||
|
|
4c853f176b | ||
|
|
5583d95ecc | ||
|
|
9d1302ef0a | ||
|
|
75b5d31414 | ||
|
|
1e90885f3f | ||
|
|
02577c7502 | ||
|
|
375c0a7339 | ||
|
|
99d5276cfa | ||
|
|
60b2cf5911 | ||
|
|
8d7cd26fa9 | ||
|
|
1ba8e2fd47 | ||
|
|
c7c22bd210 | ||
|
|
6e0c79ca2c | ||
|
|
9140110435 | ||
|
|
e49f7c1a75 | ||
|
|
098127148e | ||
|
|
308adf0101 | ||
|
|
574bd7ecef | ||
|
|
d6b0714b4e | ||
|
|
af8ea8349e | ||
|
|
8eeabfc995 | ||
|
|
ddb636ed73 | ||
|
|
92ff5fe449 | ||
|
|
edcd5aa2a7 | ||
|
|
b5d471e9c8 | ||
|
|
f084b484b7 | ||
|
|
b868ad591d | ||
|
|
42734f16c3 | ||
|
|
b70c727bae | ||
|
|
9b0598b051 | ||
|
|
66cdfe64ce | ||
|
|
f6536573ae | ||
|
|
415c961937 | ||
|
|
ba6b25f3f1 | ||
|
|
a9002763e2 | ||
|
|
b75d064f20 | ||
|
|
a46fb9c8af | ||
|
|
6696636df8 | ||
|
|
37bc88dbad | ||
|
|
7a191cf78b | ||
|
|
733e99f094 | ||
|
|
9b25b6d03e | ||
|
|
05f848ee23 | ||
|
|
4da1b7d8fc | ||
|
|
4ff2bff974 | ||
|
|
47918aab1b | ||
|
|
e48672cf77 | ||
|
|
763abf43b9 | ||
|
|
649261c386 | ||
|
|
e02371d2bf | ||
|
|
4af20b8dc0 | ||
|
|
b5172802bb | ||
|
|
f64306be5e | ||
|
|
05f6d61cea | ||
|
|
0174051f3e | ||
|
|
136686f230 | ||
|
|
54981950b0 | ||
|
|
29a222eef4 | ||
|
|
76b21147e4 | ||
|
|
78bcf8db1b | ||
|
|
39c43e3b3d | ||
|
|
55ee99e811 | ||
|
|
31e7ecceda | ||
|
|
d666bddbc4 | ||
|
|
1cac71fae7 | ||
|
|
79ff95565d | ||
|
|
808328f5a4 | ||
|
|
ddbeadd3c8 | ||
|
|
501d69500d | ||
|
|
e07365b066 | ||
|
|
2baf5e84a5 | ||
|
|
024487bf10 | ||
|
|
97bc26a332 | ||
|
|
55c8d430ca | ||
|
|
ef45140d32 | ||
|
|
0effb97691 | ||
|
|
f7ee204262 | ||
|
|
0c1f182b6b | ||
|
|
13010c158d | ||
|
|
de26e6da2e | ||
|
|
ddb3422852 | ||
|
|
d9a2529261 | ||
|
|
e70224317d | ||
|
|
a9ecf164ab | ||
|
|
0db3cbc959 | ||
|
|
387273288c | ||
|
|
8eff9fdfab | ||
|
|
d915e5fb64 | ||
|
|
5530cfe7fe | ||
|
|
31eee4bab9 | ||
|
|
f04b97d52a | ||
|
|
df272e38ef | ||
|
|
a835af2d71 | ||
|
|
30284859c7 | ||
|
|
64c3d7e384 | ||
|
|
e6d09546b1 | ||
|
|
83a0a1e235 | ||
|
|
7c3c3b5f7b | ||
|
|
8b1b3ad57b | ||
|
|
afefbb8d46 | ||
|
|
4c2d185be4 | ||
|
|
c73940e22a | ||
|
|
09eda86d3f | ||
|
|
0d28d811a8 | ||
|
|
45a485a1fa | ||
|
|
bc202498d5 | ||
|
|
721556e777 | ||
|
|
8c9cf3a66b | ||
|
|
b88026de82 | ||
|
|
6ed8ae5812 | ||
|
|
089583a9ef | ||
|
|
2d834213ee | ||
|
|
439a4dbe8a | ||
|
|
8350bad2a6 | ||
|
|
2d56cc9be9 | ||
|
|
2547c10060 | ||
|
|
01779fa59e | ||
|
|
fc38427ed0 | ||
|
|
3cbfb70860 | ||
|
|
c32b740dac | ||
|
|
a02091a7ad | ||
|
|
bc311adf6a | ||
|
|
8aca1623f4 | ||
|
|
a8be40579e | ||
|
|
705b942800 | ||
|
|
d68b81e6b8 | ||
|
|
616e4d018d | ||
|
|
68e141b787 | ||
|
|
1e6f5e2c8c | ||
|
|
9495b34430 | ||
|
|
65fc489b9c | ||
|
|
bd686c092c | ||
|
|
6cff98ddb8 | ||
|
|
dcc35335f5 | ||
|
|
8a54ae2456 | ||
|
|
e26654a005 | ||
|
|
c1c06c68bb | ||
|
|
74cf642182 | ||
|
|
c5b1731a63 | ||
|
|
40cf164df3 | ||
|
|
ff56245db8 | ||
|
|
9b4de4d152 | ||
|
|
0c9e60db8b | ||
|
|
def8ee89fa | ||
|
|
e7e07a38d7 | ||
|
|
a009e5ae32 | ||
|
|
b141240fa3 | ||
|
|
44896dee3e | ||
|
|
a5c5fa6669 | ||
|
|
95af61c4be | ||
|
|
34e495dde3 | ||
|
|
0b721661e9 | ||
|
|
9fa851a674 | ||
|
|
41f91244d9 | ||
|
|
77514bd054 | ||
|
|
4501b3894b | ||
|
|
2edf2fab89 | ||
|
|
d493bb17dc | ||
|
|
234129397d | ||
|
|
9b3a47f9be | ||
|
|
0fcc932e66 |
@@ -5,9 +5,7 @@ node_modules
|
|||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.dev.example
|
!.env.dev.example
|
||||||
.claude/
|
|
||||||
scripts/
|
scripts/
|
||||||
TODO.md
|
TODO.md
|
||||||
CLAUDE.md
|
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
|
|||||||
+20
-2
@@ -9,9 +9,16 @@
|
|||||||
# App name displayed in the UI
|
# App name displayed in the UI
|
||||||
APP_NAME=Bulwark Webmail
|
APP_NAME=Bulwark Webmail
|
||||||
|
|
||||||
# URL of your JMAP-compatible mail server (required)
|
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
|
||||||
JMAP_SERVER_URL=https://your-jmap-server.com
|
JMAP_SERVER_URL=https://your-jmap-server.com
|
||||||
|
|
||||||
|
# Allow users to specify a custom JMAP server URL on the login form.
|
||||||
|
# When enabled, a "JMAP Server" field appears on the login page.
|
||||||
|
# Users can connect to any JMAP-compatible server.
|
||||||
|
# NOTE: External JMAP servers must include this domain in their CORS
|
||||||
|
# Access-Control-Allow-Origin header, or browser requests will be blocked.
|
||||||
|
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stalwart Mail Server Integration
|
# Stalwart Mail Server Integration
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -64,7 +71,10 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# SETTINGS_SYNC_ENABLED=true
|
# SETTINGS_SYNC_ENABLED=true
|
||||||
|
|
||||||
# Directory for storing encrypted settings files (default: ./data/settings).
|
# Directory for storing encrypted settings files (default: ./data/settings).
|
||||||
# For Docker, mount a persistent volume at this path.
|
# For Docker, the working directory is /app, so the default resolves to
|
||||||
|
# /app/data/settings — mount a persistent volume there:
|
||||||
|
# volumes:
|
||||||
|
# - bulwark-settings:/app/data/settings
|
||||||
# SETTINGS_DATA_DIR=./data/settings
|
# SETTINGS_DATA_DIR=./data/settings
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -129,6 +139,14 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
|
|||||||
# URL for the company website link on the login page
|
# URL for the company website link on the login page
|
||||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Extension Directory / Marketplace
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# URL of the BulwarkMail extension directory for the admin marketplace.
|
||||||
|
# Set this to enable browsing and installing plugins/themes from the directory.
|
||||||
|
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Legacy Build-time Variables (still supported as fallback)
|
# Legacy Build-time Variables (still supported as fallback)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
name: Publish Docker Image on Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: ghcr.io/${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
type=semver,pattern=v{{version}}
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern=v{{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern=v{{major}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
|
||||||
|
- name: Create manifest list and push
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Inspect image
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||||
@@ -2,7 +2,9 @@ name: Publish Docker Image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
paths:
|
paths:
|
||||||
- "Dockerfile"
|
- "Dockerfile"
|
||||||
- ".dockerignore"
|
- ".dockerignore"
|
||||||
@@ -17,12 +19,22 @@ on:
|
|||||||
- "package.json"
|
- "package.json"
|
||||||
- "package-lock.json"
|
- "package-lock.json"
|
||||||
- ".github/workflows/docker-publish.yml"
|
- ".github/workflows/docker-publish.yml"
|
||||||
tags: ["v*.*.*"]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: ghcr.io/${{ github.repository }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build:
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -31,9 +43,6 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -48,21 +57,73 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: ${{ env.IMAGE_NAME }}
|
||||||
ghcr.io/${{ github.repository }}
|
|
||||||
tags: |
|
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=sha,prefix=
|
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||||
cache-to: type=gha,mode=max
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value={{branch}}
|
||||||
|
type=sha,prefix={{branch}}-
|
||||||
|
|
||||||
|
- name: Create manifest list and push
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Inspect image
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||||
|
|||||||
+4
-4
@@ -42,11 +42,11 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# claude code
|
|
||||||
.claude/
|
|
||||||
|
|
||||||
# settings sync data
|
# settings sync data
|
||||||
/data/
|
/data/
|
||||||
|
|
||||||
# Local private data
|
# Local private data
|
||||||
/local-data/
|
/local-data/
|
||||||
|
|
||||||
|
# Sibling repos
|
||||||
|
/repos/
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Check for AI attribution in commit message
|
|
||||||
if grep -qi "co-authored-by.*claude\|co-authored-by.*anthropic\|claude code\|claude sonnet\|claude opus" "$1"; then
|
|
||||||
echo "❌ ERROR: Commit message contains AI attribution (Claude/Anthropic)"
|
|
||||||
echo " This violates project policy in CLAUDE.md"
|
|
||||||
echo " Remove 'Co-Authored-By: Claude' and similar references"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
+179
@@ -1,5 +1,184 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.4.10 (2026-03-31)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Plugins**: Add plugin configuration UI with schema-driven admin config page, calendar event action slot, and Jitsi Meet plugin
|
||||||
|
- **Calendar**: Implement client-side recurrence expansion for calendar events
|
||||||
|
- **Calendar**: Add iCal subscription editing and batch event import
|
||||||
|
- **Calendar**: Add hover preview settings and functionality
|
||||||
|
- **Calendar**: Add virtual location input for calendar events (#121)
|
||||||
|
- **Email**: Add reply-to addresses support in email composer
|
||||||
|
- **Email**: Add mail layout settings and update email list components
|
||||||
|
- **Email**: Add auto-select reply identity feature with settings and localization
|
||||||
|
- **Email**: Enhance compose functionality with button integration and translations
|
||||||
|
- **Filters**: Preserve activation state when updating or creating Sieve scripts to avoid deactivating server-managed vacation scripts
|
||||||
|
- **Filters**: Skip server-managed vacation script in Sieve script handling
|
||||||
|
- **Settings**: Add support for custom JMAP server endpoints in login and settings
|
||||||
|
- **Settings**: Add folder expansion state management and settings navigation
|
||||||
|
- **UI**: Add options to hide account switcher and show account avatars on navigation rail
|
||||||
|
- **i18n**: Add JMAP server endpoint labels and hints in multiple languages
|
||||||
|
- **i18n**: Add missing translation keys to all non-English locales
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Security**: Patch critical auth bypass and credential leak vulnerabilities
|
||||||
|
- **Security**: Support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys and add diagnostic logging (#35)
|
||||||
|
- **Security**: Account isolation, auto-import signer certs, and no-key error handling (#35)
|
||||||
|
- **Calendar**: Fix JSCalendar 2.0 recurrenceRule single-object compatibility (#116)
|
||||||
|
- **Calendar**: Enhance calendar event handling to distinguish between events and tasks
|
||||||
|
- **Calendar**: Link existing events to target calendar during iCal import instead of skipping (#113)
|
||||||
|
- **Calendar**: Deduplicate UIDs during iCal import to prevent mass failures (#113)
|
||||||
|
- **Calendar**: Fix events disappearing after iCal import/subscription refresh
|
||||||
|
- **Calendar**: Enhance calendar event handling with full-day detection and layout adjustments
|
||||||
|
- **Calendar**: Use UTC timestamps for timed event rendering
|
||||||
|
- **Calendar**: Work around Stalwart not returning Task objects via CalendarEvent/query
|
||||||
|
- **Email**: Enhance email loading and deduplication logic in email store (#119)
|
||||||
|
- **Email**: Ensure draft editing function is called correctly in EmailViewer component (#60)
|
||||||
|
- **Email**: Match hover action background to selected row state
|
||||||
|
- **Email**: Align tag counts with mailbox folder counts in sidebar
|
||||||
|
- **Auth**: Handle 2FA/TOTP session expiry with basic auth (#117)
|
||||||
|
- **Mailbox**: Improve mailbox tree logic and enhance mailbox handling with logging (#118)
|
||||||
|
- **UI**: Improve dark mode handling for media elements and background images
|
||||||
|
- **UI**: Adjust account list spacing and remove push connection indicator
|
||||||
|
- **UI**: Fix nested button in theme card
|
||||||
|
|
||||||
|
## 1.4.9 (2026-03-27)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Admin**: Add Stalwart admin authentication, sidebar access, and a reorganized dashboard with dedicated policy sections
|
||||||
|
- **Plugins**: Add plugin/theme admin dashboard, harness tooling, forced enable or disable controls, managed policy enforcement, and a resizable detail sidebar
|
||||||
|
- **Filters**: Add vacation responder management with Sieve generation and parsing, UI integration, and improved sync preservation
|
||||||
|
- **Email**: Add plain text only composer mode, optional conversation threading disable, configurable hover action placement, and OAuth app password support
|
||||||
|
- **UI**: Add drag-and-drop customization for sidebar apps
|
||||||
|
- **Files**: Use dynamic server-configured maximum upload sizes
|
||||||
|
- **i18n**: Add Russian locale support and complete missing translation strings for recent task features
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Calendar**: Improve date parsing and event normalization, prevent calendar page re-render loops, ensure unique ICal subscription IDs, and create all-day events with correct JSCalendar midnight handling
|
||||||
|
- **Email**: Respect the configured mark-as-read delay in EmailViewer and fetch full email content when needed while editing drafts (#60, #95)
|
||||||
|
- **Auth**: Improve network error handling, add JMAP rate limiting handling, and enhance settings retrieval and persistence diagnostics (#100, #104)
|
||||||
|
- **UI**: Improve mobile layout behavior on contacts and calendar pages (#103)
|
||||||
|
- **Themes**: Repair theme ZIP bundle handling and enforce admin theme locks correctly
|
||||||
|
- **Code Quality**: Resolve outstanding ESLint warnings across the codebase
|
||||||
|
|
||||||
|
## 1.4.8 (2026-03-23)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Email**: Add support for marking emails as answered or forwarded and display status icons in email list and thread views
|
||||||
|
- **Email**: Enhance identity selection by supporting sub-addressing (plus addressing) in email composer
|
||||||
|
- **Settings**: Add notification settings with sound picker, preview playback, and configurable alert sounds
|
||||||
|
- **Settings**: Add default mail program settings with localization support across all locales
|
||||||
|
- **Auth**: Implement path prefix handling for OAuth callbacks and login redirects, enabling reverse proxy deployments
|
||||||
|
- **Validation**: Add all multi-part TLDs for domain validation in favicon API (#81)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Calendar**: Fix bugs in duration parsing, RFC compliance, and event handling across calendar components
|
||||||
|
- **Calendar**: Detect tasks created by external CalDAV clients such as Thunderbird
|
||||||
|
- **Settings**: Enhance account settings with username and authentication method display (#90)
|
||||||
|
|
||||||
|
## 1.4.7 (2026-03-21)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Calendar**: Add task management features with task creation, editing, and status tracking
|
||||||
|
- **Calendar**: Add option to show week numbers in mini-calendar
|
||||||
|
- **Email**: Add resizable image component and rich text editor with image upload support
|
||||||
|
- **Files**: Support uploading folders via drag-and-drop and toolbar button
|
||||||
|
- **Filters**: Add expanded visual view for filter rules
|
||||||
|
- **Auth**: Add non-interactive SSO login flow for embedded/iframe deployments (#69)
|
||||||
|
- **DevOps**: Add separate Docker build workflow for releases and dev branch images
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Calendar**: Handle updates and deletions for synthetic JMAP IDs in calendar events with fallback to destroy and recreate
|
||||||
|
- **Security**: Extend CryptoEngine to support legacy algorithms and integrate with LinerEngine for decryption
|
||||||
|
- **Auth**: Refactor logout to use synchronous flow with full page redirect
|
||||||
|
- **Email**: Update iframe sandbox attributes to allow popups to escape sandbox
|
||||||
|
- **i18n**: Add missing translation keys across all locales
|
||||||
|
- **Docker**: Update .env.example to clarify Docker volume mounting for settings data directory
|
||||||
|
|
||||||
|
## 1.4.6 (2026-03-21)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Demo**: Add full demo mode with fixture data for emails, calendars, contacts, files, filters, identities, mailboxes, and vacation responses
|
||||||
|
- **Demo**: Implement JMAP client interface abstraction to support demo and live backends
|
||||||
|
- **Contacts**: Add no-category filter, drag-and-drop to category, and category combo box in contact form
|
||||||
|
- **Email**: Add hover actions for emails with configurable quick-action buttons
|
||||||
|
- **Settings**: Implement keyword migration functionality for upgrading legacy email tags
|
||||||
|
- **Security**: Enhance S/MIME certificate extraction and add legacy PBE (password-based encryption) support
|
||||||
|
- **Tour**: Add interactive guided tour overlay for new user onboarding
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Settings**: Add missing `showTimeInMonthView` and `showOnMobile` type definitions to settings store
|
||||||
|
- **UI**: Adjust padding and size of sidebar buttons for improved layout
|
||||||
|
|
||||||
|
## 1.4.5 (2026-03-20)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Calendar**: Add prev/next navigation buttons and date label to desktop calendar toolbar
|
||||||
|
- **Calendar**: Add pending event preview functionality to calendar views and event modal
|
||||||
|
- **Calendar**: Add setting to show event start time in month view
|
||||||
|
- **Contacts**: Implement pagination for fetching contacts with maxObjectsInGet capability
|
||||||
|
- **Email**: Add attachment position setting in email settings
|
||||||
|
- **Layout**: Add mobile visibility toggle for sidebar apps
|
||||||
|
- **Error**: Add NotFound component to handle 404 errors and redirect unauthenticated users
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Auth**: Enhance account switching logic and clear stores on account change
|
||||||
|
- **Auth**: Improve account restoration logic and handle stale accounts
|
||||||
|
- **Auth**: Improve draft handling in email composer and enhance session cookie verification
|
||||||
|
- **Calendar**: Expand recurring events in CalendarEvent/query so individual occurrences are returned (#65)
|
||||||
|
- **Calendar**: Validate event start field when fetching calendar events
|
||||||
|
- **Calendar**: Auto-scroll agenda view to today's events and include today's date in groups
|
||||||
|
- **Calendar**: Correct JSX syntax in CalendarToolbar component
|
||||||
|
- **Dependencies**: Update flatted to 3.4.2
|
||||||
|
- **DevOps**: Use native ARM runners instead of QEMU for Docker builds
|
||||||
|
- **DevOps**: Enhance health check with detailed memory diagnostics and stable liveness probe
|
||||||
|
|
||||||
|
## 1.4.4 (2026-03-19)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Calendar**: Implement CalDAV discovery API with automatic calendar home resolution for multi-account setups
|
||||||
|
- **Calendar**: Enhance calendar management settings with mailbox role reassignment controls
|
||||||
|
- **Email**: Add signature rendering utilities with HTML-to-text conversion and sanitization
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Auth**: Fix account session handling to update existing accounts instead of duplicating entries
|
||||||
|
- **Auth**: Fix logout redirects and unauthenticated home page rendering
|
||||||
|
- **Calendar**: Fix duplicate calendar edits and prevent double-save submissions in event modal
|
||||||
|
- **Calendar**: Remove stale calendar ID references in favor of CalDAV-discovered IDs
|
||||||
|
- **Contacts**: Improve RFC 9553 compliance for contact birthdays and address formatting
|
||||||
|
- **Email**: Fix email signature rendering for identity signatures
|
||||||
|
- **Folders**: Improve mailbox role management by clearing roles from all mailboxes before reassigning
|
||||||
|
|
||||||
|
## 1.4.3 (2026-03-19)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Auth**: Implement multi-account support with up to 5 simultaneous accounts and instant switching
|
||||||
|
- **Auth**: Add account switcher component with connection status, default account selection, and per-account logout
|
||||||
|
- **Auth**: Support multi-account OAuth and basic auth with per-account session persistence
|
||||||
|
- **Contacts**: Enhance contacts sidebar with collapsible sections, bulk operations, and address book grouping
|
||||||
|
- **Contacts**: Add contact import functionality and keyword filtering
|
||||||
|
- **Settings**: Add per-account encrypted settings storage with server-side sync support
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **UI**: Adjust popover alignment in sub-address helper component
|
||||||
|
- **Settings**: Improve error logging in settings sync functionality
|
||||||
|
|
||||||
## 1.4.2 (2026-03-19)
|
## 1.4.2 (2026-03-19)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+1
-1
@@ -195,7 +195,7 @@ webmail/
|
|||||||
- **Never commit sensitive data** (API keys, passwords, etc.)
|
- **Never commit sensitive data** (API keys, passwords, etc.)
|
||||||
- **Sanitize user input** and email content
|
- **Sanitize user input** and email content
|
||||||
- **Block external content** by default for privacy
|
- **Block external content** by default for privacy
|
||||||
- Report security vulnerabilities privately
|
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ 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
|
||||||
|
RUN mkdir -p /app/data/settings /app/data/admin && chown -R nextjs:nodejs /app/data
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||||
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="480" />
|
<img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
# Bulwark Webmail
|
# Bulwark Webmail
|
||||||
@@ -11,9 +11,10 @@
|
|||||||
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/).<br/>
|
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/).<br/>
|
||||||
Built with Next.js and the JMAP protocol.
|
Built with Next.js and the JMAP protocol.
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](CHANGELOG.md)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](CHANGELOG.md)
|
||||||
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -25,16 +26,16 @@ Built with Next.js and the JMAP protocol.
|
|||||||
<tr>
|
<tr>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/inbox.png" width="100%" alt="Inbox - three-pane layout with sidebar, email list, and viewer (dark mode)">
|
<img src="screenshots/inbox.png" width="100%" alt="Inbox — three-pane layout with sidebar, email list, and viewer (dark mode)">
|
||||||
|
|
||||||
**Mail** - Three-pane layout with sidebar, email list, and viewer
|
**Mail** — Three-pane layout with sidebar, email list, and viewer
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
|
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
|
||||||
|
|
||||||
**Calendar** - Month, week, day, and agenda views with event management
|
**Calendar** — Month, week, day, and agenda views with event management
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -43,14 +44,14 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
||||||
|
|
||||||
**Contacts** - Contact management with groups and vCard support
|
**Contacts** — Contact management with groups and vCard support
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/files.png" width="100%" alt="File browser">
|
<img src="screenshots/files.png" width="100%" alt="File browser">
|
||||||
|
|
||||||
**Files** - Cloud file browser with upload, preview, and folder navigation
|
**Files** — Cloud file browser with upload, preview, and folder navigation
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -62,16 +63,16 @@ Built with Next.js and the JMAP protocol.
|
|||||||
<tr>
|
<tr>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox - light mode">
|
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox — light mode">
|
||||||
|
|
||||||
**Light mode** - Full theme support with intelligent color transformation
|
**Light mode** — Full theme support with intelligent color transformation
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
|
|
||||||
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
||||||
|
|
||||||
**Settings** - Appearance, identities, filters, templates, and more
|
**Settings** — Appearance, identities, filters, templates, and more
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -80,7 +81,7 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
<img src="screenshots/login.png" width="100%" alt="Login page">
|
<img src="screenshots/login.png" width="100%" alt="Login page">
|
||||||
|
|
||||||
**Login** - Configurable branding with OAuth2/OIDC and 2FA support
|
**Login** — Configurable branding with OAuth2/OIDC and 2FA support
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%">
|
<td width="50%">
|
||||||
@@ -94,21 +95,22 @@ Built with Next.js and the JMAP protocol.
|
|||||||
### Mail
|
### Mail
|
||||||
|
|
||||||
- **Read, compose, reply, reply-all, forward** with rich HTML rendering
|
- **Read, compose, reply, reply-all, forward** with rich HTML rendering
|
||||||
- **Threading** - Gmail-style inline expansion with thread navigation
|
- **Threading** — Gmail-style inline expansion with thread navigation
|
||||||
- **Draft auto-save** with discard confirmation
|
- **Draft auto-save** with discard confirmation
|
||||||
- **Attachments** - upload, download, and inline preview
|
- **Attachments** — upload, download, and inline preview
|
||||||
- **Search** - full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
|
- **Search** — full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
|
||||||
- **Batch operations** - multi-select with checkboxes, archive, delete, move, tag
|
- **Batch operations** — multi-select with checkboxes, archive, delete, move, tag
|
||||||
- **Archive modes** - archive directly or organize archived mail by year or month
|
- **Archive modes** — archive directly or organize archived mail by year or month
|
||||||
- **Print** emails directly from the viewer
|
- **Print** emails directly from the viewer
|
||||||
|
- **Answered/forwarded status icons** in email list and thread views
|
||||||
- **Color tags/labels** and star/unstar
|
- **Color tags/labels** and star/unstar
|
||||||
- **Virtual scrolling** for large mailboxes
|
- **Virtual scrolling** for large mailboxes
|
||||||
- **Quick reply** from the viewer
|
- **Quick reply** from the viewer
|
||||||
- **Sender avatars** - favicon-based with negative caching for performance
|
- **Sender avatars** — favicon-based with negative caching for performance
|
||||||
- **Recipient popover** for quick contact interaction
|
- **Recipient popover** for quick contact interaction
|
||||||
- **Folder management** - create, rename, delete folders with icon picker and subfolder support
|
- **TNEF support** — extract Outlook `winmail.dat` message bodies and attachments automatically
|
||||||
- **Tag counts** - unread and total counts displayed in sidebar
|
- **Folder management** — create, rename, delete folders with icon picker and subfolder support
|
||||||
- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically
|
- **Tag counts** — unread and total counts displayed in sidebar
|
||||||
|
|
||||||
### Calendar
|
### Calendar
|
||||||
|
|
||||||
@@ -116,10 +118,12 @@ Built with Next.js and the JMAP protocol.
|
|||||||
- **Event hover preview** popover with details
|
- **Event hover preview** popover with details
|
||||||
- **Drag-and-drop rescheduling**, click-drag creation, edge-resize (15-min snap)
|
- **Drag-and-drop rescheduling**, click-drag creation, edge-resize (15-min snap)
|
||||||
- **Recurring events** with edit/delete scope (this / this and following / all)
|
- **Recurring events** with edit/delete scope (this / this and following / all)
|
||||||
- **Participant scheduling** - iTIP invitations, organizer/attendee UI, RSVP
|
- **Participant scheduling** — iTIP invitations, organizer/attendee UI, RSVP
|
||||||
- **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import
|
- **Inline calendar invitations** in email viewer — auto-detect `.ics`, RSVP, import
|
||||||
- **iCalendar import** with preview and bulk create
|
- **iCalendar import** with preview and bulk create
|
||||||
- **Notifications** with configurable sound and alert persistence
|
- **Task management** — create, edit, and track tasks with due dates, priority, and completion status
|
||||||
|
- **Week numbers** in mini-calendar sidebar
|
||||||
|
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
|
||||||
- **Real-time sync** via JMAP push
|
- **Real-time sync** via JMAP push
|
||||||
|
|
||||||
### Contacts
|
### Contacts
|
||||||
@@ -128,15 +132,15 @@ Built with Next.js and the JMAP protocol.
|
|||||||
- **Contact groups** with group expansion and member management
|
- **Contact groups** with group expansion and member management
|
||||||
- **vCard import/export** (RFC 6350) with duplicate detection
|
- **vCard import/export** (RFC 6350) with duplicate detection
|
||||||
- **Autocomplete** in composer (To/Cc/Bcc)
|
- **Autocomplete** in composer (To/Cc/Bcc)
|
||||||
- **Bulk operations** - multi-select, delete, group add, export
|
- **Bulk operations** — multi-select, delete, group add, export
|
||||||
|
|
||||||
### Filters & Automation
|
### Filters & Automation
|
||||||
|
|
||||||
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
|
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
|
||||||
- **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
- **Visual rule builder** — conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
||||||
- **Raw Sieve editor** with syntax validation
|
- **Raw Sieve editor** with syntax validation
|
||||||
- **Vacation responder** with date range scheduling and sidebar indicator
|
- **Vacation responder** with date range scheduling and sidebar indicator
|
||||||
- **Email templates** - reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
|
- **Email templates** — reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
|
||||||
|
|
||||||
### Files
|
### Files
|
||||||
|
|
||||||
@@ -144,39 +148,40 @@ Built with Next.js and the JMAP protocol.
|
|||||||
- **Upload and download** files with progress tracking and folder upload support
|
- **Upload and download** files with progress tracking and folder upload support
|
||||||
- **Folder navigation** with breadcrumb path and tree sidebar
|
- **Folder navigation** with breadcrumb path and tree sidebar
|
||||||
- **Grid and list views** with sorting by name, size, or date
|
- **Grid and list views** with sorting by name, size, or date
|
||||||
- **Clipboard operations** - cut, copy, paste, duplicate files
|
- **Clipboard operations** — cut, copy, paste, duplicate files
|
||||||
- **File preview** for images, text, audio, video, and more
|
- **File preview** for images, text, audio, video, and more
|
||||||
- **Favorites and recent files** for quick access
|
- **Favorites and recent files** for quick access
|
||||||
- **Bulk operations** - multi-select, delete, move, download
|
- **Bulk operations** — multi-select, delete, move, download
|
||||||
|
|
||||||
### Security & Privacy
|
### Security & Privacy
|
||||||
|
|
||||||
- **External content blocked** by default - trusted senders list for auto-load
|
- **External content blocked** by default — trusted senders list for auto-load
|
||||||
- **HTML sanitization** via DOMPurify with XSS prevention
|
- **HTML sanitization** via DOMPurify with XSS prevention
|
||||||
- **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures
|
- **S/MIME** — manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures
|
||||||
- **SPF/DKIM/DMARC** status indicators
|
- **SPF/DKIM/DMARC** status indicators
|
||||||
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode
|
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode and non-interactive SSO for embedded/iframe deployments
|
||||||
- **TOTP two-factor authentication**
|
- **TOTP two-factor authentication**
|
||||||
- **Account security panel** - manage passwords and 2FA via Stalwart admin API
|
- **Account security panel** — manage passwords and 2FA via Stalwart admin API
|
||||||
- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
|
- **"Remember me"** — AES-256-GCM encrypted httpOnly cookie (opt-in)
|
||||||
- **Security headers** - CSP with per-request nonce, X-Frame-Options, Referrer-Policy
|
- **Security headers** — CSP with per-request nonce, X-Frame-Options, Referrer-Policy
|
||||||
- **Newsletter unsubscribe** (RFC 2369)
|
- **Newsletter unsubscribe** (RFC 2369)
|
||||||
|
|
||||||
### Interface
|
### Interface
|
||||||
|
|
||||||
- **Three-pane layout** - sidebar, email list, viewer with resizable columns
|
- **Three-pane layout** — sidebar, email list, viewer with resizable columns
|
||||||
- **Dark and light themes** with intelligent email color transformation
|
- **Dark and light themes** with intelligent email color transformation
|
||||||
- **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support
|
|
||||||
- **Keyboard shortcuts** - full navigation without a mouse
|
|
||||||
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
|
||||||
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
|
||||||
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
||||||
|
- **Responsive** — desktop sidebar + mobile bottom tab bar with tablet support
|
||||||
|
- **Keyboard shortcuts** — full navigation without a mouse
|
||||||
|
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
||||||
|
- **Interactive guided tour** — onboarding walkthrough for new users
|
||||||
|
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
||||||
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
||||||
- **Sidebar apps** - pin custom tools to the navigation rail and open them inline or in a new tab
|
- **Sidebar apps** — pin custom tools to the navigation rail and open them inline or in a new tab
|
||||||
- **Settings sync** - preferences synchronized with the server (encrypted)
|
- **Settings sync** — preferences synchronized with the server (encrypted)
|
||||||
- **Storage quota** display
|
- **Storage quota** display
|
||||||
- **Shared folders** - multi-account access
|
- **Shared folders** — multi-account access
|
||||||
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
- **Accessibility** — WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
||||||
|
|
||||||
### Internationalization
|
### Internationalization
|
||||||
|
|
||||||
@@ -187,13 +192,14 @@ Automatic browser detection with persistent preference.
|
|||||||
### Identity Management
|
### Identity Management
|
||||||
|
|
||||||
- **Multiple sender identities** with per-identity signatures
|
- **Multiple sender identities** with per-identity signatures
|
||||||
- **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions
|
- **Identity refresh** — keep the identity manager aligned with server-side changes after edits
|
||||||
- **Identity refresh** - keep the identity manager aligned with server-side changes after edits
|
- **Sub-addressing** — `user+tag@domain.com` with contextual tag suggestions
|
||||||
- **Identity badges** in viewer and email list
|
- **Identity badges** in viewer and email list
|
||||||
|
|
||||||
### Operations
|
### Operations
|
||||||
|
|
||||||
- **Automatic update check** - server logs when a newer release is available
|
- **Automatic update check** — server logs when a newer release is available
|
||||||
|
- **Demo mode** — try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes — no mail server required
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -211,7 +217,7 @@ Or with Docker Compose:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edit .env.local - set JMAP_SERVER_URL
|
# Edit .env.local — set JMAP_SERVER_URL
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -222,7 +228,7 @@ git clone https://github.com/bulwarkmail/webmail.git
|
|||||||
cd webmail
|
cd webmail
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edit .env.local - set JMAP_SERVER_URL
|
# Edit .env.local — set JMAP_SERVER_URL
|
||||||
npm run build && npm start
|
npm run build && npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -246,7 +252,7 @@ JMAP_SERVER_URL=https://mail.example.com
|
|||||||
APP_NAME=My Webmail
|
APP_NAME=My Webmail
|
||||||
```
|
```
|
||||||
|
|
||||||
All variables are **runtime** - Docker deployments can be configured without rebuilding.
|
All variables are **runtime** — Docker deployments can be configured without rebuilding.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Server Listen Address</summary>
|
<summary>Server Listen Address</summary>
|
||||||
@@ -313,16 +319,12 @@ Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day exp
|
|||||||
|
|
||||||
## Why Stalwart?
|
## Why Stalwart?
|
||||||
|
|
||||||
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** - not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
|
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** — not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
See [ROADMAP.md](ROADMAP.md) for planned features and current status.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[GNU AGPL v3](LICENSE)
|
[GNU AGPL v3](LICENSE)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Suspense, useEffect, useState } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||||
import { Loader2, AlertCircle } from "lucide-react";
|
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";
|
||||||
@@ -13,7 +14,7 @@ function OAuthCallbackInner() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
const { loginWithOAuth } = useAuthStore();
|
const { loginWithOAuth, loginWithServerSso } = useAuthStore();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -32,43 +33,73 @@ function OAuthCallbackInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const savedState = sessionStorage.getItem("oauth_state");
|
const savedState = sessionStorage.getItem("oauth_state");
|
||||||
if (!state || state !== savedState) {
|
|
||||||
setError("invalid_state");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
|
if (savedState) {
|
||||||
const serverUrl = sessionStorage.getItem("oauth_server_url");
|
// Classic flow — sessionStorage has the PKCE state (same-tab OAuth)
|
||||||
|
if (!state || state !== savedState) {
|
||||||
|
setError("invalid_state");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!codeVerifier || !serverUrl) {
|
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
|
||||||
setError("missing_params");
|
const serverUrl = sessionStorage.getItem("oauth_server_url");
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
if (!codeVerifier || !serverUrl) {
|
||||||
|
setError("missing_params");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri)
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
.then((success) => {
|
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||||
if (success) {
|
|
||||||
sessionStorage.removeItem("oauth_state");
|
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri)
|
||||||
sessionStorage.removeItem("oauth_code_verifier");
|
.then((success) => {
|
||||||
sessionStorage.removeItem("oauth_server_url");
|
if (success) {
|
||||||
let redirectTo = `/${params.locale}`;
|
sessionStorage.removeItem("oauth_state");
|
||||||
try {
|
sessionStorage.removeItem("oauth_code_verifier");
|
||||||
const saved = sessionStorage.getItem('redirect_after_login');
|
sessionStorage.removeItem("oauth_server_url");
|
||||||
if (saved) {
|
sessionStorage.removeItem("oauth_add_account_mode");
|
||||||
sessionStorage.removeItem('redirect_after_login');
|
let redirectTo = `${prefix}/${params.locale}`;
|
||||||
redirectTo = saved;
|
try {
|
||||||
}
|
const saved = sessionStorage.getItem('redirect_after_login');
|
||||||
} catch { /* sessionStorage may be unavailable */ }
|
if (saved) {
|
||||||
router.push(redirectTo);
|
sessionStorage.removeItem('redirect_after_login');
|
||||||
} else {
|
redirectTo = saved;
|
||||||
|
}
|
||||||
|
} catch { /* sessionStorage may be unavailable */ }
|
||||||
|
router.push(redirectTo);
|
||||||
|
} else {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
}
|
});
|
||||||
})
|
} else if (state) {
|
||||||
.catch(() => {
|
// Server-side SSO flow — state was stored in encrypted httpOnly cookie
|
||||||
setError("token_exchange_failed");
|
const ssoPrefix = getPathPrefix(params.locale as string);
|
||||||
});
|
loginWithServerSso(code, state)
|
||||||
|
.then((success) => {
|
||||||
|
if (success) {
|
||||||
|
let redirectTo = `${ssoPrefix}/${params.locale}`;
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem('redirect_after_login');
|
||||||
|
if (saved) {
|
||||||
|
sessionStorage.removeItem('redirect_after_login');
|
||||||
|
redirectTo = saved;
|
||||||
|
}
|
||||||
|
} catch { /* sessionStorage may be unavailable */ }
|
||||||
|
router.push(redirectTo);
|
||||||
|
} else {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setError("invalid_state");
|
||||||
|
}
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -86,7 +117,7 @@ function OAuthCallbackInner() {
|
|||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => router.push(`/${params.locale}/login`)}
|
onClick={() => router.push(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)}
|
||||||
>
|
>
|
||||||
{t("oauth_error.back_to_login")}
|
{t("oauth_error.back_to_login")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+281
-39
@@ -7,11 +7,11 @@ import { Plus } from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||||
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
|
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
|
||||||
format, parseISO,
|
startOfDay, format, parseISO,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { isCalendarViewMode } from "@/stores/calendar-store";
|
import { isCalendarViewMode } from "@/stores/calendar-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
@@ -23,9 +23,12 @@ import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
|
|||||||
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
|
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
|
||||||
import { CalendarDayView } from "@/components/calendar/calendar-day-view";
|
import { CalendarDayView } from "@/components/calendar/calendar-day-view";
|
||||||
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
|
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
|
||||||
|
import { TaskListView } from "@/components/calendar/task-list-view";
|
||||||
|
import { TaskToolbar } from "@/components/calendar/task-toolbar";
|
||||||
|
import { TaskModal } from "@/components/calendar/task-modal";
|
||||||
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||||
import { EventModal } from "@/components/calendar/event-modal";
|
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
||||||
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||||
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
||||||
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
|
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
|
||||||
@@ -35,6 +38,9 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
|||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
|
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||||
|
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||||
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||||
@@ -61,9 +67,11 @@ export default function CalendarPage() {
|
|||||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||||
refreshAllSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const { firstDayOfWeek, timeFormat } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview } = useSettingsStore();
|
||||||
|
const taskStore = useTaskStore();
|
||||||
|
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
||||||
|
|
||||||
@@ -75,6 +83,7 @@ export default function CalendarPage() {
|
|||||||
const [showEventModal, setShowEventModal] = useState(false);
|
const [showEventModal, setShowEventModal] = useState(false);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||||
|
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||||
@@ -82,6 +91,12 @@ export default function CalendarPage() {
|
|||||||
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
||||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
|
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
|
||||||
|
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
|
||||||
|
const [showTaskModal, setShowTaskModal] = useState(false);
|
||||||
|
const [editTask, setEditTask] = useState<import("@/lib/jmap/types").CalendarTask | null>(null);
|
||||||
|
const [mobileReturnToMonth, setMobileReturnToMonth] = useState(false);
|
||||||
|
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null);
|
||||||
|
const [swipeKey, setSwipeKey] = useState(0);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
|
|
||||||
// Sidebar resize state
|
// Sidebar resize state
|
||||||
@@ -104,7 +119,7 @@ export default function CalendarPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||||
router.push("/login");
|
redirectToLogin();
|
||||||
} else if (client && !supportsCalendar) {
|
} else if (client && !supportsCalendar) {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
@@ -156,14 +171,27 @@ export default function CalendarPage() {
|
|||||||
start: format(d, "yyyy-MM-dd'T'00:00:00"),
|
start: format(d, "yyyy-MM-dd'T'00:00:00"),
|
||||||
end: format(d, "yyyy-MM-dd'T'23:59:59"),
|
end: format(d, "yyyy-MM-dd'T'23:59:59"),
|
||||||
};
|
};
|
||||||
case "agenda":
|
case "agenda": {
|
||||||
|
// Agenda always starts from today at the earliest
|
||||||
|
const today = startOfDay(new Date());
|
||||||
|
const agendaStart = d >= today ? d : today;
|
||||||
return {
|
return {
|
||||||
start: format(d, "yyyy-MM-dd'T'00:00:00"),
|
start: format(agendaStart, "yyyy-MM-dd'T'00:00:00"),
|
||||||
end: format(addDays(d, 30), "yyyy-MM-dd'T'23:59:59"),
|
end: format(addDays(agendaStart, 30), "yyyy-MM-dd'T'23:59:59"),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
case "tasks":
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}, [selectedDate, normalizedViewMode, firstDayOfWeek]);
|
}, [selectedDate, normalizedViewMode, firstDayOfWeek]);
|
||||||
|
|
||||||
|
// Fetch tasks when tasks view is active or when tasks are shown on calendar grid
|
||||||
|
useEffect(() => {
|
||||||
|
if (client && enableCalendarTasks && (normalizedViewMode === "tasks" || showTasksOnCalendar)) {
|
||||||
|
fetchTasksFn(client);
|
||||||
|
}
|
||||||
|
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (client && calendars.length > 0 && dateRange) {
|
if (client && calendars.length > 0 && dateRange) {
|
||||||
fetchEvents(client, dateRange.start, dateRange.end);
|
fetchEvents(client, dateRange.start, dateRange.end);
|
||||||
@@ -177,6 +205,7 @@ export default function CalendarPage() {
|
|||||||
case "week": next = subWeeks(selectedDate, 1); break;
|
case "week": next = subWeeks(selectedDate, 1); break;
|
||||||
case "day": next = subDays(selectedDate, 1); break;
|
case "day": next = subDays(selectedDate, 1); break;
|
||||||
case "agenda": next = subMonths(selectedDate, 1); break;
|
case "agenda": next = subMonths(selectedDate, 1); break;
|
||||||
|
case "tasks": return;
|
||||||
}
|
}
|
||||||
setSelectedDate(next);
|
setSelectedDate(next);
|
||||||
setMiniMonth(next);
|
setMiniMonth(next);
|
||||||
@@ -189,6 +218,7 @@ export default function CalendarPage() {
|
|||||||
case "week": next = addWeeks(selectedDate, 1); break;
|
case "week": next = addWeeks(selectedDate, 1); break;
|
||||||
case "day": next = addDays(selectedDate, 1); break;
|
case "day": next = addDays(selectedDate, 1); break;
|
||||||
case "agenda": next = addMonths(selectedDate, 1); break;
|
case "agenda": next = addMonths(selectedDate, 1); break;
|
||||||
|
case "tasks": return;
|
||||||
}
|
}
|
||||||
setSelectedDate(next);
|
setSelectedDate(next);
|
||||||
setMiniMonth(next);
|
setMiniMonth(next);
|
||||||
@@ -207,6 +237,8 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const handleTouchEnd = useCallback((e: ReactTouchEvent) => {
|
const handleTouchEnd = useCallback((e: ReactTouchEvent) => {
|
||||||
if (!touchStartRef.current || !isMobile) return;
|
if (!touchStartRef.current || !isMobile) return;
|
||||||
|
// Week view has its own horizontal scroll, skip swipe navigation
|
||||||
|
if (normalizedViewMode === 'week') { touchStartRef.current = null; return; }
|
||||||
const touch = e.changedTouches[0];
|
const touch = e.changedTouches[0];
|
||||||
const dx = touch.clientX - touchStartRef.current.x;
|
const dx = touch.clientX - touchStartRef.current.x;
|
||||||
const dy = touch.clientY - touchStartRef.current.y;
|
const dy = touch.clientY - touchStartRef.current.y;
|
||||||
@@ -215,20 +247,34 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
// Only trigger swipe if horizontal movement is dominant and fast enough
|
// Only trigger swipe if horizontal movement is dominant and fast enough
|
||||||
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5 && elapsed < 400) {
|
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5 && elapsed < 400) {
|
||||||
if (dx > 0) navigatePrev();
|
if (dx > 0) {
|
||||||
else navigateNext();
|
setSwipeDirection('right');
|
||||||
|
navigatePrev();
|
||||||
|
} else {
|
||||||
|
setSwipeDirection('left');
|
||||||
|
navigateNext();
|
||||||
|
}
|
||||||
|
setSwipeKey(k => k + 1);
|
||||||
|
// Clear direction after animation completes
|
||||||
|
setTimeout(() => setSwipeDirection(null), 250);
|
||||||
}
|
}
|
||||||
}, [isMobile, navigatePrev, navigateNext]);
|
}, [isMobile, normalizedViewMode, navigatePrev, navigateNext]);
|
||||||
|
|
||||||
const handleSelectDate = useCallback((date: Date) => {
|
const handleSelectDate = useCallback((date: Date) => {
|
||||||
setSelectedDate(date);
|
setSelectedDate(date);
|
||||||
setMiniMonth(date);
|
setMiniMonth(date);
|
||||||
// On mobile month view, tapping a date switches to day view
|
// On mobile month view, tapping a date switches to day view
|
||||||
if (isMobile && normalizedViewMode === "month") {
|
if (isMobile && normalizedViewMode === "month") {
|
||||||
|
setMobileReturnToMonth(true);
|
||||||
setViewMode("day");
|
setViewMode("day");
|
||||||
}
|
}
|
||||||
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
|
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
|
||||||
|
|
||||||
|
const navigateBackToMonth = useCallback(() => {
|
||||||
|
setMobileReturnToMonth(false);
|
||||||
|
setViewMode("month");
|
||||||
|
}, [setViewMode]);
|
||||||
|
|
||||||
const handleMiniMonthChange = useCallback((date: Date) => {
|
const handleMiniMonthChange = useCallback((date: Date) => {
|
||||||
setMiniMonth(date);
|
setMiniMonth(date);
|
||||||
setSelectedDate(date);
|
setSelectedDate(date);
|
||||||
@@ -249,6 +295,34 @@ export default function CalendarPage() {
|
|||||||
setShowEventModal(true);
|
setShowEventModal(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openCreateTaskModal = useCallback(() => {
|
||||||
|
setEditTask(null);
|
||||||
|
setShowTaskModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openEditTaskModal = useCallback((task: import("@/lib/jmap/types").CalendarTask) => {
|
||||||
|
setEditTask(task);
|
||||||
|
setShowTaskModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveTask = useCallback(async (data: Partial<import("@/lib/jmap/types").CalendarTask>) => {
|
||||||
|
if (!client) return;
|
||||||
|
if (editTask) {
|
||||||
|
await taskStore.updateTask(client, editTask.id, data);
|
||||||
|
} else {
|
||||||
|
await taskStore.createTask(client, data);
|
||||||
|
}
|
||||||
|
setShowTaskModal(false);
|
||||||
|
setEditTask(null);
|
||||||
|
}, [client, editTask, taskStore]);
|
||||||
|
|
||||||
|
const handleDeleteTask = useCallback(async (id: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
await taskStore.deleteTask(client, id);
|
||||||
|
setShowTaskModal(false);
|
||||||
|
setEditTask(null);
|
||||||
|
}, [client, taskStore]);
|
||||||
|
|
||||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const closeDetail = useCallback(() => {
|
const closeDetail = useCallback(() => {
|
||||||
@@ -264,18 +338,29 @@ export default function CalendarPage() {
|
|||||||
}, [closeDetail, openEditModal]);
|
}, [closeDetail, openEditModal]);
|
||||||
|
|
||||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||||
|
if (isMobile) return;
|
||||||
|
if (calendarHoverPreview === 'off') return;
|
||||||
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||||
// Don't show hover popover if the sidebar is already open for this event
|
// Don't show hover popover if the sidebar is already open for this event
|
||||||
if (showEventModal && editEvent?.id === event.id) return;
|
if (showEventModal && editEvent?.id === event.id) return;
|
||||||
setDetailEvent(event);
|
if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') {
|
||||||
setDetailAnchorRect(anchorRect);
|
const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000;
|
||||||
}, [showEventModal, editEvent]);
|
hoverTimerRef.current = setTimeout(() => {
|
||||||
|
setDetailEvent(event);
|
||||||
|
setDetailAnchorRect(anchorRect);
|
||||||
|
}, ms);
|
||||||
|
} else {
|
||||||
|
setDetailEvent(event);
|
||||||
|
setDetailAnchorRect(anchorRect);
|
||||||
|
}
|
||||||
|
}, [isMobile, calendarHoverPreview, showEventModal, editEvent]);
|
||||||
|
|
||||||
const handleHoverLeave = useCallback(() => {
|
const handleHoverLeave = useCallback(() => {
|
||||||
|
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||||
hoverTimerRef.current = setTimeout(() => {
|
hoverTimerRef.current = setTimeout(() => {
|
||||||
setDetailEvent(null);
|
setDetailEvent(null);
|
||||||
setDetailAnchorRect(null);
|
setDetailAnchorRect(null);
|
||||||
}, 200);
|
}, 300);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEditFromDetail = useCallback(() => {
|
const handleEditFromDetail = useCallback(() => {
|
||||||
@@ -312,6 +397,24 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}, [client, fetchEvents]);
|
}, [client, fetchEvents]);
|
||||||
|
|
||||||
|
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
|
||||||
|
if (!event.start) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventDate = getEventStartDate({
|
||||||
|
start: event.start,
|
||||||
|
utcStart: event.utcStart ?? null,
|
||||||
|
showWithoutTime: event.showWithoutTime ?? false,
|
||||||
|
});
|
||||||
|
if (Number.isNaN(eventDate.getTime())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedDate(eventDate);
|
||||||
|
setMiniMonth(eventDate);
|
||||||
|
}, [setSelectedDate]);
|
||||||
|
|
||||||
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => {
|
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => {
|
||||||
if (!client) { toast.error(t("notifications.event_error")); return; }
|
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||||
try {
|
try {
|
||||||
@@ -328,6 +431,9 @@ export default function CalendarPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
|
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
|
||||||
|
if (data.start) {
|
||||||
|
focusCalendarOnEvent({ start: data.start });
|
||||||
|
}
|
||||||
toast.success(t("notifications.event_updated"));
|
toast.success(t("notifications.event_updated"));
|
||||||
} else {
|
} else {
|
||||||
const created = await createEvent(client, data, sendSchedulingMessages);
|
const created = await createEvent(client, data, sendSchedulingMessages);
|
||||||
@@ -335,6 +441,7 @@ export default function CalendarPage() {
|
|||||||
toast.error(t("notifications.event_error"));
|
toast.error(t("notifications.event_error"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
focusCalendarOnEvent(created);
|
||||||
if (sendSchedulingMessages) {
|
if (sendSchedulingMessages) {
|
||||||
toast.success(t("notifications.invitation_sent"));
|
toast.success(t("notifications.invitation_sent"));
|
||||||
} else {
|
} else {
|
||||||
@@ -346,7 +453,7 @@ export default function CalendarPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
toast.error(t("notifications.event_error"));
|
toast.error(t("notifications.event_error"));
|
||||||
}
|
}
|
||||||
}, [client, editEvent, createEvent, updateEvent, t]);
|
}, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, t]);
|
||||||
|
|
||||||
const handleDuplicateEvent = useCallback(async (data: Partial<CalendarEvent>) => {
|
const handleDuplicateEvent = useCallback(async (data: Partial<CalendarEvent>) => {
|
||||||
if (!client) { toast.error(t("notifications.event_error")); return; }
|
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||||
@@ -356,6 +463,7 @@ export default function CalendarPage() {
|
|||||||
toast.error(t("notifications.event_error"));
|
toast.error(t("notifications.event_error"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
focusCalendarOnEvent(created);
|
||||||
toast.success(t("notifications.event_duplicated"));
|
toast.success(t("notifications.event_duplicated"));
|
||||||
setEditEvent(created);
|
setEditEvent(created);
|
||||||
setDefaultModalDate(undefined);
|
setDefaultModalDate(undefined);
|
||||||
@@ -364,7 +472,7 @@ export default function CalendarPage() {
|
|||||||
setShowEventModal(false);
|
setShowEventModal(false);
|
||||||
setEditEvent(null);
|
setEditEvent(null);
|
||||||
}
|
}
|
||||||
}, [client, createEvent, t]);
|
}, [client, createEvent, focusCalendarOnEvent, t]);
|
||||||
|
|
||||||
const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => {
|
const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => {
|
||||||
if (!client) { toast.error(t("notifications.event_error")); return; }
|
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||||
@@ -418,9 +526,22 @@ export default function CalendarPage() {
|
|||||||
try {
|
try {
|
||||||
if (type === "edit" && updates) {
|
if (type === "edit" && updates) {
|
||||||
switch (scope) {
|
switch (scope) {
|
||||||
case "this":
|
case "this": {
|
||||||
await updateEvent(client, event.id, updates, sendScheduling);
|
// Synthetic IDs (from expandRecurrences) can't be updated directly.
|
||||||
|
// Patch the master event's recurrenceOverrides instead.
|
||||||
|
const master = await findMasterEvent(event);
|
||||||
|
if (master && event.recurrenceId) {
|
||||||
|
const patchUpdates: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue;
|
||||||
|
patchUpdates[`recurrenceOverrides/${event.recurrenceId}/${key}`] = value;
|
||||||
|
}
|
||||||
|
await updateEvent(client, master.id, patchUpdates as Partial<CalendarEvent>, sendScheduling);
|
||||||
|
} else {
|
||||||
|
await updateEvent(client, event.id, updates, sendScheduling);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "this_and_future": {
|
case "this_and_future": {
|
||||||
const result = await truncateRecurrenceAtEvent(event);
|
const result = await truncateRecurrenceAtEvent(event);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
@@ -478,9 +599,20 @@ export default function CalendarPage() {
|
|||||||
toast.success(t("notifications.event_updated"));
|
toast.success(t("notifications.event_updated"));
|
||||||
} else {
|
} else {
|
||||||
switch (scope) {
|
switch (scope) {
|
||||||
case "this":
|
case "this": {
|
||||||
await deleteEvent(client, event.id, sendScheduling);
|
// Synthetic IDs (from expandRecurrences) can't be destroyed directly.
|
||||||
|
// Exclude the instance via recurrenceOverrides on the master event.
|
||||||
|
const delMaster = await findMasterEvent(event);
|
||||||
|
if (delMaster && event.recurrenceId) {
|
||||||
|
await updateEvent(
|
||||||
|
client, delMaster.id,
|
||||||
|
{ [`recurrenceOverrides/${event.recurrenceId}`]: { excluded: true } } as Partial<CalendarEvent>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await deleteEvent(client, event.id, sendScheduling);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "this_and_future": {
|
case "this_and_future": {
|
||||||
const result = await truncateRecurrenceAtEvent(event);
|
const result = await truncateRecurrenceAtEvent(event);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
@@ -536,7 +668,7 @@ export default function CalendarPage() {
|
|||||||
if (!detailEvent || !client) return;
|
if (!detailEvent || !client) return;
|
||||||
const start = parseISO(detailEvent.start);
|
const start = parseISO(detailEvent.start);
|
||||||
const newStart = addDays(start, 1);
|
const newStart = addDays(start, 1);
|
||||||
const data: Partial<CalendarEvent> = {
|
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
|
||||||
title: detailEvent.title,
|
title: detailEvent.title,
|
||||||
description: detailEvent.description,
|
description: detailEvent.description,
|
||||||
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||||
@@ -547,7 +679,7 @@ export default function CalendarPage() {
|
|||||||
status: "confirmed",
|
status: "confirmed",
|
||||||
freeBusyStatus: detailEvent.freeBusyStatus,
|
freeBusyStatus: detailEvent.freeBusyStatus,
|
||||||
privacy: detailEvent.privacy,
|
privacy: detailEvent.privacy,
|
||||||
};
|
});
|
||||||
if (detailEvent.locations) data.locations = structuredClone(detailEvent.locations);
|
if (detailEvent.locations) data.locations = structuredClone(detailEvent.locations);
|
||||||
if (detailEvent.recurrenceRules) data.recurrenceRules = structuredClone(detailEvent.recurrenceRules);
|
if (detailEvent.recurrenceRules) data.recurrenceRules = structuredClone(detailEvent.recurrenceRules);
|
||||||
if (detailEvent.alerts) data.alerts = structuredClone(detailEvent.alerts);
|
if (detailEvent.alerts) data.alerts = structuredClone(detailEvent.alerts);
|
||||||
@@ -592,6 +724,7 @@ export default function CalendarPage() {
|
|||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return;
|
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return;
|
||||||
|
if (target.getAttribute("contenteditable") === "true") return;
|
||||||
if (showEventModal || detailEvent) return;
|
if (showEventModal || detailEvent) return;
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
@@ -602,21 +735,52 @@ export default function CalendarPage() {
|
|||||||
case "w": setViewMode("week"); break;
|
case "w": setViewMode("week"); break;
|
||||||
case "d": setViewMode("day"); break;
|
case "d": setViewMode("day"); break;
|
||||||
case "a": setViewMode("agenda"); break;
|
case "a": setViewMode("agenda"); break;
|
||||||
|
case "k": if (enableCalendarTasks) setViewMode("tasks"); break;
|
||||||
case "n": openCreateModal(); break;
|
case "n": openCreateModal(); break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKey);
|
window.addEventListener("keydown", handleKey);
|
||||||
return () => window.removeEventListener("keydown", handleKey);
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent]);
|
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]);
|
||||||
|
|
||||||
const visibleEvents = useMemo(() =>
|
const visibleEvents = useMemo(() =>
|
||||||
events.filter((e) => {
|
events.filter((e) => {
|
||||||
|
if (!e.start || !e.calendarIds) return false;
|
||||||
const calIds = Object.keys(e.calendarIds);
|
const calIds = Object.keys(e.calendarIds);
|
||||||
return calIds.some((id) => selectedCalendarIds.includes(id));
|
return calIds.some((id) => selectedCalendarIds.includes(id));
|
||||||
}),
|
}),
|
||||||
[events, selectedCalendarIds]
|
[events, selectedCalendarIds]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hiddenEvents = events.filter((event) => {
|
||||||
|
if (!event.start || !event.calendarIds) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !Object.keys(event.calendarIds).some((calendarId) => selectedCalendarIds.includes(calendarId));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hiddenEvents.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug.log('Calendar visibility summary', {
|
||||||
|
totalEvents: events.length,
|
||||||
|
visibleEvents: visibleEvents.length,
|
||||||
|
hiddenEvents: hiddenEvents.length,
|
||||||
|
selectedCalendarIds,
|
||||||
|
hiddenSamples: hiddenEvents.slice(0, 5).map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
originalId: event.originalId,
|
||||||
|
title: event.title,
|
||||||
|
calendarIds: event.calendarIds,
|
||||||
|
originalCalendarIds: event.originalCalendarIds,
|
||||||
|
accountId: event.accountId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}, [events, selectedCalendarIds, visibleEvents]);
|
||||||
|
|
||||||
if (!isAuthenticated || !supportsCalendar) return null;
|
if (!isAuthenticated || !supportsCalendar) return null;
|
||||||
|
|
||||||
const renderView = () => {
|
const renderView = () => {
|
||||||
@@ -643,6 +807,7 @@ export default function CalendarPage() {
|
|||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
pendingPreview={pendingPreview}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "week":
|
case "week":
|
||||||
@@ -659,6 +824,9 @@ export default function CalendarPage() {
|
|||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
pendingPreview={pendingPreview}
|
||||||
|
tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined}
|
||||||
|
onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "day":
|
case "day":
|
||||||
@@ -673,6 +841,9 @@ export default function CalendarPage() {
|
|||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
pendingPreview={pendingPreview}
|
||||||
|
tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined}
|
||||||
|
onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "agenda":
|
case "agenda":
|
||||||
@@ -687,11 +858,38 @@ export default function CalendarPage() {
|
|||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "tasks":
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<TaskToolbar
|
||||||
|
filter={taskStore.filter}
|
||||||
|
showCompleted={taskStore.showCompleted}
|
||||||
|
onFilterChange={taskStore.setFilter}
|
||||||
|
onShowCompletedChange={taskStore.setShowCompleted}
|
||||||
|
onCreateTask={openCreateTaskModal}
|
||||||
|
/>
|
||||||
|
<TaskListView
|
||||||
|
tasks={taskStore.tasks}
|
||||||
|
calendars={calendars}
|
||||||
|
selectedCalendarIds={selectedCalendarIds}
|
||||||
|
filter={taskStore.filter}
|
||||||
|
showCompleted={taskStore.showCompleted}
|
||||||
|
onSelectTask={openEditTaskModal}
|
||||||
|
onToggleComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
|
||||||
|
selectedTaskId={taskStore.selectedTaskId}
|
||||||
|
onQuickCreate={(title) => {
|
||||||
|
if (client) {
|
||||||
|
taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [calendars[0]?.id ?? ""]: true } });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex-1 flex flex-col overflow-hidden">
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
{viewContent}
|
{viewContent}
|
||||||
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
|
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
|
||||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
||||||
@@ -703,7 +901,7 @@ export default function CalendarPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh bg-background overflow-hidden">
|
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
|
||||||
{/* Left Navigation Rail */}
|
{/* Left Navigation Rail */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
@@ -711,7 +909,7 @@ export default function CalendarPage() {
|
|||||||
collapsed
|
collapsed
|
||||||
quota={quota}
|
quota={quota}
|
||||||
isPushConnected={isPushConnected}
|
isPushConnected={isPushConnected}
|
||||||
onLogout={() => { logout(); router.push('/login'); }}
|
onLogout={logout}
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
onInlineApp={handleInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onCloseInlineApp={closeInlineApp}
|
||||||
@@ -741,6 +939,7 @@ export default function CalendarPage() {
|
|||||||
onChangeMonth={handleMiniMonthChange}
|
onChangeMonth={handleMiniMonthChange}
|
||||||
events={events}
|
events={events}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
|
showWeekNumbers={showWeekNumbers}
|
||||||
/>
|
/>
|
||||||
<CalendarSidebarPanel
|
<CalendarSidebarPanel
|
||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
@@ -750,6 +949,7 @@ export default function CalendarPage() {
|
|||||||
updateCalendar(client, calendarId, { color });
|
updateCalendar(client, calendarId, { color });
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
|
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||||
client={client}
|
client={client}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -766,29 +966,41 @@ export default function CalendarPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!inlineApp && (
|
{!inlineApp && (
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||||
<CalendarToolbar
|
<CalendarToolbar
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
viewMode={normalizedViewMode}
|
viewMode={normalizedViewMode}
|
||||||
onPrev={navigatePrev}
|
onPrev={navigatePrev}
|
||||||
onNext={navigateNext}
|
onNext={navigateNext}
|
||||||
onToday={goToToday}
|
onToday={goToToday}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={(mode) => { setMobileReturnToMonth(false); setViewMode(mode); }}
|
||||||
onCreateEvent={() => openCreateModal()}
|
onCreateEvent={() => openCreateModal()}
|
||||||
onImport={() => setShowImportModal(true)}
|
onImport={() => setShowImportModal(true)}
|
||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
|
||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
selectedCalendarIds={selectedCalendarIds}
|
selectedCalendarIds={selectedCalendarIds}
|
||||||
onToggleVisibility={toggleCalendarVisibility}
|
onToggleVisibility={toggleCalendarVisibility}
|
||||||
|
enableCalendarTasks={enableCalendarTasks}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex flex-1 overflow-hidden relative"
|
className="flex flex-1 overflow-hidden relative"
|
||||||
|
data-tour="calendar-view"
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
{renderView()}
|
<div
|
||||||
|
key={swipeKey}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 min-w-0",
|
||||||
|
isMobile && swipeDirection === 'left' && "animate-slide-in-right",
|
||||||
|
isMobile && swipeDirection === 'right' && "animate-slide-in-left",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderView()}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Desktop event panel */}
|
{/* Desktop event panel */}
|
||||||
{!isMobile && showEventModal && (
|
{!isMobile && showEventModal && (
|
||||||
@@ -803,13 +1015,29 @@ export default function CalendarPage() {
|
|||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
|
||||||
|
onPreviewChange={setPendingPreview}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Desktop task panel */}
|
||||||
|
{!isMobile && showTaskModal && (
|
||||||
|
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
|
||||||
|
<TaskModal
|
||||||
|
key={editTask?.id ?? 'new-task'}
|
||||||
|
task={editTask}
|
||||||
|
calendars={calendars}
|
||||||
|
onSave={handleSaveTask}
|
||||||
|
onDelete={handleDeleteTask}
|
||||||
|
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
|
||||||
|
isMobile={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Floating Create Event Button (mobile) */}
|
{/* Floating Create Event Button (mobile) */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Button
|
<Button
|
||||||
@@ -826,13 +1054,15 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{/* Mobile Bottom Navigation */}
|
{/* Mobile Bottom Navigation */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<NavigationRail
|
<div className="shrink-0">
|
||||||
orientation="horizontal"
|
<NavigationRail
|
||||||
onManageApps={handleManageApps}
|
orientation="horizontal"
|
||||||
onInlineApp={handleInlineApp}
|
onManageApps={handleManageApps}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
activeAppId={inlineApp?.id ?? null}
|
onCloseInlineApp={closeInlineApp}
|
||||||
/>
|
activeAppId={inlineApp?.id ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{detailEvent && detailAnchorRect && (
|
{detailEvent && detailAnchorRect && (
|
||||||
@@ -886,6 +1116,18 @@ export default function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingSubscription && client && (() => {
|
||||||
|
const sub = icalSubscriptions.find(s => s.id === editingSubscription);
|
||||||
|
if (!sub) return null;
|
||||||
|
return (
|
||||||
|
<ICalSubscriptionModal
|
||||||
|
client={client}
|
||||||
|
editSubscription={sub}
|
||||||
|
onClose={() => setEditingSubscription(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<RecurrenceScopeDialog
|
<RecurrenceScopeDialog
|
||||||
isOpen={!!pendingScopeAction}
|
isOpen={!!pendingScopeAction}
|
||||||
|
|||||||
+120
-26
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { ArrowLeft, Users } from "lucide-react";
|
import { ArrowLeft, Users } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -13,9 +12,10 @@ import { ContactForm } from "@/components/contacts/contact-form";
|
|||||||
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
||||||
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
||||||
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
||||||
|
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||||
import { exportContacts } from "@/components/contacts/contact-export";
|
import { exportContacts } from "@/components/contacts/contact-export";
|
||||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-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 { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -38,7 +38,6 @@ type View =
|
|||||||
| "bulk-add-to-group";
|
| "bulk-add-to-group";
|
||||||
|
|
||||||
export default function ContactsPage() {
|
export default function ContactsPage() {
|
||||||
const router = useRouter();
|
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
@@ -73,10 +72,12 @@ export default function ContactsPage() {
|
|||||||
bulkDeleteContacts,
|
bulkDeleteContacts,
|
||||||
bulkAddToGroup,
|
bulkAddToGroup,
|
||||||
moveContactToAddressBook,
|
moveContactToAddressBook,
|
||||||
|
importContacts,
|
||||||
} = useContactStore();
|
} = useContactStore();
|
||||||
|
|
||||||
const [view, setView] = useState<View>("list");
|
const [view, setView] = useState<View>("list");
|
||||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||||
|
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
@@ -84,10 +85,10 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
// Panel resize state - sidebar (categories)
|
// Panel resize state - sidebar (categories)
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 180; } catch { return 180; }
|
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; }
|
||||||
});
|
});
|
||||||
const [isSidebarResizing, setIsSidebarResizing] = useState(false);
|
const [isSidebarResizing, setIsSidebarResizing] = useState(false);
|
||||||
const sidebarDragStartWidth = useRef(180);
|
const sidebarDragStartWidth = useRef(256);
|
||||||
|
|
||||||
// Panel resize state - contact list
|
// Panel resize state - contact list
|
||||||
const [listWidth, setListWidth] = useState(() => {
|
const [listWidth, setListWidth] = useState(() => {
|
||||||
@@ -106,9 +107,9 @@ export default function ContactsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||||
router.push("/login");
|
redirectToLogin();
|
||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading, router]);
|
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (client && supportsSync && !hasFetched.current) {
|
if (client && supportsSync && !hasFetched.current) {
|
||||||
@@ -121,25 +122,36 @@ export default function ContactsPage() {
|
|||||||
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
|
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
|
||||||
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
||||||
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
||||||
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
|
const selectedGroupMembers = useMemo(() => selectedGroupId ? getGroupMembers(selectedGroupId) : [], [selectedGroupId, getGroupMembers]);
|
||||||
|
|
||||||
|
// Collect all unique keywords across contacts
|
||||||
|
const allKeywords = useMemo(() => {
|
||||||
|
const kws = new Set<string>();
|
||||||
|
for (const contact of individuals) {
|
||||||
|
if (!contact.keywords) continue;
|
||||||
|
for (const [kw, active] of Object.entries(contact.keywords)) {
|
||||||
|
if (active) kws.add(kw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(kws).sort((a, b) => a.localeCompare(b));
|
||||||
|
}, [individuals]);
|
||||||
|
|
||||||
// Contacts to display based on active category
|
// Contacts to display based on active category
|
||||||
const displayedContacts = useMemo(() => {
|
const displayedContacts = useMemo(() => {
|
||||||
if (activeCategory === "all") return individuals.filter(c => !c.isShared);
|
if (activeCategory === "all") return individuals;
|
||||||
|
if (activeCategory === "uncategorized") {
|
||||||
|
return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0);
|
||||||
|
}
|
||||||
if ("addressBookId" in activeCategory) {
|
if ("addressBookId" in activeCategory) {
|
||||||
const bookId = activeCategory.addressBookId;
|
const bookId = activeCategory.addressBookId;
|
||||||
return individuals.filter(c => {
|
return individuals.filter(c => {
|
||||||
if (!c.addressBookIds) return false;
|
if (!c.addressBookIds) return false;
|
||||||
// Check both namespaced (accountId:bookId) and raw bookId
|
return c.addressBookIds[bookId] === true;
|
||||||
if (c.addressBookIds[bookId]) return true;
|
|
||||||
// For shared contacts, match namespaced id
|
|
||||||
if (c.isShared && c.accountId) {
|
|
||||||
const namespacedId = `${c.accountId}:${Object.keys(c.addressBookIds).find(k => c.addressBookIds[k])}`;
|
|
||||||
return namespacedId === bookId;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if ("keyword" in activeCategory) {
|
||||||
|
return individuals.filter(c => c.keywords?.[activeCategory.keyword]);
|
||||||
|
}
|
||||||
// Show members of the selected group
|
// Show members of the selected group
|
||||||
return getGroupMembers(activeCategory.groupId);
|
return getGroupMembers(activeCategory.groupId);
|
||||||
}, [activeCategory, individuals, getGroupMembers]);
|
}, [activeCategory, individuals, getGroupMembers]);
|
||||||
@@ -147,10 +159,14 @@ export default function ContactsPage() {
|
|||||||
// Label for the current category
|
// Label for the current category
|
||||||
const categoryLabel = useMemo(() => {
|
const categoryLabel = useMemo(() => {
|
||||||
if (activeCategory === "all") return t("tabs.all");
|
if (activeCategory === "all") return t("tabs.all");
|
||||||
|
if (activeCategory === "uncategorized") return t("no_category");
|
||||||
if ("addressBookId" in activeCategory) {
|
if ("addressBookId" in activeCategory) {
|
||||||
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
|
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
|
||||||
return book?.name || t("tabs.all");
|
return book?.name || t("tabs.all");
|
||||||
}
|
}
|
||||||
|
if ("keyword" in activeCategory) {
|
||||||
|
return activeCategory.keyword;
|
||||||
|
}
|
||||||
const group = contacts.find(c => c.id === activeCategory.groupId);
|
const group = contacts.find(c => c.id === activeCategory.groupId);
|
||||||
return group ? getContactDisplayName(group) : t("tabs.all");
|
return group ? getContactDisplayName(group) : t("tabs.all");
|
||||||
}, [activeCategory, contacts, addressBooks, t]);
|
}, [activeCategory, contacts, addressBooks, t]);
|
||||||
@@ -160,6 +176,7 @@ export default function ContactsPage() {
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
if (typeof category === "object" && "groupId" in category) {
|
if (typeof category === "object" && "groupId" in category) {
|
||||||
setSelectedGroupId(category.groupId);
|
setSelectedGroupId(category.groupId);
|
||||||
|
setView("group-detail");
|
||||||
} else {
|
} else {
|
||||||
setSelectedGroupId(null);
|
setSelectedGroupId(null);
|
||||||
}
|
}
|
||||||
@@ -179,6 +196,38 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
}, [client, moveContactToAddressBook, t]);
|
}, [client, moveContactToAddressBook, t]);
|
||||||
|
|
||||||
|
const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => {
|
||||||
|
if (!client && supportsSync) return;
|
||||||
|
try {
|
||||||
|
for (const contactId of contactIds) {
|
||||||
|
const contact = contacts.find(c => c.id === contactId);
|
||||||
|
if (!contact) continue;
|
||||||
|
const existingKeywords = contact.keywords || {};
|
||||||
|
if (existingKeywords[keyword]) continue; // already has this keyword
|
||||||
|
const updatedKeywords = { ...existingKeywords, [keyword]: true };
|
||||||
|
if (supportsSync && client) {
|
||||||
|
await updateContact(client, contactId, { keywords: updatedKeywords });
|
||||||
|
} else {
|
||||||
|
updateLocalContact(contactId, { keywords: updatedKeywords });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const msg = contactIds.length === 1
|
||||||
|
? t("category_added", { name: keyword })
|
||||||
|
: t("category_added_plural", { count: contactIds.length, name: keyword });
|
||||||
|
toast.success(msg);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add contacts to category:', error);
|
||||||
|
toast.error(t("toast.error_update"));
|
||||||
|
}
|
||||||
|
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
|
||||||
|
|
||||||
|
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
||||||
|
return importContacts(
|
||||||
|
supportsSync && client ? client : null,
|
||||||
|
importedContacts
|
||||||
|
);
|
||||||
|
}, [supportsSync, client, importContacts]);
|
||||||
|
|
||||||
const handleSelectContact = (id: string) => {
|
const handleSelectContact = (id: string) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -258,7 +307,7 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectGroup = (id: string) => {
|
const _handleSelectGroup = (id: string) => {
|
||||||
setSelectedGroupId(id);
|
setSelectedGroupId(id);
|
||||||
setActiveCategory({ groupId: id });
|
setActiveCategory({ groupId: id });
|
||||||
setView("group-detail");
|
setView("group-detail");
|
||||||
@@ -273,6 +322,35 @@ export default function ContactsPage() {
|
|||||||
setView("group-edit");
|
setView("group-edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditGroupFromSidebar = useCallback((groupId: string) => {
|
||||||
|
setSelectedGroupId(groupId);
|
||||||
|
setActiveCategory({ groupId });
|
||||||
|
setView("group-edit");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: t("groups.delete_confirm_title"),
|
||||||
|
message: t("groups.delete_confirm"),
|
||||||
|
confirmText: t("form.delete"),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteGroup(supportsSync && client ? client : null, groupId);
|
||||||
|
toast.success(t("toast.deleted"));
|
||||||
|
if (selectedGroupId === groupId) {
|
||||||
|
setSelectedGroupId(null);
|
||||||
|
setActiveCategory("all");
|
||||||
|
setView("list");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete group:', error);
|
||||||
|
toast.error(t("toast.error_delete"));
|
||||||
|
}
|
||||||
|
}, [confirmDialog, deleteGroup, supportsSync, client, selectedGroupId, t]);
|
||||||
|
|
||||||
const handleDeleteGroup = async () => {
|
const handleDeleteGroup = async () => {
|
||||||
if (!selectedGroup) return;
|
if (!selectedGroup) return;
|
||||||
|
|
||||||
@@ -391,7 +469,7 @@ export default function ContactsPage() {
|
|||||||
const renderRightPanel = () => {
|
const renderRightPanel = () => {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "create":
|
case "create":
|
||||||
return <ContactForm addressBooks={addressBooks} onSave={handleSaveNew} onCancel={handleCancel} />;
|
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||||
|
|
||||||
case "edit":
|
case "edit":
|
||||||
if (!selectedContact) return null;
|
if (!selectedContact) return null;
|
||||||
@@ -399,6 +477,7 @@ export default function ContactsPage() {
|
|||||||
<ContactForm
|
<ContactForm
|
||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
addressBooks={addressBooks}
|
addressBooks={addressBooks}
|
||||||
|
allKeywords={allKeywords}
|
||||||
onSave={handleSaveEdit}
|
onSave={handleSaveEdit}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
/>
|
/>
|
||||||
@@ -416,7 +495,6 @@ export default function ContactsPage() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onSelectMember={(id) => {
|
onSelectMember={(id) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
setActiveCategory("all");
|
|
||||||
setView("detail");
|
setView("detail");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -506,7 +584,7 @@ export default function ContactsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh bg-background overflow-hidden">
|
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
|
||||||
{/* Navigation Rail - desktop only */}
|
{/* Navigation Rail - desktop only */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
@@ -514,7 +592,7 @@ export default function ContactsPage() {
|
|||||||
collapsed
|
collapsed
|
||||||
quota={quota}
|
quota={quota}
|
||||||
isPushConnected={isPushConnected}
|
isPushConnected={isPushConnected}
|
||||||
onLogout={() => { logout(); router.push('/login'); }}
|
onLogout={logout}
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
onInlineApp={handleInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onCloseInlineApp={closeInlineApp}
|
||||||
@@ -523,7 +601,7 @@ export default function ContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||||
{inlineApp && (
|
{inlineApp && (
|
||||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
||||||
)}
|
)}
|
||||||
@@ -548,23 +626,28 @@ export default function ContactsPage() {
|
|||||||
onSelectCategory={handleSelectCategory}
|
onSelectCategory={handleSelectCategory}
|
||||||
onCreateGroup={handleCreateGroup}
|
onCreateGroup={handleCreateGroup}
|
||||||
onCreateContact={handleCreateNew}
|
onCreateContact={handleCreateNew}
|
||||||
|
onImport={() => setShowImportDialog(true)}
|
||||||
|
onEditGroup={handleEditGroupFromSidebar}
|
||||||
|
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||||
onDropContacts={handleDropContacts}
|
onDropContacts={handleDropContacts}
|
||||||
|
onDropContactsToCategory={handleDropContactsToCategory}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
||||||
onResize={(delta) => setSidebarWidth(Math.max(140, Math.min(300, sidebarDragStartWidth.current + delta)))}
|
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
|
||||||
onResizeEnd={() => {
|
onResizeEnd={() => {
|
||||||
setIsSidebarResizing(false);
|
setIsSidebarResizing(false);
|
||||||
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => { setSidebarWidth(180); localStorage.setItem("contacts-sidebar-width", "180"); }}
|
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Panel 2: Contact list */}
|
{/* Panel 2: Contact list */}
|
||||||
<div
|
<div
|
||||||
|
data-tour="contacts-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-background flex flex-col flex-shrink-0",
|
"border-r border-border bg-background flex flex-col flex-shrink-0",
|
||||||
isMobile ? "w-full" : "",
|
isMobile ? "w-full" : "",
|
||||||
@@ -618,7 +701,7 @@ export default function ContactsPage() {
|
|||||||
className="touch-manipulation"
|
className="touch-manipulation"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
{t("back_to_mail")}
|
{t("back_to_contacts")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -642,6 +725,17 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
{showImportDialog && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||||||
|
<ContactImportDialog
|
||||||
|
existingContacts={contacts}
|
||||||
|
onImport={handleImportContacts}
|
||||||
|
onClose={() => setShowImportDialog(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-23
@@ -7,11 +7,11 @@ import { ArrowLeft } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-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 { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
@@ -45,7 +45,7 @@ export default function FilesPage() {
|
|||||||
navigateByPath,
|
navigateByPath,
|
||||||
refresh,
|
refresh,
|
||||||
createDirectory,
|
createDirectory,
|
||||||
uploadFile,
|
uploadFile: _uploadFile,
|
||||||
uploadFiles,
|
uploadFiles,
|
||||||
uploadFolder,
|
uploadFolder,
|
||||||
deleteResource,
|
deleteResource,
|
||||||
@@ -112,9 +112,9 @@ export default function FilesPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||||
router.push("/login");
|
redirectToLogin();
|
||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading, router]);
|
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||||
|
|
||||||
// Initialize JMAP files client
|
// Initialize JMAP files client
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -156,39 +156,43 @@ export default function FilesPage() {
|
|||||||
}
|
}
|
||||||
}, [createDirectory, t]);
|
}, [createDirectory, t]);
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
const maxSizeUpload = client?.getMaxSizeUpload() || 0;
|
||||||
|
|
||||||
const handleUploadFiles = useCallback(async (files: File[]) => {
|
const handleUploadFiles = useCallback(async (files: File[]) => {
|
||||||
const oversized = files.filter(f => f.size > MAX_FILE_SIZE);
|
if (maxSizeUpload > 0) {
|
||||||
const valid = files.filter(f => f.size <= MAX_FILE_SIZE);
|
const oversized = files.filter(f => f.size > maxSizeUpload);
|
||||||
if (oversized.length > 0) {
|
files = files.filter(f => f.size <= maxSizeUpload);
|
||||||
toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" }));
|
if (oversized.length > 0) {
|
||||||
|
toast.error(t("file_too_large", { name: oversized[0].name, max: formatFileSize(maxSizeUpload) }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (valid.length === 0) return;
|
if (files.length === 0) return;
|
||||||
try {
|
try {
|
||||||
await uploadFiles(valid);
|
await uploadFiles(files);
|
||||||
toast.success(t("upload_success", { count: valid.length }));
|
toast.success(t("upload_success", { count: files.length }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to upload files:", err);
|
console.error("Failed to upload files:", err);
|
||||||
toast.error(t("upload_error"));
|
toast.error(t("upload_error"));
|
||||||
}
|
}
|
||||||
}, [uploadFiles, t]);
|
}, [uploadFiles, t, maxSizeUpload]);
|
||||||
|
|
||||||
const handleUploadFolder = useCallback(async (files: File[]) => {
|
const handleUploadFolder = useCallback(async (files: File[]) => {
|
||||||
const oversized = files.filter(f => f.size > MAX_FILE_SIZE);
|
if (maxSizeUpload > 0) {
|
||||||
const valid = files.filter(f => f.size <= MAX_FILE_SIZE);
|
const oversized = files.filter(f => f.size > maxSizeUpload);
|
||||||
if (oversized.length > 0) {
|
files = files.filter(f => f.size <= maxSizeUpload);
|
||||||
toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" }));
|
if (oversized.length > 0) {
|
||||||
|
toast.error(t("file_too_large", { name: oversized[0].name, max: formatFileSize(maxSizeUpload) }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (valid.length === 0) return;
|
if (files.length === 0) return;
|
||||||
try {
|
try {
|
||||||
await uploadFolder(valid);
|
await uploadFolder(files);
|
||||||
toast.success(t("upload_success", { count: valid.length }));
|
toast.success(t("upload_success", { count: files.length }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to upload folder:", err);
|
console.error("Failed to upload folder:", err);
|
||||||
toast.error(t("upload_error"));
|
toast.error(t("upload_error"));
|
||||||
}
|
}
|
||||||
}, [uploadFolder, t]);
|
}, [uploadFolder, t, maxSizeUpload]);
|
||||||
|
|
||||||
const handleDelete = useCallback(async (name: string) => {
|
const handleDelete = useCallback(async (name: string) => {
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
@@ -357,7 +361,7 @@ export default function FilesPage() {
|
|||||||
collapsed
|
collapsed
|
||||||
quota={quota}
|
quota={quota}
|
||||||
isPushConnected={isPushConnected}
|
isPushConnected={isPushConnected}
|
||||||
onLogout={() => { logout(); router.push('/login'); }}
|
onLogout={logout}
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
onInlineApp={handleInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onCloseInlineApp={closeInlineApp}
|
||||||
|
|||||||
+10
-1
@@ -2,6 +2,9 @@ import { notFound } from "next/navigation";
|
|||||||
import { IntlProvider } from "@/components/providers/intl-provider";
|
import { IntlProvider } from "@/components/providers/intl-provider";
|
||||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||||
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
||||||
|
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
|
||||||
|
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
|
||||||
|
import { TourProvider } from "@/components/tour/tour-provider";
|
||||||
import { locales } from "@/i18n/routing";
|
import { locales } from "@/i18n/routing";
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
@@ -26,7 +29,13 @@ export default async function LocaleLayout({
|
|||||||
<IntlProvider locale={locale} messages={messages}>
|
<IntlProvider locale={locale} messages={messages}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<CalendarAlertProvider>
|
<CalendarAlertProvider>
|
||||||
{children}
|
<RateLimitToastProvider>
|
||||||
|
<EmbeddedBridgeProvider>
|
||||||
|
<TourProvider>
|
||||||
|
{children}
|
||||||
|
</TourProvider>
|
||||||
|
</EmbeddedBridgeProvider>
|
||||||
|
</RateLimitToastProvider>
|
||||||
</CalendarAlertProvider>
|
</CalendarAlertProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
|
|||||||
+322
-28
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
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";
|
||||||
@@ -11,12 +11,12 @@ import { useThemeStore } from "@/stores/theme-store";
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield } from "lucide-react";
|
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react";
|
||||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||||
|
|
||||||
const APP_VERSION = "1.4.2";
|
const APP_VERSION = "1.4.10";
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||||
@@ -28,15 +28,18 @@ export default function LoginPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
const searchParams = useSearchParams();
|
||||||
|
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||||
|
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: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
|
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
});
|
});
|
||||||
|
const [jmapEndpoint, setJmapEndpoint] = useState("");
|
||||||
const [totpCode, setTotpCode] = useState("");
|
const [totpCode, setTotpCode] = useState("");
|
||||||
const [showTotpField, setShowTotpField] = useState(false);
|
const [showTotpField, setShowTotpField] = useState(false);
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
@@ -52,6 +55,7 @@ export default function LoginPage() {
|
|||||||
const [oauthMetadata, setOauthMetadata] = useState<OAuthMetadata | null>(null);
|
const [oauthMetadata, setOauthMetadata] = useState<OAuthMetadata | null>(null);
|
||||||
const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false);
|
const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false);
|
||||||
const [oauthLoading, setOauthLoading] = useState(false);
|
const [oauthLoading, setOauthLoading] = useState(false);
|
||||||
|
const [demoLoading, setDemoLoading] = useState(false);
|
||||||
|
|
||||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -70,6 +74,12 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [appName, serverUrl]);
|
}, [appName, serverUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverUrl && !jmapEndpoint) {
|
||||||
|
setJmapEndpoint(serverUrl);
|
||||||
|
}
|
||||||
|
}, [serverUrl, jmapEndpoint]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
if (sessionStorage.getItem('session_expired') === 'true') {
|
if (sessionStorage.getItem('session_expired') === 'true') {
|
||||||
@@ -88,6 +98,14 @@ export default function LoginPage() {
|
|||||||
prevError.current = error;
|
prevError.current = error;
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
|
// Auto-show and focus TOTP field when server requires it
|
||||||
|
useEffect(() => {
|
||||||
|
if (error === 'totp_required') {
|
||||||
|
setShowTotpField(true);
|
||||||
|
setTimeout(() => totpInputRef.current?.focus(), 100);
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverUrl) return;
|
if (!serverUrl) return;
|
||||||
const saved = localStorage.getItem("webmail_usernames");
|
const saved = localStorage.getItem("webmail_usernames");
|
||||||
@@ -102,7 +120,7 @@ export default function LoginPage() {
|
|||||||
}, [serverUrl]);
|
}, [serverUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated && !isAddAccountMode) {
|
||||||
let redirectTo = '/';
|
let redirectTo = '/';
|
||||||
try {
|
try {
|
||||||
const saved = sessionStorage.getItem('redirect_after_login');
|
const saved = sessionStorage.getItem('redirect_after_login');
|
||||||
@@ -113,7 +131,7 @@ export default function LoginPage() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
router.push(redirectTo);
|
router.push(redirectTo);
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, router]);
|
}, [isAuthenticated, router, isAddAccountMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearError();
|
clearError();
|
||||||
@@ -170,6 +188,63 @@ export default function LoginPage() {
|
|||||||
});
|
});
|
||||||
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
|
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
|
||||||
|
|
||||||
|
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
||||||
|
const ssoError = searchParams.get("sso_error");
|
||||||
|
const autoSsoTriggered = useRef(false);
|
||||||
|
|
||||||
|
const startServerSideSso = useCallback(async () => {
|
||||||
|
setOauthLoading(true);
|
||||||
|
try {
|
||||||
|
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
||||||
|
const res = await fetch('/api/auth/sso/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ redirect_uri: redirectUri, locale: params.locale }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setOauthLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { authorize_url } = await res.json();
|
||||||
|
|
||||||
|
// Navigate to the authorize URL
|
||||||
|
const isIframe = (() => { try { return window.self !== window.top; } catch { return true; } })();
|
||||||
|
if (isIframe) {
|
||||||
|
// In an iframe, try top-level navigation
|
||||||
|
try {
|
||||||
|
window.top!.location.href = authorize_url;
|
||||||
|
} catch {
|
||||||
|
// Cross-origin restriction — fall back to current frame
|
||||||
|
window.location.href = authorize_url;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.location.href = authorize_url;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setOauthLoading(false);
|
||||||
|
}
|
||||||
|
}, [params.locale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
||||||
|
if (ssoError || isAddAccountMode || isAuthenticated) return;
|
||||||
|
if (autoSsoTriggered.current) return;
|
||||||
|
|
||||||
|
// Guard against redirect loops
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem("sso_attempted")) return;
|
||||||
|
sessionStorage.setItem("sso_attempted", "1");
|
||||||
|
// Clear the flag after 30 seconds so retries are possible
|
||||||
|
setTimeout(() => { try { sessionStorage.removeItem("sso_attempted"); } catch { /* ignore */ } }, 30000);
|
||||||
|
} catch { /* sessionStorage unavailable */ }
|
||||||
|
|
||||||
|
autoSsoTriggered.current = true;
|
||||||
|
startServerSideSso();
|
||||||
|
}, [autoSsoEnabled, oauthOnly, oauthDiscoveryDone, oauthMetadata, ssoError, isAddAccountMode, isAuthenticated, startServerSideSso]);
|
||||||
|
|
||||||
const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => {
|
const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => {
|
||||||
setTheme(newTheme);
|
setTheme(newTheme);
|
||||||
setShowThemeMenu(false);
|
setShowThemeMenu(false);
|
||||||
@@ -204,7 +279,7 @@ export default function LoginPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!serverUrl) {
|
if (!serverUrl && !demoMode && !allowCustomJmapEndpoint) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
||||||
<div className="w-full max-w-md mx-auto px-4 text-center">
|
<div className="w-full max-w-md mx-auto px-4 text-center">
|
||||||
@@ -302,7 +377,10 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
sessionStorage.setItem("oauth_code_verifier", verifier);
|
sessionStorage.setItem("oauth_code_verifier", verifier);
|
||||||
sessionStorage.setItem("oauth_state", state);
|
sessionStorage.setItem("oauth_state", state);
|
||||||
sessionStorage.setItem("oauth_server_url", serverUrl!);
|
sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
|
||||||
|
if (isAddAccountMode) {
|
||||||
|
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||||
|
}
|
||||||
|
|
||||||
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
||||||
authUrl.searchParams.set("response_type", "code");
|
authUrl.searchParams.set("response_type", "code");
|
||||||
@@ -319,8 +397,9 @@ export default function LoginPage() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
|
||||||
const success = await login(
|
const success = await login(
|
||||||
serverUrl,
|
effectiveServerUrl,
|
||||||
formData.username,
|
formData.username,
|
||||||
formData.password,
|
formData.password,
|
||||||
totpCode || undefined,
|
totpCode || undefined,
|
||||||
@@ -329,15 +408,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
saveUsername(formData.username);
|
saveUsername(formData.username);
|
||||||
let redirectTo = '/';
|
router.push('/');
|
||||||
try {
|
|
||||||
const saved = sessionStorage.getItem('redirect_after_login');
|
|
||||||
if (saved) {
|
|
||||||
sessionStorage.removeItem('redirect_after_login');
|
|
||||||
redirectTo = saved;
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
router.push(redirectTo);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -356,9 +427,167 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDemoLogin = async () => {
|
||||||
|
setDemoLoading(true);
|
||||||
|
const success = await loginDemo();
|
||||||
|
if (success) {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
setDemoLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
|
const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
|
||||||
const CurrentThemeIcon = currentThemeOption.icon;
|
const CurrentThemeIcon = currentThemeOption.icon;
|
||||||
|
|
||||||
|
// Demo-only mode: show only a large demo login button
|
||||||
|
if (demoMode && !isAddAccountMode) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<div className="absolute top-5 right-5" ref={themeMenuRef} suppressHydrationWarning>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowThemeMenu(!showThemeMenu)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm transition-all duration-200",
|
||||||
|
showThemeMenu
|
||||||
|
? "bg-secondary border-border text-foreground shadow-md"
|
||||||
|
: "bg-background/60 backdrop-blur-sm border-border/50 text-muted-foreground hover:text-foreground hover:bg-secondary/80 hover:border-border"
|
||||||
|
)}
|
||||||
|
aria-label={`Theme: ${currentThemeOption.label}`}
|
||||||
|
aria-expanded={showThemeMenu}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
>
|
||||||
|
<CurrentThemeIcon className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline" suppressHydrationWarning>{currentThemeOption.label}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showThemeMenu && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Theme selection"
|
||||||
|
>
|
||||||
|
{THEME_OPTIONS.map((option) => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
const isActive = theme === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onClick={() => handleThemeSelect(option.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-3 px-3.5 py-2.5 text-sm transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-foreground font-medium"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
<span className="flex-1 text-left">{option.label}</span>
|
||||||
|
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-[440px] mx-auto">
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
|
||||||
|
{/* Header with logo */}
|
||||||
|
<div className="px-8 pt-12 pb-4 text-center">
|
||||||
|
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||||
|
<img
|
||||||
|
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
||||||
|
alt={appName}
|
||||||
|
className="max-w-20 max-h-20 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground tracking-tight">
|
||||||
|
{appName}
|
||||||
|
</h1>
|
||||||
|
<p className="text-base text-muted-foreground mt-2 max-w-xs mx-auto leading-relaxed">
|
||||||
|
{t("demo_tagline")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Large demo button */}
|
||||||
|
<div className="px-8 pb-10 pt-4">
|
||||||
|
{error && (
|
||||||
|
<div className={cn(
|
||||||
|
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3",
|
||||||
|
shakeError && "animate-shake"
|
||||||
|
)}>
|
||||||
|
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
|
||||||
|
{t(`error.${error}`) || t("error.generic")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full h-14 font-semibold text-lg bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/30 hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
onClick={handleDemoLogin}
|
||||||
|
disabled={demoLoading || isLoading}
|
||||||
|
>
|
||||||
|
{demoLoading ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
{t("demo_launching")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Play className="w-5 h-5" />
|
||||||
|
{t("demo_login_button")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground mt-4 leading-relaxed">
|
||||||
|
{t("demo_no_signup")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-6 flex flex-col items-center gap-2">
|
||||||
|
{loginCompanyName && (
|
||||||
|
<p className="text-center text-xs text-muted-foreground/60 font-medium">
|
||||||
|
{loginCompanyName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(loginImprintUrl || loginPrivacyPolicyUrl || loginWebsiteUrl) && (
|
||||||
|
<div className="flex items-center gap-3 flex-wrap justify-center">
|
||||||
|
{loginWebsiteUrl && (
|
||||||
|
<a href={loginWebsiteUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
||||||
|
{t("website")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{loginImprintUrl && (
|
||||||
|
<a href={loginImprintUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
||||||
|
{t("imprint")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{loginPrivacyPolicyUrl && (
|
||||||
|
<a href={loginPrivacyPolicyUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
||||||
|
{t("privacy_policy")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-center text-xs text-muted-foreground/40">
|
||||||
|
v{APP_VERSION}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
||||||
{/* Theme toggle - top right, dropdown style */}
|
{/* Theme toggle - top right, dropdown style */}
|
||||||
@@ -426,10 +655,10 @@ export default function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||||
{appName}
|
{isAddAccountMode ? t("add_account_title") : appName}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1.5">
|
<p className="text-sm text-muted-foreground mt-1.5">
|
||||||
{t("title") !== appName ? t("title") : "Sign in to your account"}
|
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -520,9 +749,9 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : oauthDiscoveryDone ? (
|
) : oauthDiscoveryDone ? (
|
||||||
<div className="p-3.5 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-2">
|
<div className="p-3.5 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2">
|
||||||
<AlertCircle className="w-4 h-4 text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-400">
|
<p className="text-sm text-warning">
|
||||||
{t("error.oauth_discovery_failed")}
|
{t("error.oauth_discovery_failed")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -536,6 +765,27 @@ export default function LoginPage() {
|
|||||||
/* Login Form */
|
/* Login Form */
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<fieldset disabled={isLoading} className="space-y-4">
|
<fieldset disabled={isLoading} className="space-y-4">
|
||||||
|
{/* JMAP Endpoint field (when custom endpoints are allowed) */}
|
||||||
|
{allowCustomJmapEndpoint && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
|
||||||
|
{t("jmap_endpoint_label")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="jmap-endpoint"
|
||||||
|
type="url"
|
||||||
|
value={jmapEndpoint}
|
||||||
|
onChange={(e) => setJmapEndpoint(e.target.value)}
|
||||||
|
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
|
||||||
|
placeholder={t("jmap_endpoint_placeholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
{t("jmap_endpoint_cors_hint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Username field */}
|
{/* Username field */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
||||||
@@ -648,7 +898,10 @@ export default function LoginPage() {
|
|||||||
maxLength={6}
|
maxLength={6}
|
||||||
value={totpCode}
|
value={totpCode}
|
||||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||||
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest"
|
className={cn(
|
||||||
|
"h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest",
|
||||||
|
error === 'totp_required' && "border-primary ring-2 ring-primary/30"
|
||||||
|
)}
|
||||||
placeholder={t("totp_placeholder")}
|
placeholder={t("totp_placeholder")}
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
aria-label={t("totp_label")}
|
aria-label={t("totp_label")}
|
||||||
@@ -728,15 +981,56 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
|
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
|
||||||
<div className="mt-2 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-2">
|
<div className="mt-2 p-3 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2">
|
||||||
<AlertCircle className="w-4 h-4 text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-400">
|
<p className="text-sm text-warning">
|
||||||
{t("error.oauth_discovery_failed")}
|
{t("error.oauth_discovery_failed")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAddAccountMode && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full h-10 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Demo Mode Button */}
|
||||||
|
{demoMode && !isAddAccountMode && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-border/40">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-11 font-medium text-[15px] rounded-xl border-border/60 hover:bg-muted/50"
|
||||||
|
onClick={handleDemoLogin}
|
||||||
|
disabled={demoLoading || isLoading}
|
||||||
|
>
|
||||||
|
{demoLoading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
{t("demo_launching")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
{t("try_demo")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-xs text-muted-foreground mt-2">
|
||||||
|
{t("demo_description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+155
-55
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Sidebar } from "@/components/layout/sidebar";
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
import { EmailList } from "@/components/email/email-list";
|
import { EmailList } from "@/components/email/email-list";
|
||||||
@@ -13,7 +12,7 @@ import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-hea
|
|||||||
import { ThreadGroup, Email } from "@/lib/jmap/types";
|
import { ThreadGroup, Email } from "@/lib/jmap/types";
|
||||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
@@ -31,6 +30,7 @@ import {
|
|||||||
ComposerErrorFallback,
|
ComposerErrorFallback,
|
||||||
} from "@/components/error";
|
} from "@/components/error";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
||||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||||
@@ -41,16 +41,20 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
|||||||
import { Input } from "@/components/ui/input";
|
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 { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square } from "lucide-react";
|
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
||||||
|
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
||||||
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";
|
||||||
|
import { usePluginStore } from "@/stores/plugin-store";
|
||||||
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter();
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { appName } = useConfig();
|
const { appName } = useConfig();
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const [showComposer, setShowComposer] = useState(false);
|
const [showComposer, setShowComposer] = useState(false);
|
||||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||||
const [composerDraftText, setComposerDraftText] = useState("");
|
const [composerDraftText, setComposerDraftText] = useState("");
|
||||||
@@ -67,11 +71,28 @@ export default function Home() {
|
|||||||
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
|
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
|
||||||
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
||||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||||
|
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
|
||||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
|
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
|
||||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost } = useAuthStore();
|
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRateLimited || !rateLimitUntil) {
|
||||||
|
setRateLimitSecondsLeft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCountdown = () => {
|
||||||
|
const seconds = Math.max(1, Math.ceil((rateLimitUntil - Date.now()) / 1000));
|
||||||
|
setRateLimitSecondsLeft(seconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCountdown();
|
||||||
|
const timer = setInterval(updateCountdown, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [isRateLimited, rateLimitUntil]);
|
||||||
|
|
||||||
// Mobile/tablet responsive hooks
|
// Mobile/tablet responsive hooks
|
||||||
const { isMobile, isTablet } = useDeviceDetection();
|
const { isMobile, isTablet } = useDeviceDetection();
|
||||||
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
|
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
|
||||||
@@ -97,6 +118,7 @@ export default function Home() {
|
|||||||
markAsRead,
|
markAsRead,
|
||||||
toggleStar,
|
toggleStar,
|
||||||
moveToMailbox,
|
moveToMailbox,
|
||||||
|
moveThreadToMailbox,
|
||||||
searchEmails,
|
searchEmails,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
@@ -267,6 +289,13 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
}, [checkAuth]);
|
}, [checkAuth]);
|
||||||
|
|
||||||
|
// Initialize plugins on mount (re-activates enabled plugins after refresh)
|
||||||
|
// Also syncs server-managed plugins and themes to the client
|
||||||
|
useEffect(() => {
|
||||||
|
usePluginStore.getState().initializePlugins();
|
||||||
|
useThemeStore.getState().syncServerThemes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Hydrate persisted column widths from localStorage
|
// Hydrate persisted column widths from localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -284,9 +313,9 @@ export default function Home() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||||
router.push('/login');
|
redirectToLogin();
|
||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading, router]);
|
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||||
|
|
||||||
// Load mailboxes and emails when authenticated (only if not already loaded)
|
// Load mailboxes and emails when authenticated (only if not already loaded)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -408,7 +437,10 @@ export default function Home() {
|
|||||||
// Handle new email notifications - play sound
|
// Handle new email notifications - play sound
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (newEmailNotification) {
|
if (newEmailNotification) {
|
||||||
playNotificationSound();
|
const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState();
|
||||||
|
if (emailNotificationsEnabled && emailNotificationSound) {
|
||||||
|
playNotificationSound(notificationSoundChoice);
|
||||||
|
}
|
||||||
debug.log('New email received:', newEmailNotification.subject);
|
debug.log('New email received:', newEmailNotification.subject);
|
||||||
clearNewEmailNotification();
|
clearNewEmailNotification();
|
||||||
}
|
}
|
||||||
@@ -442,9 +474,27 @@ export default function Home() {
|
|||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||||
|
const originalEmailId = selectedEmail?.id;
|
||||||
|
|
||||||
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments);
|
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments);
|
||||||
setShowComposer(false);
|
setShowComposer(false);
|
||||||
|
|
||||||
|
// Mark the original email with $answered or $forwarded keyword
|
||||||
|
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) {
|
||||||
|
try {
|
||||||
|
await client.setKeyword(originalEmailId, '$answered');
|
||||||
|
} catch (e) {
|
||||||
|
debug.error('Failed to set $answered keyword:', e);
|
||||||
|
}
|
||||||
|
} else if (originalEmailId && effectiveMode === 'forward') {
|
||||||
|
try {
|
||||||
|
await client.setKeyword(originalEmailId, '$forwarded');
|
||||||
|
} catch (e) {
|
||||||
|
debug.error('Failed to set $forwarded keyword:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh the current mailbox to update the UI
|
// Refresh the current mailbox to update the UI
|
||||||
await fetchEmails(client, selectedMailbox);
|
await fetchEmails(client, selectedMailbox);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -469,9 +519,24 @@ export default function Home() {
|
|||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditDraft = (email?: Email) => {
|
const handleEditDraft = async (email?: Email) => {
|
||||||
const draft = email || selectedEmail;
|
if (!client) return;
|
||||||
|
const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string'
|
||||||
|
? email
|
||||||
|
: selectedEmail;
|
||||||
|
let draft = draftCandidate;
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
|
|
||||||
|
// The email list only fetches limited properties (no bodyValues/htmlBody/bcc).
|
||||||
|
// Fetch the full email so the composer gets all draft content.
|
||||||
|
if (!draft.bodyValues) {
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
|
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||||
|
const fullDraft = await client.getEmail(draft.id, accountId);
|
||||||
|
if (!fullDraft) return;
|
||||||
|
draft = fullDraft;
|
||||||
|
}
|
||||||
|
|
||||||
const bodyText = draft.bodyValues
|
const bodyText = draft.bodyValues
|
||||||
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
|
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
|
||||||
: '';
|
: '';
|
||||||
@@ -545,8 +610,8 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchive = async () => {
|
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !emailToArchive) return;
|
||||||
|
|
||||||
// Find archive mailbox
|
// Find archive mailbox
|
||||||
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
||||||
@@ -556,10 +621,10 @@ export default function Home() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (archiveMode === 'single') {
|
if (archiveMode === 'single') {
|
||||||
await moveToMailbox(client, selectedEmail.id, archiveMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id);
|
||||||
} else {
|
} else {
|
||||||
// Determine year/month from the email's received date
|
// Determine year/month from the email's received date
|
||||||
const emailDate = new Date(selectedEmail.receivedAt);
|
const emailDate = new Date(emailToArchive.receivedAt);
|
||||||
const year = emailDate.getFullYear().toString();
|
const year = emailDate.getFullYear().toString();
|
||||||
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
|
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
|
||||||
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
||||||
@@ -574,7 +639,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (archiveMode === 'year') {
|
if (archiveMode === 'year') {
|
||||||
await moveToMailbox(client, selectedEmail.id, yearMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
||||||
} else {
|
} else {
|
||||||
// archiveMode === 'month' — find or create month subfolder under year
|
// archiveMode === 'month' — find or create month subfolder under year
|
||||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||||
@@ -585,9 +650,16 @@ export default function Home() {
|
|||||||
monthMailbox = await client.createMailbox(month, yearId);
|
monthMailbox = await client.createMailbox(month, yearId);
|
||||||
await fetchMailboxes(client);
|
await fetchMailboxes(client);
|
||||||
}
|
}
|
||||||
await moveToMailbox(client, selectedEmail.id, monthMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (conversationThread?.threadId === emailToArchive.threadId) {
|
||||||
|
setConversationThread(null);
|
||||||
|
setConversationEmails([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchMailboxes(client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to archive email:", error);
|
console.error("Failed to archive email:", error);
|
||||||
}
|
}
|
||||||
@@ -767,10 +839,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = logout;
|
||||||
logout();
|
|
||||||
router.push('/login');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = async (query: string) => {
|
const handleSearch = async (query: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
@@ -813,13 +882,13 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
|
const handleDownloadAttachment = async (blobId: string, name: string, type?: string, forceDownload?: boolean) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { mailAttachmentAction } = useSettingsStore.getState();
|
const { mailAttachmentAction } = useSettingsStore.getState();
|
||||||
|
|
||||||
if (mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
|
if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
|
||||||
setPreviewAttachment({ blobId, name, type });
|
setPreviewAttachment({ blobId, name, type });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -860,10 +929,9 @@ export default function Home() {
|
|||||||
const primaryIdentity = identities[0];
|
const primaryIdentity = identities[0];
|
||||||
|
|
||||||
// Append signature from the primary identity
|
// Append signature from the primary identity
|
||||||
let finalBody = body;
|
const finalBody = appendPlainTextSignature(body, primaryIdentity);
|
||||||
if (primaryIdentity?.textSignature) {
|
|
||||||
finalBody = body + '\n\n-- \n' + primaryIdentity.textSignature;
|
const originalEmailId = selectedEmail.id;
|
||||||
}
|
|
||||||
|
|
||||||
// Send reply with just the body text
|
// Send reply with just the body text
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
@@ -879,6 +947,13 @@ export default function Home() {
|
|||||||
primaryIdentity?.name || undefined
|
primaryIdentity?.name || undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mark the original email as answered
|
||||||
|
try {
|
||||||
|
await client.setKeyword(originalEmailId, '$answered');
|
||||||
|
} catch (e) {
|
||||||
|
debug.error('Failed to set $answered keyword:', e);
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh emails to show the sent reply
|
// Refresh emails to show the sent reply
|
||||||
await fetchEmails(client, selectedMailbox);
|
await fetchEmails(client, selectedMailbox);
|
||||||
};
|
};
|
||||||
@@ -897,6 +972,10 @@ export default function Home() {
|
|||||||
|
|
||||||
// Get current mailbox name for mobile header
|
// Get current mailbox name for mobile header
|
||||||
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||||
|
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||||
|
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
|
||||||
|
|
||||||
// Handle email selection with mobile view switching
|
// Handle email selection with mobile view switching
|
||||||
const handleEmailSelect = async (email: { id: string }) => {
|
const handleEmailSelect = async (email: { id: string }) => {
|
||||||
@@ -947,6 +1026,9 @@ export default function Home() {
|
|||||||
setConversationEmails([]);
|
setConversationEmails([]);
|
||||||
}
|
}
|
||||||
selectEmail(null);
|
selectEmail(null);
|
||||||
|
if (isTablet) {
|
||||||
|
setTabletListVisible(true);
|
||||||
|
}
|
||||||
setActiveView("list");
|
setActiveView("list");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1020,9 +1102,20 @@ export default function Home() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropProvider>
|
<DragDropProvider>
|
||||||
<div className="flex flex-col h-dvh bg-background overflow-hidden">
|
<div className="flex flex-col h-dvh bg-background overflow-hidden">
|
||||||
|
{isRateLimited && rateLimitSecondsLeft !== null && (
|
||||||
|
<div className="flex items-center justify-center gap-2 bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-300 text-sm py-1.5 px-4 flex-shrink-0">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
<span>{tCommon('rate_limited_title')}</span>
|
||||||
|
<span className="text-amber-700/80 dark:text-amber-300/80">{tCommon('rate_limited_detail', { seconds: rateLimitSecondsLeft })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{connectionLost && (
|
{connectionLost && (
|
||||||
<div className="flex items-center justify-center gap-2 bg-destructive/10 border-b border-destructive/30 text-destructive text-sm py-1.5 px-4 flex-shrink-0">
|
<div className="flex items-center justify-center gap-2 bg-destructive/10 border-b border-destructive/30 text-destructive text-sm py-1.5 px-4 flex-shrink-0">
|
||||||
<RotateCcw className="h-3.5 w-3.5 animate-spin" />
|
<RotateCcw className="h-3.5 w-3.5 animate-spin" />
|
||||||
@@ -1118,10 +1211,9 @@ export default function Home() {
|
|||||||
// Tablet/Desktop: fixed width with collapse animation
|
// Tablet/Desktop: fixed width with collapse animation
|
||||||
"md:flex-shrink-0 md:shadow-sm",
|
"md:flex-shrink-0 md:shadow-sm",
|
||||||
!isResizing && "transition-all duration-200 ease-out",
|
!isResizing && "transition-all duration-200 ease-out",
|
||||||
// Tablet: collapse when email selected
|
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
||||||
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
|
||||||
)}
|
)}
|
||||||
style={!isMobile && !(isTablet && !tabletListVisible) ? { width: emailListWidth } : undefined}
|
style={!isMobile && !shouldCollapseListPane ? { width: emailListWidth } : undefined}
|
||||||
>
|
>
|
||||||
{/* Mobile Header for List View */}
|
{/* Mobile Header for List View */}
|
||||||
<MobileHeader
|
<MobileHeader
|
||||||
@@ -1170,6 +1262,7 @@ export default function Home() {
|
|||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||||
data-search-input
|
data-search-input
|
||||||
|
data-tour="search-input"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -1379,8 +1472,7 @@ export default function Home() {
|
|||||||
await handleDelete();
|
await handleDelete();
|
||||||
}}
|
}}
|
||||||
onArchive={async (email) => {
|
onArchive={async (email) => {
|
||||||
selectEmail(email);
|
await handleArchive(email);
|
||||||
await handleArchive();
|
|
||||||
}}
|
}}
|
||||||
onSetColorTag={(emailId, color) => {
|
onSetColorTag={(emailId, color) => {
|
||||||
handleSetColorTag(emailId, color);
|
handleSetColorTag(emailId, color);
|
||||||
@@ -1406,24 +1498,27 @@ export default function Home() {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Compose Button (mobile) */}
|
{/* Floating Compose Button */}
|
||||||
{isMobile && (
|
<Button
|
||||||
<Button
|
onClick={() => {
|
||||||
onClick={() => {
|
setComposerMode('compose');
|
||||||
setComposerMode('compose');
|
setShowComposer(true);
|
||||||
setShowComposer(true);
|
if (isMobile) setActiveView('viewer');
|
||||||
setActiveView('viewer');
|
}}
|
||||||
}}
|
className={cn(
|
||||||
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
|
"absolute z-40 rounded-full shadow-lg",
|
||||||
aria-label={t('sidebar.compose')}
|
isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12"
|
||||||
>
|
)}
|
||||||
<PenSquare className="h-6 w-6" />
|
aria-label={t('sidebar.compose')}
|
||||||
</Button>
|
title={t('sidebar.compose_hint')}
|
||||||
)}
|
data-tour="compose-button"
|
||||||
|
>
|
||||||
|
<PenSquare className={isMobile ? "h-6 w-6" : "h-5 w-5"} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email list resize handle (desktop only) */}
|
{/* Email list resize handle (desktop only) */}
|
||||||
{!isMobile && !isTablet && (
|
{!isMobile && !isTablet && !isFocusedMailLayout && (
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||||
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||||
@@ -1435,12 +1530,13 @@ export default function Home() {
|
|||||||
{/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */}
|
{/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col h-full bg-background",
|
"flex flex-col h-full bg-background flex-1 min-w-0",
|
||||||
// Mobile: full screen overlay when active
|
// Mobile: full screen overlay when active
|
||||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||||
// Tablet/Desktop: flex grow, min-w-0 allows truncation of long subjects
|
// Tablet/Desktop: relative
|
||||||
"md:flex-1 md:min-w-0 md:relative"
|
"md:relative",
|
||||||
|
shouldHideViewerPane && "md:hidden"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Inline Composer - shown in viewer pane */}
|
{/* Inline Composer - shown in viewer pane */}
|
||||||
@@ -1456,8 +1552,10 @@ export default function Home() {
|
|||||||
mode={pendingDraft?.mode ?? composerMode}
|
mode={pendingDraft?.mode ?? composerMode}
|
||||||
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
|
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
|
||||||
from: selectedEmail.from,
|
from: selectedEmail.from,
|
||||||
|
replyToAddresses: selectedEmail.replyTo,
|
||||||
to: selectedEmail.to,
|
to: selectedEmail.to,
|
||||||
cc: selectedEmail.cc,
|
cc: selectedEmail.cc,
|
||||||
|
bcc: selectedEmail.bcc,
|
||||||
subject: selectedEmail.subject,
|
subject: selectedEmail.subject,
|
||||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||||
@@ -1567,16 +1665,17 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
onDownloadAttachment={handleDownloadAttachment}
|
onDownloadAttachment={handleDownloadAttachment}
|
||||||
onQuickReply={handleQuickReply}
|
onQuickReply={handleQuickReply}
|
||||||
onBack={() => {
|
onBack={handleMobileBack}
|
||||||
setTabletListVisible(true);
|
|
||||||
selectEmail(null);
|
|
||||||
}}
|
|
||||||
onNavigateNext={handleNavigateNext}
|
onNavigateNext={handleNavigateNext}
|
||||||
onNavigatePrev={handleNavigatePrev}
|
onNavigatePrev={handleNavigatePrev}
|
||||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||||
onEditDraft={handleEditDraft}
|
onEditDraft={handleEditDraft}
|
||||||
currentUserEmail={client?.["username"]}
|
onCompose={() => {
|
||||||
currentUserName={client?.["username"]?.split("@")[0]}
|
setComposerMode('compose');
|
||||||
|
setShowComposer(true);
|
||||||
|
}}
|
||||||
|
currentUserEmail={client?.getUsername()}
|
||||||
|
currentUserName={client?.getUsername()?.split("@")[0]}
|
||||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||||
mailboxes={mailboxes}
|
mailboxes={mailboxes}
|
||||||
selectedMailbox={selectedMailbox}
|
selectedMailbox={selectedMailbox}
|
||||||
@@ -1628,6 +1727,7 @@ export default function Home() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
<TotpReauthDialog />
|
||||||
</div>
|
</div>
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
BookUser,
|
BookUser,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
|
Bell,
|
||||||
|
Puzzle,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -44,7 +46,10 @@ import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
|||||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
import { SmimeSettings } from '@/components/settings/smime-settings';
|
||||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||||
|
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||||
|
import { PluginsSettings } from '@/components/settings/plugins-settings';
|
||||||
|
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||||
import { useEmailStore } from '@/stores/email-store';
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||||
import { NavigationRail } from '@/components/layout/navigation-rail';
|
import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||||
@@ -53,9 +58,10 @@ import { InlineAppView } from '@/components/layout/inline-app-view';
|
|||||||
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
||||||
import { ResizeHandle } from '@/components/layout/resize-handle';
|
import { ResizeHandle } from '@/components/layout/resize-handle';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'advanced';
|
type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'themes' | 'plugins' | 'advanced';
|
||||||
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
|
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
|
||||||
|
|
||||||
interface TabDef {
|
interface TabDef {
|
||||||
@@ -63,11 +69,13 @@ interface TabDef {
|
|||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
group: TabGroup;
|
group: TabGroup;
|
||||||
|
experimental?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabIcons: Record<Tab, LucideIcon> = {
|
const tabIcons: Record<Tab, LucideIcon> = {
|
||||||
appearance: Palette,
|
appearance: Palette,
|
||||||
email: Mail,
|
email: Mail,
|
||||||
|
notifications: Bell,
|
||||||
account: User,
|
account: User,
|
||||||
security: Shield,
|
security: Shield,
|
||||||
identities: UserPen,
|
identities: UserPen,
|
||||||
@@ -81,6 +89,8 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
|||||||
keywords: Tags,
|
keywords: Tags,
|
||||||
files: HardDrive,
|
files: HardDrive,
|
||||||
sidebar_apps: PanelLeftClose,
|
sidebar_apps: PanelLeftClose,
|
||||||
|
themes: Palette,
|
||||||
|
plugins: Puzzle,
|
||||||
advanced: Wrench,
|
advanced: Wrench,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,6 +105,7 @@ export default function SettingsPage() {
|
|||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
const { stalwartFeaturesEnabled } = useConfig();
|
const { stalwartFeaturesEnabled } = useConfig();
|
||||||
|
const { isFeatureEnabled } = usePolicyStore();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>(() => {
|
const [activeTab, setActiveTab] = useState<Tab>(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('settings-active-tab');
|
const saved = localStorage.getItem('settings-active-tab');
|
||||||
@@ -119,12 +130,25 @@ export default function SettingsPage() {
|
|||||||
});
|
});
|
||||||
}, [checkAuth]);
|
}, [checkAuth]);
|
||||||
|
|
||||||
|
// Listen for tab change events from child components
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const tab = (e as CustomEvent).detail as Tab;
|
||||||
|
if (tab) {
|
||||||
|
setActiveTab(tab);
|
||||||
|
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('settings-tab-change', handler);
|
||||||
|
return () => window.removeEventListener('settings-tab-change', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||||
router.push('/login');
|
redirectToLogin();
|
||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading, router]);
|
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return null;
|
return null;
|
||||||
@@ -138,19 +162,22 @@ export default function SettingsPage() {
|
|||||||
const tabs: TabDef[] = [
|
const tabs: TabDef[] = [
|
||||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
|
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
|
||||||
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
|
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
|
||||||
|
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
||||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
|
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
|
||||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
|
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
|
||||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
|
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
|
||||||
{ id: 'encryption', label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' },
|
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' as TabGroup }] : []),
|
||||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
|
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
|
||||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []),
|
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []),
|
||||||
{ id: 'templates', label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' },
|
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' as TabGroup }] : []),
|
||||||
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
|
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
|
||||||
{ id: 'keywords', label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' },
|
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' as TabGroup }] : []),
|
||||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||||
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
|
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
|
||||||
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||||
{ id: 'sidebar_apps', label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' },
|
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||||
|
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'system' as TabGroup, experimental: true }] : []),
|
||||||
|
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'system' as TabGroup, experimental: true }] : []),
|
||||||
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
|
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -177,6 +204,7 @@ export default function SettingsPage() {
|
|||||||
<>
|
<>
|
||||||
{activeTab === 'appearance' && <AppearanceSettings />}
|
{activeTab === 'appearance' && <AppearanceSettings />}
|
||||||
{activeTab === 'email' && <EmailSettings />}
|
{activeTab === 'email' && <EmailSettings />}
|
||||||
|
{activeTab === 'notifications' && <NotificationSettings />}
|
||||||
{activeTab === 'account' && <AccountSettings />}
|
{activeTab === 'account' && <AccountSettings />}
|
||||||
{activeTab === 'security' && <AccountSecuritySettings />}
|
{activeTab === 'security' && <AccountSecuritySettings />}
|
||||||
{activeTab === 'identities' && <IdentitySettings />}
|
{activeTab === 'identities' && <IdentitySettings />}
|
||||||
@@ -190,6 +218,8 @@ export default function SettingsPage() {
|
|||||||
{activeTab === 'keywords' && <KeywordSettings />}
|
{activeTab === 'keywords' && <KeywordSettings />}
|
||||||
{activeTab === 'files' && <FilesSettingsComponent />}
|
{activeTab === 'files' && <FilesSettingsComponent />}
|
||||||
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||||
|
{activeTab === 'themes' && <ThemesSettings />}
|
||||||
|
{activeTab === 'plugins' && <PluginsSettings />}
|
||||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
{activeTab === 'advanced' && <AdvancedSettings />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -274,6 +304,11 @@ export default function SettingsPage() {
|
|||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
{tab.experimental && (
|
||||||
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning">
|
||||||
|
Experimental
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -286,7 +321,7 @@ export default function SettingsPage() {
|
|||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
<div className="border-t border-border px-5 py-3">
|
<div className="border-t border-border px-5 py-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => { logout(); router.push('/login'); }}
|
onClick={logout}
|
||||||
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
|
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
@@ -317,7 +352,7 @@ export default function SettingsPage() {
|
|||||||
collapsed
|
collapsed
|
||||||
quota={quota}
|
quota={quota}
|
||||||
isPushConnected={isPushConnected}
|
isPushConnected={isPushConnected}
|
||||||
onLogout={() => { logout(); router.push('/login'); }}
|
onLogout={logout}
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
onInlineApp={handleInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onCloseInlineApp={closeInlineApp}
|
||||||
@@ -352,7 +387,7 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex-1 overflow-y-auto py-2">
|
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
||||||
<div className="px-2 space-y-0.5">
|
<div className="px-2 space-y-0.5">
|
||||||
{groupedTabs.map((group, groupIndex) => (
|
{groupedTabs.map((group, groupIndex) => (
|
||||||
<div key={group.group}>
|
<div key={group.group}>
|
||||||
@@ -380,6 +415,11 @@ export default function SettingsPage() {
|
|||||||
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||||
)} />
|
)} />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
{tab.experimental && (
|
||||||
|
<span className="ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning shrink-0">
|
||||||
|
Experimental
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Save, Loader2, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ConfigEntry {
|
||||||
|
value: unknown;
|
||||||
|
source: 'admin' | 'env' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminAuthPage() {
|
||||||
|
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||||
|
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { fetchConfig(); }, []);
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('/api/admin/config');
|
||||||
|
if (res.ok) setConfig(await res.json());
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(key: string, value: unknown) {
|
||||||
|
setEdits(prev => ({ ...prev, [key]: value }));
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentValue(key: string): unknown {
|
||||||
|
if (key in edits) return edits[key];
|
||||||
|
return config[key]?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (Object.keys(edits).length === 0) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(edits),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Authentication settings saved.' });
|
||||||
|
setEdits({});
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevert(key: string) {
|
||||||
|
const res = await fetch('/api/admin/config', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setEdits(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
|
await fetchConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEdits = Object.keys(edits).length > 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
|
||||||
|
</div>
|
||||||
|
{hasEdits && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OAuth */}
|
||||||
|
<Section title="OAuth / OpenID Connect">
|
||||||
|
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" />
|
||||||
|
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* SSO */}
|
||||||
|
<Section title="Single Sign-On">
|
||||||
|
<Toggle label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Session & Security */}
|
||||||
|
<Section title="Session & Security">
|
||||||
|
<Select label="Cookie SameSite" configKey="cookieSameSite" value={currentValue('cookieSameSite') as string} source={config.cookieSameSite?.source} options={['lax', 'strict', 'none']} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<Text label="Allowed Frame Ancestors" configKey="allowedFrameAncestors" value={currentValue('allowedFrameAncestors') as string} source={config.allowedFrameAncestors?.source} onChange={handleChange} onRevert={handleRevert} placeholder="'none' or https://..." />
|
||||||
|
<Text label="Parent Origin" description="For embedded mode communication" configKey="parentOrigin" value={currentValue('parentOrigin') as string} source={config.parentOrigin?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
</Section>
|
||||||
|
</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>
|
||||||
|
<div className="divide-y divide-border">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceBadge({ source }: { source?: string }) {
|
||||||
|
if (!source || source === 'default') return null;
|
||||||
|
return (
|
||||||
|
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||||
|
{source}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Text({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text' }: {
|
||||||
|
label: string; description?: string; configKey: string; value: string; source?: string;
|
||||||
|
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder}
|
||||||
|
className="h-8 w-64 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" />
|
||||||
|
{source === 'admin' && (
|
||||||
|
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({ label, description, configKey, value, source, onChange, onRevert }: {
|
||||||
|
label: string; description?: string; configKey: string; value: boolean; source?: string;
|
||||||
|
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => onChange(configKey, !value)}
|
||||||
|
className={`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>
|
||||||
|
{source === 'admin' && (
|
||||||
|
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Select({ label, configKey, value, source, options, onChange, onRevert }: {
|
||||||
|
label: string; configKey: string; value: string; source?: string; options: string[];
|
||||||
|
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select value={value ?? ''} onChange={(e) => onChange(configKey, 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(o => <option key={o} value={o}>{o}</option>)}
|
||||||
|
</select>
|
||||||
|
{source === 'admin' && (
|
||||||
|
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ConfigEntry {
|
||||||
|
value: unknown;
|
||||||
|
source: 'admin' | 'env' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_FIELDS = [
|
||||||
|
{ key: 'faviconUrl', label: 'Favicon', accept: '.svg,.png,.ico,.webp' },
|
||||||
|
{ key: 'appLogoLightUrl', label: 'App Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEXT_FIELDS = [
|
||||||
|
{ key: 'loginCompanyName', label: 'Company Name' },
|
||||||
|
{ key: 'loginImprintUrl', label: 'Imprint URL' },
|
||||||
|
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
|
||||||
|
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminBrandingPage() {
|
||||||
|
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||||
|
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('/api/admin/config');
|
||||||
|
if (res.ok) setConfig(await res.json());
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(key: string, value: string) {
|
||||||
|
setEdits(prev => ({ ...prev, [key]: value }));
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentValue(key: string): string {
|
||||||
|
if (key in edits) return edits[key] as string;
|
||||||
|
return (config[key]?.value as string) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (Object.keys(edits).length === 0) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(edits),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' });
|
||||||
|
setEdits({});
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(slot: string, file: File) {
|
||||||
|
setUploading(slot);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('slot', slot);
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/branding', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
|
||||||
|
// Remove any pending URL edit for this slot since upload sets it
|
||||||
|
setEdits(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[slot];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Update config to reflect the uploaded URL
|
||||||
|
setConfig(prev => ({
|
||||||
|
...prev,
|
||||||
|
[slot]: { value: data.url, source: 'admin' },
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||||
|
}
|
||||||
|
setUploading(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteUpload(slot: string) {
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/branding', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slot }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Uploaded file removed. Reverted to default.' });
|
||||||
|
setEdits(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[slot];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to remove' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevert(key: string) {
|
||||||
|
const res = await fetch('/api/admin/config', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setEdits(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await fetchConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUploadedFile = (key: string): boolean => {
|
||||||
|
const val = currentValue(key);
|
||||||
|
return val.startsWith('/api/admin/branding/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasEdits = Object.keys(edits).length > 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Branding</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
|
||||||
|
</div>
|
||||||
|
{hasEdits && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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">Images & Logos</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{IMAGE_FIELDS.map(field => (
|
||||||
|
<div key={field.key} className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
|
||||||
|
{config[field.key]?.source === 'admin' && (
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||||
|
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={currentValue(field.key)}
|
||||||
|
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||||
|
placeholder="Enter URL or upload a file"
|
||||||
|
className="h-8 w-64 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
|
||||||
|
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||||
|
type="file"
|
||||||
|
accept={field.accept}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleUpload(field.key, file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||||
|
disabled={uploading === field.key}
|
||||||
|
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||||
|
title="Upload file"
|
||||||
|
>
|
||||||
|
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
{isUploadedFile(field.key) && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUpload(field.key)}
|
||||||
|
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
title="Remove uploaded file"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
|
||||||
|
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Preview */}
|
||||||
|
{currentValue(field.key) && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||||
|
<img
|
||||||
|
src={currentValue(field.key)}
|
||||||
|
alt={field.label}
|
||||||
|
className="max-h-6 max-w-[200px] object-contain"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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">Company Information</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{TEXT_FIELDS.map(field => (
|
||||||
|
<div key={field.key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
|
||||||
|
{config[field.key]?.source === 'admin' && (
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={currentValue(field.key)}
|
||||||
|
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||||
|
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
|
||||||
|
className="h-8 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"
|
||||||
|
/>
|
||||||
|
{config[field.key]?.source === 'admin' && (
|
||||||
|
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Lock } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ChangePasswordPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setError('New password must be at least 8 characters.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('New passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('/api/admin/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setSuccess(true);
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setTimeout(() => router.push('/admin'), 2000);
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
setError(data.error || 'Failed to change password.');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Change Password</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Update your admin password.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">Current Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={e => setCurrentPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500">{error}</p>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<p className="text-sm text-green-600">Password changed. Redirecting...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Changing...' : 'Change Password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Settings,
|
||||||
|
Palette,
|
||||||
|
Shield,
|
||||||
|
Scale,
|
||||||
|
ScrollText,
|
||||||
|
LogOut,
|
||||||
|
KeyRound,
|
||||||
|
Puzzle,
|
||||||
|
SwatchBook,
|
||||||
|
Mail,
|
||||||
|
Calendar,
|
||||||
|
BookUser,
|
||||||
|
HardDrive,
|
||||||
|
ArrowLeft,
|
||||||
|
Store,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useConfig } from '@/hooks/use-config';
|
||||||
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
const NAV_GROUPS = [
|
||||||
|
{
|
||||||
|
label: 'Overview',
|
||||||
|
items: [
|
||||||
|
{ href: '/admin', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Configuration',
|
||||||
|
items: [
|
||||||
|
{ href: '/admin/settings', label: 'Settings', icon: Settings },
|
||||||
|
{ href: '/admin/branding', label: 'Branding', icon: Palette },
|
||||||
|
{ href: '/admin/auth', label: 'Authentication', icon: Shield },
|
||||||
|
{ href: '/admin/policy', label: 'Policy', icon: Scale },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Extensions',
|
||||||
|
items: [
|
||||||
|
{ href: '/admin/plugins', label: 'Plugins', icon: Puzzle },
|
||||||
|
{ href: '/admin/themes', label: 'Themes', icon: SwatchBook },
|
||||||
|
{ href: '/admin/marketplace', label: 'Marketplace', icon: Store },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'System',
|
||||||
|
items: [
|
||||||
|
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||||
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
|
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
const logoUrl = resolvedTheme === 'dark'
|
||||||
|
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||||
|
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pathname !== '/admin/login') {
|
||||||
|
checkAuth();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
function getJmapHeaders(): Record<string, string> {
|
||||||
|
const client = useAuthStore.getState().client;
|
||||||
|
if (!client) return {};
|
||||||
|
return {
|
||||||
|
'Authorization': client.getAuthHeader(),
|
||||||
|
'X-JMAP-Server-URL': client.getServerUrl(),
|
||||||
|
'X-JMAP-Username': client.getUsername(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const jmapHeaders = getJmapHeaders();
|
||||||
|
const res = await fetch('/api/admin/auth', { headers: jmapHeaders });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const stalwartAdmin = data.stalwartAdmin === true;
|
||||||
|
setIsStalwartAdmin(stalwartAdmin);
|
||||||
|
|
||||||
|
// If neither password-based admin nor Stalwart admin, redirect away
|
||||||
|
if (!data.enabled && !stalwartAdmin) {
|
||||||
|
router.replace('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.authenticated) {
|
||||||
|
setAuthenticated(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If Stalwart admin but not yet authenticated, auto-login
|
||||||
|
if (stalwartAdmin) {
|
||||||
|
const loginRes = await fetch('/api/admin/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
||||||
|
body: JSON.stringify({ stalwartAuth: true }),
|
||||||
|
});
|
||||||
|
if (loginRes.ok) {
|
||||||
|
setAuthenticated(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace('/admin/login');
|
||||||
|
} catch {
|
||||||
|
router.replace('/admin/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await fetch('/api/admin/auth', { method: 'DELETE' });
|
||||||
|
router.replace('/admin/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't gate the login page
|
||||||
|
if (pathname === '/admin/login') {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authenticated === null) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="animate-pulse text-muted-foreground text-sm">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex bg-background">
|
||||||
|
{/* Slim webmail nav rail */}
|
||||||
|
<nav className="w-14 bg-secondary flex flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
|
||||||
|
{logoUrl ? (
|
||||||
|
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
||||||
|
) : (
|
||||||
|
<div className="w-7 h-7 mb-2" />
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title="Mail"
|
||||||
|
>
|
||||||
|
<Mail className="w-[18px] h-[18px]" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/calendar"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title="Calendar"
|
||||||
|
>
|
||||||
|
<Calendar className="w-[18px] h-[18px]" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/contacts"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title="Contacts"
|
||||||
|
>
|
||||||
|
<BookUser className="w-[18px] h-[18px]" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/files"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title="Files"
|
||||||
|
>
|
||||||
|
<HardDrive className="w-[18px] h-[18px]" />
|
||||||
|
</a>
|
||||||
|
<div className="mt-auto flex flex-col items-center gap-2">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
|
||||||
|
<Shield className="w-[18px] h-[18px]" />
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/settings"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<Settings className="w-[18px] h-[18px]" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Admin Sidebar */}
|
||||||
|
<aside className="w-60 border-r border-border bg-secondary flex flex-col sticky top-0 h-screen">
|
||||||
|
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||||
|
{logoUrl ? (
|
||||||
|
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||||
|
) : (
|
||||||
|
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||||
|
)}
|
||||||
|
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
|
<div className="px-2 space-y-0.5">
|
||||||
|
{NAV_GROUPS.map((group, groupIndex) => (
|
||||||
|
<div key={group.label}>
|
||||||
|
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
||||||
|
<div className="px-3 pt-2.5 pb-1">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{group.items.map(({ href, label, icon: Icon }) => {
|
||||||
|
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
|
active
|
||||||
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
|
: 'hover:bg-muted text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn(
|
||||||
|
'w-4 h-4 shrink-0',
|
||||||
|
active ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||||
|
)} />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
|
||||||
|
{!isStalwartAdmin && (
|
||||||
|
<Link
|
||||||
|
href="/admin/change-password"
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
|
pathname === '/admin/change-password'
|
||||||
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
|
: 'hover:bg-muted text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<KeyRound className={cn(
|
||||||
|
'w-4 h-4 shrink-0',
|
||||||
|
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||||
|
)} />
|
||||||
|
Change Password
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Shield } from 'lucide-react';
|
||||||
|
import { useConfig } from '@/hooks/use-config';
|
||||||
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
|
||||||
|
export default function AdminLoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl;
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || 'Login failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/admin');
|
||||||
|
} catch {
|
||||||
|
setError('Network error. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="flex flex-col items-center mb-8">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
|
||||||
|
{logoUrl ? (
|
||||||
|
<img src={logoUrl} alt="" className="w-8 h-8 object-contain" />
|
||||||
|
) : (
|
||||||
|
<Shield className="w-6 h-6 text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring"
|
||||||
|
placeholder="Enter admin password"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !password}
|
||||||
|
className="w-full h-10 rounded-md bg-primary text-primary-foreground font-medium text-sm hover:bg-primary/90 disabled:opacity-50 disabled:pointer-events-none transition-all duration-200 shadow-sm"
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
|
||||||
|
export default function AdminLogsPage() {
|
||||||
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionFilter, setActionFilter] = useState('');
|
||||||
|
const limit = 50;
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
|
if (actionFilter) params.set('action', actionFilter);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/audit?${params}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setEntries(data.entries || []);
|
||||||
|
setTotal(data.total || 0);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [page, actionFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchLogs}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">All actions</option>
|
||||||
|
<option value="admin.login">Login</option>
|
||||||
|
<option value="admin.logout">Logout</option>
|
||||||
|
<option value="admin.login_failed">Login Failed</option>
|
||||||
|
<option value="admin.login_blocked">Login Blocked</option>
|
||||||
|
<option value="admin.change-password">Password Change</option>
|
||||||
|
<option value="config.update">Config Update</option>
|
||||||
|
<option value="config.revert">Config Revert</option>
|
||||||
|
<option value="policy.update">Policy Update</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-muted/30">
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Time</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Action</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-muted-foreground">IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{loading && entries.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">Loading...</td>
|
||||||
|
</tr>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">No entries found</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
entries.map((entry, i) => (
|
||||||
|
<tr key={i} className="hover:bg-muted/20">
|
||||||
|
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{new Date(entry.ts).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{entry.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-xs text-foreground max-w-xs truncate">
|
||||||
|
{formatDetail(entry.detail)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-xs text-muted-foreground font-mono">
|
||||||
|
{entry.ip}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetail(detail: Record<string, unknown>): string {
|
||||||
|
if (!detail || Object.keys(detail).length === 0) return '—';
|
||||||
|
if (detail.reason) return String(detail.reason);
|
||||||
|
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
||||||
|
if (detail.changes && Array.isArray(detail.changes)) {
|
||||||
|
return detail.changes.map((c: Record<string, unknown>) => `${c.key}`).join(', ');
|
||||||
|
}
|
||||||
|
if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`;
|
||||||
|
return JSON.stringify(detail).slice(0, 100);
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Extension {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
type: 'plugin' | 'theme';
|
||||||
|
pluginType: string | null;
|
||||||
|
description: string;
|
||||||
|
permissions: string[];
|
||||||
|
tags: string[];
|
||||||
|
totalDownloads: number;
|
||||||
|
featured: boolean;
|
||||||
|
minAppVersion: string | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
installed: boolean;
|
||||||
|
author: {
|
||||||
|
displayName: string;
|
||||||
|
githubLogin: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
data: Extension[];
|
||||||
|
meta: {
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type TypeFilter = 'all' | 'plugin' | 'theme';
|
||||||
|
|
||||||
|
export default function AdminMarketplacePage() {
|
||||||
|
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [perPage] = useState(12);
|
||||||
|
const [installing, setInstalling] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchExtensions = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query) params.set('q', query);
|
||||||
|
if (typeFilter !== 'all') params.set('type', typeFilter);
|
||||||
|
params.set('page', String(page));
|
||||||
|
params.set('perPage', String(perPage));
|
||||||
|
params.set('sort', 'newest');
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/marketplace?${params}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
setError(data.error || 'Failed to connect to extension directory');
|
||||||
|
setExtensions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: SearchResult = await res.json();
|
||||||
|
setExtensions(data.data || []);
|
||||||
|
setTotal(data.meta?.total || 0);
|
||||||
|
} catch {
|
||||||
|
setError('Failed to connect to extension directory. Make sure it is running.');
|
||||||
|
setExtensions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [query, typeFilter, page, perPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchExtensions();
|
||||||
|
}, [fetchExtensions]);
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setQuery(searchInput);
|
||||||
|
setPage(1);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
|
async function handleInstall(ext: Extension) {
|
||||||
|
setInstalling(ext.slug);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/marketplace', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: ext.slug,
|
||||||
|
version: ext.latestVersion || '1.0.0',
|
||||||
|
type: ext.type,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||||
|
setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` });
|
||||||
|
// Mark as installed in the UI
|
||||||
|
setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e));
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Installation failed — network error' });
|
||||||
|
} finally {
|
||||||
|
setInstalling(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / perPage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Marketplace</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Browse and install plugins and themes from the BulwarkMail extension directory
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search extensions..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5">
|
||||||
|
{(['all', 'plugin', 'theme'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => { setTypeFilter(t); setPage(1); }}
|
||||||
|
className={`h-8 px-3 rounded text-sm font-medium transition-colors ${
|
||||||
|
typeFilter === t
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === 'all' ? 'All' : t === 'plugin' ? 'Plugins' : 'Themes'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && (
|
||||||
|
<div className="border border-border rounded-lg p-12 text-center">
|
||||||
|
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">{error}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Start the extension directory server on the configured port
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchExtensions}
|
||||||
|
className="mt-4 inline-flex items-center gap-2 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && !error && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">Searching extensions...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!loading && !error && extensions.length === 0 && (
|
||||||
|
<div className="border border-border rounded-lg p-12 text-center">
|
||||||
|
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">No extensions found</p>
|
||||||
|
{query && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Try a different search term
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Extension Grid */}
|
||||||
|
{!loading && !error && extensions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{total} extension{total !== 1 ? 's' : ''} found
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{extensions.map((ext) => (
|
||||||
|
<ExtensionCard
|
||||||
|
key={ext.slug}
|
||||||
|
extension={ext}
|
||||||
|
installing={installing === ext.slug}
|
||||||
|
onInstall={() => handleInstall(ext)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExtensionCard({
|
||||||
|
extension,
|
||||||
|
installing,
|
||||||
|
onInstall,
|
||||||
|
}: {
|
||||||
|
extension: Extension;
|
||||||
|
installing: boolean;
|
||||||
|
onInstall: () => void;
|
||||||
|
}) {
|
||||||
|
const isPlugin = extension.type === 'plugin';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0">
|
||||||
|
{isPlugin ? (
|
||||||
|
<Puzzle className="w-5 h-5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<SwatchBook className="w-5 h-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm font-medium text-foreground truncate">{extension.name}</span>
|
||||||
|
{extension.featured && (
|
||||||
|
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||||
|
isPlugin
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
|
||||||
|
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
|
||||||
|
}`}>
|
||||||
|
{isPlugin ? (extension.pluginType || 'plugin') : 'theme'}
|
||||||
|
</span>
|
||||||
|
{extension.author && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
by {extension.author.displayName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-xs text-muted-foreground mt-3 line-clamp-2">
|
||||||
|
{extension.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{extension.tags && extension.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-3">
|
||||||
|
{extension.tags.slice(0, 3).map(tag => (
|
||||||
|
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
{extension.totalDownloads.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{extension.permissions && extension.permissions.length > 0 && (
|
||||||
|
<span title={extension.permissions.join(', ')}>
|
||||||
|
{extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{extension.installed ? (
|
||||||
|
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
Installed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onInstall}
|
||||||
|
disabled={installing}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{installing ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||||
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
|
||||||
|
interface AdminStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
|
lastLogin: string | null;
|
||||||
|
passwordChangedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigData {
|
||||||
|
appName?: string;
|
||||||
|
jmapServerUrl?: string;
|
||||||
|
settingsSyncEnabled?: boolean;
|
||||||
|
stalwartFeaturesEnabled?: boolean;
|
||||||
|
oauthEnabled?: boolean;
|
||||||
|
devMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboardPage() {
|
||||||
|
const [status, setStatus] = useState<AdminStatus | null>(null);
|
||||||
|
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
|
||||||
|
const [config, setConfig] = useState<ConfigData | null>(null);
|
||||||
|
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null);
|
||||||
|
const [warnings, setWarnings] = useState<string[]>([]);
|
||||||
|
const [pluginCount, setPluginCount] = useState(0);
|
||||||
|
const [themeCount, setThemeCount] = useState(0);
|
||||||
|
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
||||||
|
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDashboardData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchDashboardData() {
|
||||||
|
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
|
||||||
|
fetch('/api/admin/auth'),
|
||||||
|
fetch('/api/admin/audit?limit=10'),
|
||||||
|
fetch('/api/config'),
|
||||||
|
fetch('/api/admin/config'),
|
||||||
|
fetch('/api/admin/plugins').catch(() => null),
|
||||||
|
fetch('/api/admin/themes').catch(() => null),
|
||||||
|
fetch('/api/admin/policy').catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (statusRes.ok) setStatus(await statusRes.json());
|
||||||
|
if (auditRes.ok) {
|
||||||
|
const data = await auditRes.json();
|
||||||
|
setRecentActivity(data.entries || []);
|
||||||
|
}
|
||||||
|
let configData: ConfigData | null = null;
|
||||||
|
if (configRes.ok) {
|
||||||
|
configData = await configRes.json();
|
||||||
|
setConfig(configData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pluginRes?.ok) {
|
||||||
|
const plugins = await pluginRes.json();
|
||||||
|
setPluginCount(Array.isArray(plugins) ? plugins.length : 0);
|
||||||
|
}
|
||||||
|
if (themeRes?.ok) {
|
||||||
|
const themes = await themeRes.json();
|
||||||
|
setThemeCount(Array.isArray(themes) ? themes.length : 0);
|
||||||
|
}
|
||||||
|
if (policyRes?.ok) {
|
||||||
|
const policy = await policyRes.json();
|
||||||
|
const restrictionCount = policy.restrictions ? Object.keys(policy.restrictions).length : 0;
|
||||||
|
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
|
||||||
|
setPolicyRuleCount(restrictionCount + disabledGates);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configData?.jmapServerUrl) {
|
||||||
|
try {
|
||||||
|
const jmapRes = await fetch('/api/config');
|
||||||
|
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
||||||
|
} catch {
|
||||||
|
setJmapHealth('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const w: string[] = [];
|
||||||
|
if (adminConfigRes.ok) {
|
||||||
|
const sources = await adminConfigRes.json();
|
||||||
|
setConfigSources(sources);
|
||||||
|
const sessionSecret = sources?.sessionSecret;
|
||||||
|
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') {
|
||||||
|
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
|
||||||
|
}
|
||||||
|
const adminPassword = sources?.adminPassword;
|
||||||
|
if (adminPassword?.value && adminPassword.source === 'env') {
|
||||||
|
w.push('ADMIN_PASSWORD is still set in environment variables. Remove it now that the hash is stored securely.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setWarnings(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jmapUrl = config?.jmapServerUrl || '—';
|
||||||
|
const jmapHostname = jmapUrl !== '—' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '—';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-8">
|
||||||
|
{/* Warnings */}
|
||||||
|
{warnings.map((msg, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm text-warning">{msg}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{status && !status.lastLogin && (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-warning">First login detected</p>
|
||||||
|
<p className="text-sm text-warning/80 mt-0.5">
|
||||||
|
Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Server Info */}
|
||||||
|
<SettingsSection title="Server" description="Application and connection details">
|
||||||
|
<SettingItem label="Application">
|
||||||
|
<span className="text-sm text-foreground">{config?.appName || '—'}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="JMAP Server" description={jmapUrl !== '—' ? jmapUrl : undefined}>
|
||||||
|
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="JMAP Connection">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${
|
||||||
|
jmapHealth === 'ok' ? 'text-green-600 dark:text-green-400' : jmapHealth === 'error' ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground'
|
||||||
|
}`}>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${
|
||||||
|
jmapHealth === 'ok' ? 'bg-green-500' : jmapHealth === 'error' ? 'bg-red-500' : 'bg-muted-foreground/40'
|
||||||
|
}`} />
|
||||||
|
{jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Last Login">
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
||||||
|
</span>
|
||||||
|
</SettingItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<SettingsSection title="Features" description="Enabled integrations and modules">
|
||||||
|
<SettingItem label="Admin Panel" description="Administrative access to server configuration">
|
||||||
|
<ToggleSwitch checked={!!status?.enabled} onChange={() => {}} disabled />
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Settings Sync" description="Synchronize user settings across devices">
|
||||||
|
<ToggleSwitch checked={!!config?.settingsSyncEnabled} onChange={() => {}} disabled />
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="OAuth" description="OAuth authentication provider">
|
||||||
|
<ToggleSwitch checked={!!config?.oauthEnabled} onChange={() => {}} disabled />
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Stalwart Integration" description="Stalwart mail server features">
|
||||||
|
<ToggleSwitch checked={config?.stalwartFeaturesEnabled !== false} onChange={() => {}} disabled />
|
||||||
|
</SettingItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Extensions */}
|
||||||
|
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
|
||||||
|
<SettingItem label="Plugins">
|
||||||
|
<span className="text-sm text-foreground">{pluginCount}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Themes">
|
||||||
|
<span className="text-sm text-foreground">{themeCount}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Policy Rules">
|
||||||
|
<span className="text-sm text-foreground">{policyRuleCount}</span>
|
||||||
|
</SettingItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<SettingsSection title="Recent Activity" description="Latest administrative actions">
|
||||||
|
{recentActivity.length === 0 ? (
|
||||||
|
<div className="py-4 text-sm text-muted-foreground">
|
||||||
|
No activity recorded yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
recentActivity.map((entry, i) => (
|
||||||
|
<SettingItem
|
||||||
|
key={i}
|
||||||
|
label={entry.action}
|
||||||
|
description={formatDetail(entry.detail) || undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>{entry.ip}</span>
|
||||||
|
<span>{new Date(entry.ts).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</SettingItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetail(detail: Record<string, unknown>): string {
|
||||||
|
if (!detail || Object.keys(detail).length === 0) return '';
|
||||||
|
if (detail.key) return `${detail.key}: ${detail.old} → ${detail.new}`;
|
||||||
|
if (detail.reason) return String(detail.reason);
|
||||||
|
if (detail.changes && Array.isArray(detail.changes)) return `${detail.changes.length} setting(s) changed`;
|
||||||
|
return JSON.stringify(detail).slice(0, 80);
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface ConfigField {
|
||||||
|
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: unknown;
|
||||||
|
placeholder?: string;
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginConfig {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
type: string;
|
||||||
|
permissions: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
configSchema?: Record<string, ConfigField>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PluginConfigPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const pluginId = params.id as string;
|
||||||
|
const [plugin, setPlugin] = useState<PluginInfo | null>(null);
|
||||||
|
const [config, setConfig] = useState<PluginConfig>({});
|
||||||
|
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pluginId]);
|
||||||
|
|
||||||
|
// Initialize form values from config + schema defaults when data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (!plugin?.configSchema) return;
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||||
|
const stored = config[key];
|
||||||
|
if (stored !== undefined && stored !== null) {
|
||||||
|
initial[key] = String(stored);
|
||||||
|
} else if (field.default !== undefined) {
|
||||||
|
initial[key] = String(field.default);
|
||||||
|
} else {
|
||||||
|
initial[key] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFormValues(initial);
|
||||||
|
}, [plugin, config]);
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [pluginsRes, configRes] = await Promise.all([
|
||||||
|
fetch('/api/admin/plugins'),
|
||||||
|
fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (pluginsRes.ok) {
|
||||||
|
const plugins: PluginInfo[] = await pluginsRes.json();
|
||||||
|
setPlugin(plugins.find(p => p.id === pluginId) || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configRes.ok) {
|
||||||
|
setConfig(await configRes.json());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveAll() {
|
||||||
|
if (!plugin?.configSchema) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||||
|
if (field.required && !formValues[key]?.trim()) {
|
||||||
|
setMessage({ type: 'error', text: `"${field.label}" is required` });
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save each changed field
|
||||||
|
let hasError = false;
|
||||||
|
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||||
|
const newVal = formValues[key] ?? '';
|
||||||
|
const oldVal = config[key] !== undefined ? String(config[key]) : '';
|
||||||
|
|
||||||
|
// Skip unchanged fields (and skip secret fields that show as empty when they have a stored value)
|
||||||
|
if (newVal === oldVal) continue;
|
||||||
|
if (field.type === 'secret' && !newVal && config[key]) continue;
|
||||||
|
|
||||||
|
// Convert types
|
||||||
|
let value: unknown = newVal;
|
||||||
|
if (field.type === 'boolean') value = newVal === 'true';
|
||||||
|
else if (field.type === 'number') value = Number(newVal);
|
||||||
|
|
||||||
|
// Delete if clearing a non-required field
|
||||||
|
if (!newVal && !field.required) {
|
||||||
|
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setConfig(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
|
} else {
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key, value }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setConfig(prev => ({ ...prev, [key]: value }));
|
||||||
|
} else {
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage(hasError
|
||||||
|
? { type: 'error', text: 'Some settings failed to save' }
|
||||||
|
: { type: 'success', text: 'Configuration saved' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save configuration' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Back to Plugins
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-destructive">Plugin not found: {pluginId}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = plugin.configSchema;
|
||||||
|
const hasSchema = schema && Object.keys(schema).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Puzzle className="w-5 h-5" />
|
||||||
|
{plugin.name} Configuration
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
v{plugin.version} by {plugin.author}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSchema ? (
|
||||||
|
<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">Settings</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-5">
|
||||||
|
{Object.entries(schema).map(([key, field]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="text-sm font-medium text-foreground block mb-1">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
{field.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === 'boolean' ? (
|
||||||
|
<select
|
||||||
|
value={formValues[key] ?? String(field.default ?? 'false')}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="true">Enabled</option>
|
||||||
|
<option value="false">Disabled</option>
|
||||||
|
</select>
|
||||||
|
) : field.type === 'select' && field.options ? (
|
||||||
|
<select
|
||||||
|
value={formValues[key] ?? ''}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">— Select —</option>
|
||||||
|
{field.options.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : field.type === 'secret' ? (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={revealSecrets[key] ? 'text' : 'password'}
|
||||||
|
value={formValues[key] ?? ''}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
|
||||||
|
className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRevealSecrets(prev => ({ ...prev, [key]: !prev[key] }))}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={revealSecrets[key] ? 'Hide' : 'Show'}
|
||||||
|
>
|
||||||
|
{revealSecrets[key] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={field.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={formValues[key] ?? ''}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
placeholder={field.placeholder || ''}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSaveAll}
|
||||||
|
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" /> : null}
|
||||||
|
Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border border-border rounded-lg p-8 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
||||||
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
|
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
|
|
||||||
|
interface PluginEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
description: string;
|
||||||
|
type: string;
|
||||||
|
enabled: boolean;
|
||||||
|
forceEnabled?: boolean;
|
||||||
|
permissions: string[];
|
||||||
|
installedAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPluginsPage() {
|
||||||
|
const [plugins, setPlugins] = useState<PluginEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||||
|
const [policyDirty, setPolicyDirty] = useState(false);
|
||||||
|
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPlugins(); fetchPolicy(); }, []);
|
||||||
|
|
||||||
|
async function fetchPolicy() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/policy');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPolicy(data);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePluginsEnabled() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, pluginsEnabled: !prev.features.pluginsEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePluginsUploadEnabled() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, pluginsUploadEnabled: !prev.features.pluginsUploadEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSavePolicy() {
|
||||||
|
setSavingPolicy(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/policy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(policy),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Plugin policy saved. Users will see changes on next login.' });
|
||||||
|
setPolicyDirty(false);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save policy' });
|
||||||
|
} finally {
|
||||||
|
setSavingPolicy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlugins() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/plugins');
|
||||||
|
if (res.ok) setPlugins(await res.json());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/plugins', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||||
|
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
|
||||||
|
await fetchPlugins();
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Upload failed' });
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePlugin(id: string, enabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
const res = await fetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, enabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setPlugins(prev => prev.map(p => p.id === id ? { ...p, enabled } : p));
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
// If force-enabling, also ensure the plugin is enabled
|
||||||
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p));
|
||||||
|
// Also update policy
|
||||||
|
setPolicy(prev => {
|
||||||
|
const current = prev.forceEnabledPlugins || [];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
forceEnabledPlugins: forceEnabled
|
||||||
|
? [...current.filter(pid => pid !== id), id]
|
||||||
|
: current.filter(pid => pid !== id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceEnableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const disabled = plugins.filter(p => !p.enabled);
|
||||||
|
if (disabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All plugins are already enabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const p of disabled) {
|
||||||
|
const res = await fetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: p.id, enabled: true }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
setPlugins(prev => prev.map(p => failed === 0 ? { ...p, enabled: true } : p));
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'success', text: `All ${disabled.length} plugin(s) enabled` });
|
||||||
|
} else {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'error', text: `${failed} plugin(s) failed to enable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceDisableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const enabled = plugins.filter(p => p.enabled);
|
||||||
|
if (enabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All plugins are already disabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const p of enabled) {
|
||||||
|
const res = await fetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: p.id, enabled: false }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'success', text: `All ${enabled.length} plugin(s) disabled` });
|
||||||
|
} else {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'error', text: `${failed} plugin(s) failed to disable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePlugin(id: string, name: string) {
|
||||||
|
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
setMessage(null);
|
||||||
|
const res = await fetch('/api/admin/plugins', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setPlugins(prev => prev.filter(p => p.id !== id));
|
||||||
|
setMessage({ type: 'success', text: `Plugin "${name}" removed` });
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Delete failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
||||||
|
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{policyDirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleSavePolicy}
|
||||||
|
disabled={savingPolicy}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
Save Policy
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<label 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 cursor-pointer transition-all shadow-sm">
|
||||||
|
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||||
|
Upload Plugin
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
onChange={handleUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plugin Policy */}
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Plugin Policy</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">Plugins Enabled</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={togglePluginsEnabled}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsEnabled ? '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 ${pluginsEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">User Plugin Uploads</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={togglePluginsUploadEnabled}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsUploadEnabled ? '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 ${pluginsUploadEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Force enable / disable all */}
|
||||||
|
{plugins.length > 0 && (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={forceEnableAll}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Power className="w-3.5 h-3.5" />
|
||||||
|
Enable All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={forceDisableAll}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<PowerOff className="w-3.5 h-3.5" />
|
||||||
|
Disable All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deployed Plugins */}
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Deployed Plugins</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded plugins for all users</p>
|
||||||
|
</div>
|
||||||
|
{plugins.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<Package className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">No plugins installed</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Upload a plugin ZIP file to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{plugins.map(plugin => (
|
||||||
|
<div key={plugin.id} className="px-4 py-4 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">{plugin.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
|
||||||
|
{plugin.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
{plugin.forceEnabled && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
|
||||||
|
<Lock className="w-3 h-3" /> Forced
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{plugin.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">{plugin.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
by {plugin.author} · {plugin.type} · installed {new Date(plugin.installedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
{plugin.permissions.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<AlertTriangle className="w-3 h-3 text-warning" />
|
||||||
|
<span className="text-xs text-warning">
|
||||||
|
Permissions: {plugin.permissions.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/admin/plugins/${plugin.id}`}
|
||||||
|
title="Configure"
|
||||||
|
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
||||||
|
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
|
||||||
|
className={`p-2 rounded-md transition-colors ${plugin.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
{plugin.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => togglePlugin(plugin.id, !plugin.enabled)}
|
||||||
|
title={plugin.enabled ? 'Disable' : 'Enable'}
|
||||||
|
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Power className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deletePlugin(plugin.id, plugin.name)}
|
||||||
|
title="Remove"
|
||||||
|
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Save, Loader2, Lock } from 'lucide-react';
|
||||||
|
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
||||||
|
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
|
|
||||||
|
// Feature gates managed on their own admin pages (excluded from this list)
|
||||||
|
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
||||||
|
|
||||||
|
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||||
|
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
||||||
|
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
|
||||||
|
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
||||||
|
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
||||||
|
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
||||||
|
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
||||||
|
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
|
||||||
|
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||||
|
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||||
|
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESTRICTABLE_SETTINGS = [
|
||||||
|
{ key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] },
|
||||||
|
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
||||||
|
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
||||||
|
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||||
|
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||||
|
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||||
|
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
|
||||||
|
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||||
|
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
||||||
|
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
||||||
|
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
|
||||||
|
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
|
||||||
|
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
|
||||||
|
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
||||||
|
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
||||||
|
{ key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' },
|
||||||
|
{ key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminPolicyPage() {
|
||||||
|
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||||
|
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(() => { fetchPolicy(); }, []);
|
||||||
|
|
||||||
|
async function fetchPolicy() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/policy');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPolicy(data);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFeature(key: keyof FeatureGates) {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, [key]: !prev.features[key] },
|
||||||
|
}));
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLocked(settingKey: string) {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const existing = prev.restrictions[settingKey] || {};
|
||||||
|
const newRestrictions = { ...prev.restrictions };
|
||||||
|
if (existing.locked) {
|
||||||
|
delete newRestrictions[settingKey];
|
||||||
|
} else {
|
||||||
|
newRestrictions[settingKey] = { ...existing, locked: true };
|
||||||
|
}
|
||||||
|
return { ...prev, restrictions: newRestrictions };
|
||||||
|
});
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHidden(settingKey: string) {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const existing = prev.restrictions[settingKey] || {};
|
||||||
|
const newRestrictions = { ...prev.restrictions };
|
||||||
|
newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden };
|
||||||
|
if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) {
|
||||||
|
delete newRestrictions[settingKey];
|
||||||
|
}
|
||||||
|
return { ...prev, restrictions: newRestrictions };
|
||||||
|
});
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/policy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(policy),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Policy saved. Users will see changes on next login.' });
|
||||||
|
setDirty(false);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</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 policy
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feature Gates */}
|
||||||
|
<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">Feature Gates</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[])
|
||||||
|
.filter(key => !EXCLUDED_FEATURE_GATES.includes(key))
|
||||||
|
.map(key => {
|
||||||
|
const meta = FEATURE_GATE_LABELS[key];
|
||||||
|
if (!meta) return null;
|
||||||
|
const { label, description } = meta;
|
||||||
|
const enabled = policy.features[key];
|
||||||
|
return (
|
||||||
|
<div key={key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => toggleFeature(key)}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${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 ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Setting Restrictions */}
|
||||||
|
{categories.map(category => (
|
||||||
|
<div key={category} 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">{category}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
|
||||||
|
const restriction = policy.restrictions[setting.key] || {};
|
||||||
|
return (
|
||||||
|
<div key={setting.key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<span className="text-sm text-foreground">{setting.label}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
|
||||||
|
className="rounded border-input" />
|
||||||
|
<Lock className="w-3 h-3" /> Lock
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input type="checkbox" checked={!!restriction.hidden} onChange={() => toggleHidden(setting.key)}
|
||||||
|
className="rounded border-input" />
|
||||||
|
Hide
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ConfigEntry {
|
||||||
|
value: unknown;
|
||||||
|
source: 'admin' | 'env' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminSettingsPage() {
|
||||||
|
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||||
|
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('/api/admin/config');
|
||||||
|
if (res.ok) {
|
||||||
|
setConfig(await res.json());
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(key: string, value: unknown) {
|
||||||
|
setEdits(prev => ({ ...prev, [key]: value }));
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentValue(key: string): unknown {
|
||||||
|
if (key in edits) return edits[key];
|
||||||
|
return config[key]?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (Object.keys(edits).length === 0) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(edits),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Settings saved. Changes take effect on next page load.' });
|
||||||
|
setEdits({});
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevert(key: string) {
|
||||||
|
const res = await fetch('/api/admin/config', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setEdits(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await fetchConfig();
|
||||||
|
setMessage({ type: 'success', text: `${key} reverted to default` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEdits = Object.keys(edits).length > 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">General server configuration</p>
|
||||||
|
</div>
|
||||||
|
{hasEdits && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* General */}
|
||||||
|
<SettingsSection title="General">
|
||||||
|
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
||||||
|
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
{!!currentValue('allowCustomJmapEndpoint') && (
|
||||||
|
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
||||||
|
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||||
|
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
|
||||||
|
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Logging */}
|
||||||
|
<SettingsSection title="Logging">
|
||||||
|
<SelectSetting label="Log Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<SelectSetting label="Log Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Settings Sync */}
|
||||||
|
<SettingsSection title="Settings Sync">
|
||||||
|
<ToggleSetting label="Settings Sync Enabled" description="Requires SESSION_SECRET to be set" configKey="settingsSyncEnabled" value={currentValue('settingsSyncEnabled') as boolean} source={config.settingsSyncEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsSection({ 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>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceBadge({ source }: { source?: string }) {
|
||||||
|
if (!source || source === 'default') return null;
|
||||||
|
return (
|
||||||
|
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||||
|
{source}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextSetting({ label, configKey, value, source, onChange, onRevert, placeholder }: {
|
||||||
|
label: string; configKey: string; value: string; source?: string;
|
||||||
|
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<label className="text-sm text-foreground whitespace-nowrap">{label}</label>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(configKey, e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="h-8 w-64 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"
|
||||||
|
/>
|
||||||
|
{source === 'admin' && (
|
||||||
|
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleSetting({ label, description, configKey, value, source, onChange, onRevert }: {
|
||||||
|
label: string; description?: string; configKey: string; value: boolean; source?: string;
|
||||||
|
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(configKey, !value)}
|
||||||
|
className={`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>
|
||||||
|
{source === 'admin' && (
|
||||||
|
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSetting({ label, configKey, value, source, options, onChange, onRevert }: {
|
||||||
|
label: string; configKey: string; value: string; source?: string; options: string[];
|
||||||
|
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(configKey, 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={opt}>{opt}</option>)}
|
||||||
|
</select>
|
||||||
|
{source === 'admin' && (
|
||||||
|
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
||||||
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
|
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||||
|
|
||||||
|
const BUILTIN_THEME_OPTIONS = [
|
||||||
|
{ id: 'builtin-nord', name: 'Nord' },
|
||||||
|
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
|
||||||
|
{ id: 'builtin-solarized', name: 'Solarized' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ThemeEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
description: string;
|
||||||
|
variants: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
forceEnabled?: boolean;
|
||||||
|
installedAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminThemesPage() {
|
||||||
|
const [themes, setThemes] = useState<ThemeEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||||
|
const [policyDirty, setPolicyDirty] = useState(false);
|
||||||
|
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { fetchThemes(); fetchPolicy(); }, []);
|
||||||
|
|
||||||
|
async function fetchPolicy() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/policy');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPolicy({
|
||||||
|
...data,
|
||||||
|
themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleThemesEnabled() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, themesEnabled: !prev.features.themesEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUserThemeUploads() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, userThemesEnabled: !prev.features.userThemesEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBuiltinTheme(themeId: string) {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const disabled = prev.themePolicy?.disabledBuiltinThemes || [];
|
||||||
|
const isDisabled = disabled.includes(themeId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
themePolicy: {
|
||||||
|
...DEFAULT_THEME_POLICY,
|
||||||
|
...prev.themePolicy,
|
||||||
|
disabledBuiltinThemes: isDisabled
|
||||||
|
? disabled.filter((id: string) => id !== themeId)
|
||||||
|
: [...disabled, themeId],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAdminTheme(themeId: string) {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const disabled = prev.themePolicy?.disabledThemes || [];
|
||||||
|
const isDisabled = disabled.includes(themeId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
themePolicy: {
|
||||||
|
...DEFAULT_THEME_POLICY,
|
||||||
|
...prev.themePolicy,
|
||||||
|
disabledThemes: isDisabled
|
||||||
|
? disabled.filter((id: string) => id !== themeId)
|
||||||
|
: [...disabled, themeId],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDefaultTheme(themeId: string | null) {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
themePolicy: {
|
||||||
|
...DEFAULT_THEME_POLICY,
|
||||||
|
...prev.themePolicy,
|
||||||
|
defaultThemeId: themeId,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSavePolicy() {
|
||||||
|
setSavingPolicy(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/policy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(policy),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Theme policy saved. Users will see changes on next login.' });
|
||||||
|
setPolicyDirty(false);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save policy' });
|
||||||
|
} finally {
|
||||||
|
setSavingPolicy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchThemes() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/themes');
|
||||||
|
if (res.ok) setThemes(await res.json());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/themes', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||||
|
setMessage({ type: 'success', text: `Theme "${data.theme.name}" installed${warnings}` });
|
||||||
|
await fetchThemes();
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Upload failed' });
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTheme(id: string, enabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
const res = await fetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, enabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setThemes(prev => prev.map(t => t.id === id ? { ...t, enabled } : t));
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setThemes(prev => prev.map(t => t.id === id ? { ...t, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : t));
|
||||||
|
setPolicy(prev => {
|
||||||
|
const current = prev.forceEnabledThemes || [];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
forceEnabledThemes: forceEnabled
|
||||||
|
? [...current.filter(tid => tid !== id), id]
|
||||||
|
: current.filter(tid => tid !== id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceEnableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const disabled = themes.filter(t => !t.enabled);
|
||||||
|
if (disabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All themes are already enabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const t of disabled) {
|
||||||
|
const res = await fetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: t.id, enabled: true }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'success', text: `All ${disabled.length} theme(s) enabled` });
|
||||||
|
} else {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'error', text: `${failed} theme(s) failed to enable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceDisableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const enabled = themes.filter(t => t.enabled);
|
||||||
|
if (enabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All themes are already disabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const t of enabled) {
|
||||||
|
const res = await fetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: t.id, enabled: false }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'success', text: `All ${enabled.length} theme(s) disabled` });
|
||||||
|
} else {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'error', text: `${failed} theme(s) failed to disable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTheme(id: string, name: string) {
|
||||||
|
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
setMessage(null);
|
||||||
|
const res = await fetch('/api/admin/themes', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setThemes(prev => prev.filter(t => t.id !== id));
|
||||||
|
setMessage({ type: 'success', text: `Theme "${name}" removed` });
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Delete failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const themesEnabled = policy.features.themesEnabled ?? true;
|
||||||
|
const userThemesEnabled = policy.features.userThemesEnabled ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Themes</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{policyDirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleSavePolicy}
|
||||||
|
disabled={savingPolicy}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
Save Policy
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<label 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 cursor-pointer transition-all shadow-sm">
|
||||||
|
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||||
|
Upload Theme
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
onChange={handleUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Theme Policy */}
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Theme Policy</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Control theme availability and defaults for users</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{/* Master toggle */}
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">Themes Enabled</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={toggleThemesEnabled}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? '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 ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User uploads toggle */}
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">User Theme Uploads</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={toggleUserThemeUploads}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? '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 ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Force enable / disable all */}
|
||||||
|
{themes.length > 0 && (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={forceEnableAll}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Power className="w-3.5 h-3.5" />
|
||||||
|
Enable All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={forceDisableAll}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<PowerOff className="w-3.5 h-3.5" />
|
||||||
|
Disable All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Default Theme */}
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">Default Theme</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={policy.themePolicy?.defaultThemeId || ''}
|
||||||
|
onChange={(e) => setDefaultTheme(e.target.value || null)}
|
||||||
|
className="h-8 px-2 rounded-md border border-input bg-background text-sm text-foreground"
|
||||||
|
>
|
||||||
|
<option value="">System Default</option>
|
||||||
|
<optgroup label="Built-in">
|
||||||
|
{BUILTIN_THEME_OPTIONS
|
||||||
|
.filter(t => !(policy.themePolicy?.disabledBuiltinThemes || []).includes(t.id))
|
||||||
|
.map(t => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
{themes.length > 0 && (
|
||||||
|
<optgroup label="Admin-deployed">
|
||||||
|
{themes
|
||||||
|
.filter(t => !(policy.themePolicy?.disabledThemes || []).includes(t.id))
|
||||||
|
.map(t => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Built-in themes */}
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Built-in Themes</span>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{BUILTIN_THEME_OPTIONS.map(theme => {
|
||||||
|
const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id);
|
||||||
|
return (
|
||||||
|
<div key={theme.id} className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-sm text-foreground">{theme.name}</span>
|
||||||
|
<button onClick={() => toggleBuiltinTheme(theme.id)}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? '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 ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin-deployed themes */}
|
||||||
|
{themes.length > 0 && (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Admin-deployed Themes</span>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{themes.map(theme => {
|
||||||
|
const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id);
|
||||||
|
return (
|
||||||
|
<div key={theme.id} className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-sm text-foreground">{theme.name}</span>
|
||||||
|
<button onClick={() => toggleAdminTheme(theme.id)}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? '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 ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deployed Themes */}
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Palette className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Deployed Themes</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded themes available to all users</p>
|
||||||
|
</div>
|
||||||
|
{themes.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<Palette className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">No themes installed</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Upload a theme ZIP file to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{themes.map(theme => (
|
||||||
|
<div key={theme.id} className="px-4 py-4 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">{theme.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">v{theme.version}</span>
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
|
||||||
|
{theme.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
{theme.forceEnabled && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
|
||||||
|
<Lock className="w-3 h-3" /> Forced
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{theme.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">{theme.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
by {theme.author} · {theme.variants.join(', ')} · installed {new Date(theme.installedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleForceEnabled(theme.id, !theme.forceEnabled)}
|
||||||
|
title={theme.forceEnabled ? 'Remove force-enable (users can deactivate)' : 'Force enable (users cannot deactivate)'}
|
||||||
|
className={`p-2 rounded-md transition-colors ${theme.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
{theme.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleTheme(theme.id, !theme.enabled)}
|
||||||
|
title={theme.enabled ? 'Disable' : 'Enable'}
|
||||||
|
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Power className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteTheme(theme.id, theme.name)}
|
||||||
|
title="Remove"
|
||||||
|
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
import { readAuditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/audit — Get paginated audit log entries (admin-protected)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
|
||||||
|
const limit = Math.min(200, Math.max(1, parseInt(request.nextUrl.searchParams.get('limit') || '50', 10)));
|
||||||
|
const action = request.nextUrl.searchParams.get('action') || undefined;
|
||||||
|
|
||||||
|
const { entries, total } = await readAuditLog(page, limit, action);
|
||||||
|
|
||||||
|
return NextResponse.json({ entries, total, page, limit }, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Audit log read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { initAdminPassword, verifyAdminPassword, updateLastLogin, isAdminEnabled, getAdminMeta } from '@/lib/admin/password';
|
||||||
|
import { setAdminSessionCookie, clearAdminSessionCookie, requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { checkRateLimit } from '@/lib/admin/rate-limit';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user is a Stalwart admin by probing an admin-only endpoint.
|
||||||
|
*/
|
||||||
|
async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) return false;
|
||||||
|
|
||||||
|
// Probe admin-only endpoint: listing principals requires admin privileges
|
||||||
|
const response = await fetch(`${creds.apiUrl}/api/principal?limit=1`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Authorization': creds.authHeader },
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAdmin = response.ok;
|
||||||
|
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin });
|
||||||
|
return isAdmin;
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('Stalwart admin check error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/auth — Login
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Stalwart-based admin authentication
|
||||||
|
if (body.stalwartAuth === true) {
|
||||||
|
const isStalwartAdmin = await checkStalwartAdmin(request);
|
||||||
|
if (!isStalwartAdmin) {
|
||||||
|
await auditLog('admin.login_failed', { method: 'stalwart' }, ip);
|
||||||
|
return NextResponse.json({ error: 'Not a Stalwart admin' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setAdminSessionCookie();
|
||||||
|
await auditLog('admin.login', { method: 'stalwart' }, ip);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password-based admin authentication
|
||||||
|
await initAdminPassword();
|
||||||
|
if (!isAdminEnabled()) {
|
||||||
|
return NextResponse.json({ error: 'Admin dashboard is not configured' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit check
|
||||||
|
const limit = checkRateLimit(ip);
|
||||||
|
if (!limit.allowed) {
|
||||||
|
const retryAfter = Math.ceil(limit.retryAfterMs / 1000);
|
||||||
|
await auditLog('admin.login_blocked', { reason: 'rate_limit' }, ip);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many login attempts. Try again later.' },
|
||||||
|
{ status: 429, headers: { 'Retry-After': String(retryAfter) } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password } = body;
|
||||||
|
|
||||||
|
if (!password || typeof password !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Password is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await verifyAdminPassword(password);
|
||||||
|
if (!valid) {
|
||||||
|
await auditLog('admin.login_failed', {}, ip);
|
||||||
|
logger.warn('Admin login failed', { ip });
|
||||||
|
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setAdminSessionCookie();
|
||||||
|
await updateLastLogin();
|
||||||
|
await auditLog('admin.login', {}, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin login error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/auth — Check session status
|
||||||
|
* Also checks if the user is a Stalwart admin (admin panel enabled even without password).
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
await initAdminPassword();
|
||||||
|
const adminEnabled = isAdminEnabled();
|
||||||
|
const isStalwartAdmin = await checkStalwartAdmin(request);
|
||||||
|
|
||||||
|
// If neither password-based admin nor Stalwart admin, admin is disabled
|
||||||
|
if (!adminEnabled && !isStalwartAdmin) {
|
||||||
|
return NextResponse.json({ enabled: false, authenticated: false, stalwartAdmin: false }, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) {
|
||||||
|
return NextResponse.json({
|
||||||
|
enabled: adminEnabled,
|
||||||
|
authenticated: false,
|
||||||
|
stalwartAdmin: isStalwartAdmin,
|
||||||
|
}, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = getAdminMeta();
|
||||||
|
return NextResponse.json({
|
||||||
|
enabled: adminEnabled,
|
||||||
|
authenticated: true,
|
||||||
|
stalwartAdmin: isStalwartAdmin,
|
||||||
|
lastLogin: meta?.lastLogin,
|
||||||
|
passwordChangedAt: meta?.passwordChangedAt,
|
||||||
|
}, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin status error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/auth — Logout
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
await clearAdminSessionCookie();
|
||||||
|
await auditLog('admin.logout', {}, ip);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin logout error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
|
||||||
|
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/branding/[filename] — Serve uploaded branding images
|
||||||
|
*
|
||||||
|
* This endpoint is public (no admin auth) so browsers can load images.
|
||||||
|
* Only files in the branding directory are served; directory traversal is prevented.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ filename: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { filename } = await params;
|
||||||
|
|
||||||
|
// Sanitize: only allow basename, no path separators
|
||||||
|
const safe = path.basename(filename);
|
||||||
|
if (safe !== filename || filename.includes('..')) {
|
||||||
|
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(safe).toLowerCase();
|
||||||
|
const contentType = MIME_TYPES[ext];
|
||||||
|
if (!contentType) {
|
||||||
|
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(BRANDING_DIR, safe);
|
||||||
|
|
||||||
|
// Ensure resolved path is still within BRANDING_DIR
|
||||||
|
const resolved = path.resolve(filePath);
|
||||||
|
if (!resolved.startsWith(path.resolve(BRANDING_DIR))) {
|
||||||
|
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStat = await stat(resolved).catch(() => null);
|
||||||
|
if (!fileStat || !fileStat.isFile()) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await readFile(resolved);
|
||||||
|
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||||
|
'Content-Length': String(buffer.length),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
|
||||||
|
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||||
|
const ALLOWED_MIME_TYPES = new Set([
|
||||||
|
'image/svg+xml',
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/webp',
|
||||||
|
'image/x-icon',
|
||||||
|
'image/vnd.microsoft.icon',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Slots that correspond to branding config keys */
|
||||||
|
const VALID_SLOTS = new Set([
|
||||||
|
'faviconUrl',
|
||||||
|
'appLogoLightUrl',
|
||||||
|
'appLogoDarkUrl',
|
||||||
|
'loginLogoLightUrl',
|
||||||
|
'loginLogoDarkUrl',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
// Strip directory traversal, keep only safe chars
|
||||||
|
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/branding — Upload a branding image file
|
||||||
|
*
|
||||||
|
* Expects multipart/form-data with:
|
||||||
|
* - file: the image file
|
||||||
|
* - slot: which branding field this is for (e.g. "faviconUrl")
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
const slot = formData.get('slot') as string | null;
|
||||||
|
|
||||||
|
if (!file || !slot) {
|
||||||
|
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_SLOTS.has(slot)) {
|
||||||
|
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unsupported file type: ${file.type}. Allowed: SVG, PNG, JPEG, WebP, ICO` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine extension from mime type
|
||||||
|
const extMap: Record<string, string> = {
|
||||||
|
'image/svg+xml': '.svg',
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
'image/x-icon': '.ico',
|
||||||
|
'image/vnd.microsoft.icon': '.ico',
|
||||||
|
};
|
||||||
|
const ext = extMap[file.type] || '.png';
|
||||||
|
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||||
|
const filePath = path.join(BRANDING_DIR, safeName);
|
||||||
|
|
||||||
|
// Ensure branding directory exists
|
||||||
|
if (!existsSync(BRANDING_DIR)) {
|
||||||
|
await mkdir(BRANDING_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write file to disk
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
await writeFile(filePath, buffer);
|
||||||
|
|
||||||
|
// Update config to point to the served URL
|
||||||
|
const servedUrl = `/api/admin/branding/${safeName}`;
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||||
|
|
||||||
|
await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ url: servedUrl, filename: safeName });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Branding upload error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/branding — Remove an uploaded branding file
|
||||||
|
*
|
||||||
|
* Expects JSON body: { slot: string }
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { slot } = await request.json();
|
||||||
|
|
||||||
|
if (!slot || !VALID_SLOTS.has(slot)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and remove matching files for this slot
|
||||||
|
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
||||||
|
let removed = false;
|
||||||
|
for (const ext of possibleExts) {
|
||||||
|
const filePath = path.join(BRANDING_DIR, `${slot}${ext}`);
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
await unlink(filePath);
|
||||||
|
removed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the config override so it falls back to default/env
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
await configManager.removeAdminOverride(slot);
|
||||||
|
|
||||||
|
await auditLog('branding_delete', { slot, fileRemoved: removed }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Branding delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { changeAdminPassword } from '@/lib/admin/password';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/change-password — Change admin password
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { currentPassword, newPassword } = await request.json();
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword || typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Both current and new password are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
return NextResponse.json({ error: 'New password must be at least 8 characters' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await changeAdminPassword(currentPassword, newPassword);
|
||||||
|
if (!success) {
|
||||||
|
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog('admin.change-password', {}, ip);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin change password 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 { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/config — Get full config with sources (admin-protected)
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const config = configManager.getAllWithSources();
|
||||||
|
|
||||||
|
return NextResponse.json(config, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin config read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/admin/config — Update config overrides (admin-protected)
|
||||||
|
*/
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const updates = await request.json();
|
||||||
|
|
||||||
|
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
||||||
|
return NextResponse.json({ error: 'Request body must be an object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate keys
|
||||||
|
const validKeys = Object.keys(CONFIG_ENV_MAP);
|
||||||
|
const invalidKeys = Object.keys(updates).filter(k => !validKeys.includes(k));
|
||||||
|
if (invalidKeys.length > 0) {
|
||||||
|
return NextResponse.json({ error: `Unknown config keys: ${invalidKeys.join(', ')}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get old values for audit
|
||||||
|
const oldValues: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(updates)) {
|
||||||
|
oldValues[key] = configManager.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
await configManager.setAdminConfig(updates);
|
||||||
|
await auditLog('config.update', { changes: Object.keys(updates).map(k => ({ key: k, old: oldValues[k], new: updates[k] })) }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin config update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/config — Remove admin override for a key (revert to env/default)
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { key } = await request.json();
|
||||||
|
|
||||||
|
if (!key || typeof key !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Key is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CONFIG_ENV_MAP[key]) {
|
||||||
|
return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldValue = configManager.get(key);
|
||||||
|
await configManager.removeAdminOverride(key);
|
||||||
|
await auditLog('config.revert', { key, oldValue }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin config revert error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
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 {
|
||||||
|
savePlugin,
|
||||||
|
saveTheme,
|
||||||
|
getPluginRegistry,
|
||||||
|
getThemeRegistry,
|
||||||
|
type ServerPlugin,
|
||||||
|
type ServerTheme,
|
||||||
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||||
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
|
|
||||||
|
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/marketplace — Search/browse the extension directory
|
||||||
|
* Proxies to the extension directory API
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { searchParams } = request.nextUrl;
|
||||||
|
const url = new URL('/api/v1/extensions', DIRECTORY_URL);
|
||||||
|
|
||||||
|
// Forward all search params
|
||||||
|
for (const [key, value] of searchParams.entries()) {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch from extension directory' },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Enrich with install status
|
||||||
|
const [pluginRegistry, themeRegistry] = await Promise.all([
|
||||||
|
getPluginRegistry(),
|
||||||
|
getThemeRegistry(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
|
||||||
|
const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
|
||||||
|
|
||||||
|
if (data.data) {
|
||||||
|
data.data = data.data.map((ext: Record<string, unknown>) => ({
|
||||||
|
...ext,
|
||||||
|
installed: ext.type === 'theme'
|
||||||
|
? installedThemes.has(ext.slug as string)
|
||||||
|
: installedPlugins.has(ext.slug as string),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Marketplace search error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Failed to connect to extension directory' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/marketplace — Install an extension from the directory
|
||||||
|
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { slug, version, type } = await request.json();
|
||||||
|
|
||||||
|
if (!slug || !version || !type) {
|
||||||
|
return NextResponse.json({ error: 'Missing slug, version, or type' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type !== 'plugin' && type !== 'theme') {
|
||||||
|
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the bundle from the directory
|
||||||
|
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL);
|
||||||
|
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bundleRes.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Failed to download bundle: ${bundleRes.status}` },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await bundleRes.arrayBuffer();
|
||||||
|
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
|
||||||
|
|
||||||
|
if (buffer.byteLength > maxSize) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the ZIP
|
||||||
|
let zip: JSZip;
|
||||||
|
try {
|
||||||
|
zip = await JSZip.loadAsync(buffer);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid ZIP file from directory' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find root directory
|
||||||
|
const entries = Object.keys(zip.files);
|
||||||
|
const topDirs = new Set(entries.map(e => e.split('/')[0]));
|
||||||
|
let root = '';
|
||||||
|
if (topDirs.size === 1) {
|
||||||
|
const dir = [...topDirs][0];
|
||||||
|
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
|
||||||
|
root = dir + '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read manifest
|
||||||
|
const manifestFile = zip.file(root + 'manifest.json');
|
||||||
|
if (!manifestFile) {
|
||||||
|
return NextResponse.json({ error: 'Bundle missing manifest.json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(await manifestFile.async('string'));
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid manifest.json in bundle' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
if (type === 'theme') {
|
||||||
|
// Read theme.css
|
||||||
|
const cssFile = zip.file(root + 'theme.css');
|
||||||
|
if (!cssFile) {
|
||||||
|
return NextResponse.json({ error: 'Theme bundle missing theme.css' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let css = await cssFile.async('string');
|
||||||
|
|
||||||
|
// Validate and sanitize CSS
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const safety = validateThemeCSSSafety(css);
|
||||||
|
if (!safety.valid) {
|
||||||
|
const sanitized = sanitizeThemeCSS(css);
|
||||||
|
css = sanitized.css;
|
||||||
|
warnings.push(...sanitized.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme: ServerTheme = {
|
||||||
|
id: (manifest.id as string) || slug,
|
||||||
|
name: (manifest.name as string) || slug,
|
||||||
|
version: (manifest.version as string) || version,
|
||||||
|
author: (manifest.author as string) || 'Unknown',
|
||||||
|
description: (manifest.description as string) || '',
|
||||||
|
variants: (manifest.variants as string[]) || ['light', 'dark'],
|
||||||
|
enabled: true,
|
||||||
|
installedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveTheme(theme, css);
|
||||||
|
await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, theme, warnings });
|
||||||
|
} else {
|
||||||
|
// Plugin installation
|
||||||
|
// Read entrypoint JS
|
||||||
|
const entrypoint = (manifest.entrypoint as string) || 'index.js';
|
||||||
|
const jsFile = zip.file(root + entrypoint);
|
||||||
|
if (!jsFile) {
|
||||||
|
return NextResponse.json({ error: `Bundle missing entrypoint: ${entrypoint}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await jsFile.async('string');
|
||||||
|
|
||||||
|
// Validate permissions
|
||||||
|
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
|
||||||
|
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
||||||
|
const unknownPerms = permissions.filter(p => !validPerms.has(p));
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
if (unknownPerms.length > 0) {
|
||||||
|
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin: ServerPlugin = {
|
||||||
|
id: (manifest.id as string) || slug,
|
||||||
|
name: (manifest.name as string) || slug,
|
||||||
|
version: (manifest.version as string) || version,
|
||||||
|
author: (manifest.author as string) || 'Unknown',
|
||||||
|
description: (manifest.description as string) || '',
|
||||||
|
type: (manifest.type as string) || 'hook',
|
||||||
|
permissions,
|
||||||
|
entrypoint,
|
||||||
|
enabled: true,
|
||||||
|
installedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await savePlugin(plugin, code);
|
||||||
|
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, plugin, warnings });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Installation failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/plugins/[id]/bundle — Serve plugin JS bundle
|
||||||
|
*
|
||||||
|
* Public endpoint so the client-side plugin loader can fetch bundles.
|
||||||
|
* Only serves plugins that exist in the registry and are enabled.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Validate ID format to prevent path traversal
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await getPlugin(id);
|
||||||
|
if (!plugin) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin.enabled) {
|
||||||
|
return NextResponse.json({ error: 'Plugin is disabled' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await getPluginBundle(id);
|
||||||
|
if (!code) {
|
||||||
|
return NextResponse.json({ error: 'Bundle not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(code, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/javascript; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||||
|
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
|
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
||||||
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/plugins/[id]/config — Read all config for a plugin
|
||||||
|
*
|
||||||
|
* Returns the full config object for admin-configured plugin settings.
|
||||||
|
* This endpoint is accessible from the client-side plugin API.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await getPlugin(id);
|
||||||
|
if (!plugin) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getPluginConfig(id);
|
||||||
|
return NextResponse.json(config, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/admin/plugins/[id]/config — Set a config key
|
||||||
|
*
|
||||||
|
* Body: { key: string, value: unknown }
|
||||||
|
* Requires admin authentication (checked via admin session).
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await getPlugin(id);
|
||||||
|
if (!plugin) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { key?: string; value?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.key || typeof body.key !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'key is required and must be a string' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate key format (alphanumeric, hyphens, underscores, dots)
|
||||||
|
if (!/^[a-zA-Z0-9._-]+$/.test(body.key)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setPluginConfig(id, body.key, body.value);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/plugins/[id]/config — Delete a config key
|
||||||
|
*
|
||||||
|
* Body: { key: string }
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { key?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.key || typeof body.key !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'key is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await deletePluginConfigKey(id, body.key);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
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 {
|
||||||
|
getPluginRegistry,
|
||||||
|
savePlugin,
|
||||||
|
deletePlugin as removePlugin,
|
||||||
|
type ServerPlugin,
|
||||||
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
|
||||||
|
// Server-side extraction using the same validation logic
|
||||||
|
// ZIP parsing needs to happen on the server for admin-uploaded plugins
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { MAX_PLUGIN_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||||
|
|
||||||
|
const SUSPICIOUS_JS_PATTERNS = [
|
||||||
|
{ pattern: /\beval\s*\(/g, label: 'eval()' },
|
||||||
|
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
|
||||||
|
{ pattern: /document\.cookie/g, label: 'document.cookie' },
|
||||||
|
{ pattern: /document\.write/g, label: 'document.write' },
|
||||||
|
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/plugins — List all admin-managed plugins
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const registry = await getPluginRegistry();
|
||||||
|
return NextResponse.json(registry.plugins, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Plugin list error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/plugins — Upload and install a plugin ZIP
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_PLUGIN_SIZE) {
|
||||||
|
return NextResponse.json({ error: 'Plugin ZIP exceeds 5 MB size limit' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and validate ZIP
|
||||||
|
let zip: JSZip;
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
zip = await JSZip.loadAsync(buffer);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find root
|
||||||
|
const entries = Object.keys(zip.files);
|
||||||
|
const topDirs = new Set(entries.map(e => e.split('/')[0]));
|
||||||
|
let root = '';
|
||||||
|
if (topDirs.size === 1) {
|
||||||
|
const dir = [...topDirs][0];
|
||||||
|
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
|
||||||
|
root = dir + '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read manifest
|
||||||
|
const manifestFile = zip.file(root + 'manifest.json');
|
||||||
|
if (!manifestFile) {
|
||||||
|
return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(await manifestFile.async('string'));
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate manifest
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
|
||||||
|
if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
|
||||||
|
if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
|
||||||
|
if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
|
||||||
|
if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') errors.push('Missing or invalid "entrypoint"');
|
||||||
|
|
||||||
|
const validTypes = ['ui-extension', 'sidebar-app', 'hook'];
|
||||||
|
if (!validTypes.includes(manifest.type as string)) {
|
||||||
|
errors.push(`Invalid type. Must be one of: ${validTypes.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
|
||||||
|
errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.permissions && Array.isArray(manifest.permissions)) {
|
||||||
|
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
||||||
|
const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p));
|
||||||
|
if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file extensions
|
||||||
|
for (const [filePath, entry] of Object.entries(zip.files)) {
|
||||||
|
if (entry.dir) continue;
|
||||||
|
const ext = filePath.lastIndexOf('.') >= 0 ? filePath.slice(filePath.lastIndexOf('.')).toLowerCase() : '';
|
||||||
|
if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) {
|
||||||
|
errors.push(`Disallowed file type: ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read entrypoint code
|
||||||
|
const entryFile = zip.file(root + (manifest.entrypoint as string));
|
||||||
|
if (!entryFile) {
|
||||||
|
return NextResponse.json({ error: `Missing entrypoint: ${manifest.entrypoint}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
const code = await entryFile.async('string');
|
||||||
|
|
||||||
|
// Security warnings (logged but not blocking for admin)
|
||||||
|
const warnings: string[] = [];
|
||||||
|
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||||
|
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const plugin: ServerPlugin = {
|
||||||
|
id: manifest.id as string,
|
||||||
|
name: manifest.name as string,
|
||||||
|
version: manifest.version as string,
|
||||||
|
author: manifest.author as string,
|
||||||
|
description: (manifest.description as string) || '',
|
||||||
|
type: manifest.type as string,
|
||||||
|
permissions: (manifest.permissions as string[]) || [],
|
||||||
|
entrypoint: manifest.entrypoint as string,
|
||||||
|
enabled: true,
|
||||||
|
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||||
|
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||||
|
: {}),
|
||||||
|
installedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await savePlugin(plugin, code);
|
||||||
|
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, warnings }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ plugin, warnings });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/admin/plugins — Update plugin metadata (enable/disable)
|
||||||
|
* Body: { id: string, enabled: boolean }
|
||||||
|
*/
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { id, enabled, forceEnabled } = await request.json();
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') {
|
||||||
|
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
|
||||||
|
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
||||||
|
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
||||||
|
|
||||||
|
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
|
||||||
|
const updated = await updatePluginMeta(id, updates);
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog('plugin.update', { id, ...updates }, ip);
|
||||||
|
return NextResponse.json({ plugin: updated });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Plugin update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/plugins — Remove a plugin
|
||||||
|
* Body: { id: string }
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { id } = await request.json();
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = await removePlugin(id);
|
||||||
|
if (!removed) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog('plugin.delete', { id }, ip);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Plugin delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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 { SettingsPolicy } from '@/lib/admin/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/policy — Get settings policy (NOT admin-protected — users read this)
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const policy = configManager.getPolicy();
|
||||||
|
return NextResponse.json(policy, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Policy read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/admin/policy — Update settings policy (admin-protected)
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const policy = await request.json() as SettingsPolicy;
|
||||||
|
|
||||||
|
if (!policy || typeof policy !== 'object') {
|
||||||
|
return NextResponse.json({ error: 'Invalid policy object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (policy.restrictions && typeof policy.restrictions !== 'object') {
|
||||||
|
return NextResponse.json({ error: 'restrictions must be an object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (policy.features && typeof policy.features !== 'object') {
|
||||||
|
return NextResponse.json({ error: 'features must be an object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (policy.themePolicy && typeof policy.themePolicy !== 'object') {
|
||||||
|
return NextResponse.json({ error: 'themePolicy must be an object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await configManager.setPolicy(policy);
|
||||||
|
await auditLog('policy.update', { restrictionCount: Object.keys(policy.restrictions || {}).length }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Policy update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/stalwart-check
|
||||||
|
* Check if the currently logged-in user is a Stalwart admin.
|
||||||
|
* Probes the admin-only principal-list endpoint — if the user can access it, they're an admin.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) {
|
||||||
|
return NextResponse.json({ isStalwartAdmin: false }, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe an admin-only endpoint: listing principals requires admin privileges.
|
||||||
|
// Use limit=1 to minimize payload.
|
||||||
|
const url = `${creds.apiUrl}/api/principal?limit=1`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Authorization': creds.authHeader },
|
||||||
|
});
|
||||||
|
|
||||||
|
const isStalwartAdmin = response.ok;
|
||||||
|
logger.info('Stalwart admin check', { username: creds.username, status: response.status, isStalwartAdmin });
|
||||||
|
|
||||||
|
return NextResponse.json({ isStalwartAdmin }, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Stalwart admin check error', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ isStalwartAdmin: false }, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/themes/[id]/css — Serve theme CSS to clients
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Validate ID format
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid theme ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify theme exists and is enabled
|
||||||
|
const registry = await getThemeRegistry();
|
||||||
|
const theme = registry.themes.find(t => t.id === id);
|
||||||
|
if (!theme) {
|
||||||
|
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!theme.enabled) {
|
||||||
|
return NextResponse.json({ error: 'Theme is disabled' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const css = await getThemeCSS(id);
|
||||||
|
if (!css) {
|
||||||
|
return NextResponse.json({ error: 'Theme CSS not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(css, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Theme CSS serve error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
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 {
|
||||||
|
getThemeRegistry,
|
||||||
|
saveTheme,
|
||||||
|
deleteTheme as removeTheme,
|
||||||
|
type ServerTheme,
|
||||||
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||||
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/themes — List all admin-managed themes
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const registry = await getThemeRegistry();
|
||||||
|
return NextResponse.json(registry.themes, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Theme list error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/themes — Upload and install a theme ZIP
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_THEME_SIZE) {
|
||||||
|
return NextResponse.json({ error: 'Theme ZIP exceeds 1 MB size limit' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and validate ZIP
|
||||||
|
let zip: JSZip;
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
zip = await JSZip.loadAsync(buffer);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find root
|
||||||
|
const entries = Object.keys(zip.files);
|
||||||
|
const topDirs = new Set(entries.map(e => e.split('/')[0]));
|
||||||
|
let root = '';
|
||||||
|
if (topDirs.size === 1) {
|
||||||
|
const dir = [...topDirs][0];
|
||||||
|
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
|
||||||
|
root = dir + '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read manifest
|
||||||
|
const manifestFile = zip.file(root + 'manifest.json');
|
||||||
|
if (!manifestFile) {
|
||||||
|
return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(await manifestFile.async('string'));
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate manifest
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
|
||||||
|
if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
|
||||||
|
if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
|
||||||
|
if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
|
||||||
|
|
||||||
|
if (manifest.type !== 'theme') {
|
||||||
|
errors.push(`Expected type "theme", got "${manifest.type}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
|
||||||
|
errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) {
|
||||||
|
errors.push('Missing or empty "variants" array');
|
||||||
|
} else {
|
||||||
|
const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark');
|
||||||
|
if (!valid) errors.push('Variants must be "light" or "dark"');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read theme.css
|
||||||
|
const cssFile = zip.file(root + 'theme.css');
|
||||||
|
if (!cssFile) {
|
||||||
|
return NextResponse.json({ error: 'Missing theme.css' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let css = await cssFile.async('string');
|
||||||
|
|
||||||
|
// Validate and sanitize CSS
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const safety = validateThemeCSSSafety(css);
|
||||||
|
if (!safety.valid) {
|
||||||
|
const sanitized = sanitizeThemeCSS(css);
|
||||||
|
css = sanitized.css;
|
||||||
|
warnings.push(...sanitized.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const theme: ServerTheme = {
|
||||||
|
id: manifest.id as string,
|
||||||
|
name: manifest.name as string,
|
||||||
|
version: manifest.version as string,
|
||||||
|
author: manifest.author as string,
|
||||||
|
description: (manifest.description as string) || '',
|
||||||
|
variants: manifest.variants as string[],
|
||||||
|
enabled: true,
|
||||||
|
installedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveTheme(theme, css);
|
||||||
|
await auditLog('theme.install', { id: theme.id, name: theme.name, version: theme.version, warnings }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ theme, warnings });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Theme install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/admin/themes — Update theme metadata (enable/disable)
|
||||||
|
* Body: { id: string, enabled: boolean }
|
||||||
|
*/
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { id, enabled, forceEnabled } = await request.json();
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing theme id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') {
|
||||||
|
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
|
||||||
|
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
||||||
|
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
||||||
|
|
||||||
|
const { updateThemeMeta } = await import('@/lib/admin/plugin-registry');
|
||||||
|
const updated = await updateThemeMeta(id, updates);
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog('theme.update', { id, ...updates }, ip);
|
||||||
|
return NextResponse.json({ theme: updated });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Theme update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/themes — Remove a theme
|
||||||
|
* Body: { id: string }
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { id } = await request.json();
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing theme id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = await removeTheme(id);
|
||||||
|
if (!removed) {
|
||||||
|
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog('theme.delete', { id }, ip);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Theme delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,30 +2,38 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||||
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
|
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
httpOnly: true,
|
...getCookieOptions(),
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax' as const,
|
|
||||||
path: '/',
|
|
||||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getSlot(request: NextRequest): number {
|
||||||
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
|
if (raw === null) return 0;
|
||||||
|
const slot = parseInt(raw, 10);
|
||||||
|
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
|
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
|
||||||
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { serverUrl, username, password } = await request.json();
|
const { serverUrl, username, password, slot: bodySlot } = await request.json();
|
||||||
if (!serverUrl || !username || !password) {
|
if (!serverUrl || !username || !password) {
|
||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||||
|
const cookieName = sessionCookieName(slot);
|
||||||
const token = encryptSession(serverUrl, username, password);
|
const token = encryptSession(serverUrl, username, password);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(SESSION_COOKIE, token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -34,10 +42,12 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = sessionCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
const token = cookieStore.get(cookieName)?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
||||||
@@ -45,7 +55,46 @@ export async function GET() {
|
|||||||
|
|
||||||
const credentials = decryptSession(token);
|
const credentials = decryptSession(token);
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
cookieStore.delete(SESSION_COOKIE);
|
cookieStore.delete(cookieName);
|
||||||
|
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
|
||||||
|
const { serverUrl, username } = credentials;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ serverUrl, username },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Session read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT — retrieve full credentials (including password) for session restoration.
|
||||||
|
* Protected by Sec-Fetch-Site to ensure only same-origin browser requests succeed.
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Block non-browser and cross-origin requests
|
||||||
|
const secFetchSite = request.headers.get('sec-fetch-site');
|
||||||
|
if (secFetchSite !== 'same-origin') {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = sessionCookieName(slot);
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(cookieName)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = decryptSession(token);
|
||||||
|
if (!credentials) {
|
||||||
|
cookieStore.delete(cookieName);
|
||||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +107,21 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE() {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.delete(SESSION_COOKIE);
|
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||||
|
|
||||||
|
if (all) {
|
||||||
|
// Delete all session cookies (slots 0-4)
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
cookieStore.delete(sessionCookieName(i));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const slot = getSlot(request);
|
||||||
|
cookieStore.delete(sessionCookieName(slot));
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Session clear error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Session clear error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { decryptPayload } from '@/lib/auth/crypto';
|
||||||
|
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
|
||||||
|
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||||
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
|
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { code, state } = await request.json();
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and decrypt the pending SSO cookie
|
||||||
|
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
|
||||||
|
if (!pendingCookie) {
|
||||||
|
logger.warn('SSO complete: no pending cookie found');
|
||||||
|
return NextResponse.json({ error: 'No pending SSO session. Please start the login flow again.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = decryptPayload(pendingCookie);
|
||||||
|
if (!pending) {
|
||||||
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
return NextResponse.json({ error: 'Invalid SSO session' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate state
|
||||||
|
if (pending.state !== state) {
|
||||||
|
logger.warn('SSO complete: state mismatch');
|
||||||
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
return NextResponse.json({ error: 'State mismatch' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate TTL
|
||||||
|
const createdAt = pending.created_at as number;
|
||||||
|
if (!createdAt || Date.now() - createdAt > SSO_PENDING_MAX_AGE_MS) {
|
||||||
|
logger.warn('SSO complete: pending session expired');
|
||||||
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
return NextResponse.json({ error: 'SSO session expired. Please try again.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeVerifier = pending.code_verifier as string;
|
||||||
|
const redirectUri = pending.redirect_uri as string;
|
||||||
|
|
||||||
|
if (!codeVerifier || !redirectUri) {
|
||||||
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
return NextResponse.json({ error: 'Invalid SSO session data' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange code for tokens
|
||||||
|
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
|
||||||
|
|
||||||
|
// Store refresh token
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
const cookieName = refreshTokenCookieName(0);
|
||||||
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete pending cookie
|
||||||
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
expires_in: tokens.expires_in,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up pending cookie on any error
|
||||||
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
logger.error('SSO complete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { encryptPayload } from '@/lib/auth/crypto';
|
||||||
|
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
||||||
|
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||||
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
|
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
||||||
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
|
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
if (!process.env.SESSION_SECRET) {
|
||||||
|
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { redirect_uri, locale } = await request.json();
|
||||||
|
|
||||||
|
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate redirect_uri origin matches the request origin to prevent open redirects
|
||||||
|
const requestOrigin = request.headers.get('origin') || request.nextUrl.origin;
|
||||||
|
try {
|
||||||
|
const redirectOrigin = new URL(redirect_uri).origin;
|
||||||
|
if (redirectOrigin !== requestOrigin) {
|
||||||
|
logger.warn('SSO start: redirect_uri origin mismatch', { redirectOrigin, requestOrigin });
|
||||||
|
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId, discoveryUrl } = getRequiredConfig();
|
||||||
|
const metadata = await discoverOAuth(discoveryUrl);
|
||||||
|
|
||||||
|
if (!metadata?.authorization_endpoint) {
|
||||||
|
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate PKCE + state server-side
|
||||||
|
const codeVerifier = generateCodeVerifierServer();
|
||||||
|
const codeChallenge = generateCodeChallengeServer(codeVerifier);
|
||||||
|
const state = generateStateServer();
|
||||||
|
|
||||||
|
// Encrypt and store in httpOnly cookie
|
||||||
|
const pendingData = {
|
||||||
|
state,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
redirect_uri,
|
||||||
|
created_at: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const encrypted = encryptPayload(pendingData);
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const baseCookieOpts = getCookieOptions();
|
||||||
|
cookieStore.set(SSO_PENDING_COOKIE, encrypted, {
|
||||||
|
...baseCookieOpts,
|
||||||
|
maxAge: SSO_PENDING_MAX_AGE,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build authorize URL
|
||||||
|
const authUrl = new URL(metadata.authorization_endpoint);
|
||||||
|
authUrl.searchParams.set('response_type', 'code');
|
||||||
|
authUrl.searchParams.set('client_id', clientId);
|
||||||
|
authUrl.searchParams.set('redirect_uri', redirect_uri);
|
||||||
|
authUrl.searchParams.set('scope', OAUTH_SCOPES);
|
||||||
|
authUrl.searchParams.set('state', state);
|
||||||
|
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||||
|
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
|
||||||
|
if (locale) {
|
||||||
|
authUrl.searchParams.set('ui_locales', locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
authorize_url: authUrl.toString(),
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('SSO start error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
-83
@@ -1,100 +1,39 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||||
import { REFRESH_TOKEN_COOKIE } from '@/lib/oauth/tokens';
|
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||||
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
|
function getSlot(request: NextRequest): number {
|
||||||
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
const COOKIE_OPTIONS = {
|
if (raw === null) return 0;
|
||||||
httpOnly: true,
|
const slot = parseInt(raw, 10);
|
||||||
secure: process.env.NODE_ENV === 'production',
|
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
|
||||||
sameSite: 'lax' as const,
|
return slot;
|
||||||
path: '/',
|
|
||||||
maxAge: 30 * 24 * 60 * 60,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getRequiredConfig() {
|
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
|
||||||
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
|
||||||
const issuerUrl = process.env.OAUTH_ISSUER_URL;
|
|
||||||
if (!clientId || !serverUrl) {
|
|
||||||
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
|
|
||||||
}
|
|
||||||
const discoveryUrl = issuerUrl?.trim() || serverUrl;
|
|
||||||
if (issuerUrl !== undefined && !issuerUrl.trim()) {
|
|
||||||
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
|
|
||||||
}
|
|
||||||
return { clientId, serverUrl, discoveryUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getTokenEndpoint(): Promise<string> {
|
|
||||||
const { discoveryUrl } = getRequiredConfig();
|
|
||||||
const metadata = await discoverOAuth(discoveryUrl);
|
|
||||||
if (!metadata?.token_endpoint) {
|
|
||||||
throw new Error('OAuth token endpoint not found');
|
|
||||||
}
|
|
||||||
return metadata.token_endpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMetadata(): Promise<import('@/lib/oauth/discovery').OAuthMetadata | null> {
|
|
||||||
const { discoveryUrl } = getRequiredConfig();
|
|
||||||
return discoverOAuth(discoveryUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOAuthParams(base: Record<string, string>): URLSearchParams {
|
|
||||||
const { clientId } = getRequiredConfig();
|
|
||||||
const params = new URLSearchParams({ ...base, client_id: clientId });
|
|
||||||
if (CLIENT_SECRET) {
|
|
||||||
params.set('client_secret', CLIENT_SECRET);
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { code, code_verifier, redirect_uri } = await request.json();
|
const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json();
|
||||||
|
|
||||||
if (!code || !code_verifier || !redirect_uri) {
|
if (!code || !code_verifier || !redirect_uri) {
|
||||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenEndpoint = await getTokenEndpoint();
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||||
|
|
||||||
const params = buildOAuthParams({
|
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri);
|
||||||
grant_type: 'authorization_code',
|
|
||||||
code,
|
|
||||||
redirect_uri,
|
|
||||||
code_verifier,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokenResponse = await fetch(tokenEndpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: params.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tokenResponse.ok) {
|
|
||||||
const errorText = await tokenResponse.text();
|
|
||||||
logger.error('Token exchange failed', { status: tokenResponse.status, error: errorText });
|
|
||||||
return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = await tokenResponse.json();
|
|
||||||
|
|
||||||
if (!tokens.access_token) {
|
|
||||||
logger.error('Token response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) });
|
|
||||||
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
expires_in: tokens.expires_in || 3600,
|
expires_in: tokens.expires_in,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -104,10 +43,12 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT() {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
|
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||||
|
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
||||||
@@ -129,7 +70,7 @@ export async function PUT() {
|
|||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
const errorText = await tokenResponse.text();
|
const errorText = await tokenResponse.text();
|
||||||
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
||||||
cookieStore.delete(REFRESH_TOKEN_COOKIE);
|
cookieStore.delete(cookieName);
|
||||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +82,7 @@ export async function PUT() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -154,10 +95,39 @@ export async function PUT() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE() {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||||
|
|
||||||
|
if (all) {
|
||||||
|
// Revoke and delete all refresh token cookies (slots 0-4)
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const name = refreshTokenCookieName(i);
|
||||||
|
const token = cookieStore.get(name)?.value;
|
||||||
|
if (token) {
|
||||||
|
// Best-effort revocation
|
||||||
|
try {
|
||||||
|
const metadata = await getMetadata().catch(() => null);
|
||||||
|
if (metadata?.revocation_endpoint) {
|
||||||
|
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' });
|
||||||
|
await fetch(metadata.revocation_endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString(),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
cookieStore.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
|
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||||
const metadata = await getMetadata().catch((err) => {
|
const metadata = await getMetadata().catch((err) => {
|
||||||
logger.warn('Failed to discover OAuth metadata during logout', {
|
logger.warn('Failed to discover OAuth metadata during logout', {
|
||||||
error: err instanceof Error ? err.message : 'Unknown error',
|
error: err instanceof Error ? err.message : 'Unknown error',
|
||||||
@@ -186,7 +156,7 @@ export async function DELETE() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cookieStore.delete(REFRESH_TOKEN_COOKIE);
|
cookieStore.delete(cookieName);
|
||||||
}
|
}
|
||||||
|
|
||||||
let end_session_url: string | undefined;
|
let end_session_url: string | undefined;
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
|
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||||
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||||
|
*
|
||||||
|
* This allows 2FA users who log in with basic auth + TOTP to upgrade
|
||||||
|
* to token-based auth, avoiding session expiry when the TOTP rotates.
|
||||||
|
*
|
||||||
|
* Tries three strategies:
|
||||||
|
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
|
||||||
|
* 2. ROPC grant without client_id
|
||||||
|
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function tryTokenRequest(
|
||||||
|
tokenEndpoint: string,
|
||||||
|
params: URLSearchParams,
|
||||||
|
extraHeaders?: Record<string, string>,
|
||||||
|
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
|
||||||
|
const response = await fetch(tokenEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await response.json();
|
||||||
|
if (!tokens.access_token) {
|
||||||
|
return { ok: false, status: 502, error: 'Response missing access_token' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, tokens };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||||
|
// 1. Try OAuth discovery
|
||||||
|
const metadata = await discoverOAuth(serverUrl);
|
||||||
|
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||||
|
|
||||||
|
// 2. Try common Stalwart token endpoint paths directly
|
||||||
|
const candidates = [
|
||||||
|
`${serverUrl}/auth/token`,
|
||||||
|
`${serverUrl}/api/oauth/token`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const url of candidates) {
|
||||||
|
try {
|
||||||
|
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
|
||||||
|
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
|
||||||
|
if (probe.status !== 404 && probe.status !== 405) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network error — endpoint not reachable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { serverUrl, username, password, slot: bodySlot } = await request.json();
|
||||||
|
|
||||||
|
if (!serverUrl || !username || !password) {
|
||||||
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||||
|
|
||||||
|
// Use the server-side JMAP_SERVER_URL if set (may differ from the
|
||||||
|
// public URL the browser uses, e.g. inside Docker).
|
||||||
|
const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl;
|
||||||
|
|
||||||
|
const tokenEndpoint = await findTokenEndpoint(internalServerUrl);
|
||||||
|
if (!tokenEndpoint) {
|
||||||
|
// Also try with the client-provided URL in case the internal one differs
|
||||||
|
const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null;
|
||||||
|
if (!clientEndpoint) {
|
||||||
|
logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl });
|
||||||
|
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||||
|
}
|
||||||
|
return await attemptAllStrategies(clientEndpoint, username, password, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await attemptAllStrategies(tokenEndpoint, username, password, slot);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attemptAllStrategies(
|
||||||
|
tokenEndpoint: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
slot: number,
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||||
|
|
||||||
|
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
|
||||||
|
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
|
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||||
|
|
||||||
|
// Strategy 1: ROPC with client_id (if configured)
|
||||||
|
if (clientId) {
|
||||||
|
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
|
||||||
|
if (clientSecret) params.set('client_secret', clientSecret);
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: ROPC without client_id
|
||||||
|
{
|
||||||
|
const params = new URLSearchParams({ grant_type: 'password', username, password });
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
|
||||||
|
{
|
||||||
|
const params = new URLSearchParams({ grant_type: 'password' });
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 4: client_credentials with Basic Auth (last resort)
|
||||||
|
{
|
||||||
|
const params = new URLSearchParams({ grant_type: 'client_credentials' });
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('TOTP token exchange: all strategies failed', { attempts });
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'token_exchange_failed',
|
||||||
|
detail: 'All token exchange strategies failed',
|
||||||
|
attempts,
|
||||||
|
}, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeAndRespond(
|
||||||
|
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||||
|
slot: number,
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
expires_in: tokens.expires_in || 3600,
|
||||||
|
has_refresh_token: !!tokens.refresh_token,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
interface DiscoveryAccountRequest {
|
||||||
|
key: string;
|
||||||
|
candidates: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscoveryResult {
|
||||||
|
url: string | null;
|
||||||
|
resolvedAccount: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicUrl(serverUrl: string, path: string): string {
|
||||||
|
return new URL(path, serverUrl).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeCalendarHome(serverUrl: string, authHeader: string, accountName: string): Promise<string | null> {
|
||||||
|
const targetUrl = buildPublicUrl(serverUrl, `/dav/cal/${encodeURIComponent(accountName)}`);
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: 'PROPFIND',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
Depth: '0',
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
},
|
||||||
|
body: `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:">
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype/>
|
||||||
|
<D:displayname/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>`,
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 207) {
|
||||||
|
return targetUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get('Location');
|
||||||
|
if (location) {
|
||||||
|
return new URL(location, targetUrl).toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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().catch(() => ({}));
|
||||||
|
const accounts = Array.isArray(body.accounts) ? body.accounts as DiscoveryAccountRequest[] : [];
|
||||||
|
const wellKnownUrl = buildPublicUrl(creds.serverUrl, '/.well-known/caldav');
|
||||||
|
const discovered: Record<string, DiscoveryResult> = {};
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
if (!account?.key) continue;
|
||||||
|
|
||||||
|
const candidates = Array.from(new Set(
|
||||||
|
(account.candidates || [])
|
||||||
|
.map((candidate) => candidate?.trim())
|
||||||
|
.filter((candidate): candidate is string => Boolean(candidate))
|
||||||
|
));
|
||||||
|
|
||||||
|
let url: string | null = null;
|
||||||
|
let resolvedAccount: string | null = null;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
url = await probeCalendarHome(creds.serverUrl, creds.authHeader, candidate);
|
||||||
|
if (url) {
|
||||||
|
resolvedAccount = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('CalDAV discovery probe failed', {
|
||||||
|
accountKey: account.key,
|
||||||
|
candidate,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discovered[account.key] = { url, resolvedAccount };
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
wellKnownUrl,
|
||||||
|
accounts: discovered,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('CalDAV discovery failed', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-23
@@ -1,39 +1,55 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime configuration endpoint
|
* Runtime configuration endpoint
|
||||||
*
|
*
|
||||||
* This endpoint serves configuration values that can be set at runtime
|
* This endpoint serves configuration values that can be set at runtime
|
||||||
* via environment variables, enabling post-build configuration for
|
* via environment variables or admin dashboard overrides, enabling
|
||||||
* Docker deployments.
|
* post-build configuration for Docker deployments.
|
||||||
*
|
*
|
||||||
* Priority order:
|
* Priority order:
|
||||||
* 1. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
* 1. Admin dashboard overrides (data/admin/config.json)
|
||||||
* 2. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
* 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
||||||
* 3. Default values
|
* 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
||||||
|
* 4. Default values
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
logger.debug('Config requested');
|
logger.debug('Config requested');
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
|
||||||
|
const appName = configManager.get<string>('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||||
|
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
|
||||||
|
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||||
|
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
|
||||||
|
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
|
||||||
|
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
appName: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail',
|
appName,
|
||||||
jmapServerUrl: process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '',
|
jmapServerUrl,
|
||||||
oauthEnabled: process.env.OAUTH_ENABLED === 'true',
|
oauthEnabled,
|
||||||
oauthOnly: process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true',
|
oauthOnly,
|
||||||
oauthClientId: process.env.OAUTH_CLIENT_ID || '',
|
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||||
oauthIssuerUrl: process.env.OAUTH_ISSUER_URL || '',
|
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||||
rememberMeEnabled: !!process.env.SESSION_SECRET,
|
rememberMeEnabled: !!process.env.SESSION_SECRET,
|
||||||
settingsSyncEnabled: process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET,
|
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && !!process.env.SESSION_SECRET,
|
||||||
stalwartFeaturesEnabled: process.env.STALWART_FEATURES !== 'false',
|
stalwartFeaturesEnabled,
|
||||||
devMode: process.env.DEV_MOCK_JMAP === 'true',
|
devMode: configManager.get<boolean>('devMode', false),
|
||||||
faviconUrl: process.env.FAVICON_URL || '/branding/Bulwark_Favicon.svg',
|
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||||
appLogoLightUrl: process.env.APP_LOGO_LIGHT_URL || '',
|
appLogoLightUrl: configManager.get<string>('appLogoLightUrl', ''),
|
||||||
appLogoDarkUrl: process.env.APP_LOGO_DARK_URL || '',
|
appLogoDarkUrl: configManager.get<string>('appLogoDarkUrl', ''),
|
||||||
loginLogoLightUrl: process.env.LOGIN_LOGO_LIGHT_URL || '/branding/Bulwark_Logo_Color.svg',
|
loginLogoLightUrl: configManager.get<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||||
loginLogoDarkUrl: process.env.LOGIN_LOGO_DARK_URL || '/branding/Bulwark_Logo_White.svg',
|
loginLogoDarkUrl: configManager.get<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||||
loginCompanyName: process.env.LOGIN_COMPANY_NAME || '',
|
loginCompanyName: configManager.get<string>('loginCompanyName', ''),
|
||||||
loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '',
|
loginImprintUrl: configManager.get<string>('loginImprintUrl', ''),
|
||||||
loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '',
|
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
||||||
loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '',
|
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||||
|
demoMode: configManager.get<boolean>('demoMode', false),
|
||||||
|
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||||
|
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||||
|
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||||
|
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ function localDateTime(dayOffset: number, hour: number, minute: number): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a JMAP duration like "PT1H30M", "P1D", "PT45M" into milliseconds. */
|
/** Parse a JMAP duration like "PT1H30M", "P1D", "PT45M" into milliseconds. */
|
||||||
function parseDurationMs(dur: string): number {
|
function _parseDurationMs(dur: string): number {
|
||||||
let ms = 0;
|
let ms = 0;
|
||||||
const dayMatch = dur.match(/(\d+)D/);
|
const dayMatch = dur.match(/(\d+)D/);
|
||||||
const hourMatch = dur.match(/(\d+)H/);
|
const hourMatch = dur.match(/(\d+)H/);
|
||||||
|
|||||||
+352
-26
@@ -40,32 +40,358 @@ function isValidDomain(domain: string): boolean {
|
|||||||
|
|
||||||
// Known multi-part TLDs where the registrable domain includes one extra label.
|
// Known multi-part TLDs where the registrable domain includes one extra label.
|
||||||
const MULTI_PART_TLDS = new Set([
|
const MULTI_PART_TLDS = new Set([
|
||||||
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk",
|
// .ac
|
||||||
"co.jp", "or.jp", "ne.jp", "ac.jp", "go.jp",
|
"com.ac", "gov.ac", "mil.ac", "net.ac", "org.ac",
|
||||||
"co.kr", "or.kr", "go.kr", "ac.kr",
|
// .ae
|
||||||
"co.in", "net.in", "org.in", "ac.in", "gov.in",
|
"ac.ae", "co.ae", "gov.ae", "mil.ae", "name.ae", "net.ae", "org.ae", "pro.ae", "sch.ae",
|
||||||
"co.nz", "org.nz", "net.nz", "govt.nz", "ac.nz",
|
// .af
|
||||||
"co.za", "org.za", "net.za", "gov.za", "ac.za",
|
"com.af", "edu.af", "gov.af", "net.af", "org.af",
|
||||||
"com.au", "net.au", "org.au", "edu.au", "gov.au",
|
// .al
|
||||||
"com.br", "net.br", "org.br", "edu.br", "gov.br",
|
"com.al", "edu.al", "gov.al", "mil.al", "net.al", "org.al",
|
||||||
"com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn",
|
// .ao
|
||||||
"com.mx", "net.mx", "org.mx", "gob.mx", "edu.mx",
|
"co.ao", "ed.ao", "gv.ao", "it.ao", "og.ao", "pb.ao",
|
||||||
"com.ar", "net.ar", "org.ar", "gob.ar", "edu.ar",
|
// .ar
|
||||||
"com.tw", "net.tw", "org.tw", "edu.tw", "gov.tw",
|
"com.ar", "edu.ar", "gob.ar", "gov.ar", "int.ar", "mil.ar", "net.ar", "org.ar", "tur.ar",
|
||||||
"com.hk", "net.hk", "org.hk", "edu.hk", "gov.hk",
|
// .at
|
||||||
"com.sg", "net.sg", "org.sg", "edu.sg", "gov.sg",
|
"ac.at", "co.at", "gv.at", "or.at",
|
||||||
"com.my", "net.my", "org.my", "edu.my", "gov.my",
|
// .au
|
||||||
"com.ph", "net.ph", "org.ph", "edu.ph", "gov.ph",
|
"asn.au", "com.au", "csiro.au", "edu.au", "gov.au", "id.au", "net.au", "org.au",
|
||||||
"com.pk", "net.pk", "org.pk", "edu.pk", "gov.pk",
|
// .ba
|
||||||
"com.ng", "net.ng", "org.ng", "edu.ng", "gov.ng",
|
"co.ba", "com.ba", "edu.ba", "gov.ba", "mil.ba", "net.ba", "org.ba", "rs.ba",
|
||||||
"co.il", "org.il", "net.il", "ac.il", "gov.il",
|
"unbi.ba", "unmo.ba", "unsa.ba", "untz.ba", "unze.ba",
|
||||||
"co.th", "or.th", "ac.th", "go.th", "in.th",
|
// .bb
|
||||||
"co.id", "or.id", "ac.id", "go.id", "web.id",
|
"biz.bb", "co.bb", "com.bb", "edu.bb", "gov.bb", "info.bb", "net.bb", "org.bb",
|
||||||
"com.tr", "net.tr", "org.tr", "edu.tr", "gov.tr",
|
"store.bb", "tv.bb",
|
||||||
"com.ua", "net.ua", "org.ua", "edu.ua", "gov.ua",
|
// .bh
|
||||||
"com.eg", "net.eg", "org.eg", "edu.eg", "gov.eg",
|
"biz.bh", "cc.bh", "com.bh", "edu.bh", "gov.bh", "info.bh", "net.bh", "org.bh",
|
||||||
"com.sa", "net.sa", "org.sa", "edu.sa", "gov.sa",
|
// .bn
|
||||||
"co.ke", "or.ke", "ac.ke", "go.ke", "ne.ke",
|
"com.bn", "edu.bn", "gov.bn", "net.bn", "org.bn",
|
||||||
|
// .bo
|
||||||
|
"com.bo", "edu.bo", "gob.bo", "gov.bo", "int.bo", "mil.bo", "net.bo", "org.bo", "tv.bo",
|
||||||
|
// .br
|
||||||
|
"adm.br", "adv.br", "agr.br", "am.br", "arq.br", "art.br", "ato.br", "b.br",
|
||||||
|
"bio.br", "blog.br", "bmd.br", "cim.br", "cng.br", "cnt.br", "com.br", "coop.br",
|
||||||
|
"ecn.br", "edu.br", "eng.br", "esp.br", "etc.br", "eti.br", "far.br", "flog.br",
|
||||||
|
"fm.br", "fnd.br", "fot.br", "fst.br", "g12.br", "ggf.br", "gov.br", "imb.br",
|
||||||
|
"ind.br", "inf.br", "jor.br", "jus.br", "lel.br", "mat.br", "med.br", "mil.br",
|
||||||
|
"mus.br", "net.br", "nom.br", "not.br", "ntr.br", "odo.br", "org.br", "ppg.br",
|
||||||
|
"pro.br", "psc.br", "psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br",
|
||||||
|
"trd.br", "tur.br", "tv.br", "vet.br", "vlog.br", "wiki.br", "zlg.br",
|
||||||
|
// .bs
|
||||||
|
"com.bs", "edu.bs", "gov.bs", "net.bs", "org.bs",
|
||||||
|
// .bz
|
||||||
|
"com.bz", "edu.bz", "gov.bz", "net.bz", "org.bz",
|
||||||
|
// .ca
|
||||||
|
"ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca",
|
||||||
|
"nu.ca", "on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca",
|
||||||
|
// .ck
|
||||||
|
"biz.ck", "co.ck", "edu.ck", "gen.ck", "gov.ck", "info.ck", "net.ck", "org.ck",
|
||||||
|
// .cn
|
||||||
|
"ac.cn", "ah.cn", "bj.cn", "com.cn", "cq.cn", "edu.cn", "fj.cn", "gd.cn",
|
||||||
|
"gov.cn", "gs.cn", "gx.cn", "gz.cn", "ha.cn", "hb.cn", "he.cn", "hi.cn",
|
||||||
|
"hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mil.cn", "net.cn",
|
||||||
|
"nm.cn", "nx.cn", "org.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn",
|
||||||
|
"sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn",
|
||||||
|
// .co
|
||||||
|
"com.co", "edu.co", "gov.co", "mil.co", "net.co", "nom.co", "org.co",
|
||||||
|
// .cr
|
||||||
|
"ac.cr", "co.cr", "ed.cr", "fi.cr", "go.cr", "or.cr", "sa.cr",
|
||||||
|
// .cy
|
||||||
|
"ac.cy", "biz.cy", "com.cy", "ekloges.cy", "gov.cy", "ltd.cy", "name.cy",
|
||||||
|
"net.cy", "org.cy", "parliament.cy", "press.cy", "pro.cy", "tm.cy",
|
||||||
|
// .do
|
||||||
|
"art.do", "com.do", "edu.do", "gob.do", "gov.do", "mil.do", "net.do", "org.do",
|
||||||
|
"sld.do", "web.do",
|
||||||
|
// .dz
|
||||||
|
"art.dz", "asso.dz", "com.dz", "edu.dz", "gov.dz", "net.dz", "org.dz", "pol.dz",
|
||||||
|
// .ec
|
||||||
|
"com.ec", "edu.ec", "fin.ec", "gov.ec", "info.ec", "med.ec", "mil.ec", "net.ec",
|
||||||
|
"org.ec", "pro.ec",
|
||||||
|
// .eg
|
||||||
|
"com.eg", "edu.eg", "eun.eg", "gov.eg", "mil.eg", "name.eg", "net.eg", "org.eg", "sci.eg",
|
||||||
|
// .er
|
||||||
|
"com.er", "edu.er", "gov.er", "ind.er", "mil.er", "net.er", "org.er", "rochest.er", "w.er",
|
||||||
|
// .es
|
||||||
|
"com.es", "edu.es", "gob.es", "nom.es", "org.es",
|
||||||
|
// .et
|
||||||
|
"biz.et", "com.et", "edu.et", "gov.et", "info.et", "name.et", "net.et", "org.et",
|
||||||
|
// .fj
|
||||||
|
"ac.fj", "biz.fj", "com.fj", "info.fj", "mil.fj", "name.fj", "net.fj", "org.fj", "pro.fj",
|
||||||
|
// .fk
|
||||||
|
"ac.fk", "co.fk", "gov.fk", "net.fk", "nom.fk", "org.fk",
|
||||||
|
// .fr
|
||||||
|
"asso.fr", "com.fr", "gouv.fr", "nom.fr", "prd.fr", "presse.fr", "tm.fr",
|
||||||
|
// .gg
|
||||||
|
"co.gg", "net.gg", "org.gg",
|
||||||
|
// .gh
|
||||||
|
"com.gh", "edu.gh", "gov.gh", "mil.gh", "org.gh",
|
||||||
|
// .gn
|
||||||
|
"ac.gn", "com.gn", "gov.gn", "net.gn", "org.gn",
|
||||||
|
// .gr
|
||||||
|
"com.gr", "edu.gr", "gov.gr", "mil.gr", "net.gr", "org.gr",
|
||||||
|
// .gt
|
||||||
|
"com.gt", "edu.gt", "gob.gt", "ind.gt", "mil.gt", "net.gt", "org.gt",
|
||||||
|
// .gu
|
||||||
|
"com.gu", "edu.gu", "gov.gu", "net.gu", "org.gu",
|
||||||
|
// .hk
|
||||||
|
"com.hk", "edu.hk", "gov.hk", "idv.hk", "net.hk", "org.hk",
|
||||||
|
// .id
|
||||||
|
"ac.id", "co.id", "go.id", "mil.id", "net.id", "or.id", "sch.id", "web.id",
|
||||||
|
// .il
|
||||||
|
"ac.il", "co.il", "gov.il", "idf.il", "k12.il", "muni.il", "net.il", "org.il",
|
||||||
|
// .in
|
||||||
|
"4fd.in", "ac.in", "co.in", "edu.in", "ernet.in", "firm.in", "gen.in", "gov.in",
|
||||||
|
"ind.in", "mil.in", "net.in", "nic.in", "org.in", "res.in",
|
||||||
|
// .iq
|
||||||
|
"com.iq", "edu.iq", "gov.iq", "mil.iq", "net.iq", "org.iq",
|
||||||
|
// .ir
|
||||||
|
"ac.ir", "co.ir", "dnssec.ir", "gov.ir", "id.ir", "net.ir", "org.ir", "sch.ir",
|
||||||
|
// .it
|
||||||
|
"edu.it", "gov.it",
|
||||||
|
// .je
|
||||||
|
"co.je", "net.je", "org.je",
|
||||||
|
// .jo
|
||||||
|
"com.jo", "edu.jo", "gov.jo", "mil.jo", "name.jo", "net.jo", "org.jo", "sch.jo",
|
||||||
|
// .jp
|
||||||
|
"ac.jp", "ad.jp", "co.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp", "ne.jp", "or.jp",
|
||||||
|
// .ke
|
||||||
|
"ac.ke", "co.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "ne.ke", "or.ke", "sc.ke",
|
||||||
|
// .kh
|
||||||
|
"com.kh", "edu.kh", "gov.kh", "mil.kh", "net.kh", "org.kh", "per.kh",
|
||||||
|
// .ki
|
||||||
|
"biz.ki", "com.ki", "de.ki", "edu.ki", "gov.ki", "info.ki", "mob.ki", "net.ki",
|
||||||
|
"org.ki", "tel.ki",
|
||||||
|
// .km
|
||||||
|
"asso.km", "com.km", "coop.km", "edu.km", "gouv.km", "medecin.km", "mil.km",
|
||||||
|
"nom.km", "notaires.km", "pharmaciens.km", "presse.km", "tm.km", "veterinaire.km",
|
||||||
|
// .kn
|
||||||
|
"edu.kn", "gov.kn", "net.kn", "org.kn",
|
||||||
|
// .kr
|
||||||
|
"ac.kr", "busan.kr", "chungbuk.kr", "chungnam.kr", "co.kr", "daegu.kr",
|
||||||
|
"daejeon.kr", "es.kr", "gangwon.kr", "go.kr", "gwangju.kr", "gyeongbuk.kr",
|
||||||
|
"gyeonggi.kr", "gyeongnam.kr", "hs.kr", "incheon.kr", "jeju.kr", "jeonbuk.kr",
|
||||||
|
"jeonnam.kr", "kg.kr", "mil.kr", "ms.kr", "ne.kr", "or.kr", "pe.kr", "re.kr",
|
||||||
|
"sc.kr", "seoul.kr", "ulsan.kr",
|
||||||
|
// .kw
|
||||||
|
"com.kw", "edu.kw", "gov.kw", "net.kw", "org.kw",
|
||||||
|
// .ky
|
||||||
|
"com.ky", "edu.ky", "gov.ky", "net.ky", "org.ky",
|
||||||
|
// .kz
|
||||||
|
"com.kz", "edu.kz", "gov.kz", "mil.kz", "net.kz", "org.kz",
|
||||||
|
// .lb
|
||||||
|
"com.lb", "edu.lb", "gov.lb", "net.lb", "org.lb",
|
||||||
|
// .lk
|
||||||
|
"assn.lk", "com.lk", "edu.lk", "gov.lk", "grp.lk", "hotel.lk", "int.lk", "ltd.lk",
|
||||||
|
"net.lk", "ngo.lk", "org.lk", "sch.lk", "soc.lk", "web.lk",
|
||||||
|
// .lr
|
||||||
|
"com.lr", "edu.lr", "gov.lr", "net.lr", "org.lr",
|
||||||
|
// .lv
|
||||||
|
"asn.lv", "com.lv", "conf.lv", "edu.lv", "gov.lv", "id.lv", "mil.lv", "net.lv", "org.lv",
|
||||||
|
// .ly
|
||||||
|
"com.ly", "edu.ly", "gov.ly", "id.ly", "med.ly", "net.ly", "org.ly", "plc.ly", "sch.ly",
|
||||||
|
// .ma
|
||||||
|
"ac.ma", "co.ma", "gov.ma", "net.ma", "org.ma", "press.ma",
|
||||||
|
// .mc
|
||||||
|
"asso.mc", "tm.mc",
|
||||||
|
// .me
|
||||||
|
"ac.me", "co.me", "edu.me", "gov.me", "its.me", "net.me", "org.me", "priv.me",
|
||||||
|
// .mg
|
||||||
|
"com.mg", "edu.mg", "gov.mg", "mil.mg", "nom.mg", "org.mg", "prd.mg", "tm.mg",
|
||||||
|
// .mk
|
||||||
|
"com.mk", "edu.mk", "gov.mk", "inf.mk", "name.mk", "net.mk", "org.mk", "pro.mk",
|
||||||
|
// .ml
|
||||||
|
"com.ml", "edu.ml", "gov.ml", "net.ml", "org.ml", "presse.ml",
|
||||||
|
// .mn
|
||||||
|
"edu.mn", "gov.mn", "org.mn",
|
||||||
|
// .mo
|
||||||
|
"com.mo", "edu.mo", "gov.mo", "net.mo", "org.mo",
|
||||||
|
// .mt
|
||||||
|
"com.mt", "edu.mt", "gov.mt", "net.mt", "org.mt",
|
||||||
|
// .mu
|
||||||
|
"ac.mu", "co.mu", "com.mu", "gov.mu", "net.mu", "or.mu", "org.mu",
|
||||||
|
// .mv
|
||||||
|
"aero.mv", "biz.mv", "com.mv", "coop.mv", "edu.mv", "gov.mv", "info.mv",
|
||||||
|
"int.mv", "mil.mv", "museum.mv", "name.mv", "net.mv", "org.mv", "pro.mv",
|
||||||
|
// .mw
|
||||||
|
"ac.mw", "co.mw", "com.mw", "coop.mw", "edu.mw", "gov.mw", "int.mw",
|
||||||
|
"museum.mw", "net.mw", "org.mw",
|
||||||
|
// .mx
|
||||||
|
"com.mx", "edu.mx", "gob.mx", "net.mx", "org.mx",
|
||||||
|
// .my
|
||||||
|
"com.my", "edu.my", "gov.my", "mil.my", "name.my", "net.my", "org.my", "sch.my",
|
||||||
|
// .mz
|
||||||
|
"ac.mz", "co.mz", "edu.mz", "gov.mz", "org.mz",
|
||||||
|
// .na
|
||||||
|
"co.na", "com.na",
|
||||||
|
// .nf
|
||||||
|
"arts.nf", "com.nf", "firm.nf", "info.nf", "net.nf", "other.nf", "per.nf",
|
||||||
|
"rec.nf", "store.nf", "web.nf",
|
||||||
|
// .ng
|
||||||
|
"biz.ng", "com.ng", "edu.ng", "gov.ng", "mil.ng", "mobi.ng", "name.ng",
|
||||||
|
"net.ng", "org.ng", "sch.ng",
|
||||||
|
// .ni
|
||||||
|
"ac.ni", "co.ni", "com.ni", "edu.ni", "gob.ni", "mil.ni", "net.ni", "nom.ni", "org.ni",
|
||||||
|
// .np
|
||||||
|
"com.np", "edu.np", "gov.np", "mil.np", "net.np", "org.np",
|
||||||
|
// .nr
|
||||||
|
"biz.nr", "com.nr", "edu.nr", "gov.nr", "info.nr", "net.nr", "org.nr",
|
||||||
|
// .nz
|
||||||
|
"ac.nz", "co.nz", "cri.nz", "geek.nz", "gen.nz", "govt.nz", "health.nz",
|
||||||
|
"iwi.nz", "maori.nz", "mil.nz", "net.nz", "org.nz", "parliament.nz", "school.nz",
|
||||||
|
// .om
|
||||||
|
"ac.om", "biz.om", "co.om", "com.om", "edu.om", "gov.om", "med.om", "mil.om",
|
||||||
|
"museum.om", "net.om", "org.om", "pro.om", "sch.om",
|
||||||
|
// .pa
|
||||||
|
"abo.pa", "ac.pa", "com.pa", "edu.pa", "gob.pa", "ing.pa", "med.pa", "net.pa",
|
||||||
|
"nom.pa", "org.pa", "sld.pa",
|
||||||
|
// .pe
|
||||||
|
"com.pe", "edu.pe", "gob.pe", "mil.pe", "net.pe", "nom.pe", "org.pe", "sld.pe",
|
||||||
|
// .ph
|
||||||
|
"com.ph", "edu.ph", "gov.ph", "i.ph", "mil.ph", "net.ph", "ngo.ph", "org.ph",
|
||||||
|
// .pk
|
||||||
|
"biz.pk", "com.pk", "edu.pk", "fam.pk", "gob.pk", "gok.pk", "gon.pk", "gop.pk",
|
||||||
|
"gos.pk", "gov.pk", "net.pk", "org.pk", "web.pk",
|
||||||
|
// .pl
|
||||||
|
"art.pl", "bialystok.pl", "biz.pl", "com.pl", "edu.pl", "gda.pl", "gdansk.pl",
|
||||||
|
"gorzow.pl", "gov.pl", "info.pl", "katowice.pl", "krakow.pl", "lodz.pl",
|
||||||
|
"lublin.pl", "mil.pl", "net.pl", "ngo.pl", "olsztyn.pl", "org.pl", "poznan.pl",
|
||||||
|
"pwr.pl", "radom.pl", "slupsk.pl", "szczecin.pl", "torun.pl", "warszawa.pl",
|
||||||
|
"waw.pl", "wroc.pl", "wroclaw.pl", "zgora.pl",
|
||||||
|
// .pr
|
||||||
|
"ac.pr", "biz.pr", "com.pr", "edu.pr", "est.pr", "gov.pr", "info.pr", "isla.pr",
|
||||||
|
"name.pr", "net.pr", "org.pr", "pro.pr", "prof.pr",
|
||||||
|
// .ps
|
||||||
|
"com.ps", "edu.ps", "gov.ps", "net.ps", "org.ps", "plo.ps", "sec.ps",
|
||||||
|
// .pt
|
||||||
|
"com.pt", "edu.pt", "gov.pt", "int.pt", "net.pt", "nome.pt", "org.pt", "publ.pt",
|
||||||
|
// .pw
|
||||||
|
"belau.pw", "co.pw", "ed.pw", "go.pw", "ne.pw", "or.pw",
|
||||||
|
// .py
|
||||||
|
"com.py", "edu.py", "gov.py", "mil.py", "net.py", "org.py",
|
||||||
|
// .qa
|
||||||
|
"com.qa", "edu.qa", "gov.qa", "mil.qa", "net.qa", "org.qa",
|
||||||
|
// .re
|
||||||
|
"asso.re", "com.re", "nom.re",
|
||||||
|
// .ro
|
||||||
|
"arts.ro", "com.ro", "firm.ro", "info.ro", "nom.ro", "nt.ro", "org.ro",
|
||||||
|
"rec.ro", "store.ro", "tm.ro", "www.ro",
|
||||||
|
// .rs
|
||||||
|
"ac.rs", "co.rs", "edu.rs", "gov.rs", "in.rs", "org.rs",
|
||||||
|
// .ru
|
||||||
|
"ac.ru", "adygeya.ru", "altai.ru", "amur.ru", "arkhangelsk.ru", "astrakhan.ru",
|
||||||
|
"bashkiria.ru", "belgorod.ru", "bir.ru", "bryansk.ru", "buryatia.ru", "cbg.ru",
|
||||||
|
"chel.ru", "chelyabinsk.ru", "chita.ru", "chukotka.ru", "chuvashia.ru", "com.ru",
|
||||||
|
"dagestan.ru", "e-burg.ru", "edu.ru", "gov.ru", "grozny.ru", "int.ru",
|
||||||
|
"irkutsk.ru", "ivanovo.ru", "izhevsk.ru", "jar.ru", "joshkar-ola.ru",
|
||||||
|
"kalmykia.ru", "kaluga.ru", "kamchatka.ru", "karelia.ru", "kazan.ru", "kchr.ru",
|
||||||
|
"kemerovo.ru", "khabarovsk.ru", "khakassia.ru", "khv.ru", "kirov.ru",
|
||||||
|
"koenig.ru", "komi.ru", "kostroma.ru", "kranoyarsk.ru", "kuban.ru", "kurgan.ru",
|
||||||
|
"kursk.ru", "lipetsk.ru", "magadan.ru", "mari.ru", "mari-el.ru", "marine.ru",
|
||||||
|
"mil.ru", "mordovia.ru", "mosreg.ru", "msk.ru", "murmansk.ru", "nalchik.ru",
|
||||||
|
"net.ru", "nnov.ru", "nov.ru", "novosibirsk.ru", "nsk.ru", "omsk.ru",
|
||||||
|
"orenburg.ru", "org.ru", "oryol.ru", "penza.ru", "perm.ru", "pp.ru", "pskov.ru",
|
||||||
|
"ptz.ru", "rnd.ru", "ryazan.ru", "sakhalin.ru", "samara.ru", "saratov.ru",
|
||||||
|
"simbirsk.ru", "smolensk.ru", "spb.ru", "stavropol.ru", "stv.ru", "surgut.ru",
|
||||||
|
"tambov.ru", "tatarstan.ru", "tom.ru", "tomsk.ru", "tsaritsyn.ru", "tsk.ru",
|
||||||
|
"tula.ru", "tuva.ru", "tver.ru", "tyumen.ru", "udm.ru", "udmurtia.ru",
|
||||||
|
"ulan-ude.ru", "vladikavkaz.ru", "vladimir.ru", "vladivostok.ru", "volgograd.ru",
|
||||||
|
"vologda.ru", "voronezh.ru", "vrn.ru", "vyatka.ru", "yakutia.ru", "yamal.ru",
|
||||||
|
"yekaterinburg.ru", "yuzhno-sakhalinsk.ru",
|
||||||
|
// .rw
|
||||||
|
"ac.rw", "co.rw", "com.rw", "edu.rw", "gouv.rw", "gov.rw", "int.rw", "mil.rw", "net.rw",
|
||||||
|
// .sa
|
||||||
|
"com.sa", "edu.sa", "gov.sa", "med.sa", "net.sa", "org.sa", "pub.sa", "sch.sa",
|
||||||
|
// .sb
|
||||||
|
"com.sb", "edu.sb", "gov.sb", "net.sb", "org.sb",
|
||||||
|
// .sc
|
||||||
|
"com.sc", "edu.sc", "gov.sc", "net.sc", "org.sc",
|
||||||
|
// .sd
|
||||||
|
"com.sd", "edu.sd", "gov.sd", "info.sd", "med.sd", "net.sd", "org.sd", "tv.sd",
|
||||||
|
// .se
|
||||||
|
"a.se", "ac.se", "b.se", "bd.se", "c.se", "d.se", "e.se", "f.se", "g.se",
|
||||||
|
"h.se", "i.se", "k.se", "l.se", "m.se", "n.se", "o.se", "org.se", "p.se",
|
||||||
|
"parti.se", "pp.se", "press.se", "r.se", "s.se", "t.se", "tm.se", "u.se",
|
||||||
|
"w.se", "x.se", "y.se", "z.se",
|
||||||
|
// .sg
|
||||||
|
"com.sg", "edu.sg", "gov.sg", "idn.sg", "net.sg", "org.sg", "per.sg",
|
||||||
|
// .sh
|
||||||
|
"co.sh", "com.sh", "edu.sh", "gov.sh", "net.sh", "nom.sh", "org.sh",
|
||||||
|
// .sl
|
||||||
|
"com.sl", "edu.sl", "gov.sl", "net.sl", "org.sl",
|
||||||
|
// .sn
|
||||||
|
"art.sn", "com.sn", "edu.sn", "gouv.sn", "org.sn", "perso.sn", "univ.sn",
|
||||||
|
// .st
|
||||||
|
"co.st", "com.st", "consulado.st", "edu.st", "embaixada.st", "gov.st", "mil.st",
|
||||||
|
"net.st", "org.st", "principe.st", "saotome.st", "store.st",
|
||||||
|
// .sv
|
||||||
|
"com.sv", "edu.sv", "gob.sv", "org.sv", "red.sv",
|
||||||
|
// .sy
|
||||||
|
"com.sy", "edu.sy", "gov.sy", "mil.sy", "net.sy", "news.sy", "org.sy",
|
||||||
|
// .sz
|
||||||
|
"ac.sz", "co.sz", "org.sz",
|
||||||
|
// .th
|
||||||
|
"ac.th", "co.th", "go.th", "in.th", "mi.th", "net.th", "or.th",
|
||||||
|
// .tj
|
||||||
|
"ac.tj", "biz.tj", "co.tj", "com.tj", "edu.tj", "go.tj", "gov.tj", "info.tj",
|
||||||
|
"int.tj", "mil.tj", "name.tj", "net.tj", "nic.tj", "org.tj", "test.tj", "web.tj",
|
||||||
|
// .tn
|
||||||
|
"agrinet.tn", "com.tn", "defense.tn", "edunet.tn", "ens.tn", "fin.tn", "gov.tn",
|
||||||
|
"ind.tn", "info.tn", "intl.tn", "mincom.tn", "nat.tn", "net.tn", "org.tn",
|
||||||
|
"perso.tn", "rnrt.tn", "rns.tn", "rnu.tn", "tourism.tn",
|
||||||
|
// .tr
|
||||||
|
"av.tr", "bbs.tr", "bel.tr", "biz.tr", "com.tr", "dr.tr", "edu.tr", "gen.tr",
|
||||||
|
"gov.tr", "info.tr", "k12.tr", "name.tr", "net.tr", "org.tr", "pol.tr",
|
||||||
|
"tel.tr", "tsk.tr", "tv.tr", "web.tr",
|
||||||
|
// .tt
|
||||||
|
"aero.tt", "biz.tt", "cat.tt", "co.tt", "com.tt", "coop.tt", "edu.tt", "gov.tt",
|
||||||
|
"info.tt", "int.tt", "jobs.tt", "mil.tt", "mobi.tt", "museum.tt", "name.tt",
|
||||||
|
"net.tt", "org.tt", "pro.tt", "tel.tt", "travel.tt",
|
||||||
|
// .tw
|
||||||
|
"club.tw", "com.tw", "ebiz.tw", "edu.tw", "game.tw", "gov.tw", "idv.tw",
|
||||||
|
"mil.tw", "net.tw", "org.tw",
|
||||||
|
// .tz
|
||||||
|
"ac.tz", "co.tz", "go.tz", "ne.tz", "or.tz",
|
||||||
|
// .ua
|
||||||
|
"biz.ua", "cherkassy.ua", "chernigov.ua", "chernovtsy.ua", "ck.ua", "cn.ua",
|
||||||
|
"co.ua", "com.ua", "crimea.ua", "cv.ua", "dn.ua", "dnepropetrovsk.ua",
|
||||||
|
"donetsk.ua", "dp.ua", "edu.ua", "gov.ua", "if.ua", "in.ua",
|
||||||
|
"ivano-frankivsk.ua", "kh.ua", "kharkov.ua", "kherson.ua", "khmelnitskiy.ua",
|
||||||
|
"kiev.ua", "kirovograd.ua", "km.ua", "kr.ua", "ks.ua", "kv.ua", "lg.ua",
|
||||||
|
"lugansk.ua", "lutsk.ua", "lviv.ua", "me.ua", "mk.ua", "net.ua",
|
||||||
|
"nikolaev.ua", "od.ua", "odessa.ua", "org.ua", "pl.ua", "poltava.ua", "pp.ua",
|
||||||
|
"rovno.ua", "rv.ua", "sebastopol.ua", "sumy.ua", "te.ua", "ternopil.ua",
|
||||||
|
"uzhgorod.ua", "vinnica.ua", "vn.ua", "zaporizhzhe.ua", "zhitomir.ua",
|
||||||
|
"zp.ua", "zt.ua",
|
||||||
|
// .ug
|
||||||
|
"ac.ug", "co.ug", "go.ug", "ne.ug", "or.ug", "org.ug", "sc.ug",
|
||||||
|
// .uk
|
||||||
|
"ac.uk", "bl.uk", "british-library.uk", "co.uk", "cym.uk", "gov.uk", "govt.uk",
|
||||||
|
"icnet.uk", "jet.uk", "lea.uk", "ltd.uk", "me.uk", "mil.uk", "mod.uk",
|
||||||
|
"national-library-scotland.uk", "nel.uk", "net.uk", "nhs.uk", "nic.uk",
|
||||||
|
"nls.uk", "org.uk", "orgn.uk", "parliament.uk", "plc.uk", "police.uk",
|
||||||
|
"sch.uk", "scot.uk", "soc.uk",
|
||||||
|
// .us
|
||||||
|
"4fd.us", "dni.us", "fed.us", "isa.us", "kids.us", "nsn.us",
|
||||||
|
// .uy
|
||||||
|
"com.uy", "edu.uy", "gub.uy", "mil.uy", "net.uy", "org.uy",
|
||||||
|
// .ve
|
||||||
|
"co.ve", "com.ve", "edu.ve", "gob.ve", "info.ve", "mil.ve", "net.ve", "org.ve", "web.ve",
|
||||||
|
// .vi
|
||||||
|
"co.vi", "com.vi", "k12.vi", "net.vi", "org.vi",
|
||||||
|
// .vn
|
||||||
|
"ac.vn", "biz.vn", "com.vn", "edu.vn", "gov.vn", "health.vn", "info.vn",
|
||||||
|
"int.vn", "name.vn", "net.vn", "org.vn", "pro.vn",
|
||||||
|
// .ye
|
||||||
|
"co.ye", "com.ye", "gov.ye", "ltd.ye", "me.ye", "net.ye", "org.ye", "plc.ye",
|
||||||
|
// .yu
|
||||||
|
"ac.yu", "co.yu", "edu.yu", "gov.yu", "org.yu",
|
||||||
|
// .za
|
||||||
|
"ac.za", "agric.za", "alt.za", "bourse.za", "city.za", "co.za", "cybernet.za",
|
||||||
|
"db.za", "edu.za", "gov.za", "grondar.za", "iaccess.za", "imt.za", "inca.za",
|
||||||
|
"landesign.za", "law.za", "mil.za", "net.za", "ngo.za", "nis.za", "nom.za",
|
||||||
|
"olivetti.za", "org.za", "pix.za", "school.za", "tm.za", "web.za",
|
||||||
|
// .zm
|
||||||
|
"ac.zm", "co.zm", "com.zm", "edu.zm", "gov.zm", "net.zm", "org.zm", "sch.zm",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function getRootDomain(domain: string): string {
|
function getRootDomain(domain: string): string {
|
||||||
|
|||||||
+26
-35
@@ -1,10 +1,21 @@
|
|||||||
|
import v8 from 'node:v8';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
// Health check thresholds
|
const MEMORY_WARNING_THRESHOLD = 0.85;
|
||||||
const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage
|
const MEMORY_CRITICAL_THRESHOLD = 0.95;
|
||||||
const MEMORY_CRITICAL_THRESHOLD = 0.95; // 95% heap usage
|
|
||||||
|
function getHeapUsagePercent(heapUsed: number, heapTotal: number): number {
|
||||||
|
const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
|
||||||
|
const denominator = heapSizeLimit > 0 ? heapSizeLimit : heapTotal;
|
||||||
|
|
||||||
|
if (denominator <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (heapUsed / denominator) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
interface HealthStatus {
|
interface HealthStatus {
|
||||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||||
@@ -14,6 +25,7 @@ interface HealthStatus {
|
|||||||
memory?: {
|
memory?: {
|
||||||
heapUsed: number;
|
heapUsed: number;
|
||||||
heapTotal: number;
|
heapTotal: number;
|
||||||
|
heapSizeLimit: number;
|
||||||
rss: number;
|
rss: number;
|
||||||
external: number;
|
external: number;
|
||||||
heapUsagePercent: number;
|
heapUsagePercent: number;
|
||||||
@@ -27,14 +39,9 @@ interface HealthStatus {
|
|||||||
/**
|
/**
|
||||||
* Health check endpoint for container orchestration
|
* Health check endpoint for container orchestration
|
||||||
*
|
*
|
||||||
* GET /api/health - Basic health check (returns 200 OK or 503 Service Unavailable)
|
* GET /api/health - Liveness probe for container orchestration
|
||||||
* GET /api/health?detailed=true - Detailed diagnostics with memory stats
|
* GET /api/health?detailed=true - Diagnostics with advisory memory warnings
|
||||||
* HEAD /api/health - Lightweight health check (status code only)
|
* HEAD /api/health - Lightweight liveness probe (status code only)
|
||||||
*
|
|
||||||
* Health status based on Node.js heap usage:
|
|
||||||
* - Healthy (200): < 85% heap usage
|
|
||||||
* - Degraded (200): 85-95% heap usage (warnings in detailed mode)
|
|
||||||
* - Unhealthy (503): > 95% heap usage
|
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
@@ -43,38 +50,31 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const memUsage = process.memoryUsage();
|
const memUsage = process.memoryUsage();
|
||||||
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
|
const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
|
||||||
|
const heapUsagePercent = getHeapUsagePercent(memUsage.heapUsed, memUsage.heapTotal);
|
||||||
// Determine health status based on memory usage
|
|
||||||
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
let httpStatus = 200;
|
|
||||||
|
|
||||||
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
||||||
status = 'unhealthy';
|
status = 'degraded';
|
||||||
httpStatus = 503;
|
warnings.push(`V8 heap usage is very high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
|
||||||
} else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) {
|
} else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) {
|
||||||
status = 'degraded';
|
status = 'degraded';
|
||||||
warnings.push(`Memory usage high: ${heapUsagePercent.toFixed(1)}%`);
|
warnings.push(`V8 heap usage is high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build response
|
|
||||||
const response: HealthStatus = {
|
const response: HealthStatus = {
|
||||||
status,
|
status: detailed ? status : 'healthy',
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === 'unhealthy') {
|
|
||||||
response.reason = `Memory usage critical: ${heapUsagePercent.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add detailed information if requested
|
|
||||||
if (detailed) {
|
if (detailed) {
|
||||||
response.uptime = process.uptime();
|
response.uptime = process.uptime();
|
||||||
response.version = process.env.npm_package_version || '0.1.0';
|
response.version = process.env.npm_package_version || '0.1.0';
|
||||||
response.memory = {
|
response.memory = {
|
||||||
heapUsed: memUsage.heapUsed,
|
heapUsed: memUsage.heapUsed,
|
||||||
heapTotal: memUsage.heapTotal,
|
heapTotal: memUsage.heapTotal,
|
||||||
|
heapSizeLimit,
|
||||||
rss: memUsage.rss,
|
rss: memUsage.rss,
|
||||||
external: memUsage.external,
|
external: memUsage.external,
|
||||||
heapUsagePercent: Number(heapUsagePercent.toFixed(2)),
|
heapUsagePercent: Number(heapUsagePercent.toFixed(2)),
|
||||||
@@ -87,10 +87,8 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Health check', { status, detailed });
|
|
||||||
|
|
||||||
return NextResponse.json(response, {
|
return NextResponse.json(response, {
|
||||||
status: httpStatus,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||||
'Pragma': 'no-cache',
|
'Pragma': 'no-cache',
|
||||||
@@ -116,13 +114,6 @@ export async function GET(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function HEAD() {
|
export async function HEAD() {
|
||||||
try {
|
try {
|
||||||
const memUsage = process.memoryUsage();
|
|
||||||
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
|
|
||||||
|
|
||||||
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
|
||||||
return new Response(null, { status: 503 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
} catch {
|
} catch {
|
||||||
return new Response(null, { status: 503 });
|
return new Response(null, { status: 503 });
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/plugins — Public endpoint for clients to discover server-managed plugins & themes
|
||||||
|
*
|
||||||
|
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
|
||||||
|
* No admin auth required — this is how regular users receive plugins/themes.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const [pluginRegistry, themeRegistry] = await Promise.all([
|
||||||
|
getPluginRegistry(),
|
||||||
|
getThemeRegistry(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Only serve enabled plugins
|
||||||
|
const plugins = pluginRegistry.plugins
|
||||||
|
.filter(p => p.enabled)
|
||||||
|
.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
version: p.version,
|
||||||
|
author: p.author,
|
||||||
|
description: p.description,
|
||||||
|
type: p.type,
|
||||||
|
permissions: p.permissions,
|
||||||
|
entrypoint: p.entrypoint,
|
||||||
|
forceEnabled: p.forceEnabled || false,
|
||||||
|
settingsSchema: undefined, // Will be read from the bundle's manifest
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Only serve enabled themes
|
||||||
|
const themes = themeRegistry.themes
|
||||||
|
.filter(t => t.enabled)
|
||||||
|
.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
version: t.version,
|
||||||
|
author: t.author,
|
||||||
|
description: t.description,
|
||||||
|
variants: t.variants,
|
||||||
|
forceEnabled: t.forceEnabled || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ plugins, themes },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Plugin list error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
-20
@@ -2,27 +2,74 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { decryptSession } from '@/lib/auth/crypto';
|
import { decryptSession } from '@/lib/auth/crypto';
|
||||||
import { SESSION_COOKIE } from '@/lib/auth/session-cookie';
|
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
|
function classifyError(error: unknown): { message: string; status: number } {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
|
switch (code) {
|
||||||
|
case 'EACCES':
|
||||||
|
case 'EPERM':
|
||||||
|
return {
|
||||||
|
message: 'Write permission denied on settings data directory. Check filesystem permissions for the SETTINGS_DATA_DIR (or data/settings/).',
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
case 'EROFS':
|
||||||
|
return {
|
||||||
|
message: 'Filesystem is read-only. Settings cannot be saved. Ensure the data directory is on a writable volume.',
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
case 'ENOSPC':
|
||||||
|
return {
|
||||||
|
message: 'No disk space available to save settings.',
|
||||||
|
status: 507,
|
||||||
|
};
|
||||||
|
case 'ENOENT':
|
||||||
|
return {
|
||||||
|
message: 'Settings data directory does not exist and could not be created. Check SETTINGS_DATA_DIR configuration.',
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
if (msg.includes('SESSION_SECRET')) {
|
||||||
|
return {
|
||||||
|
message: 'Server configuration error: SESSION_SECRET is not set.',
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message: `Internal server error: ${msg}`,
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isEnabled(): boolean {
|
function isEnabled(): boolean {
|
||||||
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET;
|
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify identity against the session cookie if available.
|
* Verify identity against session cookies across all account slots.
|
||||||
* Returns true if no session cookie exists (can't verify) or if identity matches.
|
* With multi-account, the requesting account may be on any slot (0-4).
|
||||||
* Returns false if session cookie exists but identity doesn't match.
|
* Returns true only if a matching session cookie is found.
|
||||||
*/
|
*/
|
||||||
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const sessionToken = cookieStore.get(SESSION_COOKIE)?.value;
|
|
||||||
if (!sessionToken) return true; // No session cookie, can't verify (same-origin protection applies)
|
|
||||||
|
|
||||||
const session = decryptSession(sessionToken);
|
for (let slot = 0; slot <= 4; slot++) {
|
||||||
if (!session) return true; // Invalid session cookie, skip verification
|
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||||
|
if (!token) continue;
|
||||||
|
|
||||||
return session.username === username && session.serverUrl === serverUrl;
|
const session = decryptSession(token);
|
||||||
|
if (session && session.username === username && session.serverUrl === serverUrl) {
|
||||||
|
return true; // Found a matching slot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No matching session found (or no cookies at all) → reject
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -42,13 +89,13 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = await loadUserSettings(username, serverUrl);
|
const settings = await loadUserSettings(username, serverUrl);
|
||||||
if (!settings) {
|
return NextResponse.json({ settings: settings || null });
|
||||||
return NextResponse.json({ error: 'No settings found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
return NextResponse.json({ settings });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Settings load error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
logger.error('Settings load error', { error: message, code });
|
||||||
|
const classified = classifyError(error);
|
||||||
|
return NextResponse.json({ error: classified.message }, { status: classified.status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,11 +118,41 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveUserSettings(username, serverUrl, settings);
|
// Enforce admin policy — strip locked settings so users can't override them
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const policy = configManager.getPolicy();
|
||||||
|
const filteredSettings = { ...settings };
|
||||||
|
for (const key of Object.keys(filteredSettings)) {
|
||||||
|
const restriction = policy.restrictions[key];
|
||||||
|
if (!restriction) continue;
|
||||||
|
if (restriction.locked) {
|
||||||
|
delete filteredSettings[key];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = filteredSettings[key];
|
||||||
|
if (restriction.allowedValues && restriction.allowedValues.length > 0) {
|
||||||
|
if (!restriction.allowedValues.includes(value)) {
|
||||||
|
delete filteredSettings[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (restriction.min !== undefined && value < restriction.min) {
|
||||||
|
delete filteredSettings[key];
|
||||||
|
}
|
||||||
|
if (restriction.max !== undefined && value > restriction.max) {
|
||||||
|
delete filteredSettings[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveUserSettings(username, serverUrl, filteredSettings);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Settings save error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
logger.error('Settings save error', { error: message, code });
|
||||||
|
const classified = classifyError(error);
|
||||||
|
return NextResponse.json({ error: classified.message }, { status: classified.status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +174,10 @@ export async function DELETE(request: NextRequest) {
|
|||||||
await deleteUserSettings(username, serverUrl);
|
await deleteUserSettings(username, serverUrl);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Settings delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
logger.error('Settings delete error', { error: message, code });
|
||||||
|
const classified = classifyError(error);
|
||||||
|
return NextResponse.json({ error: classified.message }, { status: classified.status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+228
-47
@@ -20,6 +20,41 @@
|
|||||||
--color-accent-foreground: #1e40af;
|
--color-accent-foreground: #1e40af;
|
||||||
--color-destructive: #ef4444;
|
--color-destructive: #ef4444;
|
||||||
--color-destructive-foreground: #ffffff;
|
--color-destructive-foreground: #ffffff;
|
||||||
|
--color-popover: #ffffff;
|
||||||
|
--color-popover-foreground: #0f172a;
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
--color-sidebar: #f8fafc;
|
||||||
|
--color-sidebar-foreground: #0f172a;
|
||||||
|
--color-sidebar-border: #e2e8f0;
|
||||||
|
--color-sidebar-accent: #f1f5f9;
|
||||||
|
--color-sidebar-accent-foreground: #0f172a;
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
--color-card: #ffffff;
|
||||||
|
--color-card-foreground: #0f172a;
|
||||||
|
|
||||||
|
/* Semantic status colors */
|
||||||
|
--color-success: #22c55e;
|
||||||
|
--color-success-foreground: #ffffff;
|
||||||
|
--color-warning: #eab308;
|
||||||
|
--color-warning-foreground: #ffffff;
|
||||||
|
--color-info: #3b82f6;
|
||||||
|
--color-info-foreground: #ffffff;
|
||||||
|
|
||||||
|
/* Selection states */
|
||||||
|
--color-selection: #dbeafe;
|
||||||
|
--color-selection-foreground: #1e40af;
|
||||||
|
|
||||||
|
/* Unread indicator */
|
||||||
|
--color-unread: #3b82f6;
|
||||||
|
|
||||||
|
/* Chart colors */
|
||||||
|
--color-chart-1: #3b82f6;
|
||||||
|
--color-chart-2: #22c55e;
|
||||||
|
--color-chart-3: #f59e0b;
|
||||||
|
--color-chart-4: #ef4444;
|
||||||
|
--color-chart-5: #8b5cf6;
|
||||||
|
|
||||||
/* Settings variables */
|
/* Settings variables */
|
||||||
--font-size-base: 16px;
|
--font-size-base: 16px;
|
||||||
@@ -50,6 +85,41 @@
|
|||||||
--color-accent-foreground: #dbeafe;
|
--color-accent-foreground: #dbeafe;
|
||||||
--color-destructive: #ef4444;
|
--color-destructive: #ef4444;
|
||||||
--color-destructive-foreground: #fafafa;
|
--color-destructive-foreground: #fafafa;
|
||||||
|
--color-popover: #1c1c1c;
|
||||||
|
--color-popover-foreground: #fafafa;
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
--color-sidebar: #0a0a0a;
|
||||||
|
--color-sidebar-foreground: #fafafa;
|
||||||
|
--color-sidebar-border: #262626;
|
||||||
|
--color-sidebar-accent: #1a1a1a;
|
||||||
|
--color-sidebar-accent-foreground: #fafafa;
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
--color-card: #141414;
|
||||||
|
--color-card-foreground: #fafafa;
|
||||||
|
|
||||||
|
/* Semantic status colors */
|
||||||
|
--color-success: #16a34a;
|
||||||
|
--color-success-foreground: #ffffff;
|
||||||
|
--color-warning: #ca8a04;
|
||||||
|
--color-warning-foreground: #ffffff;
|
||||||
|
--color-info: #60a5fa;
|
||||||
|
--color-info-foreground: #ffffff;
|
||||||
|
|
||||||
|
/* Selection states */
|
||||||
|
--color-selection: rgba(59, 130, 246, 0.25);
|
||||||
|
--color-selection-foreground: #93c5fd;
|
||||||
|
|
||||||
|
/* Unread indicator */
|
||||||
|
--color-unread: #60a5fa;
|
||||||
|
|
||||||
|
/* Chart colors */
|
||||||
|
--color-chart-1: #60a5fa;
|
||||||
|
--color-chart-2: #4ade80;
|
||||||
|
--color-chart-3: #fbbf24;
|
||||||
|
--color-chart-4: #f87171;
|
||||||
|
--color-chart-5: #a78bfa;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -68,6 +138,29 @@
|
|||||||
--color-accent-foreground: var(--color-accent-foreground);
|
--color-accent-foreground: var(--color-accent-foreground);
|
||||||
--color-destructive: var(--color-destructive);
|
--color-destructive: var(--color-destructive);
|
||||||
--color-destructive-foreground: var(--color-destructive-foreground);
|
--color-destructive-foreground: var(--color-destructive-foreground);
|
||||||
|
--color-popover: var(--color-popover);
|
||||||
|
--color-popover-foreground: var(--color-popover-foreground);
|
||||||
|
--color-sidebar: var(--color-sidebar);
|
||||||
|
--color-sidebar-foreground: var(--color-sidebar-foreground);
|
||||||
|
--color-sidebar-border: var(--color-sidebar-border);
|
||||||
|
--color-sidebar-accent: var(--color-sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--color-sidebar-accent-foreground);
|
||||||
|
--color-card: var(--color-card);
|
||||||
|
--color-card-foreground: var(--color-card-foreground);
|
||||||
|
--color-success: var(--color-success);
|
||||||
|
--color-success-foreground: var(--color-success-foreground);
|
||||||
|
--color-warning: var(--color-warning);
|
||||||
|
--color-warning-foreground: var(--color-warning-foreground);
|
||||||
|
--color-info: var(--color-info);
|
||||||
|
--color-info-foreground: var(--color-info-foreground);
|
||||||
|
--color-selection: var(--color-selection);
|
||||||
|
--color-selection-foreground: var(--color-selection-foreground);
|
||||||
|
--color-unread: var(--color-unread);
|
||||||
|
--color-chart-1: var(--color-chart-1);
|
||||||
|
--color-chart-2: var(--color-chart-2);
|
||||||
|
--color-chart-3: var(--color-chart-3);
|
||||||
|
--color-chart-4: var(--color-chart-4);
|
||||||
|
--color-chart-5: var(--color-chart-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -157,21 +250,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.email-content blockquote {
|
.email-content blockquote {
|
||||||
border-left: 3px solid #d1d5db;
|
border-left: 3px solid var(--color-border);
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
color: #4b5563;
|
color: var(--color-muted-foreground);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .email-content blockquote {
|
|
||||||
border-left-color: #4b5563;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-content pre {
|
.email-content pre {
|
||||||
background-color: #f5f5f7;
|
background-color: var(--color-muted);
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
|
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
|
||||||
@@ -180,23 +268,13 @@ body {
|
|||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .email-content pre {
|
|
||||||
background-color: #1f2937;
|
|
||||||
border-color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-content code {
|
.email-content code {
|
||||||
background-color: #f3f4f6;
|
background-color: var(--color-muted);
|
||||||
padding: 0.125rem 0.375rem;
|
padding: 0.125rem 0.375rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
|
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #dc2626;
|
color: var(--color-destructive);
|
||||||
}
|
|
||||||
|
|
||||||
.dark .email-content code {
|
|
||||||
background-color: #374151;
|
|
||||||
color: #f87171;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-content h1,
|
.email-content h1,
|
||||||
@@ -276,26 +354,15 @@ body {
|
|||||||
.email-content table[border="1"] th,
|
.email-content table[border="1"] th,
|
||||||
.email-content table[border="1"] td {
|
.email-content table[border="1"] td {
|
||||||
padding: 0.625rem;
|
padding: 0.625rem;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--color-border);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-content table.data-table th,
|
.email-content table.data-table th,
|
||||||
.email-content table[border="1"] th {
|
.email-content table[border="1"] th {
|
||||||
background-color: #f9fafb;
|
background-color: var(--color-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #374151;
|
color: var(--color-foreground);
|
||||||
}
|
|
||||||
|
|
||||||
.dark .email-content table.data-table th,
|
|
||||||
.dark .email-content table[border="1"] th {
|
|
||||||
background-color: #1f2937;
|
|
||||||
color: #d1d5db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .email-content table.data-table td,
|
|
||||||
.dark .email-content table[border="1"] td {
|
|
||||||
border-color: #374151;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reset styles for layout tables (commonly used in HTML emails) */
|
/* Reset styles for layout tables (commonly used in HTML emails) */
|
||||||
@@ -317,28 +384,19 @@ body {
|
|||||||
|
|
||||||
.email-content hr {
|
.email-content hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid var(--color-border);
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .email-content hr {
|
|
||||||
border-top-color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Email thread quoted text */
|
/* Email thread quoted text */
|
||||||
.email-content .quoted-text {
|
.email-content .quoted-text {
|
||||||
border-left: 3px solid #d1d5db;
|
border-left: 3px solid var(--color-border);
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
color: #4b5563;
|
color: var(--color-muted-foreground);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .email-content .quoted-text {
|
|
||||||
border-left-color: #4b5563;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toast animations */
|
/* Toast animations */
|
||||||
@keyframes toast-enter {
|
@keyframes toast-enter {
|
||||||
from {
|
from {
|
||||||
@@ -471,6 +529,37 @@ body {
|
|||||||
animation: slide-in-from-left 0.3s ease-out;
|
animation: slide-in-from-left 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Calendar swipe animations (subtler slide for view transitions) */
|
||||||
|
@keyframes cal-slide-in-right {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cal-slide-in-left {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-30%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-right {
|
||||||
|
animation: cal-slide-in-right 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-left {
|
||||||
|
animation: cal-slide-in-left 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reduced motion: respect user OS preference */
|
/* Reduced motion: respect user OS preference */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
@@ -490,3 +579,95 @@ body {
|
|||||||
-webkit-backdrop-filter: none !important;
|
-webkit-backdrop-filter: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TipTap Rich Text Editor */
|
||||||
|
.tiptap {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap li {
|
||||||
|
margin: 0.125rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap blockquote {
|
||||||
|
border-left: 3px solid var(--color-border);
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap pre {
|
||||||
|
background-color: var(--color-muted);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap code {
|
||||||
|
background-color: var(--color-muted);
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap .ProseMirror-selectednode img {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,10 +31,14 @@ export default async function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const nonce = (await headers()).get("x-nonce") ?? "";
|
const nonce = (await headers()).get("x-nonce") ?? "";
|
||||||
|
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<html lang={locale} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
|
{parentOrigin && (
|
||||||
|
<meta name="parent-origin" content={parentOrigin} />
|
||||||
|
)}
|
||||||
<script
|
<script
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
// Don't redirect admin routes to the webmail login page
|
||||||
|
const isAdminRoute = window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/');
|
||||||
|
if (!isAdminRoute) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
// Allow admin routes to render the 404 without redirecting
|
||||||
|
const isAdmin = typeof window !== 'undefined' &&
|
||||||
|
(window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/'));
|
||||||
|
if (!isAdmin) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="text-center max-w-md px-4">
|
||||||
|
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
|
||||||
|
<p className="text-muted-foreground mb-6">This page could not be found.</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
Go home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useRef, useEffect, useCallback } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { format, parseISO, isToday, isTomorrow } from "date-fns";
|
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
|
||||||
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
|
import { MapPin, Users } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { getEventDayBounds } from "@/lib/calendar-utils";
|
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ interface DayGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarAgendaView({
|
export function CalendarAgendaView({
|
||||||
|
selectedDate,
|
||||||
events,
|
events,
|
||||||
calendars,
|
calendars,
|
||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
@@ -43,9 +44,12 @@ export function CalendarAgendaView({
|
|||||||
return map;
|
return map;
|
||||||
}, [calendars]);
|
}, [calendars]);
|
||||||
|
|
||||||
|
const todayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const sorted = [...events].sort((a, b) =>
|
const sorted = [...events].sort((a, b) =>
|
||||||
new Date(a.start).getTime() - new Date(b.start).getTime()
|
getEventStartDate(a).getTime() - getEventStartDate(b).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
const groups: DayGroup[] = [];
|
const groups: DayGroup[] = [];
|
||||||
@@ -69,10 +73,38 @@ export function CalendarAgendaView({
|
|||||||
} catch { /* skip invalid dates */ }
|
} catch { /* skip invalid dates */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Always include today's date in the groups so the view has a "Today" anchor
|
||||||
|
const todayKey = format(new Date(), "yyyy-MM-dd");
|
||||||
|
if (!groupMap.has(todayKey)) {
|
||||||
|
const todayGroup = { date: startOfDay(new Date()), dateKey: todayKey, events: [] as CalendarEvent[] };
|
||||||
|
groupMap.set(todayKey, todayGroup);
|
||||||
|
groups.push(todayGroup);
|
||||||
|
}
|
||||||
|
|
||||||
groups.sort((a, b) => a.date.getTime() - b.date.getTime());
|
groups.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||||
return groups;
|
return groups;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
|
// Auto-scroll to today's section on mount and when selectedDate changes to today
|
||||||
|
const scrollToToday = useCallback(() => {
|
||||||
|
if (todayRef.current) {
|
||||||
|
todayRef.current.scrollIntoView({ block: "start" });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Scroll to today on mount
|
||||||
|
const frame = requestAnimationFrame(scrollToToday);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [scrollToToday]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Scroll to today when selectedDate changes to today
|
||||||
|
if (isToday(selectedDate)) {
|
||||||
|
scrollToToday();
|
||||||
|
}
|
||||||
|
}, [selectedDate, scrollToToday]);
|
||||||
|
|
||||||
const formatDateHeader = (date: Date): string => {
|
const formatDateHeader = (date: Date): string => {
|
||||||
if (isToday(date)) return t("events.today_header");
|
if (isToday(date)) return t("events.today_header");
|
||||||
if (isTomorrow(date)) return t("events.tomorrow_header");
|
if (isTomorrow(date)) return t("events.tomorrow_header");
|
||||||
@@ -86,19 +118,10 @@ export function CalendarAgendaView({
|
|||||||
return format(date, "HH:mm");
|
return format(date, "HH:mm");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (grouped.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground">
|
|
||||||
<CalendarIcon className="w-12 h-12 mb-3 opacity-30" />
|
|
||||||
<p className="text-sm">{t("events.no_events")}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
|
||||||
{grouped.map((group) => (
|
{grouped.map((group) => (
|
||||||
<div key={group.dateKey}>
|
<div key={group.dateKey} ref={isToday(group.date) ? todayRef : undefined}>
|
||||||
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-4 py-2 border-b border-border">
|
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-4 py-2 border-b border-border">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-sm font-medium",
|
"text-sm font-medium",
|
||||||
@@ -111,14 +134,19 @@ export function CalendarAgendaView({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{group.events.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t("events.no_events")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{group.events.map((ev) => {
|
{group.events.map((ev) => {
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const calendar = calendarMap.get(calId);
|
const calendar = calId ? calendarMap.get(calId) : undefined;
|
||||||
const color = getEventColor(ev, calendar);
|
const color = getEventColor(ev, calendar);
|
||||||
const start = parseISO(ev.start);
|
const start = getEventStartDate(ev);
|
||||||
const durMin = parseDuration(ev.duration);
|
const durMin = parseDuration(ev.duration);
|
||||||
const end = new Date(start.getTime() + durMin * 60000);
|
const end = getEventEndDate(ev);
|
||||||
const locationName = ev.locations
|
const locationName = ev.locations
|
||||||
? Object.values(ev.locations)[0]?.name
|
? Object.values(ev.locations)[0]?.name
|
||||||
: null;
|
: null;
|
||||||
@@ -176,6 +204,7 @@ export function CalendarAgendaView({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
import { useMemo, useEffect, useRef, useState } from "react";
|
import { useMemo, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { format, isToday, parseISO } from "date-fns";
|
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { Check } from "lucide-react";
|
||||||
|
import { EventCard } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
|
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
|
|
||||||
interface CalendarDayViewProps {
|
interface CalendarDayViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -20,6 +22,9 @@ interface CalendarDayViewProps {
|
|||||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
pendingPreview?: PendingEventPreview | null;
|
||||||
|
tasks?: CalendarTask[];
|
||||||
|
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOUR_HEIGHT = 64;
|
const HOUR_HEIGHT = 64;
|
||||||
@@ -35,6 +40,9 @@ export function CalendarDayView({
|
|||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
isMobile,
|
isMobile,
|
||||||
|
pendingPreview,
|
||||||
|
tasks,
|
||||||
|
onToggleTaskComplete,
|
||||||
}: CalendarDayViewProps) {
|
}: CalendarDayViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -58,13 +66,23 @@ export function CalendarDayView({
|
|||||||
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
||||||
if (!spansThisDay) return;
|
if (!spansThisDay) return;
|
||||||
|
|
||||||
if (ev.showWithoutTime) allDay.push(ev);
|
if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev);
|
||||||
else timed.push(ev);
|
else timed.push(ev);
|
||||||
} catch { /* skip invalid dates */ }
|
} catch { /* skip invalid dates */ }
|
||||||
});
|
});
|
||||||
return { timedEvents: timed, allDayEvents: allDay };
|
return { timedEvents: timed, allDayEvents: allDay };
|
||||||
}, [events, selectedDate]);
|
}, [events, selectedDate]);
|
||||||
|
|
||||||
|
const dayTasks = useMemo(() => {
|
||||||
|
if (!tasks?.length) return [];
|
||||||
|
return tasks.filter(task => {
|
||||||
|
if (!task.due) return false;
|
||||||
|
try {
|
||||||
|
return isSameDay(parseISO(task.due), selectedDate);
|
||||||
|
} catch { return false; }
|
||||||
|
});
|
||||||
|
}, [tasks, selectedDate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -109,10 +127,10 @@ export function CalendarDayView({
|
|||||||
return format(new Date(2000, 0, 1, h), "HH:mm");
|
return format(new Date(2000, 0, 1, h), "HH:mm");
|
||||||
};
|
};
|
||||||
|
|
||||||
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
|
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents, selectedDate), [timedEvents, selectedDate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
<div className="flex min-h-0 flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
||||||
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
|
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
|
||||||
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
||||||
{isMobile
|
{isMobile
|
||||||
@@ -122,29 +140,67 @@ export function CalendarDayView({
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{allDayEvents.length > 0 && (
|
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
|
||||||
<div className="px-4 py-2 border-b border-border">
|
<div className="px-4 py-2 border-b border-border">
|
||||||
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
{allDayEvents.length > 0 && (
|
||||||
<div className="space-y-1">
|
<>
|
||||||
{allDayEvents.map((ev) => {
|
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
<div className="space-y-1">
|
||||||
return (
|
{allDayEvents.map((ev) => {
|
||||||
<EventCard
|
const calId = getPrimaryCalendarId(ev);
|
||||||
key={ev.id}
|
return (
|
||||||
event={ev}
|
<EventCard
|
||||||
calendar={calendarMap.get(calId)}
|
key={ev.id}
|
||||||
variant="chip"
|
event={ev}
|
||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
variant="chip"
|
||||||
onMouseLeave={onHoverLeave}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
/>
|
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||||
);
|
onMouseLeave={onHoverLeave}
|
||||||
})}
|
/>
|
||||||
</div>
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{dayTasks.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className={cn("text-[10px] text-muted-foreground mb-1", allDayEvents.length > 0 && "mt-2")}>{t("tasks.label")}</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{dayTasks.map((task) => {
|
||||||
|
const isCompleted = task.progress === "completed";
|
||||||
|
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||||
|
const color = cal?.color || "#3b82f6";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
style={{ borderLeft: `3px solid ${color}` }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggleTaskComplete?.(task); }}
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 w-3.5 h-3.5 rounded-full border flex items-center justify-center",
|
||||||
|
isCompleted
|
||||||
|
? "bg-success border-success text-success-foreground"
|
||||||
|
: "border-muted-foreground/40 hover:border-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCompleted && <Check className="h-2.5 w-2.5" />}
|
||||||
|
</button>
|
||||||
|
<span className={cn("truncate", isCompleted && "line-through text-muted-foreground")}>
|
||||||
|
{task.title || t("tasks.no_title")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||||
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
|
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
|
||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
@@ -185,14 +241,12 @@ export function CalendarDayView({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const start = parseISO(ev.start);
|
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
|
||||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
|
||||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const leftPct = (column / totalColumns) * 100;
|
const leftPct = (column / totalColumns) * 100;
|
||||||
const widthPct = (1 / totalColumns) * 100;
|
const widthPct = (1 / totalColumns) * 100;
|
||||||
|
|
||||||
@@ -205,7 +259,7 @@ export function CalendarDayView({
|
|||||||
>
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
event={ev}
|
event={ev}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||||
variant="block"
|
variant="block"
|
||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||||
@@ -232,8 +286,8 @@ export function CalendarDayView({
|
|||||||
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-red-500 -ml-1" />
|
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ml-1" />
|
||||||
<div className="flex-1 h-px bg-red-500" />
|
<div className="flex-1 h-px bg-destructive" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -274,6 +328,34 @@ export function CalendarDayView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, selectedDate) && (
|
||||||
|
(() => {
|
||||||
|
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
|
||||||
|
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
|
||||||
|
const durationMin = Math.max(15, endMin - startMin);
|
||||||
|
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
|
||||||
|
const color = cal?.color || "hsl(var(--primary))";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute left-2 right-2 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
|
||||||
|
style={{
|
||||||
|
top: (startMin / 60) * HOUR_HEIGHT,
|
||||||
|
height: Math.max(24, (durationMin / 60) * HOUR_HEIGHT),
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: `${color}10`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
|
||||||
|
{pendingPreview.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
|
||||||
|
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import {
|
|||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard } from "./event-card";
|
import { EventCard } from "./event-card";
|
||||||
import { buildWeekSegments, getEventDayBounds } from "@/lib/calendar-utils";
|
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
interface CalendarMonthViewProps {
|
interface CalendarMonthViewProps {
|
||||||
@@ -25,6 +26,7 @@ interface CalendarMonthViewProps {
|
|||||||
onCreateAtTime?: (date: Date) => void;
|
onCreateAtTime?: (date: Date) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
pendingPreview?: PendingEventPreview | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarMonthView({
|
export function CalendarMonthView({
|
||||||
@@ -38,6 +40,7 @@ export function CalendarMonthView({
|
|||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
pendingPreview,
|
||||||
}: CalendarMonthViewProps) {
|
}: CalendarMonthViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -118,13 +121,14 @@ export function CalendarMonthView({
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(json);
|
const data = JSON.parse(json);
|
||||||
const originalStart = parseISO(data.originalStart);
|
const originalStart = parseISO(data.originalStart);
|
||||||
|
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
|
||||||
|
const isAllDay = event?.showWithoutTime;
|
||||||
const newStart = new Date(day);
|
const newStart = new Date(day);
|
||||||
newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0);
|
newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0);
|
||||||
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
|
const newStartISO = isAllDay ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
|
||||||
if (newStartISO === data.originalStart) return;
|
if (newStartISO === data.originalStart) return;
|
||||||
const client = useAuthStore.getState().client;
|
const client = useAuthStore.getState().client;
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
|
|
||||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -194,35 +198,67 @@ export function CalendarMonthView({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
dayEvents.length > 0 && (
|
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
||||||
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
{dayEvents.slice(0, 3).map((ev) => {
|
||||||
{dayEvents.slice(0, 3).map((ev) => {
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
const cal = calId ? calendarMap.get(calId) : undefined;
|
||||||
const cal = calendarMap.get(calId);
|
const evColor = ev.color || cal?.color || "#3b82f6";
|
||||||
const evColor = ev.color || cal?.color || "#3b82f6";
|
return (
|
||||||
return (
|
<span
|
||||||
<span
|
key={ev.id}
|
||||||
key={ev.id}
|
className="w-1.5 h-1.5 rounded-full"
|
||||||
className="w-1.5 h-1.5 rounded-full"
|
style={{ backgroundColor: evColor }}
|
||||||
style={{ backgroundColor: evColor }}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
{dayEvents.length > 3 && (
|
||||||
{dayEvents.length > 3 && (
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
)}
|
||||||
)}
|
{pendingPreview && isSameDay(pendingPreview.start, day) && (
|
||||||
</div>
|
<span
|
||||||
)
|
className="w-1.5 h-1.5 rounded-full border border-dashed"
|
||||||
|
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isMobile && pendingPreview && (() => {
|
||||||
|
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start));
|
||||||
|
if (previewDayIdx === -1) return null;
|
||||||
|
const previewRow = rowCount;
|
||||||
|
const cal = calendarMap.get(pendingPreview.calendarId);
|
||||||
|
const color = cal?.color || "#3b82f6";
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
||||||
|
<div
|
||||||
|
className="absolute px-0.5"
|
||||||
|
style={{
|
||||||
|
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
|
||||||
|
width: `calc(${(1 / 7) * 100}% - 2px)`,
|
||||||
|
top: previewRow * 22,
|
||||||
|
height: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate border-2 border-dashed"
|
||||||
|
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
|
||||||
|
>
|
||||||
|
{pendingPreview.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{!isMobile && segments.length > 0 && (
|
{!isMobile && segments.length > 0 && (
|
||||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
||||||
{segments.map((segment) => {
|
{segments.map((segment) => {
|
||||||
const calId = Object.keys(segment.event.calendarIds)[0];
|
const calId = getPrimaryCalendarId(segment.event);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
||||||
@@ -236,7 +272,7 @@ export function CalendarMonthView({
|
|||||||
>
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
event={segment.event}
|
event={segment.event}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||||
variant="span"
|
variant="span"
|
||||||
continuesBefore={segment.continuesBefore}
|
continuesBefore={segment.continuesBefore}
|
||||||
continuesAfter={segment.continuesAfter}
|
continuesAfter={segment.continuesAfter}
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from "react";
|
import { useState, useRef, useEffect, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Globe, Plus, RefreshCw, Share2, Trash2 } from "lucide-react";
|
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2 } from "lucide-react";
|
||||||
import { cn, formatDateTime } from "@/lib/utils";
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import type { JMAPClient } from "@/lib/jmap/client";
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
interface CalendarSidebarPanelProps {
|
interface CalendarSidebarPanelProps {
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
@@ -17,7 +18,8 @@ interface CalendarSidebarPanelProps {
|
|||||||
onToggleVisibility: (id: string) => void;
|
onToggleVisibility: (id: string) => void;
|
||||||
onColorChange?: (calendarId: string, color: string) => void;
|
onColorChange?: (calendarId: string, color: string) => void;
|
||||||
onSubscribe?: () => void;
|
onSubscribe?: () => void;
|
||||||
client?: JMAPClient | null;
|
onEditSubscription?: (subscriptionId: string) => void;
|
||||||
|
client?: IJMAPClient | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarSidebarPanel({
|
export function CalendarSidebarPanel({
|
||||||
@@ -26,6 +28,7 @@ export function CalendarSidebarPanel({
|
|||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
onSubscribe,
|
onSubscribe,
|
||||||
|
onEditSubscription,
|
||||||
client,
|
client,
|
||||||
}: CalendarSidebarPanelProps) {
|
}: CalendarSidebarPanelProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
@@ -35,6 +38,15 @@ export function CalendarSidebarPanel({
|
|||||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
|
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||||
|
const tasks = useTaskStore((s) => s.tasks);
|
||||||
|
const setViewMode = useCalendarStore((s) => s.setViewMode);
|
||||||
|
|
||||||
|
const pendingTaskCount = useMemo(() => tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled').length, [tasks]);
|
||||||
|
const overdueTaskCount = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||||
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
|
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
|
||||||
@@ -47,7 +59,7 @@ export function CalendarSidebarPanel({
|
|||||||
const shared = calendars.filter(c => c.isShared);
|
const shared = calendars.filter(c => c.isShared);
|
||||||
const groups = new Map<string, { accountName: string; calendars: Calendar[] }>();
|
const groups = new Map<string, { accountName: string; calendars: Calendar[] }>();
|
||||||
for (const cal of shared) {
|
for (const cal of shared) {
|
||||||
const key = cal.accountId!;
|
const key = cal.accountId || cal.accountName || cal.id;
|
||||||
if (!groups.has(key)) {
|
if (!groups.has(key)) {
|
||||||
groups.set(key, { accountName: cal.accountName || key, calendars: [] });
|
groups.set(key, { accountName: cal.accountName || key, calendars: [] });
|
||||||
}
|
}
|
||||||
@@ -163,6 +175,16 @@ export function CalendarSidebarPanel({
|
|||||||
ref={contextMenuRef}
|
ref={contextMenuRef}
|
||||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setContextMenuCalId(null);
|
||||||
|
onEditSubscription?.(sub.id);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
{tSub('edit')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRefreshSubscription(sub.id)}
|
onClick={() => handleRefreshSubscription(sub.id)}
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
||||||
@@ -209,6 +231,21 @@ export function CalendarSidebarPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
{enableCalendarTasks && (
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('tasks')}
|
||||||
|
className="flex items-center gap-2 w-full px-1.5 py-1.5 mb-3 rounded-md text-sm hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<ListTodo className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span>{t('tasks.label')}</span>
|
||||||
|
{pendingTaskCount > 0 && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
|
||||||
|
)}
|
||||||
|
{overdueTaskCount > 0 && (
|
||||||
|
<span className="text-xs text-destructive font-medium">{overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||||
{t("my_calendars")}
|
{t("my_calendars")}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft } from "lucide-react";
|
||||||
import { addDays, startOfWeek } from "date-fns";
|
import { addDays, startOfWeek } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||||
@@ -25,6 +25,7 @@ interface CalendarToolbarProps {
|
|||||||
calendars?: Calendar[];
|
calendars?: Calendar[];
|
||||||
selectedCalendarIds?: string[];
|
selectedCalendarIds?: string[];
|
||||||
onToggleVisibility?: (id: string) => void;
|
onToggleVisibility?: (id: string) => void;
|
||||||
|
enableCalendarTasks?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarToolbar({
|
export function CalendarToolbar({
|
||||||
@@ -39,13 +40,17 @@ export function CalendarToolbar({
|
|||||||
onSubscribe,
|
onSubscribe,
|
||||||
isMobile,
|
isMobile,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
|
onNavigateBack,
|
||||||
calendars,
|
calendars,
|
||||||
selectedCalendarIds,
|
selectedCalendarIds,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
|
enableCalendarTasks,
|
||||||
}: CalendarToolbarProps) {
|
}: CalendarToolbarProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const formatter = useFormatter();
|
const formatter = useFormatter();
|
||||||
const views: CalendarViewMode[] = ["month", "week", "day", "agenda"];
|
const views: CalendarViewMode[] = enableCalendarTasks
|
||||||
|
? ["month", "week", "day", "agenda", "tasks"]
|
||||||
|
: ["month", "week", "day", "agenda"];
|
||||||
const [showCalendarDropdown, setShowCalendarDropdown] = useState(false);
|
const [showCalendarDropdown, setShowCalendarDropdown] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -86,13 +91,14 @@ export function CalendarToolbar({
|
|||||||
return isMobile
|
return isMobile
|
||||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
||||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
||||||
|
case "tasks":
|
||||||
|
return t("views.tasks");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showImportDropdown, setShowImportDropdown] = useState(false);
|
const [showImportDropdown, setShowImportDropdown] = useState(false);
|
||||||
const importDropdownRef = useRef<HTMLDivElement>(null);
|
const importDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const [showViewDropdown, setShowViewDropdown] = useState(false);
|
|
||||||
const viewDropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showImportDropdown) return;
|
if (!showImportDropdown) return;
|
||||||
@@ -105,95 +111,76 @@ export function CalendarToolbar({
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [showImportDropdown]);
|
}, [showImportDropdown]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showViewDropdown) return;
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (viewDropdownRef.current && !viewDropdownRef.current.contains(e.target as Node)) {
|
|
||||||
setShowViewDropdown(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [showViewDropdown]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center gap-1.5 px-2 py-2 border-b border-border flex-wrap", !isMobile && "px-4 py-3 gap-2")}>
|
<div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}>
|
||||||
|
{/* ── MOBILE TOOLBAR ── */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex flex-col gap-1 px-2 py-2">
|
||||||
<button onClick={onPrev} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
|
{/* Row 1: Back / Date nav / Today */}
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<div className="flex items-center gap-1">
|
||||||
</button>
|
{onNavigateBack && (
|
||||||
<span className={cn("text-sm font-medium text-center min-w-[80px]")}>
|
<button
|
||||||
{getDateLabel()}
|
onClick={onNavigateBack}
|
||||||
</span>
|
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
|
||||||
<button onClick={onNext} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
|
aria-label={t("back_to_month")}
|
||||||
<ChevronRight className="w-4 h-4" />
|
>
|
||||||
</button>
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</div>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button onClick={onPrev} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-semibold text-center flex-1 select-none truncate">
|
||||||
|
{getDateLabel()}
|
||||||
|
</span>
|
||||||
|
<button onClick={onNext} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ml-0.5">
|
||||||
|
{t("views.today")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={onToday} className="touch-manipulation">
|
{/* Row 2: View switcher pills + calendar toggle */}
|
||||||
{t("views.today")}
|
<div className="flex items-center gap-1.5">
|
||||||
</Button>
|
<div className="flex flex-1 border border-border rounded-md overflow-hidden">
|
||||||
|
{views.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
onClick={() => onViewModeChange(v)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-1.5 text-[11px] font-medium transition-colors touch-manipulation",
|
||||||
|
v === viewMode
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground active:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(`views.${v}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
|
{calendars && selectedCalendarIds && onToggleVisibility && (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<Button
|
<button
|
||||||
variant="outline"
|
onClick={() => setShowCalendarDropdown((v) => !v)}
|
||||||
size="sm"
|
className={cn(
|
||||||
onClick={() => setShowCalendarDropdown((v) => !v)}
|
"p-1.5 rounded-md border border-border transition-colors touch-manipulation",
|
||||||
aria-label={t("my_calendars")}
|
showCalendarDropdown ? "bg-muted" : "hover:bg-muted"
|
||||||
className="touch-manipulation"
|
)}
|
||||||
>
|
aria-label={t("my_calendars")}
|
||||||
<CalendarDays className="w-4 h-4" />
|
>
|
||||||
</Button>
|
<CalendarDays className="w-4 h-4" />
|
||||||
{showCalendarDropdown && (
|
</button>
|
||||||
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
|
{showCalendarDropdown && (
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
|
||||||
{t("my_calendars")}
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||||
</h3>
|
{t("my_calendars")}
|
||||||
<div className="space-y-0.5">
|
|
||||||
{calendars.filter(c => !c.isShared).map((cal) => {
|
|
||||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
|
||||||
const color = cal.color || "#3b82f6";
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={cal.id}
|
|
||||||
onClick={() => onToggleVisibility(cal.id)}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
|
|
||||||
"hover:bg-muted"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
|
|
||||||
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
|
|
||||||
)}
|
|
||||||
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
|
||||||
/>
|
|
||||||
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
|
||||||
{cal.name}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
const shared = calendars.filter(c => c.isShared);
|
|
||||||
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
|
|
||||||
for (const c of shared) {
|
|
||||||
const key = c.accountId!;
|
|
||||||
if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] });
|
|
||||||
groups.get(key)!.cals.push(c);
|
|
||||||
}
|
|
||||||
return Array.from(groups.values()).map((group) => (
|
|
||||||
<div key={group.accountName} className="mt-2">
|
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
|
|
||||||
{group.accountName}
|
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{group.cals.map((cal) => {
|
{calendars.filter(c => !c.isShared).map((cal) => {
|
||||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||||
const color = cal.color || "#3b82f6";
|
const color = cal.color || "#3b82f6";
|
||||||
return (
|
return (
|
||||||
@@ -219,44 +206,76 @@ export function CalendarToolbar({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const shared = calendars.filter(c => c.isShared);
|
||||||
|
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
|
||||||
|
for (const c of shared) {
|
||||||
|
const key = c.accountId || c.accountName || c.id;
|
||||||
|
if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] });
|
||||||
|
groups.get(key)!.cals.push(c);
|
||||||
|
}
|
||||||
|
return Array.from(groups.values()).map((group) => (
|
||||||
|
<div key={group.accountName} className="mt-2">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
|
||||||
|
{group.accountName}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{group.cals.map((cal) => {
|
||||||
|
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||||
|
const color = cal.color || "#3b82f6";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cal.id}
|
||||||
|
onClick={() => onToggleVisibility(cal.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
|
||||||
|
"hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
|
||||||
|
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
|
||||||
|
)}
|
||||||
|
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
||||||
|
/>
|
||||||
|
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
||||||
|
{cal.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
));
|
)}
|
||||||
})()}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isMobile && (
|
{/* ── DESKTOP TOOLBAR ── */}
|
||||||
<div className="relative" ref={viewDropdownRef}>
|
{!isMobile && (
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
variant="outline"
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
||||||
size="sm"
|
<ChevronLeft className="w-4 h-4" />
|
||||||
onClick={() => setShowViewDropdown((v) => !v)}
|
|
||||||
className="touch-manipulation capitalize text-xs"
|
|
||||||
>
|
|
||||||
{t(`views.${viewMode}`)}
|
|
||||||
<ChevronLeft className="w-3 h-3 ml-1 rotate-[-90deg]" />
|
|
||||||
</Button>
|
</Button>
|
||||||
{showViewDropdown && (
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
|
||||||
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-1 min-w-[120px]">
|
<ChevronRight className="w-4 h-4" />
|
||||||
{views.map((v) => (
|
</Button>
|
||||||
<button
|
<span className="text-base font-semibold ml-2 select-none">
|
||||||
key={v}
|
{getDateLabel()}
|
||||||
onClick={() => { onViewModeChange(v); setShowViewDropdown(false); }}
|
</span>
|
||||||
className={cn(
|
|
||||||
"flex items-center w-full px-3 py-2 rounded-md text-sm transition-colors touch-manipulation",
|
|
||||||
v === viewMode ? "bg-primary text-primary-foreground" : "hover:bg-muted text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t(`views.${v}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
@@ -312,7 +331,7 @@ export function CalendarToolbar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Button size="sm" onClick={onCreateEvent}>
|
<Button size="sm" onClick={onCreateEvent} data-tour="create-event-button">
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
{t("events.create")}
|
{t("events.create")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import {
|
|||||||
startOfWeek, addDays, format, isSameDay, isToday, parseISO,
|
startOfWeek, addDays, format, isSameDay, isToday, parseISO,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { Check } from "lucide-react";
|
||||||
|
import { EventCard } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { buildWeekSegments, getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
|
import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
|
|
||||||
interface CalendarWeekViewProps {
|
interface CalendarWeekViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -24,6 +26,9 @@ interface CalendarWeekViewProps {
|
|||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
pendingPreview?: PendingEventPreview | null;
|
||||||
|
tasks?: CalendarTask[];
|
||||||
|
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOUR_HEIGHT = 60;
|
const HOUR_HEIGHT = 60;
|
||||||
@@ -41,20 +46,20 @@ export function CalendarWeekView({
|
|||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
isMobile,
|
isMobile,
|
||||||
|
pendingPreview,
|
||||||
|
tasks,
|
||||||
|
onToggleTaskComplete,
|
||||||
}: CalendarWeekViewProps) {
|
}: CalendarWeekViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
||||||
|
|
||||||
const weekDays = useMemo(() => {
|
const weekDays = useMemo(() => {
|
||||||
if (isMobile) {
|
|
||||||
// Show 3-day window centered on selected date
|
|
||||||
return Array.from({ length: 3 }, (_, i) => addDays(selectedDate, i - 1));
|
|
||||||
}
|
|
||||||
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
|
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
|
||||||
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
||||||
}, [selectedDate, weekStart, isMobile]);
|
}, [selectedDate, weekStart]);
|
||||||
|
|
||||||
const calendarMap = useMemo(() => {
|
const calendarMap = useMemo(() => {
|
||||||
const map = new Map<string, Calendar>();
|
const map = new Map<string, Calendar>();
|
||||||
@@ -72,7 +77,7 @@ export function CalendarWeekView({
|
|||||||
const cursor = new Date(startDay);
|
const cursor = new Date(startDay);
|
||||||
while (cursor <= endDay) {
|
while (cursor <= endDay) {
|
||||||
const key = format(cursor, "yyyy-MM-dd");
|
const key = format(cursor, "yyyy-MM-dd");
|
||||||
if (!ev.showWithoutTime) {
|
if (!ev.showWithoutTime && !isTimedEventFullDayOnDate(ev, cursor)) {
|
||||||
const arr = timed.get(key) || [];
|
const arr = timed.get(key) || [];
|
||||||
arr.push(ev);
|
arr.push(ev);
|
||||||
timed.set(key, arr);
|
timed.set(key, arr);
|
||||||
@@ -84,24 +89,70 @@ export function CalendarWeekView({
|
|||||||
return timed;
|
return timed;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
const allDaySegments = useMemo(() => buildWeekSegments(
|
const allDaySegments = useMemo(() => {
|
||||||
events.filter((event) => event.showWithoutTime),
|
const explicitAllDay = buildWeekSegmentsRaw(
|
||||||
weekDays,
|
events.filter((event) => event.showWithoutTime),
|
||||||
), [events, weekDays]);
|
weekDays,
|
||||||
|
);
|
||||||
|
const timedFullDay = buildTimedFullDayWeekSegments(
|
||||||
|
events.filter((event) => !event.showWithoutTime),
|
||||||
|
weekDays,
|
||||||
|
);
|
||||||
|
|
||||||
|
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
|
||||||
|
}, [events, weekDays]);
|
||||||
|
|
||||||
const allDayRowCount = useMemo(() => {
|
const allDayRowCount = useMemo(() => {
|
||||||
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
||||||
}, [allDaySegments]);
|
}, [allDaySegments]);
|
||||||
|
|
||||||
|
// Tasks grouped by day for the week
|
||||||
|
const tasksByDay = useMemo(() => {
|
||||||
|
if (!tasks?.length) return new Map<string, CalendarTask[]>();
|
||||||
|
const map = new Map<string, CalendarTask[]>();
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (!task.due) continue;
|
||||||
|
try {
|
||||||
|
const key = format(parseISO(task.due), "yyyy-MM-dd");
|
||||||
|
const existing = map.get(key) || [];
|
||||||
|
existing.push(task);
|
||||||
|
map.set(key, existing);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
// Max tasks on any single day in this week
|
||||||
|
const taskRowCount = useMemo(() => {
|
||||||
|
let max = 0;
|
||||||
|
for (const day of weekDays) {
|
||||||
|
const key = format(day, "yyyy-MM-dd");
|
||||||
|
const count = tasksByDay.get(key)?.length ?? 0;
|
||||||
|
if (count > max) max = count;
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}, [tasksByDay, weekDays]);
|
||||||
|
|
||||||
const hasAllDay = useMemo(() => {
|
const hasAllDay = useMemo(() => {
|
||||||
return allDaySegments.length > 0;
|
return allDaySegments.length > 0 || taskRowCount > 0;
|
||||||
}, [allDaySegments]);
|
}, [allDaySegments, taskRowCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
|
scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
|
||||||
}
|
}
|
||||||
|
// On mobile, scroll horizontally to center today's column
|
||||||
|
if (isMobile && rootRef.current) {
|
||||||
|
const todayIdx = weekDays.findIndex(d => isToday(d));
|
||||||
|
if (todayIdx >= 0) {
|
||||||
|
const gutter = 40;
|
||||||
|
const colWidth = (rootRef.current.scrollWidth - gutter) / 7;
|
||||||
|
const target = gutter + todayIdx * colWidth - rootRef.current.clientWidth / 2 + colWidth / 2;
|
||||||
|
rootRef.current.scrollLeft = Math.max(0, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [nowMinutes, setNowMinutes] = useState(() => {
|
const [nowMinutes, setNowMinutes] = useState(() => {
|
||||||
@@ -140,21 +191,29 @@ export function CalendarWeekView({
|
|||||||
return format(new Date(2000, 0, 1, h), "HH:mm");
|
return format(new Date(2000, 0, 1, h), "HH:mm");
|
||||||
};
|
};
|
||||||
|
|
||||||
const colCount = isMobile ? 3 : 7;
|
const colCount = 7;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
|
<div
|
||||||
{hasAllDay && (
|
ref={rootRef}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 min-w-0 flex-col flex-1",
|
||||||
|
isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
|
||||||
|
)}
|
||||||
|
role="grid"
|
||||||
|
aria-label={t("views.week")}
|
||||||
|
>
|
||||||
|
<div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
|
||||||
<div className="flex border-b border-border">
|
<div className="flex border-b border-border">
|
||||||
<div
|
<div
|
||||||
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10" : "w-14")}
|
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
|
||||||
style={{ minHeight: Math.max(28, allDayRowCount * 24 + 4) }}
|
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||||
>
|
>
|
||||||
{t("events.all_day")}
|
{t("events.all_day")}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn("flex-1 relative grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")}
|
className="flex-1 relative grid gap-px bg-border grid-cols-7"
|
||||||
style={{ minHeight: Math.max(28, allDayRowCount * 24 + 4) }}
|
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||||
>
|
>
|
||||||
{weekDays.map((day) => (
|
{weekDays.map((day) => (
|
||||||
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" />
|
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" />
|
||||||
@@ -162,7 +221,7 @@ export function CalendarWeekView({
|
|||||||
|
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
{allDaySegments.map((segment) => {
|
{allDaySegments.map((segment) => {
|
||||||
const calId = Object.keys(segment.event.calendarIds)[0];
|
const calId = getPrimaryCalendarId(segment.event);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
||||||
@@ -176,7 +235,7 @@ export function CalendarWeekView({
|
|||||||
>
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
event={segment.event}
|
event={segment.event}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||||
variant="span"
|
variant="span"
|
||||||
continuesBefore={segment.continuesBefore}
|
continuesBefore={segment.continuesBefore}
|
||||||
continuesAfter={segment.continuesAfter}
|
continuesAfter={segment.continuesAfter}
|
||||||
@@ -188,13 +247,56 @@ export function CalendarWeekView({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Task chips in all-day area */}
|
||||||
|
{taskRowCount > 0 && (
|
||||||
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: allDayRowCount * 24 + 2 }}>
|
||||||
|
{weekDays.map((day, dayIndex) => {
|
||||||
|
const key = format(day, "yyyy-MM-dd");
|
||||||
|
const dayTasks = tasksByDay.get(key) || [];
|
||||||
|
return dayTasks.map((task, taskIndex) => {
|
||||||
|
const isCompleted = task.progress === "completed";
|
||||||
|
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||||
|
const color = cal?.color || "#3b82f6";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`task-${task.id}`}
|
||||||
|
className="absolute px-0.5 pointer-events-auto"
|
||||||
|
style={{
|
||||||
|
left: `calc(${(dayIndex / colCount) * 100}% + 1px)`,
|
||||||
|
width: `calc(${(1 / colCount) * 100}% - 2px)`,
|
||||||
|
top: taskIndex * 24,
|
||||||
|
height: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate flex items-center gap-1 cursor-pointer hover:opacity-80"
|
||||||
|
style={{ backgroundColor: `${color}20`, borderLeft: `3px solid ${color}` }}
|
||||||
|
onClick={() => onToggleTaskComplete?.(task)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
"w-2.5 h-2.5 rounded-full border flex-shrink-0 flex items-center justify-center",
|
||||||
|
isCompleted ? "bg-success border-success" : "border-current"
|
||||||
|
)}>
|
||||||
|
{isCompleted && <Check className="h-2 w-2 text-white" />}
|
||||||
|
</span>
|
||||||
|
<span className={cn("truncate", isCompleted && "line-through text-muted-foreground")}>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex border-b border-border" role="row">
|
<div className="flex border-b border-border" role="row">
|
||||||
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")} />
|
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} />
|
||||||
<div className={cn("flex-1 border-l border-border", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
|
<div className="flex-1 border-l border-border grid grid-cols-7">
|
||||||
{weekDays.map((day) => {
|
{weekDays.map((day) => {
|
||||||
const todayCol = isToday(day);
|
const todayCol = isToday(day);
|
||||||
const selected = isSameDay(day, selectedDate);
|
const selected = isSameDay(day, selectedDate);
|
||||||
@@ -227,9 +329,9 @@ export function CalendarWeekView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||||
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")}>
|
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
|
||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
@@ -245,12 +347,12 @@ export function CalendarWeekView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn("flex-1 border-l border-border relative", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
|
<div className="flex-1 border-l border-border relative grid grid-cols-7">
|
||||||
{weekDays.map((day) => {
|
{weekDays.map((day) => {
|
||||||
const key = format(day, "yyyy-MM-dd");
|
const key = format(day, "yyyy-MM-dd");
|
||||||
const dayEvents = timedEvents.get(key) || [];
|
const dayEvents = timedEvents.get(key) || [];
|
||||||
const todayCol = isToday(day);
|
const todayCol = isToday(day);
|
||||||
const layouted = layoutOverlappingEvents(dayEvents);
|
const layouted = layoutOverlappingEvents(dayEvents, day);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -277,14 +379,12 @@ export function CalendarWeekView({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const start = parseISO(ev.start);
|
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
|
||||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
|
||||||
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const leftPct = (column / totalColumns) * 100;
|
const leftPct = (column / totalColumns) * 100;
|
||||||
const widthPct = (1 / totalColumns) * 100;
|
const widthPct = (1 / totalColumns) * 100;
|
||||||
|
|
||||||
@@ -297,7 +397,7 @@ export function CalendarWeekView({
|
|||||||
>
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
event={ev}
|
event={ev}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
||||||
variant="block"
|
variant="block"
|
||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||||
@@ -324,8 +424,8 @@ export function CalendarWeekView({
|
|||||||
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-2 h-2 rounded-full bg-red-500 -ml-1" />
|
<div className="w-2 h-2 rounded-full bg-destructive -ml-1" />
|
||||||
<div className="flex-1 h-px bg-red-500" />
|
<div className="flex-1 h-px bg-destructive" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -366,6 +466,35 @@ export function CalendarWeekView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && (
|
||||||
|
(() => {
|
||||||
|
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
|
||||||
|
let endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
|
||||||
|
if (endMin <= startMin) endMin = 1440;
|
||||||
|
const durationMin = Math.max(15, endMin - startMin);
|
||||||
|
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
|
||||||
|
const color = cal?.color || "hsl(var(--primary))";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute left-1 right-1 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
|
||||||
|
style={{
|
||||||
|
top: (startMin / 60) * HOUR_HEIGHT,
|
||||||
|
height: Math.max(20, (durationMin / 60) * HOUR_HEIGHT),
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: `${color}10`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
|
||||||
|
{pendingPreview.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
|
||||||
|
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -373,5 +502,6 @@ export function CalendarWeekView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { useCallback, useState, type CSSProperties, type DragEvent } from "react
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Users } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
|
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface EventCardProps {
|
interface EventCardProps {
|
||||||
@@ -68,13 +69,14 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
const startDate = parseISO(event.start);
|
const startDate = getEventStartDate(event);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
|
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
|
|
||||||
const calendarName = calendar?.name || "";
|
const calendarName = calendar?.name || "";
|
||||||
const durationMinutes = parseDuration(event.duration);
|
const durationMinutes = parseDuration(event.duration);
|
||||||
const endTime = new Date(startDate.getTime() + durationMinutes * 60000);
|
const endTime = getEventEndDate(event);
|
||||||
const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`;
|
const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`;
|
||||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
||||||
|
|
||||||
@@ -156,6 +158,9 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
|
{showTimeInMonthView && !event.showWithoutTime && (
|
||||||
|
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
|
||||||
|
)}
|
||||||
<span className="truncate font-medium">{event.title || t("events.no_title")}</span>
|
<span className="truncate font-medium">{event.title || t("events.no_title")}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { format, parseISO } from "date-fns";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
|
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
getUserParticipantId,
|
getUserParticipantId,
|
||||||
@@ -138,9 +139,9 @@ export function EventDetailPopover({
|
|||||||
const [isSavingNote, setIsSavingNote] = useState(false);
|
const [isSavingNote, setIsSavingNote] = useState(false);
|
||||||
|
|
||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
const startDate = parseISO(event.start);
|
const startDate = getEventStartDate(event);
|
||||||
const durationMinutes = parseDuration(event.duration);
|
const durationMinutes = parseDuration(event.duration);
|
||||||
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
const endDate = getEventEndDate(event);
|
||||||
|
|
||||||
const locationName = useMemo(() => {
|
const locationName = useMemo(() => {
|
||||||
if (!event.locations) return null;
|
if (!event.locations) return null;
|
||||||
@@ -192,6 +193,10 @@ export function EventDetailPopover({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const tag = target?.tagName?.toLowerCase();
|
||||||
|
if (tag === "input" || tag === "textarea" || tag === "select") return;
|
||||||
|
if (target?.getAttribute("contenteditable") === "true") return;
|
||||||
if (e.key === "e" && !noteExpanded) {
|
if (e.key === "e" && !noteExpanded) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onEdit();
|
onEdit();
|
||||||
@@ -293,7 +298,7 @@ export function EventDetailPopover({
|
|||||||
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">
|
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">
|
||||||
{calendar.name}
|
{calendar.name}
|
||||||
{event.status === "tentative" && (
|
{event.status === "tentative" && (
|
||||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
|
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
|
||||||
{t("detail.tentative")}
|
{t("detail.tentative")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -503,8 +508,8 @@ export function EventDetailPopover({
|
|||||||
onClick={() => onRsvp("accepted")}
|
onClick={() => onRsvp("accepted")}
|
||||||
className={
|
className={
|
||||||
userCurrentStatus === "accepted"
|
userCurrentStatus === "accepted"
|
||||||
? "bg-green-600 hover:bg-green-700 text-white dark:bg-green-500 dark:hover:bg-green-600"
|
? "bg-success hover:bg-success/80 text-success-foreground"
|
||||||
: "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"
|
: "text-success border-success/30 hover:bg-success/10"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 mr-1" />}
|
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 mr-1" />}
|
||||||
@@ -516,8 +521,8 @@ export function EventDetailPopover({
|
|||||||
onClick={() => onRsvp("tentative")}
|
onClick={() => onRsvp("tentative")}
|
||||||
className={
|
className={
|
||||||
userCurrentStatus === "tentative"
|
userCurrentStatus === "tentative"
|
||||||
? "bg-amber-600 hover:bg-amber-700 text-white dark:bg-amber-500 dark:hover:bg-amber-600"
|
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
||||||
: "border border-amber-500 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950"
|
: "border border-warning/30 text-warning hover:bg-warning/10"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 mr-1" />}
|
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 mr-1" />}
|
||||||
@@ -529,8 +534,8 @@ export function EventDetailPopover({
|
|||||||
onClick={() => onRsvp("declined")}
|
onClick={() => onRsvp("declined")}
|
||||||
className={
|
className={
|
||||||
userCurrentStatus === "declined"
|
userCurrentStatus === "declined"
|
||||||
? "bg-red-600 hover:bg-red-700 text-white dark:bg-red-500 dark:hover:bg-red-600"
|
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
||||||
: "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"
|
: "text-destructive hover:bg-destructive/10"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 mr-1" />}
|
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 mr-1" />}
|
||||||
@@ -544,7 +549,7 @@ export function EventDetailPopover({
|
|||||||
<div className="px-4 py-2.5 border-t border-border flex items-center gap-1.5">
|
<div className="px-4 py-2.5 border-t border-border flex items-center gap-1.5">
|
||||||
{showDeleteConfirm ? (
|
{showDeleteConfirm ? (
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<span className="text-sm text-red-600 dark:text-red-400 flex-1">
|
<span className="text-sm text-destructive flex-1">
|
||||||
{t("form.delete_confirm")}
|
{t("form.delete_confirm")}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -607,9 +612,9 @@ function ParticipantStatusBadge({
|
|||||||
t: ReturnType<typeof useTranslations>;
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
accepted: "text-green-600 dark:text-green-400",
|
accepted: "text-success",
|
||||||
declined: "text-red-600 dark:text-red-400",
|
declined: "text-destructive",
|
||||||
tentative: "text-amber-600 dark:text-amber-400",
|
tentative: "text-warning",
|
||||||
"needs-action": "text-muted-foreground",
|
"needs-action": "text-muted-foreground",
|
||||||
};
|
};
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Vid
|
|||||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { buildAllDayDuration, getEventDisplayEndDate } from "@/lib/calendar-utils";
|
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { ParticipantInput } from "./participant-input";
|
import { ParticipantInput } from "./participant-input";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
@@ -18,18 +18,28 @@ import {
|
|||||||
getStatusCounts,
|
getStatusCounts,
|
||||||
buildParticipantMap,
|
buildParticipantMap,
|
||||||
} from "@/lib/calendar-participants";
|
} from "@/lib/calendar-participants";
|
||||||
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
|
export interface PendingEventPreview {
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
title: string;
|
||||||
|
allDay: boolean;
|
||||||
|
calendarId: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface EventModalProps {
|
interface EventModalProps {
|
||||||
event?: CalendarEvent | null;
|
event?: CalendarEvent | null;
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
defaultDate?: Date;
|
defaultDate?: Date;
|
||||||
defaultEndDate?: Date;
|
defaultEndDate?: Date;
|
||||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void;
|
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
||||||
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||||
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
||||||
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
|
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onPreviewChange?: (preview: PendingEventPreview | null) => void;
|
||||||
currentUserEmails?: string[];
|
currentUserEmails?: string[];
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
}
|
}
|
||||||
@@ -50,10 +60,12 @@ function buildDuration(startDate: Date, endDate: Date): string {
|
|||||||
const minutes = totalMinutes % 60;
|
const minutes = totalMinutes % 60;
|
||||||
let dur = "P";
|
let dur = "P";
|
||||||
if (days > 0) dur += `${days}D`;
|
if (days > 0) dur += `${days}D`;
|
||||||
dur += "T";
|
if (hours > 0 || minutes > 0) {
|
||||||
if (hours > 0) dur += `${hours}H`;
|
dur += "T";
|
||||||
if (minutes > 0) dur += `${minutes}M`;
|
if (hours > 0) dur += `${hours}H`;
|
||||||
if (dur === "PT") dur = "PT0M";
|
if (minutes > 0) dur += `${minutes}M`;
|
||||||
|
}
|
||||||
|
if (dur === "P") dur = "PT0M";
|
||||||
return dur;
|
return dur;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +86,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
|
|||||||
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
|
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
|
||||||
const offset = first.trigger.offset;
|
const offset = first.trigger.offset;
|
||||||
if (offset === "PT0S") return t("alerts.at_time");
|
if (offset === "PT0S") return t("alerts.at_time");
|
||||||
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
const minMatch = offset.match(/-?PT(\d+)M$/);
|
||||||
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
|
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
|
||||||
const hourMatch = offset.match(/-?PT?(\d+)H$/);
|
const hourMatch = offset.match(/-?PT(\d+)H$/);
|
||||||
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
|
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
|
||||||
const dayMatch = offset.match(/-?P(\d+)D/);
|
const dayMatch = offset.match(/-?P(\d+)D/);
|
||||||
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
|
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
|
||||||
@@ -105,6 +117,7 @@ export function EventModal({
|
|||||||
onDuplicate,
|
onDuplicate,
|
||||||
onRsvp,
|
onRsvp,
|
||||||
onClose,
|
onClose,
|
||||||
|
onPreviewChange,
|
||||||
currentUserEmails = [],
|
currentUserEmails = [],
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
}: EventModalProps) {
|
}: EventModalProps) {
|
||||||
@@ -147,7 +160,7 @@ export function EventModal({
|
|||||||
}, [event, existingParticipants]);
|
}, [event, existingParticipants]);
|
||||||
|
|
||||||
const getInitialStart = (): Date => {
|
const getInitialStart = (): Date => {
|
||||||
if (event?.start) return parseISO(event.start);
|
if (event?.start) return getEventStartDate(event);
|
||||||
if (defaultDate) {
|
if (defaultDate) {
|
||||||
const d = new Date(defaultDate);
|
const d = new Date(defaultDate);
|
||||||
if (defaultEndDate) return d;
|
if (defaultEndDate) return d;
|
||||||
@@ -165,9 +178,7 @@ export function EventModal({
|
|||||||
if (event.showWithoutTime) {
|
if (event.showWithoutTime) {
|
||||||
return getEventDisplayEndDate(event);
|
return getEventDisplayEndDate(event);
|
||||||
}
|
}
|
||||||
const s = parseISO(event.start);
|
return getEventEndDate(event);
|
||||||
const dur = parseDuration(event.duration);
|
|
||||||
return new Date(s.getTime() + dur * 60000);
|
|
||||||
}
|
}
|
||||||
if (defaultEndDate) return new Date(defaultEndDate);
|
if (defaultEndDate) return new Date(defaultEndDate);
|
||||||
return addHours(getInitialStart(), 1);
|
return addHours(getInitialStart(), 1);
|
||||||
@@ -178,13 +189,16 @@ export function EventModal({
|
|||||||
const [location, setLocation] = useState(
|
const [location, setLocation] = useState(
|
||||||
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
||||||
);
|
);
|
||||||
|
const [virtualLocation, setVirtualLocation] = useState(
|
||||||
|
event?.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || "" : ""
|
||||||
|
);
|
||||||
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
|
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
|
||||||
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
||||||
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
||||||
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
|
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
|
||||||
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
||||||
const [calendarId, setCalendarId] = useState<string>(() => {
|
const [calendarId, setCalendarId] = useState<string>(() => {
|
||||||
if (event?.calendarIds) return Object.keys(event.calendarIds)[0] || calendars[0]?.id || "";
|
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
||||||
const defaultCal = calendars.find(c => c.isDefault);
|
const defaultCal = calendars.find(c => c.isDefault);
|
||||||
return defaultCal?.id || calendars[0]?.id || "";
|
return defaultCal?.id || calendars[0]?.id || "";
|
||||||
});
|
});
|
||||||
@@ -199,9 +213,9 @@ export function EventModal({
|
|||||||
if (first.trigger["@type"] === "OffsetTrigger") {
|
if (first.trigger["@type"] === "OffsetTrigger") {
|
||||||
const offset = first.trigger.offset;
|
const offset = first.trigger.offset;
|
||||||
if (offset === "PT0S") return "at_time";
|
if (offset === "PT0S") return "at_time";
|
||||||
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
const minMatch = offset.match(/-?PT(\d+)M$/);
|
||||||
if (minMatch) return minMatch[1] as AlertOption;
|
if (minMatch) return minMatch[1] as AlertOption;
|
||||||
const hourMatch = offset.match(/-?PT?(\d+)H$/);
|
const hourMatch = offset.match(/-?PT(\d+)H$/);
|
||||||
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption;
|
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption;
|
||||||
const dayMatch = offset.match(/-?P(\d+)D/);
|
const dayMatch = offset.match(/-?P(\d+)D/);
|
||||||
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption;
|
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption;
|
||||||
@@ -209,6 +223,7 @@ export function EventModal({
|
|||||||
return "none";
|
return "none";
|
||||||
});
|
});
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = 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) return [];
|
||||||
@@ -218,6 +233,18 @@ export function EventModal({
|
|||||||
});
|
});
|
||||||
const [sendInvitations, setSendInvitations] = useState(true);
|
const [sendInvitations, setSendInvitations] = useState(true);
|
||||||
|
|
||||||
|
// Report live preview to parent for grid outline
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onPreviewChange || isEdit) return;
|
||||||
|
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
|
||||||
|
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
|
||||||
|
const s = new Date(startStr);
|
||||||
|
const e = new Date(endStr);
|
||||||
|
if (isNaN(s.getTime()) || isNaN(e.getTime())) return;
|
||||||
|
onPreviewChange({ start: s, end: e, title: title || "(No title)", allDay, calendarId });
|
||||||
|
return () => onPreviewChange(null);
|
||||||
|
}, [startDate, startTime, endDate, endTime, allDay, title, calendarId, isEdit, onPreviewChange]);
|
||||||
|
|
||||||
const statusCounts = useMemo(() => {
|
const statusCounts = useMemo(() => {
|
||||||
if (!event?.participants) return null;
|
if (!event?.participants) return null;
|
||||||
return getStatusCounts(event);
|
return getStatusCounts(event);
|
||||||
@@ -231,16 +258,16 @@ export function EventModal({
|
|||||||
setAttendees(prev => prev.filter(a => a.email.toLowerCase() !== email.toLowerCase()));
|
setAttendees(prev => prev.filter(a => a.email.toLowerCase() !== email.toLowerCase()));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(async () => {
|
||||||
const trimmedTitle = title.trim();
|
const trimmedTitle = title.trim();
|
||||||
if (!trimmedTitle) return;
|
if (!trimmedTitle || isSaving) return;
|
||||||
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
|
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
|
||||||
|
|
||||||
const startStr = allDay
|
const startStr = allDay
|
||||||
? `${startDate}T00:00:00`
|
? `${startDate}T00:00:00`
|
||||||
: `${startDate}T${startTime}:00`;
|
: `${startDate}T${startTime}:00`;
|
||||||
|
|
||||||
const start = new Date(startStr);
|
const start = allDay ? parseISO(startStr) : new Date(startStr);
|
||||||
let duration: string;
|
let duration: string;
|
||||||
|
|
||||||
if (allDay) {
|
if (allDay) {
|
||||||
@@ -290,6 +317,20 @@ export function EventModal({
|
|||||||
data.locations = null;
|
data.locations = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (virtualLocation.trim()) {
|
||||||
|
data.virtualLocations = {
|
||||||
|
vl1: {
|
||||||
|
"@type": "VirtualLocation",
|
||||||
|
name: null,
|
||||||
|
description: null,
|
||||||
|
uri: virtualLocation.trim(),
|
||||||
|
features: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (event && event.virtualLocations && Object.keys(event.virtualLocations).length > 0) {
|
||||||
|
data.virtualLocations = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (recurrence !== "none") {
|
if (recurrence !== "none") {
|
||||||
data.recurrenceRules = [{
|
data.recurrenceRules = [{
|
||||||
"@type": "RecurrenceRule",
|
"@type": "RecurrenceRule",
|
||||||
@@ -343,8 +384,13 @@ export function EventModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
|
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
|
||||||
onSave(data, shouldSendScheduling);
|
setIsSaving(true);
|
||||||
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave]);
|
try {
|
||||||
|
await onSave(data, shouldSendScheduling);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||||
|
|
||||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||||
if (!event || !userParticipantId || !onRsvp) return;
|
if (!event || !userParticipantId || !onRsvp) return;
|
||||||
@@ -354,12 +400,16 @@ export function EventModal({
|
|||||||
|
|
||||||
const handleDuplicate = useCallback(() => {
|
const handleDuplicate = useCallback(() => {
|
||||||
if (!event || !onDuplicate) return;
|
if (!event || !onDuplicate) return;
|
||||||
const start = parseISO(event.start);
|
const start = getEventStartDate(event);
|
||||||
const newStart = addDays(start, 1);
|
const newStart = addDays(start, 1);
|
||||||
|
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
const data: Partial<CalendarEvent> = {
|
const data: Partial<CalendarEvent> = {
|
||||||
|
uid: newUid,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
description: event.description,
|
description: event.description,
|
||||||
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
start: event.showWithoutTime ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||||
duration: event.duration,
|
duration: event.duration,
|
||||||
timeZone: event.timeZone,
|
timeZone: event.timeZone,
|
||||||
showWithoutTime: event.showWithoutTime,
|
showWithoutTime: event.showWithoutTime,
|
||||||
@@ -369,6 +419,7 @@ export function EventModal({
|
|||||||
privacy: event.privacy,
|
privacy: event.privacy,
|
||||||
};
|
};
|
||||||
if (event.locations) data.locations = structuredClone(event.locations);
|
if (event.locations) data.locations = structuredClone(event.locations);
|
||||||
|
if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations);
|
||||||
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||||
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||||
if (event.participants) data.participants = structuredClone(event.participants);
|
if (event.participants) data.participants = structuredClone(event.participants);
|
||||||
@@ -422,9 +473,9 @@ export function EventModal({
|
|||||||
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
|
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
|
||||||
|
|
||||||
if (isAttendeeMode && event) {
|
if (isAttendeeMode && event) {
|
||||||
const startD = parseISO(event.start);
|
const startD = getEventStartDate(event);
|
||||||
const durMin = parseDuration(event.duration);
|
const durMin = parseDuration(event.duration);
|
||||||
const endD = new Date(startD.getTime() + durMin * 60000);
|
const endD = getEventEndDate(event);
|
||||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
||||||
const participants = getParticipantList(event);
|
const participants = getParticipantList(event);
|
||||||
|
|
||||||
@@ -496,8 +547,8 @@ export function EventModal({
|
|||||||
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
||||||
onClick={() => handleRsvp("accepted")}
|
onClick={() => handleRsvp("accepted")}
|
||||||
className={userCurrentStatus === "accepted"
|
className={userCurrentStatus === "accepted"
|
||||||
? "bg-green-600 hover:bg-green-700 text-white dark:bg-green-500 dark:hover:bg-green-600"
|
? "bg-success hover:bg-success/80 text-success-foreground"
|
||||||
: "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"}
|
: "text-success border-success/30 hover:bg-success/10"}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
|
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
|
||||||
{t("participants.accepted")}
|
{t("participants.accepted")}
|
||||||
@@ -507,8 +558,8 @@ export function EventModal({
|
|||||||
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
||||||
onClick={() => handleRsvp("tentative")}
|
onClick={() => handleRsvp("tentative")}
|
||||||
className={userCurrentStatus === "tentative"
|
className={userCurrentStatus === "tentative"
|
||||||
? "bg-amber-600 hover:bg-amber-700 text-white dark:bg-amber-500 dark:hover:bg-amber-600"
|
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
||||||
: "border border-amber-500 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950"}
|
: "border border-warning/30 text-warning hover:bg-warning/10"}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
|
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
|
||||||
{t("participants.tentative")}
|
{t("participants.tentative")}
|
||||||
@@ -518,8 +569,8 @@ export function EventModal({
|
|||||||
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
||||||
onClick={() => handleRsvp("declined")}
|
onClick={() => handleRsvp("declined")}
|
||||||
className={userCurrentStatus === "declined"
|
className={userCurrentStatus === "declined"
|
||||||
? "bg-red-600 hover:bg-red-700 text-white dark:bg-red-500 dark:hover:bg-red-600"
|
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
||||||
: "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"}
|
: "text-destructive hover:bg-destructive/10"}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
|
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
|
||||||
{t("participants.declined")}
|
{t("participants.declined")}
|
||||||
@@ -533,9 +584,9 @@ export function EventModal({
|
|||||||
|
|
||||||
// View mode: read-only display of event details with Edit button
|
// View mode: read-only display of event details with Edit button
|
||||||
if (mode === "view" && event) {
|
if (mode === "view" && event) {
|
||||||
const startD = parseISO(event.start);
|
const startD = getEventStartDate(event);
|
||||||
const durMin = parseDuration(event.duration);
|
const durMin = parseDuration(event.duration);
|
||||||
const endD = new Date(startD.getTime() + durMin * 60000);
|
const endD = getEventEndDate(event);
|
||||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
|
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
|
||||||
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
||||||
const viewParticipants = getParticipantList(event);
|
const viewParticipants = getParticipantList(event);
|
||||||
@@ -667,8 +718,8 @@ export function EventModal({
|
|||||||
{onDelete && (
|
{onDelete && (
|
||||||
showDeleteConfirm ? (
|
showDeleteConfirm ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">{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-red-600 dark:text-red-400 border-red-300 dark:border-red-700">
|
<Button variant="outline" size="sm" onClick={() => { 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)}>
|
||||||
@@ -676,7 +727,7 @@ export function EventModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-red-600 dark:text-red-400">
|
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-destructive">
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
{t("events.delete")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -701,7 +752,7 @@ export function EventModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">
|
||||||
{isEdit ? t("events.edit") : t("events.create")}
|
{isEdit ? t("events.edit") : t("events.create")}
|
||||||
@@ -746,6 +797,39 @@ export function EventModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-1 block">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Video className="w-4 h-4" />
|
||||||
|
{t("form.meeting_link")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={virtualLocation}
|
||||||
|
onChange={(e) => setVirtualLocation(e.target.value)}
|
||||||
|
placeholder="https://meet.example.com/..."
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
<PluginSlot
|
||||||
|
name="calendar-event-actions"
|
||||||
|
className="mt-2 flex flex-wrap gap-2"
|
||||||
|
extraProps={{
|
||||||
|
eventData: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
start: startDate + 'T' + startTime,
|
||||||
|
end: endDate + 'T' + endTime,
|
||||||
|
isAllDay: allDay,
|
||||||
|
location,
|
||||||
|
virtualLocation,
|
||||||
|
calendarId,
|
||||||
|
},
|
||||||
|
setVirtualLocation,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1 block">
|
<label className="text-sm font-medium mb-1 block">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
@@ -945,7 +1029,7 @@ export function EventModal({
|
|||||||
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
|
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
|
||||||
{t("form.cancel")}
|
{t("form.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={!title.trim()}>
|
<Button onClick={handleSave} disabled={!title.trim() || isSaving}>
|
||||||
{t("form.save")}
|
{t("form.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -963,9 +1047,9 @@ function StatusBadge({ status, isOrganizer, t }: {
|
|||||||
return <span className="text-xs text-primary">{t("participants.organizer")}</span>;
|
return <span className="text-xs text-primary">{t("participants.organizer")}</span>;
|
||||||
}
|
}
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
accepted: "text-green-600 dark:text-green-400",
|
accepted: "text-success",
|
||||||
declined: "text-red-600 dark:text-red-400",
|
declined: "text-destructive",
|
||||||
tentative: "text-amber-600 dark:text-amber-400",
|
tentative: "text-warning",
|
||||||
"needs-action": "text-muted-foreground",
|
"needs-action": "text-muted-foreground",
|
||||||
};
|
};
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
|
|||||||
@@ -6,18 +6,19 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import type { JMAPClient } from "@/lib/jmap/client";
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
interface ICalImportModalProps {
|
interface ICalImportModalProps {
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
client: JMAPClient;
|
client: IJMAPClient;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
||||||
|
|
||||||
type ImportStep = "select" | "preview" | "importing";
|
type ImportStep = "select" | "preview" | "importing";
|
||||||
@@ -195,7 +196,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
const formatEventDate = (event: Partial<CalendarEvent>): string => {
|
const formatEventDate = (event: Partial<CalendarEvent>): string => {
|
||||||
if (!event.start) return "";
|
if (!event.start) return "";
|
||||||
try {
|
try {
|
||||||
const date = parseISO(event.start);
|
const date = getEventStartDate(event as CalendarEvent);
|
||||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
return event.showWithoutTime
|
return event.showWithoutTime
|
||||||
? format(date, "MMM d, yyyy")
|
? format(date, "MMM d, yyyy")
|
||||||
@@ -349,7 +350,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-md px-3 py-2">
|
<div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,25 +4,29 @@ import { useState, useRef, useEffect, useCallback } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { X, Loader2, Globe } from "lucide-react";
|
import { X, Loader2, Globe } from "lucide-react";
|
||||||
import type { JMAPClient } from "@/lib/jmap/client";
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore, type ICalSubscription } from "@/stores/calendar-store";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
interface ICalSubscriptionModalProps {
|
interface ICalSubscriptionModalProps {
|
||||||
client: JMAPClient;
|
client: IJMAPClient;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
editSubscription?: ICalSubscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) {
|
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
|
||||||
const t = useTranslations("calendar.subscription");
|
const t = useTranslations("calendar.subscription");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
||||||
|
const updateICalSubscription = useCalendarStore((s) => s.updateICalSubscription);
|
||||||
|
|
||||||
const [url, setUrl] = useState("");
|
const isEdit = !!editSubscription;
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [color, setColor] = useState("#3b82f6");
|
const [url, setUrl] = useState(editSubscription?.url || "");
|
||||||
const [refreshInterval, setRefreshInterval] = useState(60);
|
const [name, setName] = useState(editSubscription?.name || "");
|
||||||
|
const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
|
||||||
|
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -49,15 +53,26 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
|
if (isEdit && editSubscription) {
|
||||||
if (subscription) {
|
const updates: { url?: string; name?: string; color?: string; refreshInterval?: number } = {};
|
||||||
toast.success(t("success", { name: name.trim() }));
|
if (trimmedUrl !== editSubscription.url) updates.url = trimmedUrl;
|
||||||
|
if (name.trim() !== editSubscription.name) updates.name = name.trim();
|
||||||
|
if (color !== editSubscription.color) updates.color = color;
|
||||||
|
if (refreshInterval !== editSubscription.refreshInterval) updates.refreshInterval = refreshInterval;
|
||||||
|
await updateICalSubscription(client, editSubscription.id, updates);
|
||||||
|
toast.success(t("updated", { name: name.trim() }));
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
setError(t("error"));
|
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
|
||||||
|
if (subscription) {
|
||||||
|
toast.success(t("success", { name: name.trim() }));
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setError(t("error"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("error"));
|
setError(isEdit ? t("update_error") : t("error"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -108,7 +123,7 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
|||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Globe className="w-5 h-5 text-primary" />
|
<Globe className="w-5 h-5 text-primary" />
|
||||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
<h2 className="text-lg font-semibold">{isEdit ? t("edit_title") : t("title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -192,10 +207,10 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
|||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
{t("subscribing")}
|
{isEdit ? t("saving") : t("subscribing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
t("subscribe")
|
isEdit ? t("save") : t("subscribe")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, Fragment } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
|
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||||
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||||
eachDayOfInterval, getMonth, getYear,
|
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
|
||||||
isSameDay, isSameMonth, isToday, format,
|
isSameDay, isSameMonth, isToday, format,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
|
||||||
type PickerView = "days" | "months" | "years";
|
type PickerView = "days" | "months" | "years";
|
||||||
@@ -26,6 +27,7 @@ interface MiniCalendarProps {
|
|||||||
onChangeMonth: (date: Date) => void;
|
onChangeMonth: (date: Date) => void;
|
||||||
events?: CalendarEvent[];
|
events?: CalendarEvent[];
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
|
showWeekNumbers?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MiniCalendar({
|
export function MiniCalendar({
|
||||||
@@ -35,6 +37,7 @@ export function MiniCalendar({
|
|||||||
onChangeMonth,
|
onChangeMonth,
|
||||||
events = [],
|
events = [],
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
|
showWeekNumbers = false,
|
||||||
}: MiniCalendarProps) {
|
}: MiniCalendarProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -52,7 +55,14 @@ export function MiniCalendar({
|
|||||||
const eventDates = useMemo(() => {
|
const eventDates = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
events.forEach(e => {
|
events.forEach(e => {
|
||||||
try { set.add(format(new Date(e.start), "yyyy-MM-dd")); } catch { /* skip */ }
|
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 */ }
|
||||||
});
|
});
|
||||||
return set;
|
return set;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
@@ -61,6 +71,17 @@ export function MiniCalendar({
|
|||||||
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
|
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
|
||||||
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||||
|
|
||||||
|
// Compute week numbers for each row (one per 7-day chunk)
|
||||||
|
const weekNumbers = useMemo(() => {
|
||||||
|
if (!showWeekNumbers) return [];
|
||||||
|
const nums: number[] = [];
|
||||||
|
for (let i = 0; i < days.length; i += 7) {
|
||||||
|
// Use the first day of each row to determine the week number
|
||||||
|
nums.push(weekStart === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
|
||||||
|
}
|
||||||
|
return nums;
|
||||||
|
}, [days, showWeekNumbers, weekStart]);
|
||||||
|
|
||||||
const currentYear = getYear(displayMonth);
|
const currentYear = getYear(displayMonth);
|
||||||
const currentMonth = getMonth(displayMonth);
|
const currentMonth = getMonth(displayMonth);
|
||||||
const decadeStart = Math.floor(currentYear / 10) * 10;
|
const decadeStart = Math.floor(currentYear / 10) * 10;
|
||||||
@@ -135,35 +156,49 @@ export function MiniCalendar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pickerView === "days" && (
|
{pickerView === "days" && (
|
||||||
<div className="grid grid-cols-7 gap-0">
|
<div className={cn("grid gap-0", showWeekNumbers ? "grid-cols-[auto_repeat(7,1fr)]" : "grid-cols-7")}>
|
||||||
|
{showWeekNumbers && (
|
||||||
|
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
|
||||||
|
)}
|
||||||
{dayHeaders.map((d) => (
|
{dayHeaders.map((d) => (
|
||||||
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
|
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
|
||||||
{t(`days.${d}`)}
|
{t(`days.${d}`)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{days.map((day) => {
|
{days.map((day, index) => {
|
||||||
const inMonth = isSameMonth(day, displayMonth);
|
const inMonth = isSameMonth(day, displayMonth);
|
||||||
const selected = isSameDay(day, selectedDate);
|
const selected = isSameDay(day, selectedDate);
|
||||||
const today = isToday(day);
|
const today = isToday(day);
|
||||||
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
|
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
|
||||||
|
const isFirstDayOfRow = index % 7 === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Fragment key={day.toISOString()}>
|
||||||
key={day.toISOString()}
|
{showWeekNumbers && isFirstDayOfRow && (
|
||||||
onClick={() => onSelectDate(day)}
|
<div
|
||||||
className={cn(
|
key={`wk-${index}`}
|
||||||
"relative flex items-center justify-center w-7 h-7 text-xs rounded-full transition-colors",
|
className="flex items-center justify-center w-5 text-[9px] text-muted-foreground/60 font-medium"
|
||||||
!inMonth && "text-muted-foreground/40",
|
>
|
||||||
inMonth && !selected && "hover:bg-muted",
|
{weekNumbers[index / 7]}
|
||||||
today && !selected && "font-bold text-primary",
|
</div>
|
||||||
selected && "bg-primary text-primary-foreground"
|
|
||||||
)}
|
)}
|
||||||
>
|
<button
|
||||||
{format(day, "d")}
|
key={`day-${day.toISOString()}`}
|
||||||
{hasEvent && !selected && (
|
onClick={() => onSelectDate(day)}
|
||||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
className={cn(
|
||||||
)}
|
"relative flex items-center justify-center w-7 h-7 text-xs rounded-full transition-colors",
|
||||||
</button>
|
!inMonth && "text-muted-foreground/40",
|
||||||
|
inMonth && !selected && "hover:bg-muted",
|
||||||
|
today && !selected && "font-bold text-primary",
|
||||||
|
selected && "bg-primary text-primary-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{format(day, "d")}
|
||||||
|
{hasEvent && !selected && (
|
||||||
|
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useCallback } from "react";
|
import { useMemo, useCallback, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { format, parseISO, isPast, isToday, isTomorrow } from "date-fns";
|
import { format, parseISO, isPast, isToday, isTomorrow } from "date-fns";
|
||||||
import { Check, Circle, Flag, CalendarDays, ListTodo } from "lucide-react";
|
import { Check, Flag, CalendarDays, ListTodo, Plus } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarTask, Calendar } from "@/lib/jmap/types";
|
import type { CalendarTask, Calendar } from "@/lib/jmap/types";
|
||||||
import type { TaskViewFilter } from "@/stores/task-store";
|
import type { TaskViewFilter } from "@/stores/task-store";
|
||||||
@@ -18,6 +18,7 @@ interface TaskListViewProps {
|
|||||||
onSelectTask: (task: CalendarTask) => void;
|
onSelectTask: (task: CalendarTask) => void;
|
||||||
onToggleComplete: (task: CalendarTask) => void;
|
onToggleComplete: (task: CalendarTask) => void;
|
||||||
selectedTaskId?: string | null;
|
selectedTaskId?: string | null;
|
||||||
|
onQuickCreate?: (title: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTaskPriorityIcon(priority: number) {
|
function getTaskPriorityIcon(priority: number) {
|
||||||
@@ -69,9 +70,11 @@ export function TaskListView({
|
|||||||
onSelectTask,
|
onSelectTask,
|
||||||
onToggleComplete,
|
onToggleComplete,
|
||||||
selectedTaskId,
|
selectedTaskId,
|
||||||
|
onQuickCreate,
|
||||||
}: TaskListViewProps) {
|
}: TaskListViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
|
const [quickAddTitle, setQuickAddTitle] = useState("");
|
||||||
|
|
||||||
const filteredTasks = useMemo(() => {
|
const filteredTasks = useMemo(() => {
|
||||||
let result = tasks.filter(task => {
|
let result = tasks.filter(task => {
|
||||||
@@ -128,15 +131,57 @@ export function TaskListView({
|
|||||||
|
|
||||||
if (filteredTasks.length === 0) {
|
if (filteredTasks.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground py-12">
|
<div className="flex flex-col flex-1">
|
||||||
<ListTodo className="h-12 w-12 mb-3 opacity-30" />
|
{onQuickCreate && (
|
||||||
<p className="text-sm">{t("tasks.no_tasks")}</p>
|
<div className="px-4 py-2 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={quickAddTitle}
|
||||||
|
onChange={(e) => setQuickAddTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && quickAddTitle.trim()) {
|
||||||
|
onQuickCreate(quickAddTitle.trim());
|
||||||
|
setQuickAddTitle("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t("tasks.quick_add_placeholder")}
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground py-12">
|
||||||
|
<ListTodo className="h-12 w-12 mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">{t("tasks.no_tasks")}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{onQuickCreate && (
|
||||||
|
<div className="px-4 py-2 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={quickAddTitle}
|
||||||
|
onChange={(e) => setQuickAddTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && quickAddTitle.trim()) {
|
||||||
|
onQuickCreate(quickAddTitle.trim());
|
||||||
|
setQuickAddTitle("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t("tasks.quick_add_placeholder")}
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{filteredTasks.map(task => {
|
{filteredTasks.map(task => {
|
||||||
const cal = calendars.find(c => task.calendarIds[c.id]);
|
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||||
@@ -159,7 +204,7 @@ export function TaskListView({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"mt-0.5 flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors",
|
"mt-0.5 flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors",
|
||||||
isCompleted
|
isCompleted
|
||||||
? "bg-green-500 border-green-500 text-white"
|
? "bg-success border-success text-success-foreground"
|
||||||
: "border-muted-foreground/40 hover:border-primary"
|
: "border-muted-foreground/40 hover:border-primary"
|
||||||
)}
|
)}
|
||||||
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
|
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { X, Trash2, CalendarDays, Bell, Flag } from "lucide-react";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
|
||||||
|
import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types";
|
||||||
|
|
||||||
|
interface TaskModalProps {
|
||||||
|
task?: CalendarTask | null;
|
||||||
|
calendars: Calendar[];
|
||||||
|
onSave: (data: Partial<CalendarTask>) => void | Promise<void>;
|
||||||
|
onDelete?: (id: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
isMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PriorityLevel = "none" | "high" | "medium" | "low";
|
||||||
|
type AlertOption = "none" | "at_time" | "5" | "15" | "30" | "60" | "1440";
|
||||||
|
|
||||||
|
function priorityToLevel(p: number): PriorityLevel {
|
||||||
|
if (p >= 1 && p <= 4) return "high";
|
||||||
|
if (p === 5) return "medium";
|
||||||
|
if (p >= 6 && p <= 9) return "low";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelToPriority(l: PriorityLevel): number {
|
||||||
|
switch (l) {
|
||||||
|
case "high": return 1;
|
||||||
|
case "medium": return 5;
|
||||||
|
case "low": return 9;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskModal({
|
||||||
|
task,
|
||||||
|
calendars,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
onClose,
|
||||||
|
isMobile: _isMobile,
|
||||||
|
}: TaskModalProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
const isEdit = !!task;
|
||||||
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn);
|
||||||
|
const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? "";
|
||||||
|
|
||||||
|
const [title, setTitle] = useState(task?.title ?? "");
|
||||||
|
const [description, setDescription] = useState(task?.description ?? "");
|
||||||
|
const [dueDate, setDueDate] = useState(task?.due ? format(parseISO(task.due), "yyyy-MM-dd") : "");
|
||||||
|
const [dueTime, setDueTime] = useState(task?.due && !task.showWithoutTime ? format(parseISO(task.due), "HH:mm") : "");
|
||||||
|
const [showTime, setShowTime] = useState(task?.due ? !task.showWithoutTime : false);
|
||||||
|
const [priority, setPriority] = useState<PriorityLevel>(priorityToLevel(task?.priority ?? 0));
|
||||||
|
const [progress, setProgress] = useState<CalendarTask["progress"]>(task?.progress ?? "needs-action");
|
||||||
|
const [calendarId, setCalendarId] = useState(() => {
|
||||||
|
if (task) {
|
||||||
|
const ids = Object.keys(task.calendarIds);
|
||||||
|
return ids[0] ?? defaultCalendarId;
|
||||||
|
}
|
||||||
|
return defaultCalendarId;
|
||||||
|
});
|
||||||
|
const [alertOption, setAlertOption] = useState<AlertOption>(() => {
|
||||||
|
if (!task?.alerts) return "none";
|
||||||
|
const first = Object.values(task.alerts)[0];
|
||||||
|
if (!first || first.trigger["@type"] !== "OffsetTrigger") return "none";
|
||||||
|
const offset = first.trigger.offset;
|
||||||
|
if (offset === "PT0S") return "at_time";
|
||||||
|
const m = offset.match(/-?PT?(\d+)M$/);
|
||||||
|
if (m) return m[1] as AlertOption;
|
||||||
|
const h = offset.match(/-?PT?(\d+)H$/);
|
||||||
|
if (h) return String(parseInt(h[1]) * 60) as AlertOption;
|
||||||
|
const d = offset.match(/-?P(\d+)D/);
|
||||||
|
if (d) return String(parseInt(d[1]) * 1440) as AlertOption;
|
||||||
|
return "none";
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
titleRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
let due: string | null = null;
|
||||||
|
let showWithoutTime = true;
|
||||||
|
if (dueDate) {
|
||||||
|
if (showTime && dueTime) {
|
||||||
|
due = `${dueDate}T${dueTime}:00`;
|
||||||
|
showWithoutTime = false;
|
||||||
|
} else {
|
||||||
|
due = dueDate;
|
||||||
|
showWithoutTime = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let alerts: Record<string, CalendarEventAlert> | null = null;
|
||||||
|
if (alertOption !== "none") {
|
||||||
|
const offset = alertOption === "at_time" ? "PT0S" : `-PT${alertOption}M`;
|
||||||
|
alerts = {
|
||||||
|
"default-alert": {
|
||||||
|
"@type": "Alert",
|
||||||
|
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
|
||||||
|
action: "display",
|
||||||
|
acknowledged: null,
|
||||||
|
relatedTo: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Partial<CalendarTask> = {
|
||||||
|
"@type": "Task",
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || "",
|
||||||
|
due,
|
||||||
|
showWithoutTime,
|
||||||
|
priority: levelToPriority(priority),
|
||||||
|
progress,
|
||||||
|
calendarIds: { [calendarId]: true },
|
||||||
|
alerts,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && task) {
|
||||||
|
data.id = task.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSave(data);
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [title, description, dueDate, dueTime, showTime, priority, progress, calendarId, alertOption, isEdit, task, onSave, onClose]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
}, [onClose, handleSave]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-background" onKeyDown={handleKeyDown}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<h2 className="text-sm font-semibold">
|
||||||
|
{isEdit ? t("tasks.edit") : t("tasks.create")}
|
||||||
|
</h2>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onClose}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<Input
|
||||||
|
ref={titleRef}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={t("tasks.title_placeholder")}
|
||||||
|
className="text-base font-medium"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={t("tasks.description_placeholder")}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Due Date */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<CalendarDays className="h-3.5 w-3.5" />
|
||||||
|
{t("tasks.due_date")}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
{dueDate && (
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showTime}
|
||||||
|
onChange={(e) => setShowTime(e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
{t("tasks.include_time")}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{showTime && (
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={dueTime}
|
||||||
|
onChange={(e) => setDueTime(e.target.value)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Flag className="h-3.5 w-3.5" />
|
||||||
|
{t("tasks.priority")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(e.target.value as PriorityLevel)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||||
|
>
|
||||||
|
<option value="none">{t("tasks.priority_none")}</option>
|
||||||
|
<option value="high">{t("tasks.priority_high")}</option>
|
||||||
|
<option value="medium">{t("tasks.priority_medium")}</option>
|
||||||
|
<option value="low">{t("tasks.priority_low")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("tasks.progress")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={progress}
|
||||||
|
onChange={(e) => setProgress(e.target.value as CalendarTask["progress"])}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||||
|
>
|
||||||
|
<option value="needs-action">{t("tasks.progress_needs_action")}</option>
|
||||||
|
<option value="in-process">{t("tasks.progress_in_process")}</option>
|
||||||
|
<option value="completed">{t("tasks.progress_completed")}</option>
|
||||||
|
<option value="cancelled">{t("tasks.progress_cancelled")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar */}
|
||||||
|
{writableCalendars.length > 1 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("tasks.calendar")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={calendarId}
|
||||||
|
onChange={(e) => setCalendarId(e.target.value)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||||
|
>
|
||||||
|
{writableCalendars.map((cal) => (
|
||||||
|
<option key={cal.id} value={cal.id}>{cal.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alert */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Bell className="h-3.5 w-3.5" />
|
||||||
|
{t("tasks.alert")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={alertOption}
|
||||||
|
onChange={(e) => setAlertOption(e.target.value as AlertOption)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||||
|
>
|
||||||
|
<option value="none">{t("tasks.alert_none")}</option>
|
||||||
|
<option value="at_time">{t("tasks.alert_at_time")}</option>
|
||||||
|
<option value="5">{t("tasks.alert_5min")}</option>
|
||||||
|
<option value="15">{t("tasks.alert_15min")}</option>
|
||||||
|
<option value="30">{t("tasks.alert_30min")}</option>
|
||||||
|
<option value="60">{t("tasks.alert_1hr")}</option>
|
||||||
|
<option value="1440">{t("tasks.alert_1day")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
||||||
|
<div>
|
||||||
|
{isEdit && onDelete && task && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => onDelete(task.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
{t("tasks.delete")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
|
{t("tasks.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={!title.trim() || saving}>
|
||||||
|
{t("tasks.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { TaskViewFilter } from "@/stores/task-store";
|
||||||
|
|
||||||
|
interface TaskToolbarProps {
|
||||||
|
filter: TaskViewFilter;
|
||||||
|
showCompleted: boolean;
|
||||||
|
onFilterChange: (filter: TaskViewFilter) => void;
|
||||||
|
onShowCompletedChange: (show: boolean) => void;
|
||||||
|
onCreateTask: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTERS: TaskViewFilter[] = ["all", "pending", "completed", "overdue"];
|
||||||
|
|
||||||
|
export function TaskToolbar({
|
||||||
|
filter,
|
||||||
|
showCompleted,
|
||||||
|
onFilterChange,
|
||||||
|
onShowCompletedChange,
|
||||||
|
onCreateTask,
|
||||||
|
}: TaskToolbarProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-border flex-wrap">
|
||||||
|
<div className="flex border border-border rounded-md overflow-hidden">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => onFilterChange(f)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 text-xs font-medium transition-colors",
|
||||||
|
f === filter
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "hover:bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(`tasks.filter_${f}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ml-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showCompleted}
|
||||||
|
onChange={(e) => onShowCompletedChange(e.target.checked)}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
{t("tasks.show_completed")}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<Button size="sm" onClick={onCreateTask}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
{t("tasks.create")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ describe('ContactForm', () => {
|
|||||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
const inputs = screen.getAllByRole('textbox');
|
const _inputs = screen.getAllByRole('textbox');
|
||||||
const givenNameInput = screen.getByPlaceholderText('given_name');
|
const givenNameInput = screen.getByPlaceholderText('given_name');
|
||||||
fireEvent.change(givenNameInput, { target: { value: 'Jane' } });
|
fireEvent.change(givenNameInput, { target: { value: 'Jane' } });
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe('ContactListItem', () => {
|
|||||||
it('applies selected styling', () => {
|
it('applies selected styling', () => {
|
||||||
const { container } = render(<ContactListItem contact={contact} {...baseProps} isSelected={true} />);
|
const { container } = render(<ContactListItem contact={contact} {...baseProps} isSelected={true} />);
|
||||||
const div = container.firstElementChild;
|
const div = container.firstElementChild;
|
||||||
expect(div?.className).toContain('bg-blue-200');
|
expect(div?.className).toContain('bg-selection');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows email as display name when no name exists', () => {
|
it('shows email as display name when no name exists', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { ContactList } from '../contact-list';
|
import { ContactList } from '../contact-list';
|
||||||
import type { ContactCard } from '@/lib/jmap/types';
|
import type { ContactCard } from '@/lib/jmap/types';
|
||||||
@@ -22,7 +22,7 @@ const bob = makeContact({
|
|||||||
emails: { e0: { address: 'bob@example.com' } },
|
emails: { e0: { address: 'bob@example.com' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const group = makeContact({
|
const _group = makeContact({
|
||||||
id: '3',
|
id: '3',
|
||||||
kind: 'group',
|
kind: 'group',
|
||||||
name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true },
|
name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true },
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Link, Users, Briefcase, Heart, Languages, MessageCircle, User, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react";
|
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard } from "@/lib/jmap/types";
|
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||||
import { useSmimeStore } from "@/stores/smime-store";
|
import { useSmimeStore } from "@/stores/smime-store";
|
||||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
||||||
@@ -26,12 +26,23 @@ function formatPhoneFeatures(features?: Record<string, boolean>): string {
|
|||||||
return Object.keys(features).filter(k => features[k]).join(", ");
|
return Object.keys(features).filter(k => features[k]).join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateInput: string | Record<string, unknown>): string {
|
function formatDate(dateInput: AnniversaryDate): string {
|
||||||
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
|
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
|
||||||
|
// Handle RFC 9553 Timestamp objects: { "@type": "Timestamp", utc: "..." }
|
||||||
if (typeof dateInput === 'object' && dateInput !== null) {
|
if (typeof dateInput === 'object' && dateInput !== null) {
|
||||||
const year = dateInput.year as number | undefined;
|
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
|
||||||
const month = dateInput.month as number | undefined;
|
try {
|
||||||
const day = dateInput.day as number | undefined;
|
const d = new Date(dateInput.utc as string);
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
|
||||||
|
}
|
||||||
|
} catch { /* fallback */ }
|
||||||
|
return String(dateInput.utc);
|
||||||
|
}
|
||||||
|
const pd = dateInput as PartialDate;
|
||||||
|
const year = pd.year;
|
||||||
|
const month = pd.month;
|
||||||
|
const day = pd.day;
|
||||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
|
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
|
||||||
@@ -282,9 +293,11 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
{addresses.map((a, i) => (
|
{addresses.map((a, i) => (
|
||||||
<div key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
|
<div key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
|
||||||
<div>
|
<div>
|
||||||
{a.fullAddress
|
{a.full || a.fullAddress
|
||||||
? a.fullAddress
|
? (a.full || a.fullAddress)
|
||||||
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
: a.components && a.components.length > 0
|
||||||
|
? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ")
|
||||||
|
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
||||||
{a.contexts && <ContextBadge contexts={a.contexts} />}
|
{a.contexts && <ContextBadge contexts={a.contexts} />}
|
||||||
</div>
|
</div>
|
||||||
{a.timeZone && (
|
{a.timeZone && (
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react";
|
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react";
|
||||||
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 { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook } from "@/lib/jmap/types";
|
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress } from "@/lib/jmap/types";
|
||||||
|
|
||||||
interface EmailEntry {
|
interface EmailEntry {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -48,6 +48,7 @@ interface AddressEntry {
|
|||||||
interface ContactFormProps {
|
interface ContactFormProps {
|
||||||
contact?: ContactCard | null;
|
contact?: ContactCard | null;
|
||||||
addressBooks?: AddressBook[];
|
addressBooks?: AddressBook[];
|
||||||
|
allKeywords?: string[];
|
||||||
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
@@ -123,12 +124,73 @@ function Select({ value, onChange, children, className }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactForm({ contact, addressBooks, onSave, onCancel }: ContactFormProps) {
|
export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) {
|
||||||
const t = useTranslations("contacts.form");
|
const t = useTranslations("contacts.form");
|
||||||
const isEditing = !!contact;
|
const isEditing = !!contact;
|
||||||
|
|
||||||
const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || "";
|
const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || "";
|
||||||
|
|
||||||
|
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
|
||||||
|
function anniversaryDateToString(date: AnniversaryDate): string {
|
||||||
|
if (typeof date === 'string') return date;
|
||||||
|
if (date && typeof date === 'object') {
|
||||||
|
if ('@type' in date && date['@type'] === 'Timestamp' && 'utc' in date) {
|
||||||
|
return (date as { utc: string }).utc.split('T')[0];
|
||||||
|
}
|
||||||
|
const pd = date as PartialDate;
|
||||||
|
if (pd.year && pd.month && pd.day) {
|
||||||
|
return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (pd.month && pd.day) {
|
||||||
|
return `--${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (pd.year && pd.month) {
|
||||||
|
return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (pd.year) return String(pd.year);
|
||||||
|
}
|
||||||
|
return String(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert ISO date string back to RFC 9553 PartialDate for the server
|
||||||
|
function stringToPartialDate(str: string): PartialDate {
|
||||||
|
if (str.startsWith('--')) {
|
||||||
|
const parts = str.substring(2).split('-');
|
||||||
|
const pd: PartialDate = { month: parseInt(parts[0], 10) };
|
||||||
|
if (parts[1]) pd.day = parseInt(parts[1], 10);
|
||||||
|
return pd;
|
||||||
|
}
|
||||||
|
const parts = str.split('-');
|
||||||
|
const pd: PartialDate = {};
|
||||||
|
if (parts[0]) pd.year = parseInt(parts[0], 10);
|
||||||
|
if (parts[1]) pd.month = parseInt(parts[1], 10);
|
||||||
|
if (parts[2]) pd.day = parseInt(parts[2], 10);
|
||||||
|
return pd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract flat address fields from RFC 9553 components format
|
||||||
|
function addressToFlat(a: ContactAddress): AddressEntry {
|
||||||
|
if (a.components && a.components.length > 0) {
|
||||||
|
const findComp = (kind: string) => a.components!.filter(c => c.kind === kind).map(c => c.value).join(' ');
|
||||||
|
return {
|
||||||
|
street: findComp('name') || findComp('number') ? [findComp('number'), findComp('name')].filter(Boolean).join(' ') : '',
|
||||||
|
locality: findComp('locality'),
|
||||||
|
region: findComp('region'),
|
||||||
|
postcode: findComp('postcode'),
|
||||||
|
country: findComp('country'),
|
||||||
|
context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
street: a.street || '',
|
||||||
|
locality: a.locality || '',
|
||||||
|
region: a.region || '',
|
||||||
|
postcode: a.postcode || '',
|
||||||
|
country: a.country || '',
|
||||||
|
context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const [prefix, setPrefix] = useState(findComponent("prefix"));
|
const [prefix, setPrefix] = useState(findComponent("prefix"));
|
||||||
const [givenName, setGivenName] = useState(findComponent("given"));
|
const [givenName, setGivenName] = useState(findComponent("given"));
|
||||||
const [additionalName, setAdditionalName] = useState(findComponent("additional"));
|
const [additionalName, setAdditionalName] = useState(findComponent("additional"));
|
||||||
@@ -184,14 +246,7 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
|||||||
|
|
||||||
const [addresses, setAddresses] = useState<AddressEntry[]>(() => {
|
const [addresses, setAddresses] = useState<AddressEntry[]>(() => {
|
||||||
if (contact?.addresses) {
|
if (contact?.addresses) {
|
||||||
return Object.values(contact.addresses).map(a => ({
|
return Object.values(contact.addresses).map(a => addressToFlat(a));
|
||||||
street: a.street || "",
|
|
||||||
locality: a.locality || "",
|
|
||||||
region: a.region || "",
|
|
||||||
postcode: a.postcode || "",
|
|
||||||
country: a.country || "",
|
|
||||||
context: a.contexts?.work ? "work" : a.contexts?.private ? "private" : "",
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
@@ -210,7 +265,7 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
|||||||
const [anniversaries, setAnniversaries] = useState<AnniversaryEntry[]>(() => {
|
const [anniversaries, setAnniversaries] = useState<AnniversaryEntry[]>(() => {
|
||||||
if (contact?.anniversaries) {
|
if (contact?.anniversaries) {
|
||||||
return Object.values(contact.anniversaries).map(a => ({
|
return Object.values(contact.anniversaries).map(a => ({
|
||||||
date: a.date,
|
date: anniversaryDateToString(a.date),
|
||||||
kind: a.kind,
|
kind: a.kind,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -336,12 +391,13 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
|||||||
|
|
||||||
const addressesMap: Record<string, ContactCard["addresses"] extends Record<string, infer V> ? V : never> = {};
|
const addressesMap: Record<string, ContactCard["addresses"] extends Record<string, infer V> ? V : never> = {};
|
||||||
addresses.filter(a => a.street.trim() || a.locality.trim() || a.country.trim()).forEach((a, i) => {
|
addresses.filter(a => a.street.trim() || a.locality.trim() || a.country.trim()).forEach((a, i) => {
|
||||||
const obj: Record<string, unknown> = {};
|
const components: Array<{ kind: string; value: string }> = [];
|
||||||
if (a.street.trim()) obj.street = a.street.trim();
|
if (a.street.trim()) components.push({ kind: "name", value: a.street.trim() });
|
||||||
if (a.locality.trim()) obj.locality = a.locality.trim();
|
if (a.locality.trim()) components.push({ kind: "locality", value: a.locality.trim() });
|
||||||
if (a.region.trim()) obj.region = a.region.trim();
|
if (a.region.trim()) components.push({ kind: "region", value: a.region.trim() });
|
||||||
if (a.postcode.trim()) obj.postcode = a.postcode.trim();
|
if (a.postcode.trim()) components.push({ kind: "postcode", value: a.postcode.trim() });
|
||||||
if (a.country.trim()) obj.country = a.country.trim();
|
if (a.country.trim()) components.push({ kind: "country", value: a.country.trim() });
|
||||||
|
const obj: Record<string, unknown> = { components, isOrdered: true, defaultSeparator: ", " };
|
||||||
if (a.context) obj.contexts = { [a.context]: true };
|
if (a.context) obj.contexts = { [a.context]: true };
|
||||||
// @ts-expect-error - dynamic build
|
// @ts-expect-error - dynamic build
|
||||||
addressesMap[`a${i}`] = obj;
|
addressesMap[`a${i}`] = obj;
|
||||||
@@ -357,7 +413,7 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
|||||||
|
|
||||||
const anniversariesMap: Record<string, ContactAnniversary> = {};
|
const anniversariesMap: Record<string, ContactAnniversary> = {};
|
||||||
anniversaries.filter(a => a.date.trim()).forEach((a, i) => {
|
anniversaries.filter(a => a.date.trim()).forEach((a, i) => {
|
||||||
anniversariesMap[`an${i}`] = { date: a.date.trim(), kind: a.kind };
|
anniversariesMap[`an${i}`] = { date: stringToPartialDate(a.date.trim()), kind: a.kind };
|
||||||
});
|
});
|
||||||
|
|
||||||
const personalInfoMap: Record<string, ContactPersonalInfo> = {};
|
const personalInfoMap: Record<string, ContactPersonalInfo> = {};
|
||||||
@@ -764,14 +820,14 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
|||||||
|
|
||||||
{/* Categories */}
|
{/* Categories */}
|
||||||
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
|
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
|
||||||
<div>
|
<CategoryComboBox
|
||||||
<Input
|
keywordsStr={keywordsStr}
|
||||||
value={keywordsStr}
|
onChange={setKeywordsStr}
|
||||||
onChange={(e) => setKeywordsStr(e.target.value)}
|
allKeywords={allKeywords || []}
|
||||||
placeholder={t("categories_placeholder")}
|
placeholder={t("categories_placeholder")}
|
||||||
/>
|
hint={t("categories_hint")}
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">{t("categories_hint")}</p>
|
addLabel={t("category_add")}
|
||||||
</div>
|
/>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Gender */}
|
{/* Gender */}
|
||||||
@@ -840,3 +896,142 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
|||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CategoryComboBox({
|
||||||
|
keywordsStr,
|
||||||
|
onChange,
|
||||||
|
allKeywords,
|
||||||
|
placeholder,
|
||||||
|
hint,
|
||||||
|
addLabel,
|
||||||
|
}: {
|
||||||
|
keywordsStr: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
allKeywords: string[];
|
||||||
|
placeholder: string;
|
||||||
|
hint: string;
|
||||||
|
addLabel: string;
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Parse current keywords from comma-separated string
|
||||||
|
const currentKeywords = useMemo(() => {
|
||||||
|
return keywordsStr.split(",").map(k => k.trim()).filter(Boolean);
|
||||||
|
}, [keywordsStr]);
|
||||||
|
|
||||||
|
// Suggestions: existing keywords not already selected
|
||||||
|
const suggestions = useMemo(() => {
|
||||||
|
const lower = inputValue.toLowerCase();
|
||||||
|
return allKeywords.filter(kw =>
|
||||||
|
!currentKeywords.includes(kw) &&
|
||||||
|
(!lower || kw.toLowerCase().includes(lower))
|
||||||
|
);
|
||||||
|
}, [allKeywords, currentKeywords, inputValue]);
|
||||||
|
|
||||||
|
// Can add a new keyword if typed text is non-empty and not already in the list
|
||||||
|
const canAddNew = inputValue.trim() &&
|
||||||
|
!currentKeywords.includes(inputValue.trim()) &&
|
||||||
|
!allKeywords.some(kw => kw.toLowerCase() === inputValue.trim().toLowerCase());
|
||||||
|
|
||||||
|
const addKeyword = useCallback((keyword: string) => {
|
||||||
|
const trimmed = keyword.trim();
|
||||||
|
if (!trimmed || currentKeywords.includes(trimmed)) return;
|
||||||
|
const next = [...currentKeywords, trimmed].join(", ");
|
||||||
|
onChange(next);
|
||||||
|
setInputValue("");
|
||||||
|
}, [currentKeywords, onChange]);
|
||||||
|
|
||||||
|
const removeKeyword = useCallback((keyword: string) => {
|
||||||
|
const next = currentKeywords.filter(k => k !== keyword).join(", ");
|
||||||
|
onChange(next);
|
||||||
|
}, [currentKeywords, onChange]);
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (inputValue.trim()) {
|
||||||
|
addKeyword(inputValue);
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className="relative">
|
||||||
|
{/* Keyword badges */}
|
||||||
|
{currentKeywords.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
|
{currentKeywords.map(kw => (
|
||||||
|
<span
|
||||||
|
key={kw}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary border border-primary/20"
|
||||||
|
>
|
||||||
|
{kw}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeKeyword(kw)}
|
||||||
|
className="hover:text-destructive transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input with dropdown */}
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={currentKeywords.length === 0 ? placeholder : ""}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">{hint}</p>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{isOpen && (suggestions.length > 0 || canAddNew) && (
|
||||||
|
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1">
|
||||||
|
{suggestions.map(kw => (
|
||||||
|
<button
|
||||||
|
key={kw}
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||||
|
onClick={() => { addKeyword(kw); inputRef.current?.focus(); }}
|
||||||
|
>
|
||||||
|
<Tag className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
{kw}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{canAddNew && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left text-primary"
|
||||||
|
onClick={() => { addKeyword(inputValue); inputRef.current?.focus(); }}
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
{addLabel}: "{inputValue.trim()}"
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ export function ContactImportDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isDupe && (
|
{isDupe && (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-400 flex-shrink-0">
|
<span className="text-xs px-1.5 py-0.5 rounded bg-warning/15 text-warning flex-shrink-0">
|
||||||
{t("import.duplicate")}
|
{t("import.duplicate")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
|||||||
? Array.from(selectedContactIds)
|
? Array.from(selectedContactIds)
|
||||||
: [contact.id];
|
: [contact.id];
|
||||||
|
|
||||||
e.dataTransfer.effectAllowed = "move";
|
e.dataTransfer.effectAllowed = "copyMove";
|
||||||
e.dataTransfer.setData("application/x-contact-ids", JSON.stringify(ids));
|
e.dataTransfer.setData("application/x-contact-ids", JSON.stringify(ids));
|
||||||
e.dataTransfer.setData("text/plain", name || email || contact.id);
|
e.dataTransfer.setData("text/plain", name || email || contact.id);
|
||||||
|
|
||||||
@@ -59,9 +59,9 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
|||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
|
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-blue-200 dark:bg-blue-900/50 shadow-sm"
|
? "bg-selection shadow-sm"
|
||||||
: "bg-background hover:bg-muted hover:shadow-sm",
|
: "bg-background hover:bg-muted hover:shadow-sm",
|
||||||
isChecked && !isSelected && "ring-2 ring-primary/20 bg-blue-100 dark:bg-blue-900/30",
|
isChecked && !isSelected && "ring-2 ring-primary/20 bg-selection/60",
|
||||||
)}
|
)}
|
||||||
style={{ gap: 'var(--density-item-gap)', paddingInline: '16px', paddingBlock: 'var(--density-item-py)' }}
|
style={{ gap: 'var(--density-item-gap)', paddingInline: '16px', paddingBlock: 'var(--density-item-py)' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useCallback, type DragEvent } from "react";
|
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { BookUser, Users, Plus, UserPlus, Share2, Book } from "lucide-react";
|
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||||
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName } from "@/stores/contact-store";
|
import { getContactDisplayName } from "@/stores/contact-store";
|
||||||
|
|
||||||
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string };
|
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
|
||||||
|
|
||||||
interface ContactsSidebarProps {
|
interface ContactsSidebarProps {
|
||||||
groups: ContactCard[];
|
groups: ContactCard[];
|
||||||
@@ -18,10 +20,31 @@ interface ContactsSidebarProps {
|
|||||||
onSelectCategory: (category: ContactCategory) => void;
|
onSelectCategory: (category: ContactCategory) => void;
|
||||||
onCreateGroup: () => void;
|
onCreateGroup: () => void;
|
||||||
onCreateContact: () => void;
|
onCreateContact: () => void;
|
||||||
|
onImport?: () => void;
|
||||||
|
onEditGroup?: (groupId: string) => void;
|
||||||
|
onDeleteGroup?: (groupId: string) => void;
|
||||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||||
|
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COLLAPSED_KEY = "contacts-sidebar-collapsed";
|
||||||
|
|
||||||
|
function loadCollapsed(): Record<string, boolean> {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(COLLAPSED_KEY);
|
||||||
|
return v ? JSON.parse(v) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCollapsed(state: Record<string, boolean>) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(COLLAPSED_KEY, JSON.stringify(state));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
export function ContactsSidebar({
|
export function ContactsSidebar({
|
||||||
groups,
|
groups,
|
||||||
individuals,
|
individuals,
|
||||||
@@ -30,10 +53,43 @@ export function ContactsSidebar({
|
|||||||
onSelectCategory,
|
onSelectCategory,
|
||||||
onCreateGroup,
|
onCreateGroup,
|
||||||
onCreateContact,
|
onCreateContact,
|
||||||
|
onImport,
|
||||||
|
onEditGroup,
|
||||||
|
onDeleteGroup,
|
||||||
onDropContacts,
|
onDropContacts,
|
||||||
|
onDropContactsToCategory,
|
||||||
className,
|
className,
|
||||||
}: ContactsSidebarProps) {
|
}: ContactsSidebarProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
|
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const menuBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const toggleSection = useCallback((key: string) => {
|
||||||
|
setCollapsed(prev => {
|
||||||
|
const next = { ...prev, [key]: !prev[key] };
|
||||||
|
saveCollapsed(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showMenu) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
menuRef.current && !menuRef.current.contains(e.target as Node) &&
|
||||||
|
menuBtnRef.current && !menuBtnRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [showMenu]);
|
||||||
|
|
||||||
const sortedGroups = useMemo(() => {
|
const sortedGroups = useMemo(() => {
|
||||||
return [...groups].sort((a, b) =>
|
return [...groups].sort((a, b) =>
|
||||||
@@ -73,25 +129,101 @@ export function ContactsSidebar({
|
|||||||
if (!contact.addressBookIds) continue;
|
if (!contact.addressBookIds) continue;
|
||||||
for (const bookId of Object.keys(contact.addressBookIds)) {
|
for (const bookId of Object.keys(contact.addressBookIds)) {
|
||||||
if (!contact.addressBookIds[bookId]) continue;
|
if (!contact.addressBookIds[bookId]) continue;
|
||||||
// Build the full namespaced key
|
counts[bookId] = (counts[bookId] || 0) + 1;
|
||||||
const key = contact.isShared && contact.accountId ? `${contact.accountId}:${bookId}` : bookId;
|
|
||||||
counts[key] = (counts[key] || 0) + 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [individuals]);
|
}, [individuals]);
|
||||||
|
|
||||||
|
// Auto-collect keywords from all contacts
|
||||||
|
const allKeywords = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const contact of individuals) {
|
||||||
|
if (!contact.keywords) continue;
|
||||||
|
for (const [kw, active] of Object.entries(contact.keywords)) {
|
||||||
|
if (!active) continue;
|
||||||
|
counts[kw] = (counts[kw] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
}, [individuals]);
|
||||||
|
|
||||||
|
// Count of contacts without any keywords
|
||||||
|
const uncategorizedCount = useMemo(() => {
|
||||||
|
return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0).length;
|
||||||
|
}, [individuals]);
|
||||||
|
|
||||||
|
// Resolve actual group member counts against living contacts
|
||||||
|
const memberCountByGroup = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const group of groups) {
|
||||||
|
if (!group.members) {
|
||||||
|
counts[group.id] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const memberKeys = Object.keys(group.members).filter(k => group.members![k]);
|
||||||
|
const normalizedKeys = memberKeys.map(k => k.startsWith('urn:uuid:') ? k.slice(9) : k);
|
||||||
|
counts[group.id] = individuals.filter(c => {
|
||||||
|
if (memberKeys.includes(c.id) || normalizedKeys.includes(c.id)) return true;
|
||||||
|
if (c.uid) {
|
||||||
|
const bareUid = c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid;
|
||||||
|
return memberKeys.includes(c.uid) || normalizedKeys.includes(bareUid);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [groups, individuals]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col h-full bg-secondary", className)}>
|
<div className={cn("flex flex-col h-full bg-secondary", className)}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-3 border-b border-border flex items-center justify-between" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
<div className="px-3 border-b border-border flex items-center justify-between" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||||
<Button size="icon" variant="ghost" onClick={onCreateContact} className="h-7 w-7 flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<UserPlus className="w-4 h-4" />
|
<Button
|
||||||
</Button>
|
ref={menuBtnRef}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowMenu(v => !v)}
|
||||||
|
className="h-7 w-7"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{showMenu && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||||
|
onClick={() => { setShowMenu(false); onCreateContact(); }}
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
{t("create_new")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||||
|
onClick={() => { setShowMenu(false); onCreateGroup(); }}
|
||||||
|
>
|
||||||
|
<UsersRound className="w-4 h-4" />
|
||||||
|
{t("groups.create")}
|
||||||
|
</button>
|
||||||
|
{onImport && (
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||||
|
onClick={() => { setShowMenu(false); onImport(); }}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
{t("import.title")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Categories */}
|
{/* Navigation */}
|
||||||
<div className="flex-1 overflow-y-auto py-1">
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
{/* All contacts */}
|
{/* All contacts */}
|
||||||
<button
|
<button
|
||||||
@@ -107,19 +239,27 @@ export function ContactsSidebar({
|
|||||||
<BookUser className="w-4 h-4 flex-shrink-0" />
|
<BookUser className="w-4 h-4 flex-shrink-0" />
|
||||||
<span className="truncate">{t("tabs.all")}</span>
|
<span className="truncate">{t("tabs.all")}</span>
|
||||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
{individuals.filter(c => !c.isShared).length}
|
{individuals.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Personal address books */}
|
{/* My Address Books */}
|
||||||
{personalBooks.length > 0 && (
|
{personalBooks.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="flex items-center justify-between px-3 py-1">
|
<button
|
||||||
|
onClick={() => toggleSection("addressBooks")}
|
||||||
|
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||||
|
>
|
||||||
|
{collapsed.addressBooks ? (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
{t("address_books.title")}
|
{t("address_books.title")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
{personalBooks.map((book) => (
|
{!collapsed.addressBooks && personalBooks.map((book) => (
|
||||||
<AddressBookItem
|
<AddressBookItem
|
||||||
key={book.id}
|
key={book.id}
|
||||||
book={book}
|
book={book}
|
||||||
@@ -133,29 +273,33 @@ export function ContactsSidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Groups section */}
|
{/* Groups section */}
|
||||||
{(sortedGroups.length > 0) && (
|
{sortedGroups.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="flex items-center justify-between px-3 py-1">
|
<button
|
||||||
|
onClick={() => toggleSection("groups")}
|
||||||
|
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||||
|
>
|
||||||
|
{collapsed.groups ? (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
{t("tabs.groups")}
|
{t("tabs.groups")}
|
||||||
</span>
|
</span>
|
||||||
<Button size="icon" variant="ghost" onClick={onCreateGroup} className="h-5 w-5">
|
</button>
|
||||||
<Plus className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sortedGroups.map((group) => {
|
{!collapsed.groups && sortedGroups.map((group) => {
|
||||||
const isActive = typeof activeCategory === "object" && "groupId" in activeCategory && activeCategory.groupId === group.id;
|
const isActive = typeof activeCategory === "object" && "groupId" in activeCategory && activeCategory.groupId === group.id;
|
||||||
const memberCount = group.members
|
const memberCount = memberCountByGroup[group.id] || 0;
|
||||||
? Object.values(group.members).filter(Boolean).length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={group.id}
|
key={group.id}
|
||||||
onClick={() => onSelectCategory({ groupId: group.id })}
|
onClick={() => onSelectCategory({ groupId: group.id })}
|
||||||
|
onContextMenu={(e) => openGroupContextMenu(e, group)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
|
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: "text-foreground/80 hover:bg-muted"
|
: "text-foreground/80 hover:bg-muted"
|
||||||
@@ -173,35 +317,76 @@ export function ContactsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedGroups.length === 0 && (
|
{/* Categories section (from contact keywords) */}
|
||||||
<div className="mt-2 px-3">
|
<div className="mt-2">
|
||||||
<div className="flex items-center justify-between py-1">
|
<button
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
onClick={() => toggleSection("categories")}
|
||||||
{t("tabs.groups")}
|
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||||
</span>
|
>
|
||||||
</div>
|
{collapsed.categories ? (
|
||||||
<Button
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
size="sm"
|
) : (
|
||||||
variant="ghost"
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
onClick={onCreateGroup}
|
)}
|
||||||
className="w-full justify-start text-xs text-muted-foreground h-7"
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
>
|
{t("detail.categories")}
|
||||||
<Plus className="w-3 h-3 mr-1.5" />
|
</span>
|
||||||
{t("groups.create")}
|
</button>
|
||||||
</Button>
|
|
||||||
</div>
|
{!collapsed.categories && (
|
||||||
)}
|
<>
|
||||||
|
{/* No Category item */}
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectCategory("uncategorized")}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||||
|
activeCategory === "uncategorized"
|
||||||
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
|
: "text-foreground/80 hover:bg-muted"
|
||||||
|
)}
|
||||||
|
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||||
|
>
|
||||||
|
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
|
||||||
|
<span className="truncate italic">{t("no_category")}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
|
{uncategorizedCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{allKeywords.map(([keyword, count]) => {
|
||||||
|
const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword;
|
||||||
|
return (
|
||||||
|
<CategoryItem
|
||||||
|
key={keyword}
|
||||||
|
keyword={keyword}
|
||||||
|
count={count}
|
||||||
|
isActive={isActive}
|
||||||
|
onSelect={() => onSelectCategory({ keyword })}
|
||||||
|
onDropContacts={onDropContactsToCategory}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Shared accounts with address books */}
|
{/* Shared accounts with address books */}
|
||||||
{sharedBookGroups.map((group) => (
|
{sharedBookGroups.map((group) => (
|
||||||
<div key={group.accountId} className="mt-2">
|
<div key={group.accountId} className="mt-2">
|
||||||
<div className="flex items-center justify-between px-3 py-1">
|
<button
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1">
|
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
||||||
<Share2 className="w-3 h-3" />
|
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||||
{group.accountName}
|
>
|
||||||
|
{collapsed[`shared-${group.accountId}`] ? (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<Share2 className="w-3 h-3 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
|
||||||
|
{t("address_books.shared_prefix", { name: group.accountName })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
{group.books.map((book) => (
|
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
|
||||||
<AddressBookItem
|
<AddressBookItem
|
||||||
key={book.id}
|
key={book.id}
|
||||||
book={book}
|
book={book}
|
||||||
@@ -214,10 +399,104 @@ export function ContactsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Group context menu */}
|
||||||
|
{groupContextMenu.data && (
|
||||||
|
<ContextMenu
|
||||||
|
ref={groupMenuRef}
|
||||||
|
isOpen={groupContextMenu.isOpen}
|
||||||
|
position={groupContextMenu.position}
|
||||||
|
onClose={closeGroupContextMenu}
|
||||||
|
>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={t("groups.edit")}
|
||||||
|
onClick={() => {
|
||||||
|
closeGroupContextMenu();
|
||||||
|
onEditGroup?.(groupContextMenu.data!.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("form.delete")}
|
||||||
|
onClick={() => {
|
||||||
|
closeGroupContextMenu();
|
||||||
|
onDeleteGroup?.(groupContextMenu.data!.id);
|
||||||
|
}}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CategoryItem({
|
||||||
|
keyword,
|
||||||
|
count,
|
||||||
|
isActive,
|
||||||
|
onSelect,
|
||||||
|
onDropContacts,
|
||||||
|
}: {
|
||||||
|
keyword: string;
|
||||||
|
count: number;
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onDropContacts?: (contactIds: string[], keyword: string) => void;
|
||||||
|
}) {
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: DragEvent<HTMLButtonElement>) => {
|
||||||
|
if (!e.dataTransfer.types.includes("application/x-contact-ids")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
setIsDragOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback(() => {
|
||||||
|
setIsDragOver(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: DragEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
const data = e.dataTransfer.getData("application/x-contact-ids");
|
||||||
|
if (!data || !onDropContacts) return;
|
||||||
|
try {
|
||||||
|
const contactIds = JSON.parse(data) as string[];
|
||||||
|
if (contactIds.length > 0) {
|
||||||
|
onDropContacts(contactIds, keyword);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore invalid data
|
||||||
|
}
|
||||||
|
}, [keyword, onDropContacts]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
|
: "text-foreground/80 hover:bg-muted",
|
||||||
|
isDragOver && "bg-primary/20 ring-2 ring-primary/50"
|
||||||
|
)}
|
||||||
|
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||||
|
>
|
||||||
|
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
<span className="truncate">{keyword}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AddressBookItem({
|
function AddressBookItem({
|
||||||
book,
|
book,
|
||||||
isActive,
|
isActive,
|
||||||
@@ -266,7 +545,7 @@ function AddressBookItem({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
|
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: "text-foreground/80 hover:bg-muted",
|
: "text-foreground/80 hover:bg-muted",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ describe('EmailListItem tag badge', () => {
|
|||||||
useSettingsStore.setState({
|
useSettingsStore.setState({
|
||||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||||
showPreview: false,
|
showPreview: false,
|
||||||
|
mailLayout: 'split',
|
||||||
});
|
});
|
||||||
useEmailStore.setState({
|
useEmailStore.setState({
|
||||||
selectedEmailIds: new Set<string>(),
|
selectedEmailIds: new Set<string>(),
|
||||||
@@ -104,4 +105,17 @@ describe('EmailListItem tag badge', () => {
|
|||||||
render(<EmailListItem email={email} />);
|
render(<EmailListItem email={email} />);
|
||||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders inline preview text in focused mail layout', () => {
|
||||||
|
useSettingsStore.setState({
|
||||||
|
showPreview: true,
|
||||||
|
mailLayout: 'focus',
|
||||||
|
});
|
||||||
|
const email = makeEmail({ preview: 'Inline preview content' });
|
||||||
|
const { container } = render(<EmailListItem email={email} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('p')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -171,11 +171,11 @@ function getParticipationLabel(
|
|||||||
function getParticipationTone(status: string | null): string {
|
function getParticipationTone(status: string | null): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'accepted':
|
case 'accepted':
|
||||||
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
|
return 'bg-success/15 text-success';
|
||||||
case 'tentative':
|
case 'tentative':
|
||||||
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
|
return 'bg-warning/15 text-warning';
|
||||||
case 'declined':
|
case 'declined':
|
||||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
|
return 'bg-destructive/15 text-destructive';
|
||||||
default:
|
default:
|
||||||
return 'bg-muted text-muted-foreground';
|
return 'bg-muted text-muted-foreground';
|
||||||
}
|
}
|
||||||
@@ -731,9 +731,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
|
|
||||||
if (state === 'error') {
|
if (state === 'error') {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-red-200 dark:border-red-900/50 bg-red-50/50 dark:bg-red-950/20 px-4 py-3 flex items-center gap-2.5">
|
<div className="rounded-lg border border-destructive/20 bg-destructive/10 px-4 py-3 flex items-center gap-2.5">
|
||||||
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
|
<AlertCircle className="w-4 h-4 text-destructive flex-shrink-0" />
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">{t('parse_error')}</span>
|
<span className="text-sm text-destructive">{t('parse_error')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -744,7 +744,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b border-border">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{isCancellation ? (
|
{isCancellation ? (
|
||||||
<CalendarX className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
|
<CalendarX className="w-4 h-4 text-destructive flex-shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<Calendar className="w-4 h-4 text-primary flex-shrink-0" />
|
<Calendar className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
@@ -808,19 +808,19 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
<span className={cn(
|
<span className={cn(
|
||||||
"rounded-full px-2 py-0.5 text-[11px] font-medium",
|
"rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||||
parsedEvent.status === 'cancelled'
|
parsedEvent.status === 'cancelled'
|
||||||
? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
? "bg-destructive/15 text-destructive"
|
||||||
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
: "bg-warning/15 text-warning"
|
||||||
)}>
|
)}>
|
||||||
{t(`event_status_${parsedEvent.status}`)}
|
{t(`event_status_${parsedEvent.status}`)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{existingEvent && (
|
{existingEvent && (
|
||||||
<span className="rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
<span className="rounded-full bg-success/15 px-2 py-0.5 text-[11px] font-medium text-success">
|
||||||
{t('already_in_calendar')}
|
{t('already_in_calendar')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{userIsOrganizer && (
|
{userIsOrganizer && (
|
||||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
<span className="rounded-full bg-info/15 px-2 py-0.5 text-[11px] font-medium text-info">
|
||||||
{t('organizer_role')}
|
{t('organizer_role')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -833,7 +833,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{actionFeedback && (
|
{actionFeedback && (
|
||||||
<span className="rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
<span className="rounded-full bg-success/15 px-2 py-0.5 text-[11px] font-medium text-success">
|
||||||
{actionFeedback}
|
{actionFeedback}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -877,8 +877,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
<div className={cn(
|
<div className={cn(
|
||||||
'flex items-start gap-1.5 text-xs rounded-md px-3 py-2',
|
'flex items-start gap-1.5 text-xs rounded-md px-3 py-2',
|
||||||
trustAssessment.level === 'warning'
|
trustAssessment.level === 'warning'
|
||||||
? 'bg-red-50 dark:bg-red-950/20 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-900/50'
|
? 'bg-destructive/10 text-destructive border border-destructive/20'
|
||||||
: 'bg-amber-50 dark:bg-amber-950/20 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-900/50'
|
: 'bg-warning/10 text-warning border border-warning/20'
|
||||||
)}>
|
)}>
|
||||||
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||||
<span>{trustMessage}</span>
|
<span>{trustMessage}</span>
|
||||||
@@ -887,7 +887,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
|
|
||||||
{/* Action error */}
|
{/* Action error */}
|
||||||
{actionError && (
|
{actionError && (
|
||||||
<div className="flex items-start gap-1.5 text-xs text-red-600 dark:text-red-400 rounded-md px-3 py-2 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900/50">
|
<div className="flex items-start gap-1.5 text-xs text-destructive rounded-md px-3 py-2 bg-destructive/10 border border-destructive/20">
|
||||||
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||||
<span>{actionError}</span>
|
<span>{actionError}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -905,8 +905,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
|
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
|
||||||
currentRsvp === 'accepted'
|
currentRsvp === 'accepted'
|
||||||
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800"
|
? "bg-success/15 text-success border-success/20"
|
||||||
: "text-muted-foreground hover:text-green-700 dark:hover:text-green-400 border-border hover:border-green-300 dark:hover:border-green-800 hover:bg-green-50 dark:hover:bg-green-950/20"
|
: "text-muted-foreground hover:text-success border-border hover:border-success/30 hover:bg-success/10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Check className="w-3.5 h-3.5" />
|
<Check className="w-3.5 h-3.5" />
|
||||||
@@ -919,8 +919,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
|
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
|
||||||
currentRsvp === 'tentative'
|
currentRsvp === 'tentative'
|
||||||
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 border-amber-200 dark:border-amber-800"
|
? "bg-warning/15 text-warning border-warning/20"
|
||||||
: "text-muted-foreground hover:text-amber-700 dark:hover:text-amber-400 border-border hover:border-amber-300 dark:hover:border-amber-800 hover:bg-amber-50 dark:hover:bg-amber-950/20"
|
: "text-muted-foreground hover:text-warning border-border hover:border-warning/30 hover:bg-warning/10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HelpCircle className="w-3.5 h-3.5" />
|
<HelpCircle className="w-3.5 h-3.5" />
|
||||||
@@ -933,8 +933,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
|
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
|
||||||
currentRsvp === 'declined'
|
currentRsvp === 'declined'
|
||||||
? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800"
|
? "bg-destructive/15 text-destructive border-destructive/20"
|
||||||
: "text-muted-foreground hover:text-red-700 dark:hover:text-red-400 border-border hover:border-red-300 dark:hover:border-red-800 hover:bg-red-50 dark:hover:bg-red-950/20"
|
: "text-muted-foreground hover:text-destructive border-border hover:border-destructive/30 hover:bg-destructive/10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
|
|||||||
+166
-110
@@ -18,6 +18,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|||||||
import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder";
|
import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder";
|
||||||
import type { MimeAttachment } from "@/lib/smime/mime-builder";
|
import type { MimeAttachment } from "@/lib/smime/mime-builder";
|
||||||
import { smimeSign } from "@/lib/smime/smime-sign";
|
import { smimeSign } from "@/lib/smime/smime-sign";
|
||||||
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
|
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { useTemplateStore } from "@/stores/template-store";
|
import { useTemplateStore } from "@/stores/template-store";
|
||||||
@@ -27,6 +28,16 @@ import { substitutePlaceholders } from "@/lib/template-utils";
|
|||||||
import { TemplatePicker } from "@/components/templates/template-picker";
|
import { TemplatePicker } from "@/components/templates/template-picker";
|
||||||
import { TemplateForm } from "@/components/templates/template-form";
|
import { TemplateForm } from "@/components/templates/template-form";
|
||||||
import type { EmailTemplate } from "@/lib/template-types";
|
import type { EmailTemplate } from "@/lib/template-types";
|
||||||
|
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||||
|
import { findReplyIdentityId } from "@/lib/reply-identity";
|
||||||
|
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||||
|
|
||||||
|
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||||
|
function htmlToPlainText(html: string): string {
|
||||||
|
const tmp = document.createElement('div');
|
||||||
|
tmp.innerHTML = html;
|
||||||
|
return tmp.textContent || tmp.innerText || '';
|
||||||
|
}
|
||||||
|
|
||||||
export interface ComposerDraftData {
|
export interface ComposerDraftData {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -66,8 +77,10 @@ interface EmailComposerProps {
|
|||||||
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||||
replyTo?: {
|
replyTo?: {
|
||||||
from?: { email?: string; name?: string }[];
|
from?: { email?: string; name?: string }[];
|
||||||
|
replyToAddresses?: { email?: string; name?: string }[];
|
||||||
to?: { email?: string; name?: string }[];
|
to?: { email?: string; name?: string }[];
|
||||||
cc?: { email?: string; name?: string }[];
|
cc?: { email?: string; name?: string }[];
|
||||||
|
bcc?: { email?: string; name?: string }[];
|
||||||
subject?: string;
|
subject?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
htmlBody?: string;
|
htmlBody?: string;
|
||||||
@@ -89,17 +102,21 @@ export function EmailComposer({
|
|||||||
const t = useTranslations('email_composer');
|
const t = useTranslations('email_composer');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
|
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||||
|
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||||
|
|
||||||
// Initialize with reply/forward data if provided
|
// Initialize with reply/forward data if provided
|
||||||
const getInitialTo = () => {
|
const getInitialTo = () => {
|
||||||
if (!replyTo) return "";
|
if (!replyTo) return "";
|
||||||
|
// RFC 5322: use Reply-To header if present, otherwise fall back to From
|
||||||
|
const replyTarget = replyTo.replyToAddresses?.length
|
||||||
|
? replyTo.replyToAddresses.filter(r => r.email).map(r => r.email).join(", ")
|
||||||
|
: replyTo.from?.[0]?.email || "";
|
||||||
if (mode === 'reply') {
|
if (mode === 'reply') {
|
||||||
const email = replyTo.from?.[0]?.email || "";
|
return replyTarget ? replyTarget + ', ' : "";
|
||||||
return email ? email + ', ' : "";
|
|
||||||
} else if (mode === 'replyAll') {
|
} else if (mode === 'replyAll') {
|
||||||
const from = replyTo.from?.[0]?.email || "";
|
|
||||||
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
|
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
|
||||||
const combined = [from, originalTo].filter(Boolean).join(", ");
|
const combined = [replyTarget, originalTo].filter(Boolean).join(", ");
|
||||||
return combined ? combined + ', ' : "";
|
return combined ? combined + ', ' : "";
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
@@ -124,23 +141,48 @@ export function EmailComposer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getInitialBody = () => {
|
const getInitialBody = () => {
|
||||||
const prefix = initialDraftText || "";
|
if (plainTextMode) {
|
||||||
|
// Plain text mode: produce plain text body with no HTML
|
||||||
|
const prefix = initialDraftText || "";
|
||||||
|
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||||
|
|
||||||
|
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||||
|
const from = replyTo.from?.[0];
|
||||||
|
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||||
|
|
||||||
|
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
|
||||||
|
const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n');
|
||||||
|
|
||||||
|
if (mode === 'forward') {
|
||||||
|
return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||||
|
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||||
|
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
|
||||||
|
}
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = initialDraftText ? `<p>${initialDraftText.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>` : "";
|
||||||
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||||
|
|
||||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||||
const from = replyTo.from?.[0];
|
const from = replyTo.from?.[0];
|
||||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||||
|
|
||||||
// When HTML body is available, don't include quoted text in the textarea
|
// Build quoted content as HTML
|
||||||
// The HTML original will be shown separately below the textarea
|
|
||||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||||
return prefix;
|
const quoteHeader = mode === 'forward'
|
||||||
|
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
||||||
|
: `On ${date}, ${fromStr} wrote:<br>`;
|
||||||
|
return `${prefix}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'forward') {
|
if (replyTo.body) {
|
||||||
return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`;
|
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
if (mode === 'forward') {
|
||||||
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${(replyTo.body || '').split('\n').join('\n> ')}`;
|
return `${prefix}<br><br>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
||||||
|
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||||
|
return `${prefix}<br><br>On ${date}, ${fromStr} wrote:<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return prefix;
|
return prefix;
|
||||||
};
|
};
|
||||||
@@ -156,18 +198,6 @@ export function EmailComposer({
|
|||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const lastSavedDataRef = useRef<string>("");
|
const lastSavedDataRef = useRef<string>("");
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
const autoResizeTextarea = useCallback(() => {
|
|
||||||
const el = textareaRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
el.style.height = 'auto';
|
|
||||||
el.style.height = el.scrollHeight + 'px';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
autoResizeTextarea();
|
|
||||||
}, [body, autoResizeTextarea]);
|
|
||||||
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
|
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
|
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
|
||||||
@@ -199,6 +229,39 @@ export function EmailComposer({
|
|||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const identities = useIdentityStore((s) => s.identities);
|
const identities = useIdentityStore((s) => s.identities);
|
||||||
const primaryIdentity = identities[0] ?? null;
|
const primaryIdentity = identities[0] ?? null;
|
||||||
|
const currentIdentity = selectedIdentityId
|
||||||
|
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
||||||
|
: primaryIdentity;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoSelectReplyIdentity) return;
|
||||||
|
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
||||||
|
if (mode !== 'reply' && mode !== 'replyAll') return;
|
||||||
|
|
||||||
|
const matchedIdentityId = findReplyIdentityId(identities, {
|
||||||
|
to: replyTo?.to,
|
||||||
|
cc: replyTo?.cc,
|
||||||
|
bcc: replyTo?.bcc,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchedIdentityId) {
|
||||||
|
setSelectedIdentityId(matchedIdentityId);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
autoSelectReplyIdentity,
|
||||||
|
identities,
|
||||||
|
initialData?.selectedIdentityId,
|
||||||
|
mode,
|
||||||
|
replyTo?.bcc,
|
||||||
|
replyTo?.cc,
|
||||||
|
replyTo?.to,
|
||||||
|
selectedIdentityId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const composerSignatureHtml = currentIdentity?.htmlSignature
|
||||||
|
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
||||||
|
: currentIdentity?.textSignature
|
||||||
|
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||||
|
: '';
|
||||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||||
const addTemplate = useTemplateStore((s) => s.addTemplate);
|
const addTemplate = useTemplateStore((s) => s.addTemplate);
|
||||||
const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
|
const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
|
||||||
@@ -358,9 +421,14 @@ export function EmailComposer({
|
|||||||
? substitutePlaceholders(template.body, filledValues)
|
? substitutePlaceholders(template.body, filledValues)
|
||||||
: template.body;
|
: template.body;
|
||||||
|
|
||||||
|
// In plain text mode, use template body as-is; otherwise convert to HTML
|
||||||
|
const bodyContent = plainTextMode
|
||||||
|
? filledBody
|
||||||
|
: `<p>${filledBody.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>`;
|
||||||
|
|
||||||
if (mode === 'compose') {
|
if (mode === 'compose') {
|
||||||
setSubject(filledSubject);
|
setSubject(filledSubject);
|
||||||
setBody(filledBody);
|
setBody(bodyContent);
|
||||||
if (template.defaultRecipients?.to?.length) {
|
if (template.defaultRecipients?.to?.length) {
|
||||||
setTo(template.defaultRecipients.to.join(', ') + ', ');
|
setTo(template.defaultRecipients.to.join(', ') + ', ');
|
||||||
}
|
}
|
||||||
@@ -373,7 +441,7 @@ export function EmailComposer({
|
|||||||
setShowBcc(true);
|
setShowBcc(true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setBody((prev) => filledBody + prev);
|
setBody((prev) => bodyContent + (plainTextMode ? '\n' : '') + prev);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (template.identityId) {
|
if (template.identityId) {
|
||||||
@@ -381,12 +449,14 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setShowTemplatePicker(false);
|
setShowTemplatePicker(false);
|
||||||
}, [mode]);
|
}, [mode, plainTextMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleTemplateKey = (e: KeyboardEvent) => {
|
const handleTemplateKey = (e: KeyboardEvent) => {
|
||||||
const tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
|
const target = e.target as HTMLElement;
|
||||||
|
const tag = target?.tagName?.toLowerCase();
|
||||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||||
|
if (target?.getAttribute('contenteditable') === 'true') return;
|
||||||
if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setShowTemplatePicker(true);
|
setShowTemplatePicker(true);
|
||||||
@@ -436,6 +506,18 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
|
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
|
||||||
|
if (!client) return null;
|
||||||
|
try {
|
||||||
|
const { blobId } = await client.uploadBlob(file);
|
||||||
|
return await client.fetchBlobAsObjectUrl(blobId, file.name, file.type);
|
||||||
|
} catch (error) {
|
||||||
|
debug.error(`Failed to upload inline image ${file.name}:`, error);
|
||||||
|
toast.error(t('upload_failed', { filename: file.name }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [client, t]);
|
||||||
|
|
||||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!event.target.files) return;
|
if (!event.target.files) return;
|
||||||
await addFiles(Array.from(event.target.files));
|
await addFiles(Array.from(event.target.files));
|
||||||
@@ -502,7 +584,7 @@ export function EmailComposer({
|
|||||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
|
|
||||||
if (!toAddresses.length && !subject && !body) {
|
if (!toAddresses.length && !subject && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,10 +609,6 @@ export function EmailComposer({
|
|||||||
setSaveStatus('saving');
|
setSaveStatus('saving');
|
||||||
|
|
||||||
// Get the selected identity or primary identity
|
// Get the selected identity or primary identity
|
||||||
const currentIdentity = selectedIdentityId
|
|
||||||
? identities.find(id => id.id === selectedIdentityId)
|
|
||||||
: primaryIdentity;
|
|
||||||
|
|
||||||
// Generate sub-addressed email if tag is set
|
// Generate sub-addressed email if tag is set
|
||||||
const fromEmail = currentIdentity?.email
|
const fromEmail = currentIdentity?.email
|
||||||
? subAddressTag
|
? subAddressTag
|
||||||
@@ -542,7 +620,7 @@ export function EmailComposer({
|
|||||||
const savedDraftId = await client.createDraft(
|
const savedDraftId = await client.createDraft(
|
||||||
toAddresses,
|
toAddresses,
|
||||||
subject || t('no_subject'),
|
subject || t('no_subject'),
|
||||||
body,
|
plainTextMode ? body : htmlToPlainText(body),
|
||||||
ccAddresses,
|
ccAddresses,
|
||||||
bccAddresses,
|
bccAddresses,
|
||||||
currentIdentity?.id,
|
currentIdentity?.id,
|
||||||
@@ -606,7 +684,8 @@ export function EmailComposer({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
|
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
const hasContent = body || attachments.some(att => att.blobId && !att.uploading);
|
const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim();
|
||||||
|
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
|
||||||
const canSend = toAddresses.length > 0 && !!subject && hasContent;
|
const canSend = toAddresses.length > 0 && !!subject && hasContent;
|
||||||
|
|
||||||
const getSendTooltip = (): string | undefined => {
|
const getSendTooltip = (): string | undefined => {
|
||||||
@@ -649,39 +728,14 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentIdentity = selectedIdentityId
|
|
||||||
? identities.find(id => id.id === selectedIdentityId)
|
|
||||||
: primaryIdentity;
|
|
||||||
|
|
||||||
const fromEmail = currentIdentity?.email
|
const fromEmail = currentIdentity?.email
|
||||||
? subAddressTag
|
? subAddressTag
|
||||||
? generateSubAddress(currentIdentity.email, subAddressTag)
|
? generateSubAddress(currentIdentity.email, subAddressTag)
|
||||||
: currentIdentity.email
|
: currentIdentity.email
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Append signature from the selected identity
|
// Body is already HTML from the rich text editor (or plain text in plain text mode).
|
||||||
let finalBody = body;
|
// Build HTML signature block (used only in rich text mode)
|
||||||
if (currentIdentity?.textSignature) {
|
|
||||||
finalBody = body + '\n\n-- \n' + currentIdentity.textSignature;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append quoted original text for the plain text part in reply/forward
|
|
||||||
if (replyTo && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
|
||||||
const originalText = replyTo.body || '';
|
|
||||||
if (originalText) {
|
|
||||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : '';
|
|
||||||
const fromAddr = replyTo.from?.[0];
|
|
||||||
const fromStr = fromAddr ? `${fromAddr.name || fromAddr.email}` : tCommon('unknown');
|
|
||||||
|
|
||||||
if (mode === 'forward') {
|
|
||||||
finalBody += `\n\n---------- ${t('prefix.forward')} ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
|
||||||
} else {
|
|
||||||
finalBody += `\n\nOn ${date}, ${fromStr} wrote:\n> ${originalText.split('\n').join('\n> ')}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build HTML signature block (prefer htmlSignature, fall back to escaped textSignature)
|
|
||||||
const buildSignatureHtml = (): string => {
|
const buildSignatureHtml = (): string => {
|
||||||
if (currentIdentity?.htmlSignature) {
|
if (currentIdentity?.htmlSignature) {
|
||||||
return `<br><br>-- <br>${sanitizeEmailHtml(currentIdentity.htmlSignature)}`;
|
return `<br><br>-- <br>${sanitizeEmailHtml(currentIdentity.htmlSignature)}`;
|
||||||
@@ -692,26 +746,14 @@ export function EmailComposer({
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build HTML body
|
// In plain text mode, send text/plain only (no HTML body)
|
||||||
let finalHtmlBody: string | undefined;
|
const finalBody = plainTextMode
|
||||||
const signatureHtml = buildSignatureHtml();
|
? appendPlainTextSignature(body, currentIdentity)
|
||||||
|
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
||||||
|
|
||||||
if (replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
const finalHtmlBody = plainTextMode
|
||||||
// Reply/forward with original HTML content
|
? undefined
|
||||||
const escapedBody = body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
: `<div>${body}</div>${buildSignatureHtml()}`;
|
||||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : '';
|
|
||||||
const fromAddr = replyTo.from?.[0];
|
|
||||||
const fromStr = fromAddr ? `${fromAddr.name || fromAddr.email}` : tCommon('unknown');
|
|
||||||
const quoteHeader = mode === 'forward'
|
|
||||||
? `---------- ${t('prefix.forward')} ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
|
||||||
: `On ${date}, ${fromStr} wrote:<br>`;
|
|
||||||
|
|
||||||
finalHtmlBody = `<div>${escapedBody}</div>${signatureHtml}<br><div><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote></div>`;
|
|
||||||
} else if (signatureHtml) {
|
|
||||||
// New compose or plain-text reply — include HTML body with signature
|
|
||||||
const escapedBody = body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
|
||||||
finalHtmlBody = `<div>${escapedBody}</div>${signatureHtml}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||||
@@ -888,6 +930,7 @@ export function EmailComposer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("flex flex-col h-full bg-background relative", className)}
|
className={cn("flex flex-col h-full bg-background relative", className)}
|
||||||
|
data-tour="composer"
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
@@ -956,11 +999,16 @@ export function EmailComposer({
|
|||||||
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
||||||
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
|
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
|
||||||
>
|
>
|
||||||
{identities.map((identity) => (
|
{identities.map((identity) => {
|
||||||
<option key={identity.id} value={identity.id}>
|
const displayEmail = subAddressTag
|
||||||
{identity.name ? `${identity.name} <${identity.email}>` : identity.email}
|
? generateSubAddress(identity.email, subAddressTag)
|
||||||
</option>
|
: identity.email;
|
||||||
))}
|
return (
|
||||||
|
<option key={identity.id} value={identity.id}>
|
||||||
|
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-foreground flex-1 truncate">
|
<span className="text-sm text-foreground flex-1 truncate">
|
||||||
@@ -1109,39 +1157,46 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-4 py-3">
|
{plainTextMode ? (
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
|
||||||
className={cn(
|
|
||||||
"w-full resize-none outline-none text-sm bg-transparent text-foreground placeholder:text-muted-foreground rounded min-h-[100px] overflow-hidden",
|
|
||||||
validationErrors.body && "ring-2 ring-red-500 dark:ring-red-400"
|
|
||||||
)}
|
|
||||||
placeholder={t('body_placeholder')}
|
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setBody(e.target.value);
|
setBody(e.target.value);
|
||||||
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
||||||
}}
|
}}
|
||||||
|
placeholder={t('body_placeholder')}
|
||||||
|
className={cn(
|
||||||
|
"w-full min-h-[300px] px-4 py-3 text-sm text-foreground bg-transparent resize-y focus:outline-none font-mono",
|
||||||
|
validationErrors.body && "ring-2 ring-red-500 dark:ring-red-400 rounded"
|
||||||
|
)}
|
||||||
|
style={{ height: 'calc(100vh - 350px)' }}
|
||||||
aria-invalid={validationErrors.body || undefined}
|
aria-invalid={validationErrors.body || undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
|
<RichTextEditor
|
||||||
{/* Quoted original HTML */}
|
content={body}
|
||||||
{replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward') && (
|
onChange={(html) => {
|
||||||
<div className="border-t border-border">
|
setBody(html);
|
||||||
<div className="px-4 py-2 text-xs text-muted-foreground">
|
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
||||||
{mode === 'forward'
|
}}
|
||||||
? `---------- ${t('prefix.forward')} ----------`
|
onImageUpload={handleImageUpload}
|
||||||
: `${replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ''}, ${replyTo.from?.[0]?.name || replyTo.from?.[0]?.email || tCommon('unknown')}:`
|
placeholder={t('body_placeholder')}
|
||||||
}
|
hasError={validationErrors.body}
|
||||||
</div>
|
/>
|
||||||
<div
|
|
||||||
className="email-reply-quote px-4 pb-3 border-l-2 border-muted-foreground/30 ml-4 max-w-none rounded"
|
|
||||||
style={{ backgroundColor: '#ffffff', color: '#1a1a1a', fontSize: '14px' }}
|
|
||||||
dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(replyTo.htmlBody) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{plainTextMode ? (
|
||||||
|
getPlainTextSignature(currentIdentity) ? (
|
||||||
|
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
|
||||||
|
{'-- \n'}{getPlainTextSignature(currentIdentity)}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
) : composerSignatureHtml ? (
|
||||||
|
<div
|
||||||
|
className="px-4 pb-3 text-sm leading-6 text-foreground break-words [&_a]:text-primary [&_a]:underline-offset-2 [&_a:hover]:underline"
|
||||||
|
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments */}
|
||||||
@@ -1261,6 +1316,7 @@ export function EmailComposer({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<PluginSlot name="composer-toolbar" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Discard + Send (desktop) */}
|
{/* Right side - Discard + Send (desktop) */}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ContextMenuSubMenu,
|
ContextMenuSubMenu,
|
||||||
ContextMenuHeader,
|
ContextMenuHeader,
|
||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import {
|
import {
|
||||||
Reply,
|
Reply,
|
||||||
ReplyAll,
|
ReplyAll,
|
||||||
@@ -131,7 +132,7 @@ export function EmailContextMenu({
|
|||||||
onEditDraft,
|
onEditDraft,
|
||||||
}: EmailContextMenuProps) {
|
}: EmailContextMenuProps) {
|
||||||
const t = useTranslations("context_menu");
|
const t = useTranslations("context_menu");
|
||||||
const tColor = useTranslations("email_viewer.color_tag");
|
const _tColor = useTranslations("email_viewer.color_tag");
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
@@ -366,6 +367,8 @@ export function EmailContextMenu({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PluginSlot name="context-menu-email" />
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Email } from "@/lib/jmap/types";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import type { HoverAction } from "@/stores/settings-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface EmailHoverActionsProps {
|
||||||
|
email: Email;
|
||||||
|
backgroundClassName?: string;
|
||||||
|
onToggleStar?: () => void;
|
||||||
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
onArchive?: () => void;
|
||||||
|
onSetColorTag?: (color: string | null) => void;
|
||||||
|
onMarkAsSpam?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_CONFIG: Record<HoverAction, {
|
||||||
|
icon: typeof Trash2;
|
||||||
|
titleKey: string;
|
||||||
|
className?: string;
|
||||||
|
}> = {
|
||||||
|
delete: {
|
||||||
|
icon: Trash2,
|
||||||
|
titleKey: "delete",
|
||||||
|
className: "hover:text-red-600 dark:hover:text-red-400",
|
||||||
|
},
|
||||||
|
star: {
|
||||||
|
icon: Star,
|
||||||
|
titleKey: "star",
|
||||||
|
className: "hover:text-amber-500 dark:hover:text-amber-400",
|
||||||
|
},
|
||||||
|
markRead: {
|
||||||
|
icon: Mail,
|
||||||
|
titleKey: "mark_read",
|
||||||
|
className: "hover:text-blue-600 dark:hover:text-blue-400",
|
||||||
|
},
|
||||||
|
archive: {
|
||||||
|
icon: Archive,
|
||||||
|
titleKey: "archive",
|
||||||
|
className: "hover:text-green-600 dark:hover:text-green-400",
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
icon: Tag,
|
||||||
|
titleKey: "tag",
|
||||||
|
className: "hover:text-purple-600 dark:hover:text-purple-400",
|
||||||
|
},
|
||||||
|
spam: {
|
||||||
|
icon: ShieldAlert,
|
||||||
|
titleKey: "spam",
|
||||||
|
className: "hover:text-orange-600 dark:hover:text-orange-400",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CORNER_CLASSES = {
|
||||||
|
'top-right': 'top-1 right-1',
|
||||||
|
'top-left': 'top-1 left-1',
|
||||||
|
'bottom-right': 'bottom-1 right-1',
|
||||||
|
'bottom-left': 'bottom-1 left-1',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function EmailHoverActions({
|
||||||
|
email,
|
||||||
|
backgroundClassName = "bg-muted",
|
||||||
|
onToggleStar,
|
||||||
|
onMarkAsRead,
|
||||||
|
onDelete,
|
||||||
|
onArchive,
|
||||||
|
onSetColorTag,
|
||||||
|
onMarkAsSpam,
|
||||||
|
}: EmailHoverActionsProps) {
|
||||||
|
const hoverActions = useSettingsStore((state) => state.hoverActions);
|
||||||
|
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
|
||||||
|
const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner);
|
||||||
|
const t = useTranslations("settings.email_behavior.hover_actions");
|
||||||
|
|
||||||
|
const isUnread = !email.keywords?.$seen;
|
||||||
|
const isStarred = email.keywords?.$flagged;
|
||||||
|
const hoverBackgroundClassName = backgroundClassName;
|
||||||
|
|
||||||
|
if (hoverActions.length === 0) return null;
|
||||||
|
|
||||||
|
const handleAction = (e: React.MouseEvent, action: HoverAction) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
switch (action) {
|
||||||
|
case "delete":
|
||||||
|
onDelete?.();
|
||||||
|
break;
|
||||||
|
case "star":
|
||||||
|
onToggleStar?.();
|
||||||
|
break;
|
||||||
|
case "markRead":
|
||||||
|
onMarkAsRead?.(!isUnread);
|
||||||
|
break;
|
||||||
|
case "archive":
|
||||||
|
onArchive?.();
|
||||||
|
break;
|
||||||
|
case "tag":
|
||||||
|
onSetColorTag?.(null);
|
||||||
|
break;
|
||||||
|
case "spam":
|
||||||
|
onMarkAsSpam?.();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionButtons = hoverActions.map((actionId) => {
|
||||||
|
const config = ACTION_CONFIG[actionId];
|
||||||
|
if (!config) return null;
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
const DisplayIcon = actionId === "markRead"
|
||||||
|
? (isUnread ? MailOpen : Mail)
|
||||||
|
: actionId === "star" && isStarred
|
||||||
|
? Star
|
||||||
|
: Icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={actionId}
|
||||||
|
onClick={(e) => handleAction(e, actionId)}
|
||||||
|
title={t(config.titleKey)}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
|
||||||
|
config.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DisplayIcon
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4",
|
||||||
|
actionId === "star" && isStarred && "fill-amber-400 text-amber-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hoverActionsMode === 'floating') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute z-10 hidden group-hover:flex items-center",
|
||||||
|
CORNER_CLASSES[hoverActionsCorner],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center gap-0.5 rounded-lg px-1.5 py-0.5 shadow-md border border-border",
|
||||||
|
hoverBackgroundClassName,
|
||||||
|
)}>
|
||||||
|
{actionButtons}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn("w-8 h-full", hoverBackgroundClassName)}
|
||||||
|
style={{
|
||||||
|
WebkitMaskImage: "linear-gradient(to right, transparent, black)",
|
||||||
|
maskImage: "linear-gradient(to right, transparent, black)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={cn("flex items-center gap-0.5 h-full pr-3 pl-0.5", hoverBackgroundClassName)}>
|
||||||
|
{actionButtons}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { formatDate } from "@/lib/utils";
|
|||||||
import { Email } from "@/lib/jmap/types";
|
import { Email } from "@/lib/jmap/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Paperclip, Star, Circle, CheckSquare, Square, Tag } from "lucide-react";
|
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
@@ -14,6 +14,7 @@ import { useEmailDrag } from "@/hooks/use-email-drag";
|
|||||||
import { useLongPress } from "@/hooks/use-long-press";
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||||
|
import { EmailHoverActions } from "./email-hover-actions";
|
||||||
import { getEmailColorTag } from "@/lib/thread-utils";
|
import { getEmailColorTag } from "@/lib/thread-utils";
|
||||||
|
|
||||||
interface EmailListItemProps {
|
interface EmailListItemProps {
|
||||||
@@ -21,20 +22,31 @@ interface EmailListItemProps {
|
|||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||||
|
onToggleStar?: () => void;
|
||||||
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
onArchive?: () => void;
|
||||||
|
onSetColorTag?: (color: string | null) => void;
|
||||||
|
onMarkAsSpam?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
|
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||||
const t = useTranslations('email_viewer');
|
const t = useTranslations('email_viewer');
|
||||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
|
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const { identities } = useAuthStore();
|
const { identities } = useAuthStore();
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const isImportant = email.keywords?.["$important"];
|
const isImportant = email.keywords?.["$important"];
|
||||||
|
const isAnswered = email.keywords?.$answered;
|
||||||
|
const isForwarded = email.keywords?.$forwarded;
|
||||||
const sender = email.from?.[0];
|
const sender = email.from?.[0];
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color tag using keyword definitions from settings
|
// Resolve color tag using keyword definitions from settings
|
||||||
const colorTagId = getEmailColorTag(email.keywords);
|
const colorTagId = getEmailColorTag(email.keywords);
|
||||||
@@ -74,19 +86,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
|
|||||||
{...dragHandlers}
|
{...dragHandlers}
|
||||||
{...longPressHandlers}
|
{...longPressHandlers}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative group cursor-pointer select-none transition-all duration-200 border-b border-border",
|
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||||
// Apply color tag as background, with selected and unread states
|
// Apply color tag as background, with selected and unread states
|
||||||
colorTag ? colorTag : (
|
colorTag ? colorTag : (
|
||||||
selected
|
selected
|
||||||
? "bg-blue-200 dark:bg-blue-900/50"
|
? "bg-selection"
|
||||||
: "bg-background"
|
: "bg-background"
|
||||||
),
|
),
|
||||||
selected && !colorTag && "shadow-sm",
|
selected && !colorTag && "shadow-sm",
|
||||||
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
||||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||||
isUnread && !selected && !colorTag && "bg-amber-50 dark:bg-amber-900/20",
|
isUnread && !selected && !colorTag && "bg-warning/10",
|
||||||
// Add visual feedback for checked state
|
// Add visual feedback for checked state
|
||||||
isChecked && "ring-2 ring-primary/20 bg-blue-100 dark:bg-blue-900/30",
|
isChecked && "ring-2 ring-primary/20 bg-selection/60",
|
||||||
// Drag state visual feedback
|
// Drag state visual feedback
|
||||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
||||||
// Long press visual feedback
|
// Long press visual feedback
|
||||||
@@ -105,15 +117,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start px-4" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
|
<div
|
||||||
|
className={cn('px-4', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||||
|
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
|
>
|
||||||
{/* Checkbox - only visible when in selection mode */}
|
{/* Checkbox - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleCheckboxClick}
|
onClick={handleCheckboxClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||||
|
!isFocusedMailLayout && 'mt-2',
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
"active:scale-95",
|
"active:scale-95",
|
||||||
"animate-in fade-in zoom-in-95 duration-150",
|
"animate-in fade-in zoom-in-95 duration-150",
|
||||||
@@ -131,12 +147,12 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
|
|||||||
{/* Unread indicator */}
|
{/* Unread indicator */}
|
||||||
{isUnread && (
|
{isUnread && (
|
||||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
{density !== 'extra-compact' && (
|
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
@@ -147,76 +163,146 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* First Line: Sender and Date */}
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"truncate text-sm",
|
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||||
isUnread
|
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
? "font-bold text-foreground"
|
)}>
|
||||||
: "font-medium text-muted-foreground"
|
{sender?.name || sender?.email || 'Unknown'}
|
||||||
)}>
|
</span>
|
||||||
{sender?.name || sender?.email || "Unknown"}
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||||
</span>
|
<span className={cn(
|
||||||
<div className="flex items-center gap-1.5">
|
'shrink-0 truncate',
|
||||||
{isStarred && (
|
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
)}>
|
||||||
)}
|
{email.subject || t('no_subject')}
|
||||||
{isImportant && (
|
|
||||||
<span className="px-1.5 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded font-medium">
|
|
||||||
Important
|
|
||||||
</span>
|
</span>
|
||||||
|
{inlinePreview && (
|
||||||
|
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
|
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||||
|
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
|
||||||
|
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
|
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
|
{isAnswered && isForwarded && (
|
||||||
|
<>
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<EmailIdentityBadge email={email} identities={identities} compact={true} />
|
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{email.hasAttachment && (
|
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
|
||||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
<span className={cn(
|
||||||
)}
|
'text-xs tabular-nums',
|
||||||
|
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
) : (
|
||||||
{keywordDef && (
|
<>
|
||||||
<span className={cn(
|
{/* First Line: Sender and Date */}
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
)}>
|
<span className={cn(
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
"truncate text-sm",
|
||||||
{keywordDef.label}
|
isUnread
|
||||||
</span>
|
? "font-bold text-foreground"
|
||||||
)}
|
: "font-medium text-muted-foreground"
|
||||||
<span className={cn(
|
)}>
|
||||||
"text-xs tabular-nums",
|
{sender?.name || sender?.email || "Unknown"}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isStarred && (
|
||||||
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
|
)}
|
||||||
|
{isImportant && (
|
||||||
|
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
|
||||||
|
Important
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<EmailIdentityBadge email={email} identities={identities} compact={true} />
|
||||||
|
{isAnswered && !isForwarded && (
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isForwarded && !isAnswered && (
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isAnswered && isForwarded && (
|
||||||
|
<>
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{email.hasAttachment && (
|
||||||
|
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{keywordDef && (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||||
|
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
||||||
|
)}>
|
||||||
|
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
||||||
|
{keywordDef.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs tabular-nums",
|
||||||
|
isUnread
|
||||||
|
? "text-foreground font-semibold"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Second Line: Subject */}
|
||||||
|
<div className={cn(
|
||||||
|
"mb-1 line-clamp-1 text-sm",
|
||||||
isUnread
|
isUnread
|
||||||
? "text-foreground font-semibold"
|
? "font-semibold text-foreground"
|
||||||
: "text-muted-foreground"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{formatDate(email.receivedAt)}
|
{email.subject || t('no_subject')}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Second Line: Subject */}
|
{/* Third Line: Preview (controlled by showPreview setting) */}
|
||||||
<div className={cn(
|
{showPreview && density !== 'extra-compact' && (
|
||||||
"mb-1 line-clamp-1 text-sm",
|
<p className={cn(
|
||||||
isUnread
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
? "font-semibold text-foreground"
|
isUnread
|
||||||
: "font-normal text-foreground/90"
|
? "text-muted-foreground"
|
||||||
)}>
|
: "text-muted-foreground/80"
|
||||||
{email.subject || t('no_subject')}
|
)}>
|
||||||
</div>
|
{email.preview || "No preview available"}
|
||||||
|
</p>
|
||||||
{/* Third Line: Preview (controlled by showPreview setting) */}
|
)}
|
||||||
{showPreview && density !== 'extra-compact' && (
|
</>
|
||||||
<p className={cn(
|
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
|
||||||
isUnread
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-muted-foreground/80"
|
|
||||||
)}>
|
|
||||||
{email.preview || "No preview available"}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hover Quick Actions */}
|
||||||
|
<EmailHoverActions
|
||||||
|
email={email}
|
||||||
|
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
|
||||||
|
onToggleStar={onToggleStar}
|
||||||
|
onMarkAsRead={onMarkAsRead}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onArchive={onArchive}
|
||||||
|
onSetColorTag={onSetColorTag}
|
||||||
|
onMarkAsSpam={onMarkAsSpam}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ export function EmailList({
|
|||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const {
|
const {
|
||||||
selectedEmailIds,
|
selectedEmailIds,
|
||||||
selectAllEmails,
|
selectAllEmails: _selectAllEmails,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
batchMarkAsRead,
|
batchMarkAsRead,
|
||||||
batchDelete,
|
batchDelete,
|
||||||
@@ -89,10 +89,12 @@ export function EmailList({
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
|
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
||||||
|
|
||||||
const threadGroups = useMemo(() => {
|
const threadGroups = useMemo(() => {
|
||||||
const groups = groupEmailsByThread(emails);
|
const groups = groupEmailsByThread(emails, disableThreading);
|
||||||
return sortThreadGroups(groups);
|
return sortThreadGroups(groups);
|
||||||
}, [emails]);
|
}, [emails, disableThreading]);
|
||||||
|
|
||||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
@@ -101,11 +103,16 @@ export function EmailList({
|
|||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
const estimateSize = useCallback(() => {
|
const estimateSize = useCallback(() => {
|
||||||
|
if (isFocusedMailLayout) {
|
||||||
|
return { 'extra-compact': 32, compact: 40, regular: 46, comfortable: 54 }[density];
|
||||||
|
}
|
||||||
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
|
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
|
||||||
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
||||||
}, [density, showPreview]);
|
}, [density, isFocusedMailLayout, showPreview]);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: threadGroups.length,
|
count: threadGroups.length,
|
||||||
@@ -214,15 +221,26 @@ export function EmailList({
|
|||||||
}
|
}
|
||||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
||||||
|
|
||||||
// Range-based load more: trigger when last visible item is near the end
|
// Range-based load more: trigger when last visible item is near the end.
|
||||||
|
// Debounce to prevent rapid cascade when thread grouping reduces item
|
||||||
|
// count below the viewport size (e.g. 2400 emails → fewer thread groups).
|
||||||
const virtualItems = virtualizer.getVirtualItems();
|
const virtualItems = virtualizer.getVirtualItems();
|
||||||
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
|
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
|
||||||
|
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lastVirtualItemIndex === undefined) return;
|
if (lastVirtualItemIndex === undefined) return;
|
||||||
if (lastVirtualItemIndex >= threadGroups.length - 5) {
|
if (lastVirtualItemIndex >= threadGroups.length - 5) {
|
||||||
handleLoadMore();
|
// Clear any pending timer so we don't stack calls
|
||||||
|
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
|
||||||
|
loadMoreTimerRef.current = setTimeout(() => {
|
||||||
|
handleLoadMore();
|
||||||
|
loadMoreTimerRef.current = null;
|
||||||
|
}, 150);
|
||||||
}
|
}
|
||||||
|
return () => {
|
||||||
|
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
|
||||||
|
};
|
||||||
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
|
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
|
||||||
|
|
||||||
// Scroll to the thread group containing the selected email
|
// Scroll to the thread group containing the selected email
|
||||||
@@ -242,7 +260,7 @@ export function EmailList({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
virtualizer.measure();
|
virtualizer.measure();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [density, showPreview]);
|
}, [density, isFocusedMailLayout, showPreview]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col min-h-0", className)}>
|
<div className={cn("flex flex-col min-h-0", className)}>
|
||||||
@@ -358,7 +376,7 @@ export function EmailList({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Email List */}
|
{/* Email List */}
|
||||||
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative">
|
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative" data-tour="email-list">
|
||||||
{/* Loading overlay */}
|
{/* Loading overlay */}
|
||||||
{isLoading && emails.length > 0 && (
|
{isLoading && emails.length > 0 && (
|
||||||
<div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
|
<div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
|
||||||
@@ -422,6 +440,12 @@ export function EmailList({
|
|||||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
onEmailSelect={(email) => onEmailSelect?.(email)}
|
||||||
onContextMenu={openContextMenu}
|
onContextMenu={openContextMenu}
|
||||||
onOpenConversation={onOpenConversation}
|
onOpenConversation={onOpenConversation}
|
||||||
|
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
||||||
|
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
||||||
|
onDelete={onDelete ? (email) => onDelete(email) : undefined}
|
||||||
|
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
||||||
|
onSetColorTag={onSetColorTag}
|
||||||
|
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+691
-175
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Node, mergeAttributes } from "@tiptap/core";
|
||||||
|
import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
import type { NodeViewProps } from "@tiptap/react";
|
||||||
|
|
||||||
|
function ResizableImageView({ node, updateAttributes, selected }: NodeViewProps) {
|
||||||
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
const [resizing, setResizing] = useState(false);
|
||||||
|
const startState = useRef<{ x: number; y: number; width: number; height: number; handle: string }>({
|
||||||
|
x: 0, y: 0, width: 0, height: 0, handle: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const onMouseDown = useCallback((e: React.MouseEvent, handle: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (!img) return;
|
||||||
|
startState.current = {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
width: img.offsetWidth,
|
||||||
|
height: img.offsetHeight,
|
||||||
|
handle,
|
||||||
|
};
|
||||||
|
setResizing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resizing) return;
|
||||||
|
|
||||||
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
|
const { x, width, handle } = startState.current;
|
||||||
|
const dx = e.clientX - x;
|
||||||
|
let newWidth: number;
|
||||||
|
|
||||||
|
if (handle === "right" || handle === "bottom-right" || handle === "top-right") {
|
||||||
|
newWidth = Math.max(50, width + dx);
|
||||||
|
} else {
|
||||||
|
newWidth = Math.max(50, width - dx);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAttributes({ width: Math.round(newWidth) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
setResizing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", onMouseMove);
|
||||||
|
document.addEventListener("mouseup", onMouseUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", onMouseMove);
|
||||||
|
document.removeEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
}, [resizing, updateAttributes]);
|
||||||
|
|
||||||
|
const width = node.attrs.width;
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
...(width ? { width: `${width}px` } : {}),
|
||||||
|
maxWidth: "100%",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper as="span" className="inline-block relative" draggable data-drag-handle>
|
||||||
|
<span
|
||||||
|
className={`relative inline-block group ${selected ? "ring-2 ring-primary rounded" : ""}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
src={node.attrs.src}
|
||||||
|
alt={node.attrs.alt || ""}
|
||||||
|
title={node.attrs.title || undefined}
|
||||||
|
style={{ width: "100%", height: "auto", display: "block" }}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
{selected && (
|
||||||
|
<>
|
||||||
|
{/* Resize handle: right */}
|
||||||
|
<span
|
||||||
|
onMouseDown={(e) => onMouseDown(e, "right")}
|
||||||
|
className="absolute top-1/2 -right-1.5 -translate-y-1/2 w-3 h-8 bg-primary rounded cursor-ew-resize"
|
||||||
|
/>
|
||||||
|
{/* Resize handle: left */}
|
||||||
|
<span
|
||||||
|
onMouseDown={(e) => onMouseDown(e, "left")}
|
||||||
|
className="absolute top-1/2 -left-1.5 -translate-y-1/2 w-3 h-8 bg-primary rounded cursor-ew-resize"
|
||||||
|
/>
|
||||||
|
{/* Resize handle: bottom-right corner */}
|
||||||
|
<span
|
||||||
|
onMouseDown={(e) => onMouseDown(e, "bottom-right")}
|
||||||
|
className="absolute -bottom-1.5 -right-1.5 w-3 h-3 bg-primary rounded cursor-nwse-resize"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResizableImage = Node.create({
|
||||||
|
name: "image",
|
||||||
|
group: "inline",
|
||||||
|
inline: true,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: { default: null },
|
||||||
|
alt: { default: null },
|
||||||
|
title: { default: null },
|
||||||
|
width: { default: null },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: "img[src]" }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
const attrs: Record<string, string> = { ...HTMLAttributes };
|
||||||
|
if (attrs.width) {
|
||||||
|
attrs.style = `width: ${attrs.width}px; max-width: 100%;`;
|
||||||
|
delete attrs.width;
|
||||||
|
}
|
||||||
|
return ["img", mergeAttributes(attrs)];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(ResizableImageView);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useCallback } from "react";
|
||||||
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
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 { ResizableImage } from "@/components/email/resizable-image";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Underline as UnderlineIcon,
|
||||||
|
Strikethrough,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
AlignLeft,
|
||||||
|
AlignCenter,
|
||||||
|
AlignRight,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Undo,
|
||||||
|
Redo,
|
||||||
|
Quote,
|
||||||
|
Code,
|
||||||
|
RemoveFormatting,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface RichTextEditorProps {
|
||||||
|
content: string;
|
||||||
|
onChange: (html: string) => void;
|
||||||
|
onImageUpload?: (file: File) => Promise<string | null>;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
hasError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 RichTextEditor({
|
||||||
|
content,
|
||||||
|
onChange,
|
||||||
|
onImageUpload,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
hasError,
|
||||||
|
}: RichTextEditorProps) {
|
||||||
|
const onImageUploadRef = React.useRef(onImageUpload);
|
||||||
|
onImageUploadRef.current = onImageUpload;
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
heading: { levels: [1, 2] },
|
||||||
|
}),
|
||||||
|
Underline,
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
HTMLAttributes: { rel: "noopener noreferrer nofollow" },
|
||||||
|
}),
|
||||||
|
TextAlign.configure({
|
||||||
|
types: ["heading", "paragraph"],
|
||||||
|
}),
|
||||||
|
TextStyle,
|
||||||
|
Color,
|
||||||
|
ResizableImage,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: "tiptap min-h-[100px] px-4 py-3 text-sm text-foreground",
|
||||||
|
},
|
||||||
|
handleDrop: (view, event) => {
|
||||||
|
const upload = onImageUploadRef.current;
|
||||||
|
if (!upload || !event.dataTransfer?.files?.length) return false;
|
||||||
|
const imageFiles = Array.from(event.dataTransfer.files).filter(f =>
|
||||||
|
f.type.startsWith("image/")
|
||||||
|
);
|
||||||
|
if (imageFiles.length === 0) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
for (const file of imageFiles) {
|
||||||
|
upload(file).then((url) => {
|
||||||
|
if (url) {
|
||||||
|
const { state } = view;
|
||||||
|
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||||
|
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
|
||||||
|
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
|
||||||
|
view.dispatch(tr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
handlePaste: (view, event) => {
|
||||||
|
const upload = onImageUploadRef.current;
|
||||||
|
if (!upload || !event.clipboardData?.files?.length) return false;
|
||||||
|
const imageFiles = Array.from(event.clipboardData.files).filter(f =>
|
||||||
|
f.type.startsWith("image/")
|
||||||
|
);
|
||||||
|
if (imageFiles.length === 0) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
for (const file of imageFiles) {
|
||||||
|
upload(file).then((url) => {
|
||||||
|
if (url) {
|
||||||
|
const { state } = view;
|
||||||
|
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
|
||||||
|
const tr = state.tr.replaceSelectionWith(node);
|
||||||
|
view.dispatch(tr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
onChange(editor.getHTML());
|
||||||
|
},
|
||||||
|
immediatelyRender: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync external content changes (e.g. template application)
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && content !== editor.getHTML()) {
|
||||||
|
editor.commands.setContent(content, { emitUpdate: false });
|
||||||
|
}
|
||||||
|
}, [content, editor]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return (
|
||||||
|
<div className={cn("min-h-[100px]", className)} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col", hasError && "ring-2 ring-red-500 dark:ring-red-400 rounded", className)}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<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="Bold"
|
||||||
|
>
|
||||||
|
<Bold className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("italic")}
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
title="Italic"
|
||||||
|
>
|
||||||
|
<Italic className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("underline")}
|
||||||
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||||
|
title="Underline"
|
||||||
|
>
|
||||||
|
<UnderlineIcon className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("strike")}
|
||||||
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
title="Strikethrough"
|
||||||
|
>
|
||||||
|
<Strikethrough className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarSeparator />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("heading", { level: 1 })}
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
|
title="Heading 1"
|
||||||
|
>
|
||||||
|
<Heading1 className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("heading", { level: 2 })}
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
title="Heading 2"
|
||||||
|
>
|
||||||
|
<Heading2 className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarSeparator />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("bulletList")}
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
title="Bullet List"
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("orderedList")}
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
title="Ordered List"
|
||||||
|
>
|
||||||
|
<ListOrdered className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("blockquote")}
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
title="Quote"
|
||||||
|
>
|
||||||
|
<Quote className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("codeBlock")}
|
||||||
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
|
title="Code Block"
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarSeparator />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive({ textAlign: "left" })}
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
||||||
|
title="Align Left"
|
||||||
|
>
|
||||||
|
<AlignLeft className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive({ textAlign: "center" })}
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
||||||
|
title="Align Center"
|
||||||
|
>
|
||||||
|
<AlignCenter className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive({ textAlign: "right" })}
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
||||||
|
title="Align Right"
|
||||||
|
>
|
||||||
|
<AlignRight className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarSeparator />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("link")}
|
||||||
|
onClick={addLink}
|
||||||
|
title="Link"
|
||||||
|
>
|
||||||
|
<LinkIcon className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarSeparator />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
||||||
|
title="Clear Formatting"
|
||||||
|
>
|
||||||
|
<RemoveFormatting className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarSeparator />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
|
disabled={!editor.can().undo()}
|
||||||
|
title="Undo"
|
||||||
|
>
|
||||||
|
<Undo className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
|
disabled={!editor.can().redo()}
|
||||||
|
title="Redo"
|
||||||
|
>
|
||||||
|
<Redo className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor */}
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,12 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
|||||||
text: t('unlock_key_desc'),
|
text: t('unlock_key_desc'),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
});
|
});
|
||||||
|
} else if (status.decryptionError === 'no-key') {
|
||||||
|
items.push({
|
||||||
|
icon: <Lock className="w-4 h-4" />,
|
||||||
|
text: t('status_encrypted_no_key'),
|
||||||
|
variant: 'warning',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <ShieldX className="w-4 h-4" />,
|
icon: <ShieldX className="w-4 h-4" />,
|
||||||
@@ -83,10 +89,10 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
|||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
const variantStyles = {
|
const variantStyles = {
|
||||||
success: 'bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800',
|
success: 'bg-success/10 text-success border-success/30',
|
||||||
warning: 'bg-yellow-50 dark:bg-yellow-950/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
|
warning: 'bg-warning/10 text-warning border-warning/30',
|
||||||
error: 'bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800',
|
error: 'bg-destructive/10 text-destructive border-destructive/30',
|
||||||
info: 'bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800',
|
info: 'bg-info/10 text-info border-info/30',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from "react";
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||||
|
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
@@ -304,7 +305,7 @@ function EmailCard({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
objectUrls.forEach(url => URL.revokeObjectURL(url));
|
objectUrls.forEach(url => URL.revokeObjectURL(url));
|
||||||
};
|
};
|
||||||
}, [client, email?.id]);
|
}, [client, email?.id, email?.attachments]);
|
||||||
|
|
||||||
// Sanitize and prepare email HTML content
|
// Sanitize and prepare email HTML content
|
||||||
const emailContent = useMemo(() => {
|
const emailContent = useMemo(() => {
|
||||||
@@ -320,9 +321,7 @@ function EmailCard({
|
|||||||
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
|
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
|
||||||
const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId];
|
const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId];
|
||||||
if (hasTextBody && htmlContent) {
|
if (hasTextBody && htmlContent) {
|
||||||
const stripped = htmlContent.replace(/<\/?(html|head|body|meta|!doctype|!DOCTYPE|br\s*\/?)[^>]*>/gi, '').trim();
|
useHtmlVersion = hasMeaningfulHtmlBody(htmlContent);
|
||||||
const hasRichContent = /<(table|tr|td|th|img|style|link|div\s+[^>]*class|span\s+[^>]*class|font|center|blockquote|ul|ol|li|h[1-6])\b/i.test(stripped);
|
|
||||||
useHtmlVersion = hasRichContent;
|
|
||||||
} else {
|
} else {
|
||||||
useHtmlVersion = !!htmlContent;
|
useHtmlVersion = !!htmlContent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { formatDate } from "@/lib/utils";
|
|||||||
import { Email } from "@/lib/jmap/types";
|
import { Email } from "@/lib/jmap/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
|
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
||||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||||
import { useLongPress } from "@/hooks/use-long-press";
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
@@ -29,6 +29,8 @@ export function ThreadEmailItem({
|
|||||||
}: ThreadEmailItemProps) {
|
}: ThreadEmailItemProps) {
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
|
const isAnswered = email.keywords?.$answered;
|
||||||
|
const isForwarded = email.keywords?.$forwarded;
|
||||||
const sender = email.from?.[0];
|
const sender = email.from?.[0];
|
||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
@@ -83,9 +85,9 @@ export function ThreadEmailItem({
|
|||||||
"pl-12 pr-4",
|
"pl-12 pr-4",
|
||||||
"border-l-2 border-l-transparent",
|
"border-l-2 border-l-transparent",
|
||||||
selected
|
selected
|
||||||
? "bg-blue-200 dark:bg-blue-900/50 border-l-primary"
|
? "bg-selection border-l-primary"
|
||||||
: "hover:bg-muted/50",
|
: "hover:bg-muted/50",
|
||||||
isUnread && !selected && "bg-amber-50 dark:bg-amber-900/20",
|
isUnread && !selected && "bg-warning/10",
|
||||||
!isLast && "border-b border-border/30",
|
!isLast && "border-b border-border/30",
|
||||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
||||||
@@ -119,7 +121,7 @@ export function ThreadEmailItem({
|
|||||||
{/* Unread indicator */}
|
{/* Unread indicator */}
|
||||||
{isUnread && (
|
{isUnread && (
|
||||||
<div className="absolute left-7 top-1/2 -translate-y-1/2">
|
<div className="absolute left-7 top-1/2 -translate-y-1/2">
|
||||||
<Circle className="w-1.5 h-1.5 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
<Circle className="w-1.5 h-1.5 fill-unread text-unread" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -151,6 +153,18 @@ export function ThreadEmailItem({
|
|||||||
{isStarred && (
|
{isStarred && (
|
||||||
<Star className="w-3 h-3 fill-amber-400 text-amber-400" />
|
<Star className="w-3 h-3 fill-amber-400 text-amber-400" />
|
||||||
)}
|
)}
|
||||||
|
{isAnswered && !isForwarded && (
|
||||||
|
<Reply className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isForwarded && !isAnswered && (
|
||||||
|
<Forward className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isAnswered && isForwarded && (
|
||||||
|
<>
|
||||||
|
<Reply className="w-3 h-3 text-muted-foreground" />
|
||||||
|
<Forward className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{email.hasAttachment && (
|
{email.hasAttachment && (
|
||||||
<Paperclip className="w-3 h-3 text-muted-foreground" />
|
<Paperclip className="w-3 h-3 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { formatDate } from "@/lib/utils";
|
|||||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square } from "lucide-react";
|
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
@@ -13,6 +13,7 @@ import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils";
|
|||||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||||
import { useLongPress } from "@/hooks/use-long-press";
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { ThreadEmailItem } from "./thread-email-item";
|
import { ThreadEmailItem } from "./thread-email-item";
|
||||||
|
import { EmailHoverActions } from "./email-hover-actions";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
interface ThreadListItemProps {
|
interface ThreadListItemProps {
|
||||||
@@ -25,6 +26,12 @@ interface ThreadListItemProps {
|
|||||||
onEmailSelect: (email: Email) => void;
|
onEmailSelect: (email: Email) => void;
|
||||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||||
|
onToggleStar?: (email: Email) => void;
|
||||||
|
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||||
|
onDelete?: (email: Email) => void;
|
||||||
|
onArchive?: (email: Email) => void;
|
||||||
|
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||||
|
onMarkAsSpam?: (email: Email) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SingleEmailItemProps {
|
interface SingleEmailItemProps {
|
||||||
@@ -34,17 +41,28 @@ interface SingleEmailItemProps {
|
|||||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||||
showPreview: boolean;
|
showPreview: boolean;
|
||||||
colorTag: string | null;
|
colorTag: string | null;
|
||||||
|
onToggleStar?: () => void;
|
||||||
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
onArchive?: () => void;
|
||||||
|
onSetColorTag?: (color: string | null) => void;
|
||||||
|
onMarkAsSpam?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||||
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag }, ref) {
|
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
|
const isAnswered = email.keywords?.$answered;
|
||||||
|
const isForwarded = email.keywords?.$forwarded;
|
||||||
const sender = email.from?.[0];
|
const sender = email.from?.[0];
|
||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color and keyword definition from keyword definitions if not passed directly
|
// Resolve color and keyword definition from keyword definitions if not passed directly
|
||||||
const tagId = getEmailColorTag(email.keywords);
|
const tagId = getEmailColorTag(email.keywords);
|
||||||
@@ -100,7 +118,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{...dragHandlers}
|
{...dragHandlers}
|
||||||
{...longPressHandlers}
|
{...longPressHandlers}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative group cursor-pointer select-none transition-all duration-200 border-b border-border",
|
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||||
resolvedColorTag ? resolvedColorTag : (
|
resolvedColorTag ? resolvedColorTag : (
|
||||||
selected
|
selected
|
||||||
? "bg-accent"
|
? "bg-accent"
|
||||||
@@ -116,15 +134,19 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
|
<div
|
||||||
|
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||||
|
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
|
>
|
||||||
{/* Checkbox - only visible when in selection mode */}
|
{/* Checkbox - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleCheckboxClick}
|
onClick={handleCheckboxClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||||
|
!isFocusedMailLayout && 'mt-2',
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
"active:scale-95",
|
"active:scale-95",
|
||||||
"animate-in fade-in zoom-in-95 duration-150",
|
"animate-in fade-in zoom-in-95 duration-150",
|
||||||
@@ -141,11 +163,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
|
|
||||||
{isUnread && (
|
{isUnread && (
|
||||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{density !== 'extra-compact' && (
|
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
@@ -155,67 +177,136 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className={cn(
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
"truncate text-sm",
|
<span className={cn(
|
||||||
isUnread
|
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||||
? "font-bold text-foreground"
|
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
: "font-medium text-muted-foreground"
|
)}>
|
||||||
)}>
|
{sender?.name || sender?.email || 'Unknown'}
|
||||||
{sender?.name || sender?.email || "Unknown"}
|
</span>
|
||||||
</span>
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||||
<div className="flex items-center gap-1.5">
|
<span className={cn(
|
||||||
{isStarred && (
|
'shrink-0 truncate',
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
)}
|
)}>
|
||||||
{email.hasAttachment && (
|
{email.subject || '(no subject)'}
|
||||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
</span>
|
||||||
|
{inlinePreview && (
|
||||||
|
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
|
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||||
|
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
|
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
|
{isAnswered && isForwarded && (
|
||||||
|
<>
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
|
{resolvedKeywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || 'bg-gray-400')} />}
|
||||||
|
<span className={cn(
|
||||||
|
'text-xs tabular-nums',
|
||||||
|
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
) : (
|
||||||
{resolvedKeywordDef && (
|
<>
|
||||||
<span className={cn(
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
|
<span className={cn(
|
||||||
)}>
|
"truncate text-sm",
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
|
isUnread
|
||||||
{resolvedKeywordDef.label}
|
? "font-bold text-foreground"
|
||||||
</span>
|
: "font-medium text-muted-foreground"
|
||||||
)}
|
)}>
|
||||||
<span className={cn(
|
{sender?.name || sender?.email || "Unknown"}
|
||||||
"text-xs tabular-nums",
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isStarred && (
|
||||||
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
|
)}
|
||||||
|
{isAnswered && !isForwarded && (
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isForwarded && !isAnswered && (
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isAnswered && isForwarded && (
|
||||||
|
<>
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{email.hasAttachment && (
|
||||||
|
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{resolvedKeywordDef && (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||||
|
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
|
||||||
|
)}>
|
||||||
|
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
|
||||||
|
{resolvedKeywordDef.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs tabular-nums",
|
||||||
|
isUnread
|
||||||
|
? "text-foreground font-semibold"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
"mb-1 line-clamp-1 text-sm",
|
||||||
isUnread
|
isUnread
|
||||||
? "text-foreground font-semibold"
|
? "font-semibold text-foreground"
|
||||||
: "text-muted-foreground"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{formatDate(email.receivedAt)}
|
{email.subject || "(no subject)"}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={cn(
|
{showPreview && density !== 'extra-compact' && (
|
||||||
"mb-1 line-clamp-1 text-sm",
|
<p className={cn(
|
||||||
isUnread
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
? "font-semibold text-foreground"
|
isUnread
|
||||||
: "font-normal text-foreground/90"
|
? "text-muted-foreground"
|
||||||
)}>
|
: "text-muted-foreground/80"
|
||||||
{email.subject || "(no subject)"}
|
)}>
|
||||||
</div>
|
{email.preview || "No preview available"}
|
||||||
|
</p>
|
||||||
{showPreview && density !== 'extra-compact' && (
|
)}
|
||||||
<p className={cn(
|
</>
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
|
||||||
isUnread
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-muted-foreground/80"
|
|
||||||
)}>
|
|
||||||
{email.preview || "No preview available"}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hover Quick Actions */}
|
||||||
|
<EmailHoverActions
|
||||||
|
email={email}
|
||||||
|
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")}
|
||||||
|
onToggleStar={onToggleStar}
|
||||||
|
onMarkAsRead={onMarkAsRead}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onArchive={onArchive}
|
||||||
|
onSetColorTag={onSetColorTag}
|
||||||
|
onMarkAsSpam={onMarkAsSpam}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -232,12 +323,21 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
onEmailSelect,
|
onEmailSelect,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
onOpenConversation,
|
onOpenConversation,
|
||||||
|
onToggleStar,
|
||||||
|
onMarkAsRead,
|
||||||
|
onDelete,
|
||||||
|
onArchive,
|
||||||
|
onSetColorTag,
|
||||||
|
onMarkAsSpam,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
const t = useTranslations('threads');
|
const t = useTranslations('threads');
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
|
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
||||||
|
|
||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||||
|
|
||||||
@@ -278,6 +378,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
showPreview={showPreview}
|
showPreview={showPreview}
|
||||||
colorTag={colorTag}
|
colorTag={colorTag}
|
||||||
|
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||||
|
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||||
|
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||||
|
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||||
|
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||||
|
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -339,7 +445,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{...dragHandlers}
|
{...dragHandlers}
|
||||||
{...threadLongPressHandlers}
|
{...threadLongPressHandlers}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative group cursor-pointer select-none transition-all duration-200",
|
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
|
||||||
colorTag ? colorTag : (
|
colorTag ? colorTag : (
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-accent"
|
? "bg-accent"
|
||||||
@@ -355,15 +461,19 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
)}
|
)}
|
||||||
onClick={handleHeaderClick}
|
onClick={handleHeaderClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
|
<div
|
||||||
|
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||||
|
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
|
>
|
||||||
{/* Checkbox for thread selection - only visible when in selection mode */}
|
{/* Checkbox for thread selection - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleThreadCheckboxClick}
|
onClick={handleThreadCheckboxClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||||
|
!isFocusedMailLayout && 'mt-2',
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
"active:scale-95",
|
"active:scale-95",
|
||||||
"animate-in fade-in zoom-in-95 duration-150",
|
"animate-in fade-in zoom-in-95 duration-150",
|
||||||
@@ -378,7 +488,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && !isFocusedMailLayout && (
|
||||||
<button
|
<button
|
||||||
data-expand-toggle
|
data-expand-toggle
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -406,11 +516,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
|
|
||||||
{hasUnread && (
|
{hasUnread && (
|
||||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{density !== 'extra-compact' && (
|
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={latestEmail.from?.[0]?.name}
|
name={latestEmail.from?.[0]?.name}
|
||||||
email={latestEmail.from?.[0]?.email}
|
email={latestEmail.from?.[0]?.email}
|
||||||
@@ -420,82 +530,161 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className={cn(
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
"truncate text-sm",
|
<span className={cn(
|
||||||
hasUnread
|
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||||
? "font-bold text-foreground"
|
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
: "font-medium text-muted-foreground"
|
)}>
|
||||||
)}>
|
{participantNames.join(', ')}
|
||||||
{participantNames.join(", ")}
|
</span>
|
||||||
</span>
|
<span
|
||||||
<span
|
className={cn(
|
||||||
className={cn(
|
'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium',
|
||||||
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
hasUnread ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
|
||||||
hasUnread
|
)}
|
||||||
? "bg-primary text-primary-foreground"
|
title={t('messages_tooltip', { count: emailCount })}
|
||||||
: "bg-muted text-muted-foreground"
|
>
|
||||||
)}
|
<MessageSquare className="w-3 h-3" />
|
||||||
title={t('messages_tooltip', { count: emailCount })}
|
{emailCount}
|
||||||
>
|
</span>
|
||||||
<MessageSquare className="w-3 h-3" />
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||||
{emailCount}
|
<span className={cn(
|
||||||
</span>
|
'shrink-0 truncate',
|
||||||
<div className="flex items-center gap-1.5">
|
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
{hasStarred && (
|
)}>
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
{latestEmail.subject || '(no subject)'}
|
||||||
)}
|
</span>
|
||||||
{hasAttachment && (
|
{inlinePreview && (
|
||||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
|
{hasStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||||
|
{hasAnswered && !hasForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
|
{hasForwarded && !hasAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
|
{hasAnswered && hasForwarded && (
|
||||||
|
<>
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
|
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
|
||||||
|
<span className={cn(
|
||||||
|
'text-xs tabular-nums',
|
||||||
|
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{formatDate(latestEmail.receivedAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
) : (
|
||||||
{keywordDef && (
|
<>
|
||||||
<span className={cn(
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
<span className={cn(
|
||||||
)}>
|
"truncate text-sm",
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
hasUnread
|
||||||
{keywordDef.label}
|
? "font-bold text-foreground"
|
||||||
</span>
|
: "font-medium text-muted-foreground"
|
||||||
)}
|
)}>
|
||||||
<span className={cn(
|
{participantNames.join(", ")}
|
||||||
"text-xs tabular-nums",
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
||||||
|
hasUnread
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
title={t('messages_tooltip', { count: emailCount })}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
{emailCount}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{hasStarred && (
|
||||||
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
|
)}
|
||||||
|
{hasAnswered && !hasForwarded && (
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{hasForwarded && !hasAnswered && (
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{hasAnswered && hasForwarded && (
|
||||||
|
<>
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hasAttachment && (
|
||||||
|
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{keywordDef && (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||||
|
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
||||||
|
)}>
|
||||||
|
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
||||||
|
{keywordDef.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs tabular-nums",
|
||||||
|
hasUnread
|
||||||
|
? "text-foreground font-semibold"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{formatDate(latestEmail.receivedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
"mb-1 line-clamp-1 text-sm",
|
||||||
hasUnread
|
hasUnread
|
||||||
? "text-foreground font-semibold"
|
? "font-semibold text-foreground"
|
||||||
: "text-muted-foreground"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{formatDate(latestEmail.receivedAt)}
|
{latestEmail.subject || "(no subject)"}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={cn(
|
{showPreview && density !== 'extra-compact' && (
|
||||||
"mb-1 line-clamp-1 text-sm",
|
<p className={cn(
|
||||||
hasUnread
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
? "font-semibold text-foreground"
|
hasUnread
|
||||||
: "font-normal text-foreground/90"
|
? "text-muted-foreground"
|
||||||
)}>
|
: "text-muted-foreground/80"
|
||||||
{latestEmail.subject || "(no subject)"}
|
)}>
|
||||||
</div>
|
{latestEmail.preview || "No preview available"}
|
||||||
|
</p>
|
||||||
{showPreview && density !== 'extra-compact' && (
|
)}
|
||||||
<p className={cn(
|
</>
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
|
||||||
hasUnread
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-muted-foreground/80"
|
|
||||||
)}>
|
|
||||||
{latestEmail.preview || "No preview available"}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hover Quick Actions for thread header */}
|
||||||
|
<EmailHoverActions
|
||||||
|
email={latestEmail}
|
||||||
|
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")}
|
||||||
|
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||||
|
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||||
|
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||||
|
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||||
|
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||||
|
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && !isMobile && (
|
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
||||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) {
|
|||||||
export function ComposerErrorFallback({ resetError, t }: FallbackProps) {
|
export function ComposerErrorFallback({ resetError, t }: FallbackProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-background border rounded-lg items-center justify-center p-8">
|
<div className="flex flex-col h-full bg-background border rounded-lg items-center justify-center p-8">
|
||||||
<AlertCircle className="w-10 h-10 text-amber-500 mb-3" />
|
<AlertCircle className="w-10 h-10 text-warning mb-3" />
|
||||||
<p className="text-sm text-muted-foreground text-center mb-4">
|
<p className="text-sm text-muted-foreground text-center mb-4">
|
||||||
{t("composer_error")}
|
{t("composer_error")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
|||||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||||
import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar";
|
import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
|
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||||
import type { FileResource } from "@/stores/file-store";
|
import type { FileResource } from "@/stores/file-store";
|
||||||
|
|
||||||
type SortKey = "name" | "size" | "modified";
|
type SortKey = "name" | "size" | "modified";
|
||||||
@@ -180,8 +181,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
|
||||||
|
|
||||||
function getFileIconByName(name: string, size: "sm" | "lg") {
|
function getFileIconByName(name: string, size: "sm" | "lg") {
|
||||||
const cls = size === "sm" ? "w-5 h-5" : "w-10 h-10";
|
const cls = size === "sm" ? "w-5 h-5" : "w-10 h-10";
|
||||||
if (isVectorFile(name)) return <PenTool className={`${cls} text-orange-500`} />;
|
if (isVectorFile(name)) return <PenTool className={`${cls} text-orange-500`} />;
|
||||||
@@ -454,13 +453,13 @@ export function FileBrowser({
|
|||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleNavigateUp = () => {
|
const handleNavigateUp = useCallback(() => {
|
||||||
if (currentPath === '/') return;
|
if (currentPath === '/') return;
|
||||||
const segments = currentPath.split('/').filter(Boolean);
|
const segments = currentPath.split('/').filter(Boolean);
|
||||||
segments.pop();
|
segments.pop();
|
||||||
const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/');
|
const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/');
|
||||||
onNavigate(parentPath, null);
|
onNavigate(parentPath, null);
|
||||||
};
|
}, [currentPath, onNavigate]);
|
||||||
|
|
||||||
const handleResourceClick = (resource: FileResource, e: React.MouseEvent) => {
|
const handleResourceClick = (resource: FileResource, e: React.MouseEvent) => {
|
||||||
if (resource.isDirectory) {
|
if (resource.isDirectory) {
|
||||||
@@ -624,16 +623,20 @@ export function FileBrowser({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDraggingOver(false);
|
setIsDraggingOver(false);
|
||||||
|
|
||||||
const files = Array.from(e.dataTransfer.files);
|
setIsUploading(true);
|
||||||
if (files.length > 0) {
|
try {
|
||||||
setIsUploading(true);
|
const { files, hasDirectories } = await getDroppedFilesAndFolders(e.dataTransfer);
|
||||||
try {
|
if (files.length > 0) {
|
||||||
await onUploadFiles(files);
|
if (hasDirectories) {
|
||||||
} finally {
|
await onUploadFolder(files);
|
||||||
setIsUploading(false);
|
} else {
|
||||||
|
await onUploadFiles(files);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
}
|
}
|
||||||
}, [onUploadFiles]);
|
}, [onUploadFiles, onUploadFolder]);
|
||||||
|
|
||||||
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
@@ -945,6 +948,16 @@ export function FileBrowser({
|
|||||||
>
|
>
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => folderInputRef.current?.click()}
|
||||||
|
title={t("upload_folder")}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
<FolderUp className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -1195,6 +1208,14 @@ export function FileBrowser({
|
|||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onUploadFolder={async (files: File[]) => {
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
await onUploadFolder(files);
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onCreateFolder={() => setShowNewFolder(true)}
|
onCreateFolder={() => setShowNewFolder(true)}
|
||||||
onCreateTextFile={() => setShowNewTextFile(true)}
|
onCreateTextFile={() => setShowNewTextFile(true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user