Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7511d8ea78 | ||
|
|
959d4bd6ce | ||
|
|
b7c8cd999e | ||
|
|
813185e58d | ||
|
|
3f22a3323a | ||
|
|
0e4efb5a2a | ||
|
|
b354319b82 | ||
|
|
5a8c69dac2 | ||
|
|
24056e4698 | ||
|
|
5818e60401 | ||
|
|
7beaf991e8 | ||
|
|
5105e000f5 | ||
|
|
66bc10fa0f | ||
|
|
07c473e057 | ||
|
|
0e47c3b039 | ||
|
|
e1a973663f | ||
|
|
de55fb6b73 | ||
|
|
a909593dda | ||
|
|
4ad9267a2d | ||
|
|
23a017d4b7 | ||
|
|
c7250dc921 | ||
|
|
80f76abc38 | ||
|
|
adb8686293 | ||
|
|
d531ad1930 | ||
|
|
953355d2a5 | ||
|
|
8155f98a28 | ||
|
|
fda898fc96 | ||
|
|
3dceecb4c5 | ||
|
|
53461d1142 | ||
|
|
4d9d992f3f | ||
|
|
162e420a1f | ||
|
|
e8f01871c2 | ||
|
|
6dfcb07b9a | ||
|
|
0f3459c2e5 | ||
|
|
d5017a211f | ||
|
|
f51ec50443 | ||
|
|
f2703bcc27 | ||
|
|
cda4dcbf01 | ||
|
|
f15edd336b | ||
|
|
a3f9055541 | ||
|
|
a779e101e6 | ||
|
|
c38bcc4a95 | ||
|
|
5716d91115 | ||
|
|
2f791318df | ||
|
|
60b9ae66ea | ||
|
|
abd493fb4c | ||
|
|
b4739c111f | ||
|
|
88b07a1713 | ||
|
|
18e9cf6ee6 | ||
|
|
2cb5c739b4 | ||
|
|
0a30b2fb3a | ||
|
|
0b62afb0f8 | ||
|
|
f749ee1f2a | ||
|
|
4a4950c3e5 | ||
|
|
739b72d251 | ||
|
|
682e47c970 | ||
|
|
a6d8671306 | ||
|
|
6f278845f3 | ||
|
|
334fdbfb86 | ||
|
|
578339c400 | ||
|
|
8d8bc7cb13 | ||
|
|
4dc76bbb47 | ||
|
|
04444003b2 | ||
|
|
7da3d4ae80 | ||
|
|
cb5d754113 | ||
|
|
511f9e5195 | ||
|
|
996fa7eea6 | ||
|
|
01e5cd69cf | ||
|
|
20d02214df | ||
|
|
432ba0516b | ||
|
|
37152504b4 | ||
|
|
9072bf8470 | ||
|
|
b1f6758f98 | ||
|
|
a679d82cc3 | ||
|
|
a08a9e9ed3 | ||
|
|
622adc34de | ||
|
|
4a3394cf8c | ||
|
|
a8598db44d | ||
|
|
d3addf54b4 | ||
|
|
e1e83c4a83 | ||
|
|
cdb31634a6 | ||
|
|
26c3d07d56 | ||
|
|
c3acb537d0 | ||
|
|
060c5d00d1 | ||
|
|
e05fbb2fe9 | ||
|
|
b8809c2e69 | ||
|
|
bc11450f3f | ||
|
|
034f7a4b9b | ||
|
|
2e42693228 | ||
|
|
fdad60cf03 | ||
|
|
dc72122ed8 | ||
|
|
7c221c4a4a | ||
|
|
c1acf58c5f | ||
|
|
42798c2b7c | ||
|
|
75d17d4e37 | ||
|
|
38a396d150 | ||
|
|
c6bd5f645a | ||
|
|
fa31933922 | ||
|
|
d3f77ef9cf | ||
|
|
5ccba83129 | ||
|
|
752e71198c | ||
|
|
2cb74c0186 | ||
|
|
9ab7339320 | ||
|
|
782974ecdb | ||
|
|
c47137fb49 | ||
|
|
38313639ed | ||
|
|
e933800792 | ||
|
|
60a1cc670c | ||
|
|
c0515001f1 | ||
|
|
e10fced28a | ||
|
|
3d36492518 | ||
|
|
94af4725b6 | ||
|
|
22418c17cf | ||
|
|
8904d724bb | ||
|
|
c7d551f185 | ||
|
|
6470fa86f0 | ||
|
|
8647d709ff | ||
|
|
b73d1b55d1 | ||
|
|
7db6fd7e24 | ||
|
|
e066698938 | ||
|
|
c3a97de62f | ||
|
|
29283282d5 | ||
|
|
db2c642d74 | ||
|
|
9110bc388f | ||
|
|
a4dc0b7b4e | ||
|
|
d384c3b553 | ||
|
|
d7a64fd9d6 | ||
|
|
06ddda688d | ||
|
|
6b74615969 | ||
|
|
0d73cb5dfb | ||
|
|
b1b09d54c8 | ||
|
|
9930d19ba4 | ||
|
|
902774eae1 | ||
|
|
1429a6fe1e | ||
|
|
d1da83a5d6 | ||
|
|
babacca482 | ||
|
|
e6aa79ed94 | ||
|
|
4d6b4b5b8e | ||
|
|
d259168bd7 | ||
|
|
97d87c44d0 | ||
|
|
396f5f7d60 | ||
|
|
9aef8bc393 | ||
|
|
e05ab48a45 | ||
|
|
14c2807f0a | ||
|
|
a099ab442a | ||
|
|
e9ac2de6cb | ||
|
|
c4a7f575c5 | ||
|
|
51c3a69be7 | ||
|
|
717b1d8397 | ||
|
|
f291480565 | ||
|
|
95b5a81924 | ||
|
|
9da27df249 | ||
|
|
b716f95a73 | ||
|
|
6c49427c7c | ||
|
|
2a41e73bf9 | ||
|
|
16daf6ea03 | ||
|
|
65cb6be8a9 | ||
|
|
2704d5dc23 | ||
|
|
2f5b133000 | ||
|
|
1f21a5213f | ||
|
|
d4c066622b | ||
|
|
e141abc849 | ||
|
|
1f4afe082b | ||
|
|
e0747c12ee | ||
|
|
f90cd6abc4 | ||
|
|
63e087f3ef | ||
|
|
f8b8e0b108 | ||
|
|
512adab7e3 | ||
|
|
4cdc15fc3c | ||
|
|
5e67671f57 | ||
|
|
155d99a069 | ||
|
|
d863b1fd4b | ||
|
|
70aaf0aac1 | ||
|
|
de56229ef2 | ||
|
|
1ff23790ae | ||
|
|
198407ebf5 | ||
|
|
de68d68fb7 | ||
|
|
f285a2bd64 | ||
|
|
84b6d0fd8b | ||
|
|
f972068143 | ||
|
|
7882b254a0 | ||
|
|
ae5d397512 | ||
|
|
7a022596c3 | ||
|
|
8c575e8ed8 | ||
|
|
85fbab9eb4 | ||
|
|
1119d8ed73 | ||
|
|
32f0a67dbc | ||
|
|
baf094d026 | ||
|
|
f1b9f4ba50 | ||
|
|
cc20d29636 | ||
|
|
5f713a033b | ||
|
|
079ec57204 | ||
|
|
cd247e9c47 | ||
|
|
8af6694152 | ||
|
|
751f3c1685 | ||
|
|
5c2f206c74 | ||
|
|
acc61db6f2 | ||
|
|
d671c606a1 | ||
|
|
fa3c57467b | ||
|
|
befee332d2 | ||
|
|
a29c33b50a | ||
|
|
3dd596ba50 | ||
|
|
e50fe0d4db | ||
|
|
3d3ad8f0ef | ||
|
|
d0ed4b4dfe | ||
|
|
5306f7c548 | ||
|
|
ddb596affc | ||
|
|
bfc8ba851a | ||
|
|
3f9e60843d | ||
|
|
2fac6ebfb8 | ||
|
|
dda9fd1433 | ||
|
|
3516d3c727 | ||
|
|
0b7203df0f | ||
|
|
344795a8d9 | ||
|
|
638fc7db4e | ||
|
|
ab3e0e717a | ||
|
|
c9eae3b3a1 | ||
|
|
6f615f4c32 | ||
|
|
52eacf87b9 | ||
|
|
c4f1cc23d7 | ||
|
|
4f1390fdeb | ||
|
|
c0ca6d3102 | ||
|
|
0872d3dc8d | ||
|
|
1f889b0965 | ||
|
|
0b9fe5451f | ||
|
|
fd700f412e | ||
|
|
f7d4f9d53c | ||
|
|
84aced7b4e | ||
|
|
94b1f5aa48 | ||
|
|
c51c3655d5 | ||
|
|
404a1e847c | ||
|
|
dcea4fdd5e | ||
|
|
701f96adb3 | ||
|
|
1ebfb286ad | ||
|
|
b5f15dfdb7 | ||
|
|
aee4bd78db | ||
|
|
798a33495e | ||
|
|
4c6c1aab60 | ||
|
|
848ed9774d | ||
|
|
b32e102ff9 | ||
|
|
e8feb11983 | ||
|
|
3e12fca517 | ||
|
|
8df483d8c7 | ||
|
|
1e63e2469a | ||
|
|
e1c28e767a | ||
|
|
fe5645c818 | ||
|
|
38570b1723 | ||
|
|
a4f476945d | ||
|
|
20fd9ff4de | ||
|
|
569dde9985 | ||
|
|
08f344403b | ||
|
|
be58cee989 | ||
|
|
7c11e2b3c9 | ||
|
|
f2913a7c7d | ||
|
|
a27a5be3dd | ||
|
|
964136b540 | ||
|
|
569f688fbf | ||
|
|
1d050f8469 | ||
|
|
2e4c0f9eea | ||
|
|
fd0b866339 | ||
|
|
1518ba04dd | ||
|
|
c5672c64fb | ||
|
|
5997579f54 | ||
|
|
f77d2e9103 | ||
|
|
6350e9dabc | ||
|
|
7723c134ff | ||
|
|
d11ed904a9 |
@@ -9,3 +9,8 @@ scripts/
|
|||||||
TODO.md
|
TODO.md
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
|
# Sibling projects / test harness - not part of the webmail image
|
||||||
|
examples/
|
||||||
|
integration/
|
||||||
|
e2e/
|
||||||
|
**/node_modules
|
||||||
|
|||||||
+13
-4
@@ -49,6 +49,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# OpenID Connect issuer URL for discovery
|
# OpenID Connect issuer URL for discovery
|
||||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
||||||
|
|
||||||
|
# Overrides only the user-facing authorize endpoint (e.g. a per-brand login
|
||||||
|
# host). Discovery, token exchange and refresh keep using OAUTH_ISSUER_URL.
|
||||||
|
# Leave unset to use the authorization_endpoint from discovery.
|
||||||
|
# OAUTH_AUTHORIZE_URL=https://login.your-brand.example.com/application/o/authorize/
|
||||||
|
|
||||||
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
|
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
|
||||||
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
|
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
|
||||||
# OAuth issuer's public hostname resolves to an internal IP from this server.
|
# OAuth issuer's public hostname resolves to an internal IP from this server.
|
||||||
@@ -110,12 +115,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Anonymous Telemetry
|
# Anonymous Telemetry
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
|
# Anonymous instance telemetry is OPT-IN and disabled by default. Enabling it
|
||||||
# version, platform, bucketed account counts, and feature toggles only. See
|
# helps us understand how Bulwark is used so we can make the product better.
|
||||||
|
# Heartbeats contain no PII: version, platform, bucketed account counts, and
|
||||||
|
# feature toggles only - never email addresses, hostnames, or IPs. See
|
||||||
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
|
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
|
||||||
#
|
#
|
||||||
# Disable telemetry entirely (overrides the admin UI):
|
# Enable telemetry (also toggleable in the admin UI):
|
||||||
# BULWARK_TELEMETRY=off
|
# BULWARK_TELEMETRY=on
|
||||||
|
#
|
||||||
|
# Setting this (on or off) locks the choice and disables the admin UI toggle.
|
||||||
|
|
||||||
# Directory for telemetry state: instance id, consent, login HMACs
|
# Directory for telemetry state: instance id, consent, login HMACs
|
||||||
# (default: ./data/telemetry). For Docker, the default resolves to
|
# (default: ./data/telemetry). For Docker, the default resolves to
|
||||||
|
|||||||
@@ -118,3 +118,114 @@ jobs:
|
|||||||
- name: Inspect image
|
- name: Inspect image
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||||
|
|
||||||
|
build-always:
|
||||||
|
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 }}
|
||||||
|
build-args: |
|
||||||
|
GIT_COMMIT=${{ github.sha }}
|
||||||
|
NEXT_PUBLIC_LOCALE_PREFIX=always
|
||||||
|
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha,scope=always-${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,mode=max,scope=always-${{ matrix.platform }}
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests-always
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests-always/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-always-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||||
|
path: /tmp/digests-always/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge-always:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-always
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests-always
|
||||||
|
pattern: digests-always-*
|
||||||
|
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 }}
|
||||||
|
flavor: |
|
||||||
|
suffix=-always,onlatest=true
|
||||||
|
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-always
|
||||||
|
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 }}
|
||||||
|
|||||||
+230
@@ -1,5 +1,235 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.7.8 (2026-07-22)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Unified Mailbox**: Account-bounded Unified Mailbox with opt-in cross-account aggregation (#509)
|
||||||
|
- **Unified Mailbox**: Search in the unified views
|
||||||
|
- **Unified Mailbox**: Live unified/All-Mail counters for shared and group accounts
|
||||||
|
- **Mail**: Message-list category tabs
|
||||||
|
- **Mail**: Drag-and-drop reorder for all folders
|
||||||
|
- **Mail**: Collapse quoted reply text behind a "..." toggle (#480)
|
||||||
|
- **Mail**: Bulk Not-Spam action in the junk selection toolbar
|
||||||
|
- **Mail**: Unread count badge on the favicon
|
||||||
|
- **Mail**: Message spacing setting (auto/always/edge-to-edge)
|
||||||
|
- **Mail**: Open external links in a new tab (safely)
|
||||||
|
- **Mail**: Strip external `url()`/`@import` from `<style>` blocks in the sanitizer (#457)
|
||||||
|
- **Composer**: Text color picker in the composer toolbar
|
||||||
|
- **Composer**: Contact groups as single expandable recipient chips
|
||||||
|
- **Composer**: Drag-to-reorder To/Cc/Bcc recipient chips (#593)
|
||||||
|
- **Composer**: Auto-detect paragraph text direction by default
|
||||||
|
- **Templates**: HTML template support
|
||||||
|
- **Vacation**: HTML body support in the vacation responder
|
||||||
|
- **Send**: 'Send now' action on the send-delay toast
|
||||||
|
- **Accounts**: Remove a specific account from the switcher
|
||||||
|
- **Settings**: "Refresh cached data" recovery action
|
||||||
|
- **i18n**: Full Arabic (ar) translation with RTL support
|
||||||
|
- **Login**: `LOGIN_SHOW_TOTP` and `LOGIN_SHOW_VERSION` config flags (#520)
|
||||||
|
- **Docker**: `NEXT_PUBLIC_LOCALE_PREFIX` build argument
|
||||||
|
- **Plugins**: `ui.rerenderFetchedEmails` method (#668)
|
||||||
|
- **Plugins**: `onEmailsFetched` and `onSearchResults` hooks and `getSomeEmails` JMAP method
|
||||||
|
- **Plugins**: `onRecipientChipsChange` hook
|
||||||
|
- **Plugins**: `webauthn.getOrCreate` API method
|
||||||
|
- **Plugins**: Download files generated by a plugin (with `ui:download-file` consent permission)
|
||||||
|
- **Plugins**: Submit mail without moving to a mailbox and import-to-mailbox APIs
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Render the email body on DOM parse instead of iframe load (#635)
|
||||||
|
- **Mail**: Keep sidebar tag counts in step with read/unread changes
|
||||||
|
- **Mail**: Enable thread expansion in the focused list
|
||||||
|
- **Mail**: Show the quote bar in email replies
|
||||||
|
- **Mail**: Honor part-type fallback when quoting replies (#649)
|
||||||
|
- **Mail**: Detect typing inside the quoted-HTML shadow island (#654)
|
||||||
|
- **Mail**: Keep `target`/`rel` on links in plain-text message bodies and open signature links in a new tab
|
||||||
|
- **Accounts**: Eliminate the full-screen flash when switching accounts (including cached accounts)
|
||||||
|
- **Accounts**: Recognize canonicalized login usernames in the account-switch guard
|
||||||
|
- **Auth**: Guard account switch against slot→token desync and basic-auth identity mismatches
|
||||||
|
- **Auth**: End refresh loops on sign-out and back off failed retries
|
||||||
|
- **OAuth**: Harden OIDC discovery (timeout, retry, serve-stale)
|
||||||
|
- **JMAP**: Preserve POST across redirects in the Stalwart JMAP passthrough (#627)
|
||||||
|
- **JMAP**: File the post-send message with a full `mailboxIds` replacement
|
||||||
|
- **JMAP**: Generate the Message-ID client-side using the sender's domain
|
||||||
|
- **Identity**: Sync the default sender identity per account (#507)
|
||||||
|
- **Attachments**: Download/view attachments on cross-account All-Mail messages
|
||||||
|
- **Shared folders**: Route batch actions to the owner account
|
||||||
|
- **Templates**: Insert a mail template at the caret in replies instead of prepending (#539)
|
||||||
|
- **Templates**: Keep the signature when inserting a template (#621)
|
||||||
|
- **Templates**: Hide template buttons when templates are disabled
|
||||||
|
- **Calendar**: Honor "Show time in month view" on mobile instead of forcing dots (#666)
|
||||||
|
- **Calendar**: Classify self-organized imported events as editable
|
||||||
|
- **Contacts**: Assign a UID to contact cards on creation (#644)
|
||||||
|
- **Spam**: Stop HELO `spf=none` from downgrading a MAIL FROM `spf=pass` (#650)
|
||||||
|
- **Drafts**: Label the close-dialog draft button with the generic Save
|
||||||
|
- **RTL**: Flip JS-positioned popovers and anchor floating menus with logical start/end
|
||||||
|
- **RTL**: Isolate Latin address text from RTL bidi reordering and force LTR identity options
|
||||||
|
- **i18n**: Register Arabic messages in the client IntlProvider
|
||||||
|
- **i18n**: Fix the Hebrew Drafts folder label
|
||||||
|
- **i18n**: Add missing translation keys across 22 locales
|
||||||
|
- **Deps**: Bump `dompurify` to 3.4.12 and `next-intl` to 4.13.3
|
||||||
|
|
||||||
|
## 1.7.7 (2026-07-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Plugins**: `ui.rerenderEmail` API and restyled read-receipt banner
|
||||||
|
- **Plugins**: New hooks — `onBeforeBlobUpload`, `onBeforeDraftAutoSave`, `onBeforeEditDraft` (#586)
|
||||||
|
- **Plugins**: `ui.prompt` dialog and first-class settings-section tabs
|
||||||
|
- **Calendar**: Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490)
|
||||||
|
- **i18n**: Hebrew locale with full RTL support
|
||||||
|
- **i18n**: Slovak translation
|
||||||
|
- **i18n**: User-selectable regional date format
|
||||||
|
- **Contacts**: Enable trusted-senders address book sync by default when contacts are available
|
||||||
|
- **Mail**: Pin emails to the top of the folder list
|
||||||
|
- **Mail**: Setting to disable the tag-color row tint in the message list
|
||||||
|
- **Mail**: Click the sender avatar to select a message/thread (Thunderbird-style)
|
||||||
|
- **Accounts**: Pin the default account on top and drag-to-reorder the account switcher
|
||||||
|
- **Composer**: Recipient autocomplete from Sent, with on-demand server search
|
||||||
|
- **Composer**: Preselect the identity of the active mailbox for new messages
|
||||||
|
- **Email**: Send a quick reply with Ctrl/Cmd+Enter
|
||||||
|
- **Headers**: Parse Stalwart spam headers
|
||||||
|
- **Login**: Configurable logo size and hideable heading/subtitle
|
||||||
|
- **PWA**: Apple Touch icons for the iOS home screen
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Hide Files when the account lacks the filenode capability (#563)
|
||||||
|
- **Mail**: Keep advanced search filters applied when switching folders (#553)
|
||||||
|
- **Mail**: Keep the email list scrollable when the bottom reading pane is enabled with no conversation selected
|
||||||
|
- **Mail**: Route keyword writes to the email's own account in unified view
|
||||||
|
- **Mail**: Render emails that set `height:100%` on a wrapper element
|
||||||
|
- **Mail**: Hide images that fail to load
|
||||||
|
- **Mail**: Storage quota not shown with Stalwart (#577)
|
||||||
|
- **Spam**: Hide the spam action in Sent, Drafts and Scheduled
|
||||||
|
- **Spam**: Fix stale folder counters and open message after spam actions
|
||||||
|
- **Composer**: Wait for in-flight attachment uploads before sending
|
||||||
|
- **Composer**: Only commit a recipient on Space when the input is a valid email (#571)
|
||||||
|
- **Composer**: Attachment reminder now ignores quoted text on reply/forward (#570)
|
||||||
|
- **Calendar**: Store the event organizer as owner-only to prevent duplicate ORGANIZER/ATTENDEE
|
||||||
|
- **Calendar**: Strike through cancelled events and mute their reminders (#572)
|
||||||
|
- **Calendar**: Use `calendarAddress`/`organizerCalendarAddress` for scheduling, drop retired `sendTo`/`replyTo` (#500)
|
||||||
|
- **Auth**: Keep the session when the auth server is briefly unreachable
|
||||||
|
- **Shortcuts**: Make keyboard shortcuts layout-agnostic and map by physical position
|
||||||
|
- **Shortcuts**: Don't toggle mailbox subfolders on Arrow keys while typing
|
||||||
|
- **Contacts**: Clear the photo on the server by sending `media: null` when removed
|
||||||
|
- **Plugins**: Preserve the settings slot and privileged tier
|
||||||
|
- **Pro**: Prompt to save or discard a draft when closing a compose tab via the tab-bar X
|
||||||
|
- **Pro**: Show the Edit button on draft emails opened in a new tab
|
||||||
|
- **List**: Shift-click on the checkbox extends the selection (range)
|
||||||
|
- **CSP**: Allow external/data fonts so email webfonts render
|
||||||
|
- **Notifications**: Brand push notifications with the configured PWA icon
|
||||||
|
- **Notifications**: Notification sound preview — base-path prefix and longer default beep
|
||||||
|
- **Unsubscribe**: Send `mailto:` unsubscribe ourselves instead of via the OS handler
|
||||||
|
- **Branding**: Apply per-domain favicon override in root metadata (#585)
|
||||||
|
- **Settings**: Load the trusted-senders address book on the settings page so the count isn't 0
|
||||||
|
- **Setup**: Clone source when `setup.sh` runs detached from a checkout (#518)
|
||||||
|
- **Server**: Use a callable `.get` to detect `Headers` in `pickRequestHost`
|
||||||
|
|
||||||
|
## 1.7.6 (2026-06-28)
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **S/MIME**: The built-in S/MIME implementation has been removed from core and re-delivered through the new generic crypto plugin hooks (privileged same-origin plugin tier). S/MIME signing, encryption, decryption, certificate management, and the related settings UI now live in a plugin rather than the main app. Deployments that relied on built-in S/MIME must install the S/MIME crypto plugin to retain those features.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Plugins**: Privileged same-origin plugin tier with a crypto API surface
|
||||||
|
- **Plugins**: Plugin hooks for email details, headers, and source
|
||||||
|
- **Mail**: Option to hide the total message count on folders (#498)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Hide the server scheduled folder when the virtual one is shown (#495)
|
||||||
|
- **Mail**: Stop the unified mailbox from mutating client-returned email objects
|
||||||
|
- **Composer**: HTML-escape sender and subject in the reply/forward quote header (#482)
|
||||||
|
- **Calendar**: Send calendar invites by setting `organizerCalendarAddress`
|
||||||
|
- **Identity**: Sync the default identity (`preferredPrimaryId`) to server settings (#507)
|
||||||
|
- **Auth**: Support MFA login via the structured auth endpoint
|
||||||
|
- **Admin**: Show all built-in themes in the admin theme controls (#496)
|
||||||
|
- **i18n**: Add missing translation keys across 19 locales
|
||||||
|
|
||||||
|
## 1.7.5 (2026-06-24)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Mail**: Cross-account "All accounts" views with full group/shared-account support
|
||||||
|
- **Mail**: Per-account "All Mail" folder selection
|
||||||
|
- **Mail**: "Download all" button to bundle attachments into a zip (#466)
|
||||||
|
- **Mail**: Return to the list after deleting or marking the open message unread — configurable (default on)
|
||||||
|
- **Mail**: Collapse-all-threads action in thread-list selection
|
||||||
|
- **Calendar**: Option to disable the calendar
|
||||||
|
- **Composer**: Send-now button on scheduled/delayed messages
|
||||||
|
- **Composer**: Email a contact or group via the in-app composer
|
||||||
|
- **Composer**: Split a pasted address list into recipient chips
|
||||||
|
- **Contacts**: "New address book" creation UI (#415)
|
||||||
|
- **OAuth**: `OAUTH_AUTHORIZE_URL` to override the authorize endpoint
|
||||||
|
- **i18n**: Farsi (fa) locale — complete (2654 strings)
|
||||||
|
- **i18n**: Romanian (ro) locale
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Composer**: Keep HTML signature styling in the editor and on send
|
||||||
|
- **Composer**: Guard Send against double-submit
|
||||||
|
- **Composer**: Strip display names from the `EmailSubmission` envelope addresses
|
||||||
|
- **Calendar**: Disable iMIP scheduling on calendar import (#411)
|
||||||
|
- **Mail**: Localize special-folder names by JMAP role (#404)
|
||||||
|
- **Mail**: Block remaining email tracking vectors (#457)
|
||||||
|
- **Mail**: Route counter and unread updates to the email's own account in aggregate views
|
||||||
|
- **Mail**: Fix blank space in plain-text emails
|
||||||
|
- **Mail**: Fix toolbar re-render when opening emails
|
||||||
|
- **Mail**: Truncate long subjects so they don't overlap the timestamp
|
||||||
|
- **Mail**: Strip reply/forward prefixes followed by a full-width colon
|
||||||
|
- **Mail**: Add breathing room between the unread dot and the avatar
|
||||||
|
- **Mail**: Isolate per-account state snapshots from leakage and mutation
|
||||||
|
- **Mail**: Cap filename tokens at the full 200-char limit
|
||||||
|
- **Spam**: Fetch mailboxes with `accountId` in `markAsSpam`
|
||||||
|
- **Filters**: Load mailboxes when opened directly (#485)
|
||||||
|
- **Settings**: Surface server errors on password change and TOTP toggle
|
||||||
|
- **Send now**: Gate the toolbar label and translate `send_now` across locales
|
||||||
|
- **Directory**: Fix fetching display names
|
||||||
|
- **Push**: Reap only relay-confirmed-dead leftover subscriptions
|
||||||
|
- **i18n**: Add the missing fa locale to the client `IntlProvider` messages map
|
||||||
|
- **i18n**: Add missing translation keys across 19 locales
|
||||||
|
|
||||||
|
## 1.7.4 (2026-06-15)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Mail**: New "All Mail" view across folders and accounts
|
||||||
|
- **Mail**: Edit contact directly from the email viewer contact sidebar
|
||||||
|
- **Calendar**: Recurrence editor, set-default calendar, and timezone-aware calendar queries
|
||||||
|
- **Calendar**: Agenda plugin sidecar
|
||||||
|
- **Composer**: Email display name support
|
||||||
|
- **Composer**: Drag-and-drop recipient chips between To/CC/BCC fields, with the address shown in the drag preview
|
||||||
|
- **Composer**: Avatars in recipient autocomplete suggestions, including directory users
|
||||||
|
- **Files**: JMAP file/folder sharing in the Files app (#408)
|
||||||
|
- **Auth**: QR-code SSO login and device pairing between webmail and the mobile app
|
||||||
|
- **Auth**: Require re-authentication for device pairing and SSO
|
||||||
|
- **Accounts**: Manage shared/group account settings from the Accounts page
|
||||||
|
- **Setup**: Opt-in telemetry in the web setup wizard
|
||||||
|
- **Mail**: Persist the email detail sidebar state
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Preserve line breaks in the generated `text/plain` alternative (#421)
|
||||||
|
- **Mail**: Fix inconsistent threading of email messages in the inbox and folders
|
||||||
|
- **Mail**: Stop draft emails from being marked as unread
|
||||||
|
- **Mail**: Prevent wide email tables from rendering with rotated headers (#409)
|
||||||
|
- **Mail**: Preserve the folder list when a mailbox refetch hits the concurrent-request limit
|
||||||
|
- **Mail**: Correct dark-mode background-image inversion and height clipping in the email viewer
|
||||||
|
- **Calendar**: Dedupe scheduling emails and use Stalwart-compatible calendar filters
|
||||||
|
- **Calendar**: Redesign the custom recurrence editor to match the modal UI
|
||||||
|
- **Files**: Don't send the connected-account key as the JMAP `accountId` when sharing files (#408)
|
||||||
|
- **Routing**: Strip the build-time `basePath` from `router.push` redirects after login (#390)
|
||||||
|
- **Nav**: Open recent contact emails at `/` instead of 404ing on `/mail`
|
||||||
|
- **Nav**: Hide the Add App button when `sidebarAppsEnabled` is false
|
||||||
|
- **Settings**: Move the "Plain Text Only" setting from Reading to Composing (#422)
|
||||||
|
- **Privacy**: Make telemetry opt-in
|
||||||
|
- **UI**: Fix the context menu being invisible on first right-click after page load
|
||||||
|
- **Admin**: Remove the JMAP status from the admin dashboard
|
||||||
|
- **i18n**: Add missing translation keys across 17 locales
|
||||||
|
|
||||||
## 1.7.3 (2026-06-04)
|
## 1.7.3 (2026-06-04)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
# at build time, so it cannot be changed without rebuilding.
|
# at build time, so it cannot be changed without rebuilding.
|
||||||
ARG NEXT_PUBLIC_BASE_PATH=
|
ARG NEXT_PUBLIC_BASE_PATH=
|
||||||
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||||
|
# Optional: avoid next-intl rewrite loops when served under a subpath.
|
||||||
|
# Baked in at build time.
|
||||||
|
ARG NEXT_PUBLIC_LOCALE_PREFIX=
|
||||||
|
ENV NEXT_PUBLIC_LOCALE_PREFIX=$NEXT_PUBLIC_LOCALE_PREFIX
|
||||||
# Optional: fallback UI locale (e.g. tr, de, fr) used when the visitor's
|
# Optional: fallback UI locale (e.g. tr, de, fr) used when the visitor's
|
||||||
# Accept-Language header does not match any supported locale. Baked in at
|
# Accept-Language header does not match any supported locale. Baked in at
|
||||||
# build time because next-intl wires it into client-side routing too.
|
# build time because next-intl wires it into client-side routing too.
|
||||||
|
|||||||
+8
-3
@@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
|
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
|
||||||
- Gmail-style threading with inline expansion and an optional conversation toggle
|
- Gmail-style threading with inline expansion and an optional conversation toggle
|
||||||
- Unified mailbox view across all connected accounts
|
- Unified Mailbox – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, scoped by default to the active account and its shared/group folders, with an optional admin-gated cross-account mode that spans every connected account
|
||||||
|
- Aggregated All mail / Unread / Starred entries in the Unified Mailbox – scoped by the same account boundary (or all accounts in cross-account mode) and narrowed by a per-account folder selection; each list labels the source folder of every message
|
||||||
|
- Search inside the Unified Mailbox – text search across every unified view (the per-role mailboxes and the folder-selected All mail / Unread / Starred lists); advanced filters are additionally available in the per-role unified mailboxes
|
||||||
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
||||||
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
|
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
|
||||||
- Attachment upload, download, drag-out to local file system, and inline preview – images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
|
- Attachment upload, download, drag-out to local file system, and inline preview – images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
|
||||||
@@ -69,6 +71,7 @@
|
|||||||
- Grid and list views with sorting by name, size, or date
|
- Grid and list views with sorting by name, size, or date
|
||||||
- Previews for images, text, audio, and video
|
- Previews for images, text, audio, and video
|
||||||
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
||||||
|
- JMAP sharing (RFC 9670) for files and folders – share with users or groups at read, read/write, or manager levels via a principal picker, with share indicators and a "Shared with me" sidebar section for folders other principals have shared with you
|
||||||
|
|
||||||
## Security & Privacy
|
## Security & Privacy
|
||||||
|
|
||||||
@@ -102,7 +105,7 @@
|
|||||||
|
|
||||||
## Internationalization
|
## Internationalization
|
||||||
|
|
||||||
18 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
19 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
||||||
|
|
||||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||||
|
|
||||||
@@ -114,6 +117,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
|||||||
- Configurable signature position (above or below quoted text)
|
- Configurable signature position (above or below quoted text)
|
||||||
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
|
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
|
||||||
- Shared folders across accounts
|
- Shared folders across accounts
|
||||||
|
- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the Unified Mailbox ("Include group inboxes"); their messages are fully actionable there – open, mark read, spam / not-spam, move, delete, and archive – with folder unread counts kept in sync
|
||||||
- Multiple JMAP servers per deployment with optional auto-pick by email domain
|
- Multiple JMAP servers per deployment with optional auto-pick by email domain
|
||||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||||
|
|
||||||
@@ -121,6 +125,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
|||||||
|
|
||||||
- Web setup wizard for first launch – guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
|
- Web setup wizard for first launch – guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
|
||||||
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
|
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
|
||||||
|
- Admin policy gates for the Unified Mailbox – enable or disable the All mail / Unread / Starred entries org-wide, plus a cross-account capability gate (off by default; auto-enabled on upgrade for instances that already used the cross-account views); each gated view still respects the user's own toggle
|
||||||
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
|
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
|
||||||
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
|
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
|
||||||
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
|
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
|
||||||
@@ -135,7 +140,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
|||||||
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots
|
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots
|
||||||
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
|
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
|
||||||
- Structured logging (`text` or `json`) with category-based levels
|
- Structured logging (`text` or `json`) with category-based levels
|
||||||
- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) – version, platform, bucketed account counts, feature toggles only
|
- Anonymous instance telemetry (opt-in via admin UI, the installer, or `BULWARK_TELEMETRY=on`; off by default) – version, platform, bucketed account counts, feature toggles only
|
||||||
- Release (`main`) and development (`dev`) Docker images on GHCR
|
- Release (`main`) and development (`dev`) Docker images on GHCR
|
||||||
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
|
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
|
||||||
- Demo mode with fixture data – no mail server required
|
- Demo mode with fixture data – no mail server required
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
[](https://grafana.external.bulwarkmail.org/)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -81,7 +79,7 @@ The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JM
|
|||||||
|
|
||||||
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
||||||
|
|
||||||
- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
|
- **Mail** – threading, unified inbox, cross-account "All accounts" views, full-text search, Sieve filters, S/MIME, templates
|
||||||
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||||
- **Contacts** – multiple address books, groups, vCard import/export
|
- **Contacts** – multiple address books, groups, vCard import/export
|
||||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||||
|
|||||||
@@ -4,7 +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 { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
import { apiFetch, getPathPrefix, toRouterPath } 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";
|
||||||
@@ -32,6 +32,42 @@ function OAuthCallbackInner() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step-up re-auth for device pairing: the QR generator sent the user here
|
||||||
|
// via prompt=login. Don't create a login session — just confirm the fresh
|
||||||
|
// auth (sets the short-lived pairing proof cookie) and bounce back to the
|
||||||
|
// Security settings, where the QR generation auto-resumes.
|
||||||
|
let pairReauthResume = false;
|
||||||
|
try {
|
||||||
|
pairReauthResume = sessionStorage.getItem("pair_reauth_resume") === "1";
|
||||||
|
} catch { /* sessionStorage unavailable */ }
|
||||||
|
if (pairReauthResume && state) {
|
||||||
|
try { sessionStorage.removeItem("pair_reauth_resume"); } catch { /* ignore */ }
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/auth/reauth/sso/complete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ code, state }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("pair_reauth_done", "1");
|
||||||
|
// Land back on the Security tab (readPersistedTab reads this key).
|
||||||
|
sessionStorage.setItem("settings-deep-link-tab", "security");
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
|
router.push(toRouterPath(`${prefix}/${params.locale}/settings`));
|
||||||
|
} catch {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const savedState = sessionStorage.getItem("oauth_state");
|
const savedState = sessionStorage.getItem("oauth_state");
|
||||||
|
|
||||||
if (savedState) {
|
if (savedState) {
|
||||||
@@ -69,7 +105,7 @@ function OAuthCallbackInner() {
|
|||||||
redirectTo = saved;
|
redirectTo = saved;
|
||||||
}
|
}
|
||||||
} catch { /* sessionStorage may be unavailable */ }
|
} catch { /* sessionStorage may be unavailable */ }
|
||||||
router.push(redirectTo);
|
router.push(toRouterPath(redirectTo));
|
||||||
} else {
|
} else {
|
||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
}
|
}
|
||||||
@@ -153,7 +189,7 @@ function OAuthCallbackInner() {
|
|||||||
redirectTo = saved;
|
redirectTo = saved;
|
||||||
}
|
}
|
||||||
} catch { /* sessionStorage may be unavailable */ }
|
} catch { /* sessionStorage may be unavailable */ }
|
||||||
router.push(redirectTo);
|
router.push(toRouterPath(redirectTo));
|
||||||
} else {
|
} else {
|
||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
}
|
}
|
||||||
@@ -181,7 +217,7 @@ function OAuthCallbackInner() {
|
|||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => router.push(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)}
|
onClick={() => router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`))}
|
||||||
>
|
>
|
||||||
{t("oauth_error.back_to_login")}
|
{t("oauth_error.back_to_login")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -97,6 +98,7 @@ export default function CalendarPage() {
|
|||||||
removeCalendar, clearCalendarEvents,
|
removeCalendar, clearCalendarEvents,
|
||||||
refreshAllSubscriptions, icalSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
|
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||||
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||||
@@ -179,10 +181,13 @@ export default function CalendarPage() {
|
|||||||
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 */ }
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
|
} else if (client && !calendarEnabled) {
|
||||||
|
// Calendar disabled by admin policy - send the user back to mail.
|
||||||
|
router.push("/");
|
||||||
} else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
|
} else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
|
}, [initialCheckDone, isAuthenticated, authLoading, client, calendarEnabled, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -1144,13 +1149,13 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 space-y-3">
|
<div className="px-6 py-4 space-y-3">
|
||||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
|
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
|
||||||
<span className="text-left">
|
<span className="text-start">
|
||||||
<span className="block font-medium">{tWebcalAction("import_title")}</span>
|
<span className="block font-medium">{tWebcalAction("import_title")}</span>
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
|
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
|
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
|
||||||
<span className="text-left">
|
<span className="text-start">
|
||||||
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
|
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
|
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -1164,6 +1169,7 @@ export default function CalendarPage() {
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (!isAuthenticated) return null;
|
if (!isAuthenticated) return null;
|
||||||
|
if (!calendarEnabled) return null;
|
||||||
if (!supportsCalendar) return renderWebcalAccountPicker();
|
if (!supportsCalendar) return renderWebcalAccountPicker();
|
||||||
|
|
||||||
const renderView = () => {
|
const renderView = () => {
|
||||||
@@ -1330,7 +1336,7 @@ export default function CalendarPage() {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
|
"border-e border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
|
||||||
!isResizing && "transition-[width] duration-300",
|
!isResizing && "transition-[width] duration-300",
|
||||||
isNarrow && cn(
|
isNarrow && cn(
|
||||||
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||||
@@ -1469,7 +1475,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{/* Desktop event panel */}
|
{/* Desktop event panel */}
|
||||||
{!isMobile && showEventModal && (
|
{!isMobile && showEventModal && (
|
||||||
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
|
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
|
||||||
<EventModal
|
<EventModal
|
||||||
key={editEvent?.id ?? 'new'}
|
key={editEvent?.id ?? 'new'}
|
||||||
event={editEvent}
|
event={editEvent}
|
||||||
@@ -1492,7 +1498,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{/* Desktop task panel */}
|
{/* Desktop task panel */}
|
||||||
{!isMobile && showTaskModal && (
|
{!isMobile && showTaskModal && (
|
||||||
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
|
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
|
||||||
<TaskModal
|
<TaskModal
|
||||||
key={editTask?.id ?? 'new-task'}
|
key={editTask?.id ?? 'new-task'}
|
||||||
task={editTask}
|
task={editTask}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import { ContactImportDialog } from "@/components/contacts/contact-import-dialog
|
|||||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||||
import { exportContacts } from "@/components/contacts/contact-export";
|
import { exportContacts } from "@/components/contacts/contact-export";
|
||||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||||
|
import { savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||||
|
import { formatRecipient, formatRecipientEntry, type Recipient } from "@/lib/email-composer-utils";
|
||||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
@@ -82,6 +84,7 @@ export default function ContactsPage() {
|
|||||||
bulkDeleteContacts,
|
bulkDeleteContacts,
|
||||||
bulkAddToGroup,
|
bulkAddToGroup,
|
||||||
moveContactToAddressBook,
|
moveContactToAddressBook,
|
||||||
|
createAddressBook,
|
||||||
renameAddressBook,
|
renameAddressBook,
|
||||||
removeAddressBook,
|
removeAddressBook,
|
||||||
shareAddressBook,
|
shareAddressBook,
|
||||||
@@ -93,6 +96,7 @@ export default function ContactsPage() {
|
|||||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||||
|
const [creatingAddressBook, setCreatingAddressBook] = useState(false);
|
||||||
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||||
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
||||||
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
|
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
|
||||||
@@ -173,12 +177,13 @@ export default function ContactsPage() {
|
|||||||
const addEmail = searchParams.get('addEmail');
|
const addEmail = searchParams.get('addEmail');
|
||||||
const addName = searchParams.get('addName');
|
const addName = searchParams.get('addName');
|
||||||
const from = searchParams.get('from');
|
const from = searchParams.get('from');
|
||||||
|
const viewParam = searchParams.get('view');
|
||||||
if (!contactId && !addEmail && !from) return;
|
if (!contactId && !addEmail && !from) return;
|
||||||
intentAppliedRef.current = true;
|
intentAppliedRef.current = true;
|
||||||
if (from === 'email') setReturnToEmail(true);
|
if (from === 'email') setReturnToEmail(true);
|
||||||
if (contactId) {
|
if (contactId) {
|
||||||
setSelectedContact(contactId);
|
setSelectedContact(contactId);
|
||||||
setView('detail');
|
setView(viewParam === 'edit' ? 'edit' : 'detail');
|
||||||
} else if (addEmail) {
|
} else if (addEmail) {
|
||||||
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
|
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
|
||||||
setSelectedContact(null);
|
setSelectedContact(null);
|
||||||
@@ -297,6 +302,34 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
|
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
|
||||||
|
|
||||||
|
// Refresh address books (and contacts) after a structural change, staying
|
||||||
|
// multi-account aware so a freshly created book lands in the sidebar.
|
||||||
|
const refreshAddressBooks = useCallback(async () => {
|
||||||
|
if (!client) return;
|
||||||
|
if (multiAccountEnabled && accountClients.length > 0) {
|
||||||
|
const activeId = useAuthStore.getState().activeAccountId;
|
||||||
|
if (activeId) {
|
||||||
|
const { fetchAllAccountsAddressBooks } = useContactStore.getState();
|
||||||
|
await fetchAllAccountsAddressBooks(accountClients, activeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await useContactStore.getState().fetchAddressBooks(client);
|
||||||
|
}, [client, multiAccountEnabled, accountClients]);
|
||||||
|
|
||||||
|
const handleCreateAddressBook = useCallback(async (name: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
await createAddressBook(client, name);
|
||||||
|
await refreshAddressBooks();
|
||||||
|
toast.success(t("address_books.created"));
|
||||||
|
setCreatingAddressBook(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create address book:', error);
|
||||||
|
toast.error(t("address_books.create_failed"));
|
||||||
|
}
|
||||||
|
}, [client, createAddressBook, refreshAddressBooks, t]);
|
||||||
|
|
||||||
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
||||||
return importContacts(
|
return importContacts(
|
||||||
supportsSync && client ? client : null,
|
supportsSync && client ? client : null,
|
||||||
@@ -367,8 +400,8 @@ export default function ContactsPage() {
|
|||||||
}, [clearSelection, toggleContactSelection, groups.length]);
|
}, [clearSelection, toggleContactSelection, groups.length]);
|
||||||
|
|
||||||
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
|
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
|
||||||
const { id: _id, created: _created, updated: _updated, ...rest } = source;
|
const { id: _id, uid: _uid, created: _created, updated: _updated, ...rest } = source;
|
||||||
void _id; void _created; void _updated;
|
void _id; void _uid; void _created; void _updated;
|
||||||
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
|
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
|
||||||
if (supportsSync && client) {
|
if (supportsSync && client) {
|
||||||
await createContact(client, data);
|
await createContact(client, data);
|
||||||
@@ -460,6 +493,59 @@ export default function ContactsPage() {
|
|||||||
setView("group-edit");
|
setView("group-edit");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Open the in-app composer in the current session rather than routing through
|
||||||
|
// a mailto: URL. `window.location='mailto:'` hands off to the OS handler
|
||||||
|
// (which may open a different mail app), and the mailto protocol round-trip
|
||||||
|
// reloads the app - dropping the in-memory per-account JMAP clients of a
|
||||||
|
// multi-account session, which reads as a logout. Stashing the recipients and
|
||||||
|
// doing a client-side router.push keeps the session and the active account
|
||||||
|
// intact; the main route consumes the pending compose and opens the composer
|
||||||
|
// (see consumePendingMailto in page.tsx).
|
||||||
|
const openComposeInApp = useCallback((recipients: string[], field: "to" | "cc" | "bcc") => {
|
||||||
|
savePendingMailto({
|
||||||
|
to: field === "to" ? recipients : [],
|
||||||
|
cc: field === "cc" ? recipients : [],
|
||||||
|
bcc: field === "bcc" ? recipients : [],
|
||||||
|
subject: "",
|
||||||
|
body: "",
|
||||||
|
});
|
||||||
|
router.push("/");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleComposeGroupFromSidebar = useCallback((groupId: string, field: "to" | "cc" | "bcc") => {
|
||||||
|
// Hand the composer a single group chip (RFC 5322 group syntax survives
|
||||||
|
// the string hand-off) instead of one entry per member - the chip expands
|
||||||
|
// into the members when the message is sent. Dedupe by email,
|
||||||
|
// case-insensitively; members without an email are skipped.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const members: Array<{ name?: string; email: string }> = [];
|
||||||
|
for (const member of getGroupMembers(groupId)) {
|
||||||
|
const email = getContactPrimaryEmail(member).trim();
|
||||||
|
const key = email.toLowerCase();
|
||||||
|
if (!email || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
const name = getContactDisplayName(member);
|
||||||
|
members.push({ name: name && name !== email ? name : undefined, email });
|
||||||
|
}
|
||||||
|
if (members.length === 0) {
|
||||||
|
toast.error(t("groups.no_member_emails"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const group = useContactStore.getState().contacts.find((c) => c.id === groupId);
|
||||||
|
const chip: Recipient = {
|
||||||
|
name: (group && getContactDisplayName(group)) || "Group",
|
||||||
|
email: "",
|
||||||
|
group: { members },
|
||||||
|
};
|
||||||
|
openComposeInApp([formatRecipientEntry(chip)], field);
|
||||||
|
}, [getGroupMembers, t, openComposeInApp]);
|
||||||
|
|
||||||
|
const handleComposeContact = useCallback((contact: ContactCard) => {
|
||||||
|
const email = getContactPrimaryEmail(contact).trim();
|
||||||
|
if (!email) return;
|
||||||
|
openComposeInApp([formatRecipient(getContactDisplayName(contact), email)], "to");
|
||||||
|
}, [openComposeInApp]);
|
||||||
|
|
||||||
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
title: t("groups.delete_confirm_title"),
|
title: t("groups.delete_confirm_title"),
|
||||||
@@ -624,6 +710,7 @@ export default function ContactsPage() {
|
|||||||
onEdit={handleEditGroup}
|
onEdit={handleEditGroup}
|
||||||
onDelete={handleDeleteGroup}
|
onDelete={handleDeleteGroup}
|
||||||
onRemoveMember={handleRemoveGroupMember}
|
onRemoveMember={handleRemoveGroupMember}
|
||||||
|
onComposeGroup={(field) => handleComposeGroupFromSidebar(selectedGroup.id, field)}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onSelectMember={(id) => {
|
onSelectMember={(id) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
@@ -672,7 +759,7 @@ export default function ContactsPage() {
|
|||||||
<button
|
<button
|
||||||
key={group.id}
|
key={group.id}
|
||||||
onClick={() => handleBulkAddToGroupConfirm(group.id)}
|
onClick={() => handleBulkAddToGroupConfirm(group.id)}
|
||||||
className="w-full flex items-center gap-3 px-6 py-3 text-left hover:bg-muted transition-colors"
|
className="w-full flex items-center gap-3 px-6 py-3 text-start hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||||
<Users className="w-4 h-4 text-primary" />
|
<Users className="w-4 h-4 text-primary" />
|
||||||
@@ -701,6 +788,11 @@ export default function ContactsPage() {
|
|||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onCompose={
|
||||||
|
selectedContact
|
||||||
|
? () => handleComposeContact(selectedContact)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onAddToGroup={
|
onAddToGroup={
|
||||||
selectedContact
|
selectedContact
|
||||||
? () => handleAddContactToGroup(selectedContact.id)
|
? () => handleAddContactToGroup(selectedContact.id)
|
||||||
@@ -785,7 +877,7 @@ export default function ContactsPage() {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border flex flex-col flex-shrink-0 bg-background",
|
"border-e border-border flex flex-col flex-shrink-0 bg-background",
|
||||||
!isSidebarResizing && "transition-[width] duration-300",
|
!isSidebarResizing && "transition-[width] duration-300",
|
||||||
isNarrow && cn(
|
isNarrow && cn(
|
||||||
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||||
@@ -803,9 +895,11 @@ export default function ContactsPage() {
|
|||||||
onSelectCategory={handleSelectCategory}
|
onSelectCategory={handleSelectCategory}
|
||||||
onCreateGroup={handleCreateGroup}
|
onCreateGroup={handleCreateGroup}
|
||||||
onCreateContact={handleCreateNew}
|
onCreateContact={handleCreateNew}
|
||||||
|
onCreateAddressBook={client ? () => setCreatingAddressBook(true) : undefined}
|
||||||
onImport={() => setShowImportDialog(true)}
|
onImport={() => setShowImportDialog(true)}
|
||||||
onEditGroup={handleEditGroupFromSidebar}
|
onEditGroup={handleEditGroupFromSidebar}
|
||||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||||
|
onComposeGroup={handleComposeGroupFromSidebar}
|
||||||
onDropContacts={handleDropContacts}
|
onDropContacts={handleDropContacts}
|
||||||
onDropContactsToCategory={handleDropContactsToCategory}
|
onDropContactsToCategory={handleDropContactsToCategory}
|
||||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||||
@@ -851,7 +945,7 @@ export default function ContactsPage() {
|
|||||||
<div
|
<div
|
||||||
data-tour="contacts-list"
|
data-tour="contacts-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-background flex flex-col flex-shrink-0",
|
"border-e border-border bg-background flex flex-col flex-shrink-0",
|
||||||
isMobile ? "w-full" : "",
|
isMobile ? "w-full" : "",
|
||||||
!isListResizing && !isMobile && "transition-[width] duration-300"
|
!isListResizing && !isMobile && "transition-[width] duration-300"
|
||||||
)}
|
)}
|
||||||
@@ -905,7 +999,7 @@ export default function ContactsPage() {
|
|||||||
onClick={mobileBackToList}
|
onClick={mobileBackToList}
|
||||||
className="touch-manipulation"
|
className="touch-manipulation"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 me-2" />
|
||||||
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
|
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -951,6 +1045,15 @@ export default function ContactsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{creatingAddressBook && (
|
||||||
|
<RenameDialog
|
||||||
|
currentName=""
|
||||||
|
title={t("address_books.create")}
|
||||||
|
label={t("address_books.name_label")}
|
||||||
|
onCancel={() => setCreatingAddressBook(false)}
|
||||||
|
onConfirm={handleCreateAddressBook}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{renamingAddressBook && (
|
{renamingAddressBook && (
|
||||||
<RenameDialog
|
<RenameDialog
|
||||||
currentName={renamingAddressBook.name}
|
currentName={renamingAddressBook.name}
|
||||||
|
|||||||
@@ -38,11 +38,11 @@ export default function LocaleError({
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3 justify-center">
|
<div className="flex gap-3 justify-center">
|
||||||
<Button variant="outline" onClick={() => router.push('/')}>
|
<Button variant="outline" onClick={() => router.push('/')}>
|
||||||
<Home className="w-4 h-4 mr-2" />
|
<Home className="w-4 h-4 me-2" />
|
||||||
{t("go_home")}
|
{t("go_home")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={reset}>
|
<Button onClick={reset}>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 me-2" />
|
||||||
{t("try_again")}
|
{t("try_again")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useIsMobile } from "@/hooks/use-media-query";
|
|||||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { FileBrowser } from "@/components/files/file-browser";
|
import { FileBrowser } from "@/components/files/file-browser";
|
||||||
|
import type { FileNodeRights } from "@/lib/jmap/types";
|
||||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||||
@@ -88,6 +89,7 @@ export default function FilesPage() {
|
|||||||
cancelUpload,
|
cancelUpload,
|
||||||
undoLastAction,
|
undoLastAction,
|
||||||
lastAction,
|
lastAction,
|
||||||
|
shareResource,
|
||||||
} = useFileStore();
|
} = useFileStore();
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -401,6 +403,14 @@ export default function FilesPage() {
|
|||||||
|
|
||||||
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
|
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
|
||||||
|
|
||||||
|
// Sharing: the browsing client (store-attached) drives the principal picker
|
||||||
|
// and share mutations. supportsPrincipals() gates the whole Share affordance.
|
||||||
|
const sharingEnabled = !!storeClient?.supportsPrincipals();
|
||||||
|
const filesAccountId = storeClient?.getFilesAccountId() ?? null;
|
||||||
|
const handleShare = useCallback(async (id: string, principalId: string, rights: FileNodeRights | null) => {
|
||||||
|
await shareResource(id, principalId, rights);
|
||||||
|
}, [shareResource]);
|
||||||
|
|
||||||
// Pro shell only: all connected accounts are equal top-level entries at
|
// Pro shell only: all connected accounts are equal top-level entries at
|
||||||
// the root. The root path "/" itself is a cross-account picker - no
|
// the root. The root path "/" itself is a cross-account picker - no
|
||||||
// account's files are shown until the user enters one.
|
// account's files are shown until the user enters one.
|
||||||
@@ -467,7 +477,7 @@ export default function FilesPage() {
|
|||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/")}
|
||||||
className="justify-start"
|
className="justify-start"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 me-2" />
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -540,6 +550,10 @@ export default function FilesPage() {
|
|||||||
onSelectAccount={handleSelectAccount}
|
onSelectAccount={handleSelectAccount}
|
||||||
accountPickerMode={isAccountPicker}
|
accountPickerMode={isAccountPicker}
|
||||||
accountLabel={currentAccountLabel}
|
accountLabel={currentAccountLabel}
|
||||||
|
client={storeClient}
|
||||||
|
ownAccountId={filesAccountId}
|
||||||
|
sharingEnabled={sharingEnabled}
|
||||||
|
onShare={handleShare}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -597,7 +611,7 @@ export default function FilesPage() {
|
|||||||
: '0%' }}
|
: '0%' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-right">
|
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-end">
|
||||||
{migrationProgress.current} / {migrationProgress.total}
|
{migrationProgress.current} / {migrationProgress.total}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useAccountStore } from "@/stores/account-store";
|
|||||||
import { useThemeStore } from "@/stores/theme-store";
|
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 { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||||
import { type OAuthMetadata } from "@/lib/oauth/discovery";
|
import { type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
@@ -133,9 +133,16 @@ export default function LoginPage() {
|
|||||||
const isMobileHandoff = Boolean(mobileRedirectUri);
|
const isMobileHandoff = Boolean(mobileRedirectUri);
|
||||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
|
||||||
|
// Login logo sizing: when a max height/width is configured, drop the fixed
|
||||||
|
// 64×64 box so the logo (e.g. a wide wordmark) can render at its true size.
|
||||||
|
const hasLogoSize = Boolean(loginLogoMaxHeight || loginLogoMaxWidth);
|
||||||
|
const loginLogoStyle = hasLogoSize
|
||||||
|
? { maxHeight: loginLogoMaxHeight || undefined, maxWidth: loginLogoMaxWidth || undefined }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
@@ -279,7 +286,7 @@ export default function LoginPage() {
|
|||||||
redirectTo = saved;
|
redirectTo = saved;
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
router.push(redirectTo);
|
router.push(toRouterPath(redirectTo));
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
|
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
|
||||||
|
|
||||||
@@ -655,7 +662,7 @@ export default function LoginPage() {
|
|||||||
redirectTo = saved;
|
redirectTo = saved;
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
router.push(redirectTo);
|
router.push(toRouterPath(redirectTo));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -718,7 +725,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
<span className="flex-1 text-left">{option.label}</span>
|
<span className="flex-1 text-start">{option.label}</span>
|
||||||
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -815,7 +822,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<VersionBadge />
|
{loginShowVersion && <VersionBadge />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -867,7 +874,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
<span className="flex-1 text-left">{option.label}</span>
|
<span className="flex-1 text-start">{option.label}</span>
|
||||||
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -881,19 +888,24 @@ export default function LoginPage() {
|
|||||||
<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">
|
<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 section with logo */}
|
{/* Header section with logo */}
|
||||||
<div className="px-8 pt-10 pb-6 text-center">
|
<div className="px-8 pt-10 pb-6 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
|
<div className={cn("inline-flex items-center justify-center mb-5", !hasLogoSize && "w-16 h-16")}>
|
||||||
<img
|
<img
|
||||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||||
alt={appName}
|
alt={appName}
|
||||||
className="max-w-16 max-h-16 object-contain"
|
className={cn("object-contain", !hasLogoSize && "max-w-16 max-h-16")}
|
||||||
|
style={loginLogoStyle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
{loginShowHeading && (
|
||||||
{isAddAccountMode ? t("add_account_title") : appName}
|
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||||
</h1>
|
{isAddAccountMode ? t("add_account_title") : appName}
|
||||||
<p className="text-sm text-muted-foreground mt-1.5">
|
</h1>
|
||||||
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
)}
|
||||||
</p>
|
{loginShowSubtitle && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1.5">
|
||||||
|
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form section */}
|
{/* Form section */}
|
||||||
@@ -1121,7 +1133,7 @@ export default function LoginPage() {
|
|||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
className="h-11 px-3.5 pr-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
|
className="h-11 px-3.5 pe-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
|
||||||
placeholder={t("password_placeholder")}
|
placeholder={t("password_placeholder")}
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
@@ -1142,8 +1154,12 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2FA toggle / field */}
|
{/* 2FA toggle / field. The manual toggle can be hidden via
|
||||||
|
LOGIN_SHOW_TOTP (loginShowTotp) for deployments whose mail
|
||||||
|
server has no per-account TOTP (auth delegated to an
|
||||||
|
external directory); server-required TOTP still shows. */}
|
||||||
{!showTotpField ? (
|
{!showTotpField ? (
|
||||||
|
loginShowTotp ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1155,6 +1171,7 @@ export default function LoginPage() {
|
|||||||
<Shield className="w-3.5 h-3.5" />
|
<Shield className="w-3.5 h-3.5" />
|
||||||
{t("totp_toggle")}
|
{t("totp_toggle")}
|
||||||
</button>
|
</button>
|
||||||
|
) : null
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
|
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
|
||||||
@@ -1241,9 +1258,9 @@ export default function LoginPage() {
|
|||||||
disabled={oauthLoading || isLoading}
|
disabled={oauthLoading || isLoading}
|
||||||
>
|
>
|
||||||
{oauthLoading ? (
|
{oauthLoading ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||||
) : (
|
) : (
|
||||||
<LogIn className="w-4 h-4 mr-2" />
|
<LogIn className="w-4 h-4 me-2" />
|
||||||
)}
|
)}
|
||||||
{t("sign_in_sso")}
|
{t("sign_in_sso")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1349,7 +1366,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<VersionBadge />
|
{loginShowVersion && <VersionBadge />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+510
-129
File diff suppressed because it is too large
Load Diff
@@ -139,7 +139,7 @@ export default function ProHome() {
|
|||||||
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
|
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
|
||||||
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
|
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
|
||||||
const openTab = useProTabStore((s) => s.openTab);
|
const openTab = useProTabStore((s) => s.openTab);
|
||||||
const closeTab = useProTabStore((s) => s.closeTab);
|
const requestCloseTab = useProTabStore((s) => s.requestCloseTab);
|
||||||
const setActiveTab = useProTabStore((s) => s.setActiveTab);
|
const setActiveTab = useProTabStore((s) => s.setActiveTab);
|
||||||
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
|
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
|
||||||
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
|
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
|
||||||
@@ -354,7 +354,7 @@ export default function ProHome() {
|
|||||||
activeMainTabId={activeMainTabId}
|
activeMainTabId={activeMainTabId}
|
||||||
activeSplitTabId={activeSplitTabId}
|
activeSplitTabId={activeSplitTabId}
|
||||||
onActivate={setActiveTab}
|
onActivate={setActiveTab}
|
||||||
onClose={closeTab}
|
onClose={requestCloseTab}
|
||||||
onDragStateChange={setIsTabDragging}
|
onDragStateChange={setIsTabDragging}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useState, useEffect, useRef, useMemo, useSyncExternalStore } from 'react';
|
||||||
import { useRouter } from '@/i18n/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import { useTranslations, useMessages } from 'next-intl';
|
import { useTranslations, useMessages } from 'next-intl';
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
Tags,
|
Tags,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
BookUser,
|
BookUser,
|
||||||
KeyRound,
|
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
Bell,
|
Bell,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
@@ -63,17 +62,19 @@ import { AccountSecuritySettings } from '@/components/settings/account-security-
|
|||||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||||
import { DownloadsSettings } from '@/components/settings/downloads-settings';
|
import { DownloadsSettings } from '@/components/settings/downloads-settings';
|
||||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
import { ContactsSettings } from '@/components/settings/contacts-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 { NotificationSettings } from '@/components/settings/notification-settings';
|
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||||
import { PluginsSettings } from '@/components/settings/plugins-settings';
|
import { PluginsSettings } from '@/components/settings/plugins-settings';
|
||||||
|
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||||
|
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||||
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||||
import { useEmailStore } from '@/stores/email-store';
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
import { usePluginStore } from '@/stores/plugin-store';
|
import { usePluginStore } from '@/stores/plugin-store';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { useManagedAccountStore } from '@/stores/managed-account-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';
|
||||||
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||||
@@ -101,7 +102,6 @@ type Tab =
|
|||||||
| 'folders'
|
| 'folders'
|
||||||
| 'keywords'
|
| 'keywords'
|
||||||
| 'security'
|
| 'security'
|
||||||
| 'encryption'
|
|
||||||
| 'content_senders'
|
| 'content_senders'
|
||||||
| 'calendar'
|
| 'calendar'
|
||||||
| 'contacts'
|
| 'contacts'
|
||||||
@@ -115,8 +115,14 @@ type Tab =
|
|||||||
|
|
||||||
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||||
|
|
||||||
|
// A plugin that exposes a `settings-section` slot gets its own first-class
|
||||||
|
// Settings entry, keyed `plugin:<id>`, so its UI (e.g. S/MIME key import) is
|
||||||
|
// discoverable as a menu point rather than buried inside another panel.
|
||||||
|
type PluginTabId = `plugin:${string}`;
|
||||||
|
type SettingsTabId = Tab | PluginTabId;
|
||||||
|
|
||||||
interface TabDef {
|
interface TabDef {
|
||||||
id: Tab;
|
id: SettingsTabId;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
group: TabGroup;
|
group: TabGroup;
|
||||||
@@ -138,7 +144,6 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
|||||||
folders: FolderOpen,
|
folders: FolderOpen,
|
||||||
keywords: Tags,
|
keywords: Tags,
|
||||||
security: Shield,
|
security: Shield,
|
||||||
encryption: KeyRound,
|
|
||||||
content_senders: EyeOff,
|
content_senders: EyeOff,
|
||||||
calendar: Calendar,
|
calendar: Calendar,
|
||||||
contacts: BookUser,
|
contacts: BookUser,
|
||||||
@@ -182,6 +187,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
'settings.appearance.hide_account_switcher',
|
'settings.appearance.hide_account_switcher',
|
||||||
'settings.appearance.show_rail_account_list',
|
'settings.appearance.show_rail_account_list',
|
||||||
'settings.appearance.unified_mailbox',
|
'settings.appearance.unified_mailbox',
|
||||||
|
'settings.appearance.all_mail',
|
||||||
'settings.appearance.colorful_sidebar_icons',
|
'settings.appearance.colorful_sidebar_icons',
|
||||||
'settings.email_behavior.mail_layout',
|
'settings.email_behavior.mail_layout',
|
||||||
],
|
],
|
||||||
@@ -198,11 +204,11 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
'settings.email_behavior.hover_actions',
|
'settings.email_behavior.hover_actions',
|
||||||
'settings.email_behavior.permanently_delete_junk',
|
'settings.email_behavior.permanently_delete_junk',
|
||||||
'settings.email_behavior.show_preview',
|
'settings.email_behavior.show_preview',
|
||||||
'settings.email_behavior.plain_text_mode',
|
|
||||||
],
|
],
|
||||||
composing: [
|
composing: [
|
||||||
'settings.email_behavior.attachment_reminder',
|
'settings.email_behavior.attachment_reminder',
|
||||||
'settings.email_behavior.auto_select_reply_identity',
|
'settings.email_behavior.auto_select_reply_identity',
|
||||||
|
'settings.email_behavior.plain_text_mode',
|
||||||
'settings.email_behavior.default_mail_program',
|
'settings.email_behavior.default_mail_program',
|
||||||
'settings.email_behavior.signature_position',
|
'settings.email_behavior.signature_position',
|
||||||
'settings.email_behavior.sub_address_delimiter',
|
'settings.email_behavior.sub_address_delimiter',
|
||||||
@@ -215,7 +221,6 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
folders: ['settings.folders'],
|
folders: ['settings.folders'],
|
||||||
keywords: ['settings.keywords'],
|
keywords: ['settings.keywords'],
|
||||||
security: ['settings.security'],
|
security: ['settings.security'],
|
||||||
encryption: ['smime'],
|
|
||||||
content_senders: [
|
content_senders: [
|
||||||
'settings.email_behavior.always_light_mode',
|
'settings.email_behavior.always_light_mode',
|
||||||
'settings.email_behavior.external_content',
|
'settings.email_behavior.external_content',
|
||||||
@@ -250,7 +255,6 @@ const tabKeywords: Record<Tab, string> = {
|
|||||||
folders: 'mailbox subscribe',
|
folders: 'mailbox subscribe',
|
||||||
keywords: 'tags labels colors',
|
keywords: 'tags labels colors',
|
||||||
security: 'password 2fa two-factor passkey app password mfa',
|
security: 'password 2fa two-factor passkey app password mfa',
|
||||||
encryption: 's/mime smime certificate pgp gpg',
|
|
||||||
content_senders: 'block sender remote images privacy tracking',
|
content_senders: 'block sender remote images privacy tracking',
|
||||||
calendar: 'event schedule appointment meeting timezone',
|
calendar: 'event schedule appointment meeting timezone',
|
||||||
contacts: 'address book contact',
|
contacts: 'address book contact',
|
||||||
@@ -334,7 +338,7 @@ const LEGACY_TAB_MAP: Record<string, Tab> = {
|
|||||||
advanced: 'about_data',
|
advanced: 'about_data',
|
||||||
};
|
};
|
||||||
|
|
||||||
function readPersistedTab(): Tab {
|
function readPersistedTab(): SettingsTabId {
|
||||||
try {
|
try {
|
||||||
// One-shot deep link from the sidebar section gears (Folders / Tags).
|
// One-shot deep link from the sidebar section gears (Folders / Tags).
|
||||||
// Used only as the initial tab and intentionally NOT written to
|
// Used only as the initial tab and intentionally NOT written to
|
||||||
@@ -342,7 +346,7 @@ function readPersistedTab(): Tab {
|
|||||||
// default that the regular Settings button lands on. Cleared on mount.
|
// default that the regular Settings button lands on. Cleared on mount.
|
||||||
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
|
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
|
||||||
if (deepLink) {
|
if (deepLink) {
|
||||||
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as Tab;
|
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as SettingsTabId;
|
||||||
}
|
}
|
||||||
const saved = localStorage.getItem('settings-active-tab');
|
const saved = localStorage.getItem('settings-active-tab');
|
||||||
if (!saved) return 'appearance';
|
if (!saved) return 'appearance';
|
||||||
@@ -351,7 +355,7 @@ function readPersistedTab(): Tab {
|
|||||||
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
|
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
|
||||||
return migrated;
|
return migrated;
|
||||||
}
|
}
|
||||||
return saved as Tab;
|
return saved as SettingsTabId;
|
||||||
} catch {
|
} catch {
|
||||||
return 'appearance';
|
return 'appearance';
|
||||||
}
|
}
|
||||||
@@ -368,7 +372,15 @@ export default function SettingsPage() {
|
|||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
const { stalwartFeaturesEnabled } = useConfig();
|
const { stalwartFeaturesEnabled } = useConfig();
|
||||||
const { isFeatureEnabled } = usePolicyStore();
|
const { isFeatureEnabled } = usePolicyStore();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
|
const [activeTab, setActiveTab] = useState<SettingsTabId>(readPersistedTab);
|
||||||
|
// Active plugins that expose a `settings-section` slot — each becomes its own
|
||||||
|
// Settings menu entry. Referentially stable per registry mutation, so it is
|
||||||
|
// safe to feed useSyncExternalStore directly.
|
||||||
|
const pluginSettingsOffers = useSyncExternalStore(
|
||||||
|
pluginRegistrySubscribe,
|
||||||
|
() => pluginOffersForSlot('settings-section'),
|
||||||
|
() => pluginOffersForSlot('settings-section'),
|
||||||
|
);
|
||||||
// Consume the one-shot deep-link key so a section gear only steers this one
|
// Consume the one-shot deep-link key so a section gear only steers this one
|
||||||
// open, never the persisted default for future Settings-button clicks.
|
// open, never the persisted default for future Settings-button clicks.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -376,7 +388,7 @@ export default function SettingsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null);
|
const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null);
|
||||||
const isDesktop = useIsDesktop();
|
const isDesktop = useIsDesktop();
|
||||||
|
|
||||||
const messages = useMessages() as Record<string, unknown>;
|
const messages = useMessages() as Record<string, unknown>;
|
||||||
@@ -385,6 +397,12 @@ export default function SettingsPage() {
|
|||||||
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
|
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
|
||||||
const proInterface = useSettingsStore((s) => s.proInterface);
|
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||||
|
|
||||||
|
// When set, the settings panel is scoped to a shared/group account: a reduced
|
||||||
|
// tab list and a "Managing: <name>" header. null = the user's own account.
|
||||||
|
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||||
|
const managedAccount = useManagedAccountStore((s) => s.managedAccount);
|
||||||
|
const clearManagedAccount = useManagedAccountStore((s) => s.clear);
|
||||||
|
|
||||||
// Build a per-tab haystack for fulltext search and a list of sub-results
|
// Build a per-tab haystack for fulltext search and a list of sub-results
|
||||||
// (individual settings) per tab. Sub-results come from translation entries
|
// (individual settings) per tab. Sub-results come from translation entries
|
||||||
// that have a `label`/`title` field, plus dynamic content (installed
|
// that have a `label`/`title` field, plus dynamic content (installed
|
||||||
@@ -488,6 +506,10 @@ export default function SettingsPage() {
|
|||||||
return () => window.removeEventListener('settings-tab-change', handler);
|
return () => window.removeEventListener('settings-tab-change', handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Leaving the settings panel drops any shared-account scope so it never
|
||||||
|
// leaks into the next visit or another session.
|
||||||
|
useEffect(() => () => clearManagedAccount(), [clearManagedAccount]);
|
||||||
|
|
||||||
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 */ }
|
||||||
@@ -611,14 +633,21 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
// Privacy & Security
|
// Privacy & Security
|
||||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
|
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
|
||||||
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
|
|
||||||
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
|
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
|
||||||
|
|
||||||
// Apps
|
// Apps
|
||||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
...(supportsCalendar && isFeatureEnabled('calendarEnabled') ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||||
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
|
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
|
||||||
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||||
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||||
|
// Plugin-contributed settings pages: one entry per active plugin that
|
||||||
|
// offers a `settings-section` slot (e.g. S/MIME key & certificate manager).
|
||||||
|
...pluginSettingsOffers.map((offer): TabDef => ({
|
||||||
|
id: `plugin:${offer.pluginId}` as PluginTabId,
|
||||||
|
label: getActivePlugin(offer.pluginId)?.plugin.name ?? offer.pluginId,
|
||||||
|
icon: Puzzle,
|
||||||
|
group: 'apps',
|
||||||
|
})),
|
||||||
|
|
||||||
// Advanced
|
// Advanced
|
||||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
||||||
@@ -626,12 +655,28 @@ export default function SettingsPage() {
|
|||||||
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// In scoped (shared-account) mode, restrict to the account-relevant tabs the
|
||||||
|
// account actually advertises. Folders is intentionally excluded (mailbox CRUD
|
||||||
|
// is hardwired to the active account). Gated on both the per-account
|
||||||
|
// capability and the session-level support/feature flags.
|
||||||
|
const scopedTabIds: Tab[] = managedAccount
|
||||||
|
? ([
|
||||||
|
managedAccount.capabilities.sieve && supportsSieve ? 'filters' : null,
|
||||||
|
managedAccount.capabilities.mail && supportsVacation ? 'vacation' : null,
|
||||||
|
managedAccount.capabilities.calendars && supportsCalendar && isFeatureEnabled('calendarEnabled') ? 'calendar' : null,
|
||||||
|
managedAccount.capabilities.contacts && isFeatureEnabled('contactsEnabled') ? 'contacts' : null,
|
||||||
|
].filter(Boolean) as Tab[])
|
||||||
|
: [];
|
||||||
|
const visibleTabs = managedAccountId
|
||||||
|
? tabs.filter((tab) => scopedTabIds.includes(tab.id as Tab))
|
||||||
|
: tabs;
|
||||||
|
|
||||||
// Group tabs by category
|
// Group tabs by category
|
||||||
const groupedTabs = tabGroupOrder
|
const groupedTabs = tabGroupOrder
|
||||||
.map((group) => ({
|
.map((group) => ({
|
||||||
group,
|
group,
|
||||||
label: t(`tab_groups.${group}`),
|
label: t(`tab_groups.${group}`),
|
||||||
items: tabs.filter((tab) => tab.group === group),
|
items: visibleTabs.filter((tab) => tab.group === group),
|
||||||
}))
|
}))
|
||||||
.filter((g) => g.items.length > 0);
|
.filter((g) => g.items.length > 0);
|
||||||
|
|
||||||
@@ -639,12 +684,12 @@ export default function SettingsPage() {
|
|||||||
const matchesQuery = (tab: TabDef) => {
|
const matchesQuery = (tab: TabDef) => {
|
||||||
if (!trimmedQuery) return true;
|
if (!trimmedQuery) return true;
|
||||||
if (tab.label.toLowerCase().includes(trimmedQuery)) return true;
|
if (tab.label.toLowerCase().includes(trimmedQuery)) return true;
|
||||||
return tabSearchHaystacks[tab.id]?.includes(trimmedQuery) ?? false;
|
return tabSearchHaystacks[tab.id as Tab]?.includes(trimmedQuery) ?? false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const subResultsForTab = (tabId: Tab): SubResult[] => {
|
const subResultsForTab = (tabId: SettingsTabId): SubResult[] => {
|
||||||
if (!trimmedQuery) return [];
|
if (!trimmedQuery) return [];
|
||||||
const list = tabSubResults[tabId] ?? [];
|
const list = tabSubResults[tabId as Tab] ?? [];
|
||||||
return list
|
return list
|
||||||
.filter((r) =>
|
.filter((r) =>
|
||||||
r.label.toLowerCase().includes(trimmedQuery) ||
|
r.label.toLowerCase().includes(trimmedQuery) ||
|
||||||
@@ -659,11 +704,15 @@ export default function SettingsPage() {
|
|||||||
.filter((g) => g.items.length > 0)
|
.filter((g) => g.items.length > 0)
|
||||||
: groupedTabs;
|
: groupedTabs;
|
||||||
|
|
||||||
// If active tab is not in the visible list (e.g., feature disabled), fall back.
|
// If active tab is not in the visible list (e.g., feature disabled, or scoped
|
||||||
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
|
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
|
||||||
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
|
// otherwise the usual 'appearance' default.
|
||||||
|
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
|
||||||
|
const effectiveActiveTab: SettingsTabId = isActiveVisible
|
||||||
|
? activeTab
|
||||||
|
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
|
||||||
|
|
||||||
const handleTabSelect = (tabId: Tab) => {
|
const handleTabSelect = (tabId: SettingsTabId) => {
|
||||||
setActiveTab(tabId);
|
setActiveTab(tabId);
|
||||||
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
@@ -671,15 +720,31 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubResultSelect = (tabId: Tab, sub: SubResult) => {
|
const handleSubResultSelect = (tabId: SettingsTabId, sub: SubResult) => {
|
||||||
handleTabSelect(tabId);
|
handleTabSelect(tabId);
|
||||||
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
|
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
||||||
|
|
||||||
const renderTabContent = () => (
|
const renderTabContent = () => (
|
||||||
<>
|
<>
|
||||||
|
{managedAccountId && managedAccount && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
clearManagedAccount();
|
||||||
|
handleTabSelect('account');
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-start transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<span className="text-sm text-muted-foreground">{t('scoped.back')}</span>
|
||||||
|
<span className="ms-auto text-sm font-medium truncate">
|
||||||
|
{t('scoped.managing', { name: managedAccount.name })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{effectiveActiveTab === 'account' && <AccountSettings />}
|
{effectiveActiveTab === 'account' && <AccountSettings />}
|
||||||
{effectiveActiveTab === 'language' && <LanguageSettings />}
|
{effectiveActiveTab === 'language' && <LanguageSettings />}
|
||||||
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
|
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
|
||||||
@@ -695,10 +760,17 @@ export default function SettingsPage() {
|
|||||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||||
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
|
|
||||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||||
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
{effectiveActiveTab === 'calendar' && (
|
||||||
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
managedAccountId
|
||||||
|
? <CalendarManagementSettings />
|
||||||
|
: <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>
|
||||||
|
)}
|
||||||
|
{effectiveActiveTab === 'contacts' && (
|
||||||
|
managedAccountId
|
||||||
|
? <AddressBookManagementSettings />
|
||||||
|
: <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>
|
||||||
|
)}
|
||||||
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
||||||
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
||||||
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||||
@@ -706,6 +778,13 @@ export default function SettingsPage() {
|
|||||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||||
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||||
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||||
|
{effectiveActiveTab.startsWith('plugin:') && (
|
||||||
|
<PluginIframeSlot
|
||||||
|
key={effectiveActiveTab}
|
||||||
|
pluginId={effectiveActiveTab.slice('plugin:'.length)}
|
||||||
|
slot="settings-section"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -772,7 +851,7 @@ export default function SettingsPage() {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder={t('search_placeholder')}
|
placeholder={t('search_placeholder')}
|
||||||
className="pl-9 pr-9 h-10"
|
className="ps-9 pe-9 h-10"
|
||||||
aria-label={t('search_placeholder')}
|
aria-label={t('search_placeholder')}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
@@ -820,7 +899,7 @@ export default function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
key={`${tab.id}:${sub.label}`}
|
key={`${tab.id}:${sub.label}`}
|
||||||
onClick={() => handleSubResultSelect(tab.id, sub)}
|
onClick={() => handleSubResultSelect(tab.id, sub)}
|
||||||
className="w-full flex items-center pl-12 pr-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-left"
|
className="w-full flex items-center ps-12 pe-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-start"
|
||||||
>
|
>
|
||||||
<span className="truncate">{sub.label}</span>
|
<span className="truncate">{sub.label}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -884,7 +963,7 @@ export default function SettingsPage() {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-secondary flex flex-col",
|
"border-e border-border bg-secondary flex flex-col",
|
||||||
!isResizing && "transition-[width] duration-300"
|
!isResizing && "transition-[width] duration-300"
|
||||||
)}
|
)}
|
||||||
style={{ width: `${settingsSidebarWidth}px` }}
|
style={{ width: `${settingsSidebarWidth}px` }}
|
||||||
@@ -897,7 +976,7 @@ export default function SettingsPage() {
|
|||||||
onClick={() => router.push('/')}
|
onClick={() => router.push('/')}
|
||||||
className="w-full justify-start"
|
className="w-full justify-start"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 me-2" />
|
||||||
{t('back_to_mail')}
|
{t('back_to_mail')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -912,7 +991,7 @@ export default function SettingsPage() {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder={t('search_placeholder')}
|
placeholder={t('search_placeholder')}
|
||||||
className="pl-8 pr-8 h-9 text-sm"
|
className="ps-8 pe-8 h-9 text-sm"
|
||||||
aria-label={t('search_placeholder')}
|
aria-label={t('search_placeholder')}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
@@ -949,7 +1028,7 @@ export default function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleTabSelect(tab.id)}
|
onClick={() => handleTabSelect(tab.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
effectiveActiveTab === tab.id
|
effectiveActiveTab === tab.id
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'hover:bg-muted text-foreground'
|
: 'hover:bg-muted text-foreground'
|
||||||
@@ -965,7 +1044,7 @@ export default function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
key={`${tab.id}:${sub.label}`}
|
key={`${tab.id}:${sub.label}`}
|
||||||
onClick={() => handleSubResultSelect(tab.id, sub)}
|
onClick={() => handleSubResultSelect(tab.id, sub)}
|
||||||
className="w-full text-left pl-9 pr-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
|
className="w-full text-start ps-9 pe-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<span className="truncate block">{sub.label}</span>
|
<span className="truncate block">{sub.label}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
|
|||||||
Per-server OAuth (optional, overrides global)
|
Per-server OAuth (optional, overrides global)
|
||||||
</button>
|
</button>
|
||||||
{d.oauthExpanded && (
|
{d.oauthExpanded && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pl-4 border-l border-border">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 ps-4 border-s border-border">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
|
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export function DashboardTab() {
|
|||||||
const [themeCount, setThemeCount] = useState(0);
|
const [themeCount, setThemeCount] = useState(0);
|
||||||
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
||||||
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
|
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
|
||||||
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDashboardData();
|
fetchDashboardData();
|
||||||
@@ -82,15 +81,6 @@ export function DashboardTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configData?.jmapServerUrl) {
|
|
||||||
try {
|
|
||||||
const jmapRes = await apiFetch('/api/config');
|
|
||||||
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
|
||||||
} catch {
|
|
||||||
setJmapHealth('error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const w: string[] = [];
|
const w: string[] = [];
|
||||||
if (adminConfigRes.ok) {
|
if (adminConfigRes.ok) {
|
||||||
const sources = await adminConfigRes.json();
|
const sources = await adminConfigRes.json();
|
||||||
@@ -128,16 +118,6 @@ export function DashboardTab() {
|
|||||||
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
||||||
<span className="text-sm text-foreground">{jmapHostname}</span>
|
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||||
</SettingItem>
|
</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">
|
<SettingItem label="Last Login">
|
||||||
<span className="text-sm text-foreground">
|
<span className="text-sm text-foreground">
|
||||||
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
||||||
|
|||||||
@@ -96,10 +96,10 @@ export function LogsTab() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-muted/30">
|
<tr className="border-b border-border bg-muted/30">
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground">Details</th>
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export function MarketplaceTab() {
|
|||||||
placeholder="Search extensions..."
|
placeholder="Search extensions..."
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
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"
|
className="w-full h-9 ps-9 pe-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>
|
||||||
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
|
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
|
||||||
@@ -210,7 +210,7 @@ export function MarketplaceTab() {
|
|||||||
{loading && !error && (
|
{loading && !error && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||||
<span className="ml-2 text-sm text-muted-foreground">Searching extensions...</span>
|
<span className="ms-2 text-sm text-muted-foreground">Searching extensions...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -217,7 +217,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
|||||||
<div key={key}>
|
<div key={key}>
|
||||||
<label className="text-sm font-medium text-foreground block mb-1">
|
<label className="text-sm font-medium text-foreground block mb-1">
|
||||||
{field.label}
|
{field.label}
|
||||||
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
{field.required && <span className="text-destructive ms-0.5">*</span>}
|
||||||
</label>
|
</label>
|
||||||
{field.description && (
|
{field.description && (
|
||||||
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
||||||
@@ -250,7 +250,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
|||||||
value={formValues[key] ?? ''}
|
value={formValues[key] ?? ''}
|
||||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
|
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"
|
className="w-full h-9 px-3 pe-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
|||||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized
|
||||||
|
// forward on policy load), so it is hidden from the admin UI.
|
||||||
|
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled'];
|
||||||
|
|
||||||
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||||
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
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' },
|
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' },
|
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
||||||
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
||||||
|
calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' },
|
||||||
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
||||||
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
||||||
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
||||||
@@ -21,6 +24,10 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
|||||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||||
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||||
|
crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||||
|
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||||
|
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||||
|
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESTRICTABLE_SETTINGS = [
|
const RESTRICTABLE_SETTINGS = [
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function SettingsTab() {
|
|||||||
<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" />
|
<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} />
|
<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') && (
|
{!!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">
|
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
|
||||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
<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.
|
<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>
|
</p>
|
||||||
@@ -145,7 +145,7 @@ export function SettingsTab() {
|
|||||||
onRevert={() => handleRevert('jmapServers')}
|
onRevert={() => handleRevert('jmapServers')}
|
||||||
/>
|
/>
|
||||||
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
|
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
|
||||||
<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">
|
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
|
||||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||||
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <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 browser requests will be blocked.
|
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <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 browser requests will be blocked.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -118,9 +118,10 @@ export function TelemetryTab() {
|
|||||||
<header className="space-y-2">
|
<header className="space-y-2">
|
||||||
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
|
Bulwark can send one anonymous heartbeat per day so we can see how many instances are
|
||||||
running, on what platforms, and which features they use. <strong>Enabled by default</strong>;
|
running, on what platforms, and which features they use. It's <strong>off by
|
||||||
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
|
default</strong>; one click below enables it and helps us make the product better. No
|
||||||
|
email addresses, no hostnames, no IPs are sent.{' '}
|
||||||
<a
|
<a
|
||||||
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
|
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -138,8 +139,8 @@ export function TelemetryTab() {
|
|||||||
<div className="font-medium">Status</div>
|
<div className="font-medium">Status</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
|
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
|
||||||
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
|
{status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!'}
|
||||||
{status.consent === 'off' && 'Heartbeats are off.'}
|
{status.consent === 'off' && 'Heartbeats are off (default).'}
|
||||||
{envOverridden && (
|
{envOverridden && (
|
||||||
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
|
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock,
|
|||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
|
||||||
|
|
||||||
const BUILTIN_THEME_OPTIONS = [
|
// Derive from the single source of truth so newly added built-in themes show
|
||||||
{ id: 'builtin-nord', name: 'Nord' },
|
// up here automatically (was previously a hardcoded subset — see #496).
|
||||||
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
|
const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name }));
|
||||||
{ id: 'builtin-solarized', name: 'Solarized' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ThemeEntry {
|
interface ThemeEntry {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function ChangePasswordPage() {
|
|||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
onChange={e => setCurrentPassword(e.target.value)}
|
onChange={e => setCurrentPassword(e.target.value)}
|
||||||
required
|
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"
|
className="w-full h-9 ps-9 pe-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"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+12
-12
@@ -212,7 +212,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
active
|
active
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'hover:bg-muted text-foreground'
|
: 'hover:bg-muted text-foreground'
|
||||||
@@ -248,7 +248,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
<Link
|
<Link
|
||||||
href="/admin/change-password"
|
href="/admin/change-password"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
pathname === '/admin/change-password'
|
pathname === '/admin/change-password'
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'hover:bg-muted text-foreground'
|
: 'hover:bg-muted text-foreground'
|
||||||
@@ -263,7 +263,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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"
|
className="w-full text-start 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" />
|
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
Sign out
|
Sign out
|
||||||
@@ -275,7 +275,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-background">
|
<div className="min-h-screen flex bg-background">
|
||||||
{/* Slim webmail nav rail (desktop only) */}
|
{/* Slim webmail nav rail (desktop only) */}
|
||||||
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
|
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-e border-border sticky top-0 h-screen shrink-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
||||||
) : (
|
) : (
|
||||||
@@ -326,12 +326,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Admin Sidebar (desktop only) */}
|
{/* Admin Sidebar (desktop only) */}
|
||||||
<aside className="hidden md:flex w-60 border-r border-border bg-secondary flex-col sticky top-0 h-screen">
|
<aside className="hidden md:flex w-60 border-e border-border bg-secondary flex-col sticky top-0 h-screen">
|
||||||
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||||
) : (
|
) : (
|
||||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
<Shield className="w-5 h-5 text-primary me-2" />
|
||||||
)}
|
)}
|
||||||
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,7 +350,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
{/* Mobile drawer */}
|
{/* Mobile drawer */}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-r border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
|
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-e border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
|
||||||
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
)}
|
)}
|
||||||
aria-label="Admin navigation"
|
aria-label="Admin navigation"
|
||||||
@@ -359,9 +359,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
|
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
|
||||||
<div className="flex items-center min-w-0">
|
<div className="flex items-center min-w-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||||
) : (
|
) : (
|
||||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
<Shield className="w-5 h-5 text-primary me-2" />
|
||||||
)}
|
)}
|
||||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,9 +391,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</button>
|
</button>
|
||||||
<div className="flex items-center min-w-0">
|
<div className="flex items-center min-w-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||||
) : (
|
) : (
|
||||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
<Shield className="w-5 h-5 text-primary me-2" />
|
||||||
)}
|
)}
|
||||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export default function MarketplacePreviewPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -512,7 +512,7 @@ export default function MarketplacePreviewPage() {
|
|||||||
<section className="border border-border rounded-lg">
|
<section className="border border-border rounded-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowManifest(v => !v)}
|
onClick={() => setShowManifest(v => !v)}
|
||||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||||
@@ -532,7 +532,7 @@ export default function MarketplacePreviewPage() {
|
|||||||
<section className="border border-border rounded-lg">
|
<section className="border border-border rounded-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSource(v => !v)}
|
onClick={() => setShowSource(v => !v)}
|
||||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function GlobalError({
|
|||||||
onClick={reset}
|
onClick={reset}
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 me-2" />
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+22
-2
@@ -1,9 +1,16 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { getLocaleDirection } from "@/i18n/direction";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { getLocale, getTranslations } from "next-intl/server";
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||||
|
import { FaviconBadge } from "@/components/favicon-badge";
|
||||||
import { configManager } from "@/lib/admin/config-manager";
|
import { configManager } from "@/lib/admin/config-manager";
|
||||||
|
import {
|
||||||
|
matchDomainBranding,
|
||||||
|
parseDomainBranding,
|
||||||
|
pickRequestHost,
|
||||||
|
} from "@/lib/admin/domain-branding";
|
||||||
import { withBasePath } from "@/lib/browser-navigation";
|
import { withBasePath } from "@/lib/browser-navigation";
|
||||||
import { locales } from "@/i18n/routing";
|
import { locales } from "@/i18n/routing";
|
||||||
import "../globals.css";
|
import "../globals.css";
|
||||||
@@ -38,7 +45,19 @@ export const viewport: Viewport = {
|
|||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
// The <head> favicon must honor per-domain branding, exactly like
|
||||||
|
// /api/config, app/manifest.ts, and /api/pwa-icon already do. Resolve the
|
||||||
|
// request host and prefer its override; fall back to the global
|
||||||
|
// admin/env/default value when the host has no favicon override (#585).
|
||||||
|
const host = pickRequestHost(await headers());
|
||||||
|
const domainOverride = matchDomainBranding(
|
||||||
|
host,
|
||||||
|
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
||||||
|
).faviconUrl;
|
||||||
|
const faviconUrl =
|
||||||
|
domainOverride && domainOverride.length > 0
|
||||||
|
? domainOverride
|
||||||
|
: configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
||||||
// Localize the <head> description to match the UI language; a hardcoded
|
// Localize the <head> description to match the UI language; a hardcoded
|
||||||
// English description is another signal that makes Chrome offer to
|
// English description is another signal that makes Chrome offer to
|
||||||
// "translate this page". Resolve the locale from the request path, since this
|
// "translate this page". Resolve the locale from the request path, since this
|
||||||
@@ -76,7 +95,7 @@ export default async function RootLayout({
|
|||||||
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<html lang={locale} dir={getLocaleDirection(locale)} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta name="theme-color" content="#ffffff" />
|
<meta name="theme-color" content="#ffffff" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
@@ -114,6 +133,7 @@ export default async function RootLayout({
|
|||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<ServiceWorkerRegistration />
|
<ServiceWorkerRegistration />
|
||||||
|
<FaviconBadge />
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ interface WizardConfig {
|
|||||||
// Security
|
// Security
|
||||||
sessionSecret: string;
|
sessionSecret: string;
|
||||||
settingsSyncEnabled: boolean;
|
settingsSyncEnabled: boolean;
|
||||||
|
telemetryEnabled: boolean;
|
||||||
// Logging
|
// Logging
|
||||||
logFormat: 'text' | 'json';
|
logFormat: 'text' | 'json';
|
||||||
logLevel: 'error' | 'warn' | 'info' | 'debug';
|
logLevel: 'error' | 'warn' | 'info' | 'debug';
|
||||||
@@ -66,6 +67,7 @@ const EMPTY_CONFIG: WizardConfig = {
|
|||||||
oauthIssuerUrl: '',
|
oauthIssuerUrl: '',
|
||||||
sessionSecret: '',
|
sessionSecret: '',
|
||||||
settingsSyncEnabled: true,
|
settingsSyncEnabled: true,
|
||||||
|
telemetryEnabled: false,
|
||||||
logFormat: 'text',
|
logFormat: 'text',
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
faviconUrl: '',
|
faviconUrl: '',
|
||||||
@@ -778,7 +780,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="mt-3 ml-[3.25rem] flex items-center gap-2 cursor-pointer">
|
<label className="mt-3 ms-[3.25rem] flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={confirmedNonJmap}
|
checked={confirmedNonJmap}
|
||||||
@@ -868,7 +870,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasRowErrors && (
|
{hasRowErrors && (
|
||||||
<ul className="text-xs text-destructive list-disc pl-5 space-y-0.5">
|
<ul className="text-xs text-destructive list-disc ps-5 space-y-0.5">
|
||||||
{rowErrors.map((err, i) => (
|
{rowErrors.map((err, i) => (
|
||||||
<li key={i}>{err}</li>
|
<li key={i}>{err}</li>
|
||||||
))}
|
))}
|
||||||
@@ -1002,8 +1004,11 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const values: Partial<WizardConfig> = {
|
// telemetryConsent is persisted to the telemetry state file by the API,
|
||||||
|
// not to admin config - see app/api/setup/step/route.ts.
|
||||||
|
const values: Record<string, unknown> = {
|
||||||
settingsSyncEnabled: config.settingsSyncEnabled,
|
settingsSyncEnabled: config.settingsSyncEnabled,
|
||||||
|
telemetryConsent: config.telemetryEnabled ? 'on' : 'off',
|
||||||
};
|
};
|
||||||
if (config.sessionSecret) values.sessionSecret = config.sessionSecret;
|
if (config.sessionSecret) values.sessionSecret = config.sessionSecret;
|
||||||
await onNext('security', values);
|
await onNext('security', values);
|
||||||
@@ -1068,6 +1073,15 @@ function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
|
|||||||
hint="Stores user preferences server-side, encrypted with the session secret."
|
hint="Stores user preferences server-side, encrypted with the session secret."
|
||||||
disabled={!config.sessionSecret}
|
disabled={!config.sessionSecret}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||||
|
<Toggle
|
||||||
|
checked={config.telemetryEnabled}
|
||||||
|
onChange={(v) => setConfig({ ...config, telemetryEnabled: v })}
|
||||||
|
label="Send anonymous usage stats to help improve Bulwark"
|
||||||
|
hint="Off by default. One anonymous heartbeat per day with version, platform, and which features are enabled - never email addresses, hostnames, or IPs. You can change this anytime in admin settings."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Footer>
|
<Footer>
|
||||||
<SecondaryButton onClick={onBack}>Back</SecondaryButton>
|
<SecondaryButton onClick={onBack}>Back</SecondaryButton>
|
||||||
<PrimaryButton type="submit" disabled={submitting}>
|
<PrimaryButton type="submit" disabled={submitting}>
|
||||||
@@ -1370,7 +1384,7 @@ function BrandingAsset({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showUrlField && (
|
{showUrlField && (
|
||||||
<div className="mt-3 pl-[4.75rem]">
|
<div className="mt-3 ps-[4.75rem]">
|
||||||
<Input
|
<Input
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -1380,7 +1394,7 @@ function BrandingAsset({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{uploadError && (
|
{uploadError && (
|
||||||
<p className="mt-2 pl-[4.75rem] text-xs text-destructive">{uploadError}</p>
|
<p className="mt-2 ps-[4.75rem] text-xs text-destructive">{uploadError}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1480,6 +1494,7 @@ function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack
|
|||||||
: 'Off'
|
: 'Off'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<SummaryRow label="Anonymous telemetry" value={config.telemetryEnabled ? 'On' : 'Off'} />
|
||||||
</SummaryGroup>
|
</SummaryGroup>
|
||||||
|
|
||||||
<SummaryGroup icon={<FileText className="w-4 h-4" />} title="Logging">
|
<SummaryGroup icon={<FileText className="w-4 h-4" />} title="Logging">
|
||||||
@@ -1586,7 +1601,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono
|
|||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-baseline gap-3 text-sm">
|
<div className="flex justify-between items-baseline gap-3 text-sm">
|
||||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||||
<span className={'text-foreground text-right truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
|
<span className={'text-foreground text-end truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
|
||||||
{value || <span className="text-muted-foreground italic">-</span>}
|
{value || <span className="text-muted-foreground italic">-</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
|
||||||
|
|
||||||
|
// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the
|
||||||
|
// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle
|
||||||
|
// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin
|
||||||
|
// approval) is enforced host-side before this route is ever framed; the page
|
||||||
|
// itself carries no extra privilege.
|
||||||
|
//
|
||||||
|
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
|
||||||
|
// Next's injected hydration/chunk scripts.
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default function PrivilegedPluginSandboxPage() {
|
||||||
|
return <SandboxRuntime />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { JmapRedirectError, fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/account/stalwart/jmap
|
* POST /api/account/stalwart/jmap
|
||||||
@@ -23,14 +24,26 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.text();
|
const body = await request.text();
|
||||||
|
|
||||||
const response = await fetch(`${creds.serverUrl}/jmap/`, {
|
const directUrl = `${creds.serverUrl}/jmap/`;
|
||||||
method: 'POST',
|
let response = await postJmap(directUrl, creds.authHeader, body);
|
||||||
headers: {
|
|
||||||
'Authorization': creds.authHeader,
|
if (response.status === 404) {
|
||||||
'Content-Type': 'application/json',
|
// `${serverUrl}/jmap/` is not the API endpoint on this deployment
|
||||||
},
|
// (path prefix, non-Stalwart URL layout). Resolve the session's
|
||||||
body,
|
// advertised apiUrl on the same host and retry once.
|
||||||
});
|
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader);
|
||||||
|
const apiUrl = rebaseApiUrl(session, creds.serverUrl);
|
||||||
|
if (apiUrl && apiUrl !== directUrl) {
|
||||||
|
response = await postJmap(apiUrl, creds.authHeader, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.warn('Stalwart JMAP passthrough upstream error', {
|
||||||
|
status: response.status,
|
||||||
|
serverUrl: creds.serverUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const responseText = await response.text();
|
const responseText = await response.text();
|
||||||
return new NextResponse(responseText, {
|
return new NextResponse(responseText, {
|
||||||
@@ -38,6 +51,10 @@ export async function POST(request: NextRequest) {
|
|||||||
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof JmapRedirectError) {
|
||||||
|
logger.error('Stalwart JMAP passthrough redirect error', { error: error.message });
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 502 });
|
||||||
|
}
|
||||||
logger.error('Stalwart JMAP passthrough error', {
|
logger.error('Stalwart JMAP passthrough error', {
|
||||||
error: error instanceof Error ? error.message : 'Unknown',
|
error: error instanceof Error ? error.message : 'Unknown',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
invalidateFrameOriginsCache,
|
invalidateFrameOriginsCache,
|
||||||
} from '@/lib/admin/csp-frame-origins';
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS } from '@/lib/plugin-types';
|
||||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
@@ -341,6 +341,7 @@ export async function POST(request: NextRequest) {
|
|||||||
author: (manifest.author as string) || 'Unknown',
|
author: (manifest.author as string) || 'Unknown',
|
||||||
description: (manifest.description as string) || '',
|
description: (manifest.description as string) || '',
|
||||||
type: (manifest.type as string) || 'hook',
|
type: (manifest.type as string) || 'hook',
|
||||||
|
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||||
permissions,
|
permissions,
|
||||||
entrypoint,
|
entrypoint,
|
||||||
enabled: existingPlugin?.enabled ?? true,
|
enabled: existingPlugin?.enabled ?? true,
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export async function POST(request: NextRequest) {
|
|||||||
author: manifest.author as string,
|
author: manifest.author as string,
|
||||||
description: (manifest.description as string) || '',
|
description: (manifest.description as string) || '',
|
||||||
type: manifest.type as string,
|
type: manifest.type as string,
|
||||||
|
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||||
permissions: (manifest.permissions as string[]) || [],
|
permissions: (manifest.permissions as string[]) || [],
|
||||||
entrypoint: manifest.entrypoint as string,
|
entrypoint: manifest.entrypoint as string,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||||
|
import { buildOAuthParams, getRequiredConfig, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||||
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
import { createPairing } from '@/lib/auth/pairing-store';
|
||||||
|
import { hasValidPairReauth } from '@/lib/auth/pair-reauth';
|
||||||
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
|
||||||
|
// Desktop side of the cross-device QR login. The caller must be a signed-in
|
||||||
|
// webmail session (its refresh token lives in the httpOnly jmap_rt cookie). We
|
||||||
|
// refresh that token to (a) prove the session is live and (b) obtain a fresh
|
||||||
|
// access token to hand the phone, then stash the bundle under a one-time
|
||||||
|
// pairing code. The desktop renders the returned code as a QR; the phone
|
||||||
|
// redeems it at /api/auth/pair/redeem.
|
||||||
|
//
|
||||||
|
// Token sharing note: the phone receives the SAME refresh token as the desktop.
|
||||||
|
// That is correct for OAuth servers (such as Stalwart in its default config)
|
||||||
|
// that do not rotate refresh tokens on use. If the server rotates refresh
|
||||||
|
// tokens, the two devices would fight over the latest token — such deployments
|
||||||
|
// should disable rotation for this client or use a token-exchange grant.
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
try {
|
||||||
|
// Step-up gate: minting a pairing code grants new-device access, so it
|
||||||
|
// requires a recent fresh IdP re-authentication (see the reauth SSO flow).
|
||||||
|
// The client turns this 401 into a re-auth redirect, then retries.
|
||||||
|
if (!(await hasValidPairReauth())) {
|
||||||
|
return NextResponse.json({ error: 'reauth_required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const slot =
|
||||||
|
typeof body.slot === 'number' && body.slot >= 0 && body.slot < MAX_ACCOUNT_SLOTS
|
||||||
|
? body.slot
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
|
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||||
|
if (!refreshToken) {
|
||||||
|
return NextResponse.json({ error: 'Not signed in' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
|
||||||
|
|
||||||
|
const tokenEndpoint = await getTokenEndpoint(serverId);
|
||||||
|
const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken }, serverId);
|
||||||
|
|
||||||
|
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.warn('Pair create: refresh failed', { status: tokenResponse.status, error: errorText });
|
||||||
|
// Stale session — clear the dead cookie so the user is prompted to log
|
||||||
|
// back in, mirroring the token route's behaviour.
|
||||||
|
cookieStore.delete(cookieName);
|
||||||
|
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||||
|
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await tokenResponse.json();
|
||||||
|
if (!tokens.access_token) {
|
||||||
|
logger.error('Pair create: refresh response missing access_token');
|
||||||
|
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the server rotated the refresh token, persist the new one back to the
|
||||||
|
// desktop's cookie so this very session keeps working. The phone will get
|
||||||
|
// the same (new) token below.
|
||||||
|
const effectiveRefreshToken = tokens.refresh_token || refreshToken;
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId, serverUrl } = getRequiredConfig(serverId);
|
||||||
|
|
||||||
|
const { code, expiresIn } = createPairing({
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: effectiveRefreshToken,
|
||||||
|
expiresIn: tokens.expires_in,
|
||||||
|
tokenEndpoint,
|
||||||
|
clientId,
|
||||||
|
serverUrl,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ pairing_code: code, server_url: serverUrl, expires_in: expiresIn });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Pair create error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { consumePairing } from '@/lib/auth/pairing-store';
|
||||||
|
|
||||||
|
// Phone side of the cross-device QR login. The app POSTs the pairing code it
|
||||||
|
// scanned; we hand back the OAuth token bundle the desktop stashed at
|
||||||
|
// /api/auth/pair/create. The code is the only credential required — it is
|
||||||
|
// high-entropy, single-use, and expires within ~2 minutes — so this route is
|
||||||
|
// intentionally unauthenticated (the scanning device has no webmail cookies).
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { pairing_code: pairingCode } = await request.json().catch(() => ({}));
|
||||||
|
if (!pairingCode || typeof pairingCode !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing pairing code' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = consumePairing(pairingCode);
|
||||||
|
if (!tokens) {
|
||||||
|
// Unknown, expired, or already redeemed — do not distinguish.
|
||||||
|
return NextResponse.json({ error: 'Invalid or expired pairing code' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
flow: 'oauth',
|
||||||
|
server_url: tokens.serverUrl,
|
||||||
|
access_token: tokens.accessToken,
|
||||||
|
...(tokens.refreshToken ? { refresh_token: tokens.refreshToken } : {}),
|
||||||
|
...(typeof tokens.expiresIn === 'number' ? { expires_in: tokens.expiresIn } : {}),
|
||||||
|
token_endpoint: tokens.tokenEndpoint,
|
||||||
|
client_id: tokens.clientId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Pair redeem error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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 { setPairReauth } from '@/lib/auth/pair-reauth';
|
||||||
|
|
||||||
|
// Completes the step-up re-authentication for device pairing. The user was sent
|
||||||
|
// to the IdP with prompt=login (see /api/auth/sso/start with purpose=reauth);
|
||||||
|
// here we verify the returned code against the pending state and exchange it to
|
||||||
|
// confirm a fresh login actually happened, then set the short-lived pairing
|
||||||
|
// re-auth proof cookie. We deliberately do NOT issue a login session or write
|
||||||
|
// any refresh-token cookies — the user is already signed in; this only proves
|
||||||
|
// recency for the pairing action.
|
||||||
|
|
||||||
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
|
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
|
||||||
|
if (!pendingCookie) {
|
||||||
|
return NextResponse.json({ error: 'No pending re-auth session' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = decryptPayload(pendingCookie);
|
||||||
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
if (!pending) {
|
||||||
|
return NextResponse.json({ error: 'Invalid re-auth session' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only honor pending sessions that were started for the reauth purpose, so
|
||||||
|
// a normal login code can't be redirected into setting a pairing proof.
|
||||||
|
if (pending.purpose !== 'reauth') {
|
||||||
|
return NextResponse.json({ error: 'Not a re-auth session' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (pending.state !== state) {
|
||||||
|
return NextResponse.json({ error: 'State mismatch' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const createdAt = pending.created_at as number;
|
||||||
|
if (!createdAt || Date.now() - createdAt > SSO_PENDING_MAX_AGE_MS) {
|
||||||
|
return NextResponse.json({ error: 'Re-auth session expired' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeVerifier = pending.code_verifier as string;
|
||||||
|
const redirectUri = pending.redirect_uri as string;
|
||||||
|
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
|
||||||
|
if (!codeVerifier || !redirectUri) {
|
||||||
|
return NextResponse.json({ error: 'Invalid re-auth session data' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// A successful exchange proves the user just authenticated at the IdP (the
|
||||||
|
// freshness is enforced by prompt=login on the authorize request). We don't
|
||||||
|
// keep the resulting tokens.
|
||||||
|
await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
||||||
|
|
||||||
|
await setPairReauth();
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
logger.error('Reauth complete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Re-authentication failed' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
|
|||||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||||
@@ -30,8 +31,14 @@ export async function POST(request: NextRequest) {
|
|||||||
server_id: bodyServerId,
|
server_id: bodyServerId,
|
||||||
mobile_redirect_uri: rawMobileRedirectUri,
|
mobile_redirect_uri: rawMobileRedirectUri,
|
||||||
mobile_state: rawMobileState,
|
mobile_state: rawMobileState,
|
||||||
|
purpose: rawPurpose,
|
||||||
} = await request.json();
|
} = await request.json();
|
||||||
|
|
||||||
|
// `reauth` drives the step-up flow for device pairing: it forces a fresh
|
||||||
|
// IdP login (prompt=login) and the /reauth/sso/complete handler sets the
|
||||||
|
// short-lived pairing re-auth proof instead of logging the user in again.
|
||||||
|
const isReauth = rawPurpose === 'reauth';
|
||||||
|
|
||||||
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||||
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -85,6 +92,7 @@ export async function POST(request: NextRequest) {
|
|||||||
...(serverId ? { server_id: serverId } : {}),
|
...(serverId ? { server_id: serverId } : {}),
|
||||||
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
|
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
|
||||||
...(mobileState ? { mobile_state: mobileState } : {}),
|
...(mobileState ? { mobile_state: mobileState } : {}),
|
||||||
|
...(isReauth ? { purpose: 'reauth' } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const encrypted = encryptPayload(pendingData);
|
const encrypted = encryptPayload(pendingData);
|
||||||
@@ -95,8 +103,12 @@ export async function POST(request: NextRequest) {
|
|||||||
maxAge: SSO_PENDING_MAX_AGE,
|
maxAge: SSO_PENDING_MAX_AGE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build authorize URL
|
// Build authorize URL. OAUTH_AUTHORIZE_URL, when set, overrides only the
|
||||||
const authUrl = new URL(metadata.authorization_endpoint);
|
// user-facing authorize endpoint (e.g. a per-brand login host). Discovery,
|
||||||
|
// token exchange and refresh keep using the canonical discovered endpoints.
|
||||||
|
const authorizeOverride =
|
||||||
|
configManager.get<string>('oauthAuthorizeUrl', '') || process.env.OAUTH_AUTHORIZE_URL;
|
||||||
|
const authUrl = new URL(authorizeOverride?.trim() || metadata.authorization_endpoint);
|
||||||
authUrl.searchParams.set('response_type', 'code');
|
authUrl.searchParams.set('response_type', 'code');
|
||||||
authUrl.searchParams.set('client_id', clientId);
|
authUrl.searchParams.set('client_id', clientId);
|
||||||
authUrl.searchParams.set('redirect_uri', redirect_uri);
|
authUrl.searchParams.set('redirect_uri', redirect_uri);
|
||||||
@@ -109,6 +121,14 @@ export async function POST(request: NextRequest) {
|
|||||||
authUrl.searchParams.set('ui_locales', locale);
|
authUrl.searchParams.set('ui_locales', locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force a fresh credential entry for step-up re-auth. prompt=login and
|
||||||
|
// max_age=0 both ask the IdP to re-authenticate even if it has an active
|
||||||
|
// session; honoring them depends on the IdP supporting these OIDC params.
|
||||||
|
if (isReauth) {
|
||||||
|
authUrl.searchParams.set('prompt', 'login');
|
||||||
|
authUrl.searchParams.set('max_age', '0');
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
authorize_url: authUrl.toString(),
|
authorize_url: authUrl.toString(),
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -82,9 +82,16 @@ export async function PUT(request: NextRequest) {
|
|||||||
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(cookieName);
|
// Drop the refresh token only when the server definitively rejected it
|
||||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
// (invalid/expired/revoked grant). A 5xx or 429 is an outage - keeping
|
||||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
// the cookie lets the session resume once the server is back.
|
||||||
|
const status = tokenResponse.status;
|
||||||
|
if (status === 400 || status === 401 || status === 403) {
|
||||||
|
cookieStore.delete(cookieName);
|
||||||
|
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||||
|
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await tokenResponse.json();
|
const tokens = await tokenResponse.json();
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
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 { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
|
||||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
@@ -11,81 +9,173 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
|||||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
||||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
* Exchange a password + (optional) TOTP code for OAuth tokens.
|
||||||
*
|
*
|
||||||
* This allows 2FA users who log in with basic auth + TOTP to upgrade
|
* Stalwart 0.16+ no longer accepts the legacy `password$totp` convention over
|
||||||
* to token-based auth, avoiding session expiry when the TOTP rotates.
|
* HTTP Basic auth: its Basic decoder hardcodes `mfa_token: None` and never
|
||||||
|
* splits the secret on `$`, so any TOTP appended to the password is verified
|
||||||
|
* verbatim against the password hash and fails. The MFA token must instead be
|
||||||
|
* supplied as a distinct field through the structured login endpoint.
|
||||||
*
|
*
|
||||||
* Tries three strategies:
|
* This route drives that flow server-side (avoiding browser CORS against the
|
||||||
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
|
* mail server, same as OAuth discovery):
|
||||||
* 2. ROPC grant without client_id
|
* 1. POST {serverUrl}/api/auth -> authenticate with a separate `mfaToken`,
|
||||||
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
|
* receiving a short-lived authorization `clientCode`.
|
||||||
|
* 2. POST {serverUrl}/auth/token (grant_type=authorization_code) -> exchange
|
||||||
|
* the code (with PKCE) for access/refresh tokens.
|
||||||
|
*
|
||||||
|
* Token-based auth also survives TOTP rotation, unlike basic auth which embeds
|
||||||
|
* the (≈30s) code in every request.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
async function tryTokenRequest(
|
// Fallback OAuth client id used when no client is configured. Stalwart accepts
|
||||||
tokenEndpoint: string,
|
// any client id unless `require_client_registration` is enabled (default off);
|
||||||
params: URLSearchParams,
|
// when it is enabled the admin must configure `oauthClientId` with this
|
||||||
extraHeaders?: Record<string, string>,
|
// redirect URI registered.
|
||||||
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
|
const DEFAULT_CLIENT_ID = 'bulwark-webmail';
|
||||||
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) {
|
interface LoginResult {
|
||||||
const errorText = await response.text();
|
type?: string;
|
||||||
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
|
// The response keeps snake_case: only the LoginResponse variant *tags* are
|
||||||
}
|
// camelCased server-side, not the struct fields (the request fields are).
|
||||||
|
client_code?: string;
|
||||||
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, adminTrusted: boolean): Promise<string | null> {
|
function trimUrl(url: string): string {
|
||||||
// Admin-trusted callers (matched server entry or configured JMAP server URL)
|
return url.replace(/\/+$/, '');
|
||||||
// honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always
|
}
|
||||||
// go through the SSRF validator regardless of the setting.
|
|
||||||
const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl;
|
|
||||||
// 1. Try OAuth discovery
|
|
||||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint });
|
|
||||||
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
|
||||||
|
|
||||||
// 2. Try common Stalwart token endpoint paths directly
|
async function attemptLogin(
|
||||||
const candidates = [
|
upstreamUrl: string,
|
||||||
`${serverUrl}/auth/token`,
|
username: string,
|
||||||
`${serverUrl}/api/oauth/token`,
|
password: string,
|
||||||
];
|
totp: string | undefined,
|
||||||
|
redirectUri: string,
|
||||||
|
slot: number,
|
||||||
|
serverId: string | null,
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
const base = trimUrl(upstreamUrl);
|
||||||
|
|
||||||
for (const url of candidates) {
|
// Per-server OAuth credentials override the global ones when the requested
|
||||||
try {
|
// server entry has its own oauth block configured.
|
||||||
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
|
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||||
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
|
const entry = findServerById(serverList, serverId);
|
||||||
if (probe.status !== 404 && probe.status !== 405) {
|
const clientId = entry?.oauth?.clientId
|
||||||
return url;
|
|| configManager.get<string>('oauthClientId', '')
|
||||||
}
|
|| process.env.OAUTH_CLIENT_ID
|
||||||
} catch {
|
|| DEFAULT_CLIENT_ID;
|
||||||
// Network error - endpoint not reachable
|
const clientSecret = entry?.oauth?.clientSecret
|
||||||
|
|| configManager.get<string>('oauthClientSecret', '')
|
||||||
|
|| process.env.OAUTH_CLIENT_SECRET
|
||||||
|
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE)
|
||||||
|
|| '';
|
||||||
|
|
||||||
|
// PKCE proves the token exchange originates from the same client that
|
||||||
|
// initiated the login, so no client secret is required for public clients.
|
||||||
|
const verifier = generateCodeVerifier();
|
||||||
|
const challenge = await generateCodeChallenge(verifier);
|
||||||
|
|
||||||
|
// Step 1: structured login with a separate MFA token.
|
||||||
|
let login: LoginResult;
|
||||||
|
try {
|
||||||
|
const loginResponse = await fetch(`${base}/api/auth`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'authCode',
|
||||||
|
accountName: username,
|
||||||
|
accountSecret: password,
|
||||||
|
...(totp ? { mfaToken: totp } : {}),
|
||||||
|
clientId,
|
||||||
|
redirectUri,
|
||||||
|
codeChallenge: challenge,
|
||||||
|
codeChallengeMethod: 'S256',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginResponse.ok) {
|
||||||
|
const detail = (await loginResponse.text()).substring(0, 500);
|
||||||
|
logger.warn('TOTP login: /api/auth rejected request', { status: loginResponse.status });
|
||||||
|
// A 404 means the server predates the structured login endpoint; let the
|
||||||
|
// caller fall back to the legacy basic-auth path.
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: loginResponse.status === 404 ? 'login_endpoint_missing' : 'login_failed', detail },
|
||||||
|
{ status: loginResponse.status === 404 ? 404 : 502 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
login = await loginResponse.json();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('TOTP login: /api/auth request failed', { error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return NextResponse.json({ error: 'login_unreachable' }, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
switch (login.type) {
|
||||||
|
case 'authenticated':
|
||||||
|
break;
|
||||||
|
case 'mfaRequired':
|
||||||
|
return NextResponse.json({ error: 'totp_required' }, { status: 401 });
|
||||||
|
case 'failure':
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: 'invalid_credentials' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!login.client_code) {
|
||||||
|
logger.warn('TOTP login: authenticated response missing client_code');
|
||||||
|
return NextResponse.json({ error: 'login_failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: exchange the authorization code for tokens.
|
||||||
|
const tokenParams = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: login.client_code,
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: verifier,
|
||||||
|
});
|
||||||
|
// Confidential clients still send their secret; harmless for public clients.
|
||||||
|
if (clientSecret) tokenParams.set('client_secret', clientSecret);
|
||||||
|
|
||||||
|
let tokens: { access_token?: string; expires_in?: number; refresh_token?: string };
|
||||||
|
try {
|
||||||
|
const tokenResponse = await fetch(`${base}/auth/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: tokenParams.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
const detail = (await tokenResponse.text()).substring(0, 500);
|
||||||
|
logger.warn('TOTP login: token exchange failed', { status: tokenResponse.status, detail });
|
||||||
|
return NextResponse.json({ error: 'token_exchange_failed', detail }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens = await tokenResponse.json();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('TOTP login: token endpoint failed', { error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return NextResponse.json({ error: 'token_exchange_failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokens.access_token) {
|
||||||
|
return NextResponse.json({ error: 'token_exchange_failed', detail: 'Response missing access_token' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('TOTP login succeeded');
|
||||||
|
void recordLogin(username, base);
|
||||||
|
return await storeAndRespond(
|
||||||
|
{ access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token },
|
||||||
|
slot,
|
||||||
|
serverId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { serverUrl, username, password, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
const { serverUrl, username, password, totp, slot: bodySlot, server_id: bodyServerId, redirectUri: bodyRedirectUri } =
|
||||||
|
await request.json();
|
||||||
|
|
||||||
if (!serverUrl || !username || !password) {
|
if (!serverUrl || !username || !password) {
|
||||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
@@ -93,6 +183,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
|
||||||
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||||
|
const totpCode = typeof totp === 'string' && totp ? totp : undefined;
|
||||||
|
|
||||||
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
||||||
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
|
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
|
||||||
@@ -110,20 +201,17 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
let upstreamUrl: string;
|
let upstreamUrl: string;
|
||||||
let resolvedServerId: string | null = null;
|
let resolvedServerId: string | null = null;
|
||||||
let adminTrusted = false;
|
|
||||||
const requestedEntry = findServerById(serverList, requestedServerId);
|
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||||
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||||
|
|
||||||
if (matchedEntry) {
|
if (matchedEntry) {
|
||||||
upstreamUrl = matchedEntry.url;
|
upstreamUrl = matchedEntry.url;
|
||||||
resolvedServerId = matchedEntry.id;
|
resolvedServerId = matchedEntry.id;
|
||||||
adminTrusted = true;
|
|
||||||
} else if (configuredServerUrl) {
|
} else if (configuredServerUrl) {
|
||||||
upstreamUrl = configuredServerUrl;
|
upstreamUrl = configuredServerUrl;
|
||||||
adminTrusted = true;
|
|
||||||
} else if (allowCustomEndpoint) {
|
} else if (allowCustomEndpoint) {
|
||||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
logger.warn('TOTP token exchange: rejected non-public server URL');
|
logger.warn('TOTP login: rejected non-public server URL');
|
||||||
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
|
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
|
||||||
}
|
}
|
||||||
upstreamUrl = serverUrl;
|
upstreamUrl = serverUrl;
|
||||||
@@ -131,100 +219,22 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
|
// The redirect URI must be identical in the login and token-exchange steps,
|
||||||
if (!tokenEndpoint) {
|
// and (when require_client_registration is on) registered for the client.
|
||||||
logger.warn('TOTP token exchange: no token endpoint found');
|
// Prefer the browser-supplied callback URL the OAuth client already uses;
|
||||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
// fall back to the upstream URL so the two steps still agree.
|
||||||
}
|
const redirectUri =
|
||||||
|
typeof bodyRedirectUri === 'string' && /^https?:\/\//.test(bodyRedirectUri)
|
||||||
|
? bodyRedirectUri
|
||||||
|
: trimUrl(upstreamUrl);
|
||||||
|
|
||||||
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot, resolvedServerId);
|
return await attemptLogin(upstreamUrl, username, password, totpCode, redirectUri, slot, resolvedServerId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('TOTP login error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function attemptAllStrategies(
|
|
||||||
tokenEndpoint: string,
|
|
||||||
serverUrl: string,
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
slot: number,
|
|
||||||
serverId: string | null,
|
|
||||||
): Promise<NextResponse> {
|
|
||||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
|
||||||
|
|
||||||
// Per-server OAuth credentials override the global ones when the requested
|
|
||||||
// server entry has its own oauth block configured.
|
|
||||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
|
||||||
const entry = findServerById(serverList, serverId);
|
|
||||||
const clientId = entry?.oauth?.clientId
|
|
||||||
|| configManager.get<string>('oauthClientId', '')
|
|
||||||
|| process.env.OAUTH_CLIENT_ID;
|
|
||||||
const clientSecret = entry?.oauth?.clientSecret
|
|
||||||
|| configManager.get<string>('oauthClientSecret', '')
|
|
||||||
|| process.env.OAUTH_CLIENT_SECRET
|
|
||||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
|
||||||
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)');
|
|
||||||
void recordLogin(username, serverUrl);
|
|
||||||
return await storeAndRespond(result.tokens, slot, serverId);
|
|
||||||
}
|
|
||||||
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)');
|
|
||||||
void recordLogin(username, serverUrl);
|
|
||||||
return await storeAndRespond(result.tokens, slot, serverId);
|
|
||||||
}
|
|
||||||
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)');
|
|
||||||
void recordLogin(username, serverUrl);
|
|
||||||
return await storeAndRespond(result.tokens, slot, serverId);
|
|
||||||
}
|
|
||||||
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)');
|
|
||||||
void recordLogin(username, serverUrl);
|
|
||||||
return await storeAndRespond(result.tokens, slot, serverId);
|
|
||||||
}
|
|
||||||
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(
|
async function storeAndRespond(
|
||||||
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||||
slot: number,
|
slot: number,
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||||
|
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
|
||||||
|
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||||
|
import { parseISO } from 'date-fns';
|
||||||
|
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/calendar-agenda
|
||||||
|
*
|
||||||
|
* Sidecar for the "Calendar Agenda" plugin. Resolves the caller's calendar
|
||||||
|
* account from the stored Stalwart auth context, queries upcoming
|
||||||
|
* CalendarEvents over JMAP, expands recurring series server-side, and returns
|
||||||
|
* a slim, structured-cloneable agenda the plugin can render directly.
|
||||||
|
*
|
||||||
|
* Credentials never leave the server — the sandboxed plugin only ever sees
|
||||||
|
* the resulting agenda DTOs.
|
||||||
|
*
|
||||||
|
* Body: { days?: number (1-90, default 7), limit?: number (1-200, default 50) }
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CALENDAR_CAP = 'urn:ietf:params:jmap:calendars';
|
||||||
|
const PRINCIPALS_CAP = 'urn:ietf:params:jmap:principals';
|
||||||
|
|
||||||
|
// Mirror of lib/jmap/client.ts CALENDAR_EVENT_PROPERTIES, trimmed to what the
|
||||||
|
// agenda actually needs (start/recurrence/display fields).
|
||||||
|
const EVENT_PROPERTIES = [
|
||||||
|
'id', '@type', 'uid', 'calendarIds', 'title', 'start', 'duration', 'timeZone',
|
||||||
|
'showWithoutTime', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', 'color',
|
||||||
|
'locations', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule',
|
||||||
|
'recurrenceOverrides', 'excludedRecurrenceRule',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface AgendaEvent {
|
||||||
|
id: string;
|
||||||
|
uid: string | null;
|
||||||
|
title: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
allDay: boolean;
|
||||||
|
status: string | null;
|
||||||
|
color: string | null;
|
||||||
|
location: string | null;
|
||||||
|
calendarId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure, server-safe equivalents of lib/calendar-utils' getEventStartDate /
|
||||||
|
// getEventEndDate. We can't import that module here because it transitively
|
||||||
|
// pulls in a "use client" calendar component (which throws at load on the
|
||||||
|
// server). Logic mirrors the originals.
|
||||||
|
function parseDurationMinutes(duration: string | undefined): number {
|
||||||
|
if (!duration) return 0;
|
||||||
|
let total = 0;
|
||||||
|
const week = duration.match(/(\d+)W/);
|
||||||
|
const day = duration.match(/(\d+)D/);
|
||||||
|
const hour = duration.match(/(\d+)H/);
|
||||||
|
const min = duration.match(/(\d+)M/);
|
||||||
|
if (week) total += parseInt(week[1], 10) * 7 * 24 * 60;
|
||||||
|
if (day) total += parseInt(day[1], 10) * 24 * 60;
|
||||||
|
if (hour) total += parseInt(hour[1], 10) * 60;
|
||||||
|
if (min) total += parseInt(min[1], 10);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventStart(event: Partial<CalendarEvent>): Date {
|
||||||
|
if (!event.showWithoutTime && event.utcStart) {
|
||||||
|
const utc = parseISO(event.utcStart);
|
||||||
|
if (!isNaN(utc.getTime())) return utc;
|
||||||
|
}
|
||||||
|
return parseISO(event.start as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventEnd(event: Partial<CalendarEvent>): Date {
|
||||||
|
if (!event.showWithoutTime && event.utcEnd) {
|
||||||
|
const utc = parseISO(event.utcEnd);
|
||||||
|
if (!isNaN(utc.getTime())) return utc;
|
||||||
|
}
|
||||||
|
const start = eventStart(event);
|
||||||
|
if (!event.duration) return start;
|
||||||
|
return new Date(start.getTime() + parseDurationMinutes(event.duration) * 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstLocationName(event: Partial<CalendarEvent>): string | null {
|
||||||
|
const locations = event.locations;
|
||||||
|
if (!locations || typeof locations !== 'object') return null;
|
||||||
|
for (const loc of Object.values(locations)) {
|
||||||
|
const name = (loc as { name?: unknown })?.name;
|
||||||
|
if (typeof name === 'string' && name.trim()) return name.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCalendarId(event: Partial<CalendarEvent>): string | null {
|
||||||
|
const ids = event.calendarIds;
|
||||||
|
if (!ids || typeof ids !== 'object') return null;
|
||||||
|
const keys = Object.keys(ids);
|
||||||
|
return keys.length > 0 ? keys[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { days?: unknown; limit?: unknown } = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
/* empty body is fine */
|
||||||
|
}
|
||||||
|
const days = clampInt(body.days, 1, 90, 7);
|
||||||
|
const limit = clampInt(body.limit, 1, 200, 50);
|
||||||
|
|
||||||
|
// ── Resolve the calendar account from the JMAP session ──
|
||||||
|
// Hit Stalwart's canonical session endpoint on the SAME host as serverUrl.
|
||||||
|
// We deliberately avoid /.well-known/jmap: it 301s to the server's
|
||||||
|
// configured public hostname, which the host process may not be able to
|
||||||
|
// resolve (the browser client rewrites those URLs back to the origin for
|
||||||
|
// the same reason). Fall back to /.well-known/jmap for non-Stalwart servers.
|
||||||
|
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'JMAP session fetch failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
const accountId = session.primaryAccounts?.[CALENDAR_CAP];
|
||||||
|
if (!accountId) {
|
||||||
|
// No calendar account for this user — return an empty agenda, not an error.
|
||||||
|
return NextResponse.json({ events: [], generatedAt: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const using = ['urn:ietf:params:jmap:core', CALENDAR_CAP];
|
||||||
|
if (session.capabilities && PRINCIPALS_CAP in session.capabilities) {
|
||||||
|
using.push('urn:ietf:params:jmap:principals:owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send method calls to the session's apiUrl rebased onto serverUrl's host
|
||||||
|
// — never to session.apiUrl's (possibly unreachable) public host.
|
||||||
|
const apiUrl = rebaseApiUrl(session, creds.serverUrl) ?? `${creds.serverUrl}/jmap/`;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
|
// Expand from the start of today so all-day / already-running events still
|
||||||
|
// show in the agenda.
|
||||||
|
const windowStart = new Date(now);
|
||||||
|
windowStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// ── 1) Query event IDs in range + load calendars (colours) ──
|
||||||
|
const queryReq = {
|
||||||
|
using,
|
||||||
|
methodCalls: [
|
||||||
|
[
|
||||||
|
'CalendarEvent/query',
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
// Mirror the app's calendar store: an { after, before } window lets
|
||||||
|
// Stalwart evaluate recurrence so masters with occurrences in range
|
||||||
|
// are returned (a `before`-only filter can drop unbounded series).
|
||||||
|
filter: { after: windowStart.toISOString(), before: horizon.toISOString() },
|
||||||
|
limit: 1000,
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Calendar/get',
|
||||||
|
{ accountId, ids: null, properties: ['id', 'name', 'color'] },
|
||||||
|
'c',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq);
|
||||||
|
const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0');
|
||||||
|
if (!queryResp) {
|
||||||
|
const err = findResponse(queryRes, 'error', '0');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (err?.description as string) || 'CalendarEvent/query failed' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ids = (queryResp.ids as string[]) || [];
|
||||||
|
|
||||||
|
const calColors = new Map<string, { name: string; color: string | null }>();
|
||||||
|
const calResp = findResponse(queryRes, 'Calendar/get', 'c');
|
||||||
|
for (const cal of ((calResp?.list as Array<Record<string, unknown>>) || [])) {
|
||||||
|
if (typeof cal.id === 'string') {
|
||||||
|
calColors.set(cal.id, {
|
||||||
|
name: typeof cal.name === 'string' ? cal.name : '',
|
||||||
|
color: typeof cal.color === 'string' ? cal.color : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return NextResponse.json({ events: [], generatedAt: now.toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2) Fetch full event objects (batched) ──
|
||||||
|
const raw: Array<Record<string, unknown>> = [];
|
||||||
|
const BATCH = 100;
|
||||||
|
for (let i = 0; i < ids.length; i += BATCH) {
|
||||||
|
const batch = ids.slice(i, i + BATCH);
|
||||||
|
const getRes = await jmapPost(apiUrl, creds.authHeader, {
|
||||||
|
using,
|
||||||
|
methodCalls: [
|
||||||
|
['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const getResp = findResponse(getRes, 'CalendarEvent/get', '0');
|
||||||
|
if (getResp?.list) raw.push(...(getResp.list as Array<Record<string, unknown>>));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3) Normalize + expand recurrences server-side ──
|
||||||
|
const normalized = raw
|
||||||
|
.map((e) => normalizeCalendarEventLike(e as Partial<CalendarEvent>))
|
||||||
|
.filter((e) => (e['@type'] ?? 'Event') === 'Event')
|
||||||
|
// Drop malformed events without a parseable start (would crash format()/
|
||||||
|
// parseISO downstream) — mirrors the calendar store guard (#316).
|
||||||
|
.filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[];
|
||||||
|
|
||||||
|
const expanded = expandRecurringEvents(
|
||||||
|
normalized,
|
||||||
|
windowStart.toISOString(),
|
||||||
|
horizon.toISOString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 4) Keep ongoing/upcoming, sort, slice, map to DTOs ──
|
||||||
|
const agenda: AgendaEvent[] = expanded
|
||||||
|
.filter((e) => eventEnd(e).getTime() >= now.getTime())
|
||||||
|
.sort((a, b) => eventStart(a).getTime() - eventStart(b).getTime())
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((e) => {
|
||||||
|
const calId = firstCalendarId(e);
|
||||||
|
const cal = calId ? calColors.get(calId) : undefined;
|
||||||
|
return {
|
||||||
|
id: String(e.id ?? ''),
|
||||||
|
uid: e.uid ?? null,
|
||||||
|
title: (e.title ?? '').trim() || '(no title)',
|
||||||
|
start: eventStart(e).toISOString(),
|
||||||
|
end: eventEnd(e).toISOString(),
|
||||||
|
allDay: !!e.showWithoutTime,
|
||||||
|
status: e.status ?? null,
|
||||||
|
color: e.color || cal?.color || null,
|
||||||
|
location: firstLocationName(e),
|
||||||
|
calendarId: calId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ events: agenda, generatedAt: now.toISOString() });
|
||||||
|
} catch (error) {
|
||||||
|
// `fetch failed` from undici is too generic to debug — the real reason
|
||||||
|
// (ENOTFOUND, ECONNREFUSED, self-signed TLS, …) lives on `error.cause`.
|
||||||
|
const err = error as Error & { cause?: { code?: string; message?: string } };
|
||||||
|
logger.error('Calendar agenda error', {
|
||||||
|
error: err?.message ?? 'Unknown',
|
||||||
|
causeCode: err?.cause?.code,
|
||||||
|
causeMessage: err?.cause?.message,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
||||||
|
const n = typeof value === 'number' ? value : Number(value);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jmapPost(
|
||||||
|
apiUrl: string,
|
||||||
|
authHeader: string,
|
||||||
|
payload: unknown,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const res = await postJmap(apiUrl, authHeader, JSON.stringify(payload));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`JMAP request failed (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findResponse(
|
||||||
|
res: unknown,
|
||||||
|
name: string,
|
||||||
|
callId: string,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const responses = (res as { methodResponses?: unknown[] })?.methodResponses;
|
||||||
|
if (!Array.isArray(responses)) return null;
|
||||||
|
for (const entry of responses) {
|
||||||
|
if (Array.isArray(entry) && entry[0] === name && entry[2] === callId) {
|
||||||
|
return entry[1] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -74,6 +74,12 @@ export async function GET(request: NextRequest) {
|
|||||||
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
||||||
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
||||||
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
|
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
|
||||||
|
loginLogoMaxHeight: configManager.get<string>('loginLogoMaxHeight', ''),
|
||||||
|
loginLogoMaxWidth: configManager.get<string>('loginLogoMaxWidth', ''),
|
||||||
|
loginShowHeading: configManager.get<boolean>('loginShowHeading', true),
|
||||||
|
loginShowSubtitle: configManager.get<boolean>('loginShowSubtitle', true),
|
||||||
|
loginShowTotp: configManager.get<boolean>('loginShowTotp', true),
|
||||||
|
loginShowVersion: configManager.get<boolean>('loginShowVersion', true),
|
||||||
demoMode: configManager.get<boolean>('demoMode', false),
|
demoMode: configManager.get<boolean>('demoMode', false),
|
||||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||||
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||||
|
|||||||
@@ -121,9 +121,9 @@ const emails: MockEmail[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1),
|
id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1),
|
||||||
from: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }],
|
from: [{ name: 'Dubois, Pierre', email: 'pierre@dubois.example' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||||
cc: [{ name: 'Karel de Vries', email: 'karel@devries.example' }],
|
cc: [{ name: 'de Vries, Karel', email: 'karel@devries.example' }],
|
||||||
subject: 'Project Update - Q1 Review',
|
subject: 'Project Update - Q1 Review',
|
||||||
preview: 'Salut team, I wanted to share the latest project numbers. We are on track to meet our targets for Q1.',
|
preview: 'Salut team, I wanted to share the latest project numbers. We are on track to meet our targets for Q1.',
|
||||||
hasAttachment: true,
|
hasAttachment: true,
|
||||||
@@ -1679,7 +1679,8 @@ function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResul
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult {
|
function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult {
|
||||||
return ['Quota/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ resourceType: 'mail', scope: 'mail', used: 52428800, hardLimit: 1073741824 }], notFound: [] }, callId];
|
// mirroring Stalwart: resourceType "octets", scope "account"
|
||||||
|
return ['Quota/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'quota-1', resourceType: 'octets', scope: 'account', types: ['Email', 'SieveScript'], used: 52428800, hardLimit: 1073741824 }], notFound: [] }, callId];
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodResult {
|
function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodResult {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export async function GET() {
|
|||||||
author: p.author,
|
author: p.author,
|
||||||
description: p.description,
|
description: p.description,
|
||||||
type: p.type,
|
type: p.type,
|
||||||
|
// Requested execution tier; clients gate the same-origin privileged
|
||||||
|
// sandbox on this (plus signature + approval + consent).
|
||||||
|
tier: p.tier,
|
||||||
permissions: p.permissions,
|
permissions: p.permissions,
|
||||||
entrypoint: p.entrypoint,
|
entrypoint: p.entrypoint,
|
||||||
// Policy is the canonical source for force-enable. The per-plugin field
|
// Policy is the canonical source for force-enable. The per-plugin field
|
||||||
|
|||||||
@@ -59,10 +59,12 @@ export async function GET(
|
|||||||
domainOverrides.pwaIconUrl ||
|
domainOverrides.pwaIconUrl ||
|
||||||
domainOverrides.faviconUrl ||
|
domainOverrides.faviconUrl ||
|
||||||
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
|
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
|
||||||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
|
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '') ||
|
||||||
if (!iconUrl) {
|
// Fall back to the built-in default so this endpoint ALWAYS returns an app
|
||||||
return new NextResponse('No PWA icon configured', { status: 404 });
|
// icon (custom if configured, else the bundled default). This lets callers
|
||||||
}
|
// that can't run the custom-vs-default check themselves - notably the
|
||||||
|
// service worker's notifications - use a single stable URL.
|
||||||
|
`/icon-${size}x${size}.png`;
|
||||||
|
|
||||||
const pngHeaders = {
|
const pngHeaders = {
|
||||||
'Content-Type': 'image/png',
|
'Content-Type': 'image/png',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { authenticateWizardRequest } from '@/lib/setup/session';
|
|||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
||||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||||
|
import { effectiveConsent, loadState, saveState, reschedule } from '@/lib/telemetry';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -76,8 +77,32 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
|
return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const valuesObj = { ...(values as Record<string, unknown>) };
|
||||||
|
|
||||||
|
// Telemetry consent lives in the telemetry state file, not admin config, so
|
||||||
|
// it has no CONFIG_ENV_MAP entry. Pull it out of the security step and
|
||||||
|
// persist it directly, mirroring POST /api/admin/telemetry (set-consent).
|
||||||
|
if (step === 'security' && 'telemetryConsent' in valuesObj) {
|
||||||
|
const consent = valuesObj.telemetryConsent;
|
||||||
|
delete valuesObj.telemetryConsent;
|
||||||
|
if (consent !== 'on' && consent !== 'off') {
|
||||||
|
return NextResponse.json({ error: 'telemetryConsent must be "on" or "off"' }, { status: 400 });
|
||||||
|
}
|
||||||
|
// A BULWARK_TELEMETRY env var hard-locks the choice; don't fight it.
|
||||||
|
const { source } = await effectiveConsent();
|
||||||
|
if (source !== 'env') {
|
||||||
|
const tstate = await loadState();
|
||||||
|
tstate.consent = consent;
|
||||||
|
if (consent === 'on' && !tstate.consentedAt) {
|
||||||
|
tstate.consentedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
await saveState(tstate);
|
||||||
|
await reschedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
|
for (const [key, value] of Object.entries(valuesObj)) {
|
||||||
if (!allowedKeys.includes(key)) {
|
if (!allowedKeys.includes(key)) {
|
||||||
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
|
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-5
@@ -660,13 +660,13 @@ body {
|
|||||||
|
|
||||||
.tiptap ul {
|
.tiptap ul {
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
padding-left: 1.5rem;
|
padding-inline-start: 1.5rem;
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap ol {
|
.tiptap ol {
|
||||||
list-style-type: decimal;
|
list-style-type: decimal;
|
||||||
padding-left: 1.5rem;
|
padding-inline-start: 1.5rem;
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,8 +675,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap blockquote {
|
.tiptap blockquote {
|
||||||
border-left: 3px solid var(--color-border);
|
border-inline-start: 3px solid var(--color-border);
|
||||||
padding-left: 1rem;
|
padding-inline-start: 1rem;
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
color: var(--color-muted-foreground);
|
color: var(--color-muted-foreground);
|
||||||
}
|
}
|
||||||
@@ -719,7 +719,7 @@ body {
|
|||||||
|
|
||||||
.tiptap p.is-editor-empty:first-child::before {
|
.tiptap p.is-editor-empty:first-child::before {
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
float: left;
|
float: inline-start;
|
||||||
color: var(--color-muted-foreground);
|
color: var(--color-muted-foreground);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
height: 0;
|
height: 0;
|
||||||
@@ -808,3 +808,20 @@ body {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
animation: settings-search-pulse 1.6s ease-in-out forwards;
|
animation: settings-search-pulse 1.6s ease-in-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* RTL: mirror directional icons (chevrons/arrows) so prev/next, back/forward,
|
||||||
|
and panel-collapse affordances point the correct way in right-to-left layouts.
|
||||||
|
lucide-react emits a `lucide-<name>` class per icon, so we target the
|
||||||
|
directional ones only — vertical chevrons (up/down) are intentionally left. */
|
||||||
|
[dir="rtl"] .lucide-chevron-left,
|
||||||
|
[dir="rtl"] .lucide-chevron-right,
|
||||||
|
[dir="rtl"] .lucide-chevrons-left,
|
||||||
|
[dir="rtl"] .lucide-chevrons-right,
|
||||||
|
[dir="rtl"] .lucide-arrow-left,
|
||||||
|
[dir="rtl"] .lucide-arrow-right,
|
||||||
|
[dir="rtl"] .lucide-arrow-big-left,
|
||||||
|
[dir="rtl"] .lucide-arrow-big-right,
|
||||||
|
[dir="rtl"] .lucide-panel-left,
|
||||||
|
[dir="rtl"] .lucide-panel-right {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { FaviconBadge } from '@/components/favicon-badge';
|
||||||
|
import { useFaviconBadge } from '@/hooks/use-favicon-badge';
|
||||||
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import type { Mailbox } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
vi.mock('@/hooks/use-favicon-badge', () => ({
|
||||||
|
useFaviconBadge: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const useFaviconBadgeMock = vi.mocked(useFaviconBadge);
|
||||||
|
|
||||||
|
function mailbox(patch: Partial<Mailbox> & { id: string }): Mailbox {
|
||||||
|
return {
|
||||||
|
name: patch.id,
|
||||||
|
sortOrder: 0,
|
||||||
|
totalEmails: 0,
|
||||||
|
unreadEmails: 0,
|
||||||
|
totalThreads: 0,
|
||||||
|
unreadThreads: 0,
|
||||||
|
isSubscribed: true,
|
||||||
|
myRights: {
|
||||||
|
mayReadItems: true,
|
||||||
|
mayAddItems: true,
|
||||||
|
mayRemoveItems: true,
|
||||||
|
maySetSeen: true,
|
||||||
|
maySetKeywords: true,
|
||||||
|
mayCreateChild: true,
|
||||||
|
mayRename: true,
|
||||||
|
mayDelete: true,
|
||||||
|
maySubmit: true,
|
||||||
|
},
|
||||||
|
...patch,
|
||||||
|
} as Mailbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialMailboxes = useEmailStore.getState().mailboxes;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useEmailStore.setState({ mailboxes: initialMailboxes });
|
||||||
|
useSettingsStore.setState({ faviconUnreadBadge: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useEmailStore.setState({ mailboxes: initialMailboxes });
|
||||||
|
useSettingsStore.setState({ faviconUnreadBadge: true });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FaviconBadge', () => {
|
||||||
|
it('badges the unread count of the primary inbox', () => {
|
||||||
|
useEmailStore.setState({
|
||||||
|
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<FaviconBadge />);
|
||||||
|
|
||||||
|
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, true);
|
||||||
|
expect(container.firstChild).toBeNull(); // renders no markup
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the badge when the setting is off', () => {
|
||||||
|
useSettingsStore.setState({ faviconUnreadBadge: false });
|
||||||
|
useEmailStore.setState({
|
||||||
|
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<FaviconBadge />);
|
||||||
|
|
||||||
|
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a shared inbox, even when it sorts first', () => {
|
||||||
|
// Shared and group inboxes ship in the same `mailboxes` array. A plain
|
||||||
|
// `role === 'inbox'` lookup would badge somebody else's inbox on a
|
||||||
|
// delegated setup, so the store's canonical `!isShared` filter is required.
|
||||||
|
useEmailStore.setState({
|
||||||
|
mailboxes: [
|
||||||
|
mailbox({ id: 'shared', role: 'inbox', isShared: true, unreadEmails: 99 }),
|
||||||
|
mailbox({ id: 'mine', role: 'inbox', unreadEmails: 4 }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<FaviconBadge />);
|
||||||
|
|
||||||
|
expect(useFaviconBadgeMock).toHaveBeenCalledWith(4, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('badges zero when there is no inbox yet', () => {
|
||||||
|
useEmailStore.setState({ mailboxes: [] });
|
||||||
|
|
||||||
|
render(<FaviconBadge />);
|
||||||
|
|
||||||
|
expect(useFaviconBadgeMock).toHaveBeenCalledWith(0, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import { useTranslations, useFormatter } from "next-intl";
|
|||||||
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
|
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
|
||||||
import { 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 { getEventColor } from "./event-card";
|
||||||
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } 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";
|
||||||
@@ -131,7 +131,7 @@ export function CalendarAgendaView({
|
|||||||
)}>
|
)}>
|
||||||
{formatDateHeader(group.date)}
|
{formatDateHeader(group.date)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground ml-2">
|
<span className="text-xs text-muted-foreground ms-2">
|
||||||
{intlFormatter.dateTime(group.date, { month: "short", day: "numeric", year: "numeric" })}
|
{intlFormatter.dateTime(group.date, { month: "short", day: "numeric", year: "numeric" })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,8 +147,10 @@ export function CalendarAgendaView({
|
|||||||
const calendar = calId ? calendarMap.get(calId) : undefined;
|
const calendar = calId ? calendarMap.get(calId) : undefined;
|
||||||
const color = getEventColor(ev, calendar);
|
const color = getEventColor(ev, calendar);
|
||||||
const start = getEventStartDate(ev);
|
const start = getEventStartDate(ev);
|
||||||
const durMin = parseDuration(ev.duration);
|
|
||||||
const end = getEventEndDate(ev);
|
const end = getEventEndDate(ev);
|
||||||
|
// iTIP CANCEL marks the attendee's copy with status "cancelled"
|
||||||
|
// instead of deleting it (#572).
|
||||||
|
const isCancelled = ev.status === "cancelled";
|
||||||
const locationName = ev.locations
|
const locationName = ev.locations
|
||||||
? Object.values(ev.locations)[0]?.name
|
? Object.values(ev.locations)[0]?.name
|
||||||
: null;
|
: null;
|
||||||
@@ -160,7 +162,10 @@ export function CalendarAgendaView({
|
|||||||
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
||||||
onMouseLeave={() => onHoverLeave?.()}
|
onMouseLeave={() => onHoverLeave?.()}
|
||||||
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
|
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
|
||||||
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
|
className={cn(
|
||||||
|
"w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-start",
|
||||||
|
isCancelled && "opacity-60"
|
||||||
|
)}
|
||||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
|
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
|
||||||
@@ -182,7 +187,7 @@ export function CalendarAgendaView({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate">
|
<div className={cn("text-sm font-medium truncate", isCancelled && "line-through")}>
|
||||||
{ev.title || t("events.no_title")}
|
{ev.title || t("events.no_title")}
|
||||||
</div>
|
</div>
|
||||||
{locationName && (
|
{locationName && (
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export function CalendarDayView({
|
|||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
className="relative text-muted-foreground text-right pr-2"
|
className="relative text-muted-foreground text-end pe-2"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
{h > 0 && (
|
{h > 0 && (
|
||||||
@@ -231,7 +231,7 @@ export function CalendarDayView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 relative border-l border-border"
|
className="flex-1 relative border-s border-border"
|
||||||
role="row"
|
role="row"
|
||||||
aria-label={t("views.day")}
|
aria-label={t("views.day")}
|
||||||
onPointerDown={(e) => handleGridPointerDown(e, dayKey, selectedDate)}
|
onPointerDown={(e) => handleGridPointerDown(e, dayKey, selectedDate)}
|
||||||
@@ -312,7 +312,7 @@ 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-destructive -ml-1" />
|
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ms-1" />
|
||||||
<div className="flex-1 h-px bg-destructive" />
|
<div className="flex-1 h-px bg-destructive" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,7 +346,7 @@ export function CalendarDayView({
|
|||||||
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-primary -ml-1" />
|
<div className="w-2.5 h-2.5 rounded-full bg-primary -ms-1" />
|
||||||
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useCallback, type DragEvent } from "react";
|
import { useMemo, useState, useCallback, type DragEvent } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import { format, parseISO } from "date-fns";
|
||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
|
||||||
eachDayOfInterval, isSameDay, isSameMonth, isToday, format, parseISO,
|
|
||||||
} 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, getPrimaryCalendarId } 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 { useSettingsStore } from "@/stores/settings-store";
|
||||||
import type { PendingEventPreview } from "./event-modal";
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||||
|
|
||||||
interface CalendarMonthViewProps {
|
interface CalendarMonthViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -47,16 +46,28 @@ export function CalendarMonthView({
|
|||||||
pendingPreview,
|
pendingPreview,
|
||||||
}: CalendarMonthViewProps) {
|
}: CalendarMonthViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
// On mobile the month view collapses events to dots unless the user opted
|
||||||
|
// into full entries via "Show time in month view" (#666).
|
||||||
|
const showChips = !isMobile || showTimeInMonthView;
|
||||||
|
const overlayTop = isMobile ? 34 : 30;
|
||||||
|
const rowHeight = isMobile ? 18 : 22;
|
||||||
|
const chipHeight = rowHeight - 2;
|
||||||
|
const {
|
||||||
|
weekStartsOn,
|
||||||
|
dayHeaderKeys,
|
||||||
|
getMonthGridDays,
|
||||||
|
checkIsToday,
|
||||||
|
checkIsSameMonth,
|
||||||
|
checkIsSameDay,
|
||||||
|
formatDayNumber,
|
||||||
|
formatFullDate,
|
||||||
|
} = useCalendarLocale();
|
||||||
|
|
||||||
const days = useMemo(() => {
|
const days = useMemo(
|
||||||
const monthStart = startOfMonth(selectedDate);
|
() => getMonthGridDays(selectedDate),
|
||||||
const monthEnd = endOfMonth(selectedDate);
|
[selectedDate, getMonthGridDays],
|
||||||
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
|
);
|
||||||
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
|
|
||||||
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
|
||||||
}, [selectedDate, weekStart]);
|
|
||||||
|
|
||||||
const calendarMap = useMemo(() => {
|
const calendarMap = useMemo(() => {
|
||||||
const map = new Map<string, Calendar>();
|
const map = new Map<string, Calendar>();
|
||||||
@@ -83,10 +94,6 @@ export function CalendarMonthView({
|
|||||||
return map;
|
return map;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
const dayHeaders = firstDayOfWeek === 0
|
|
||||||
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
|
|
||||||
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
|
||||||
|
|
||||||
const weeks = useMemo(() => {
|
const weeks = useMemo(() => {
|
||||||
const result: Date[][] = [];
|
const result: Date[][] = [];
|
||||||
for (let i = 0; i < days.length; i += 7) {
|
for (let i = 0; i < days.length; i += 7) {
|
||||||
@@ -141,11 +148,11 @@ export function CalendarMonthView({
|
|||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { month: "long", year: "numeric" })}>
|
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={formatFullDate(selectedDate)}>
|
||||||
<div className="grid grid-cols-7 border-b border-border" role="row">
|
<div className="grid grid-cols-7 border-b border-border" role="row">
|
||||||
{dayHeaders.map((d) => (
|
{dayHeaderKeys.map((d) => (
|
||||||
<div key={d} role="columnheader" className={cn(
|
<div key={d} role="columnheader" className={cn(
|
||||||
"text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0",
|
"text-center text-xs font-medium text-muted-foreground py-2 border-e border-border last:border-e-0",
|
||||||
isMobile && "py-1.5 text-[11px]"
|
isMobile && "py-1.5 text-[11px]"
|
||||||
)}>
|
)}>
|
||||||
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
|
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
|
||||||
@@ -158,15 +165,15 @@ export function CalendarMonthView({
|
|||||||
<div key={wi} className={cn(
|
<div key={wi} className={cn(
|
||||||
"relative flex-1 border-b border-border last:border-b-0",
|
"relative flex-1 border-b border-border last:border-b-0",
|
||||||
isMobile ? "min-h-[52px]" : "min-h-[100px]"
|
isMobile ? "min-h-[52px]" : "min-h-[100px]"
|
||||||
)} role="row" style={isMobile ? undefined : { minHeight: Math.max(100, 34 + rowCount * 22 + 8) }}>
|
)} role="row" style={showChips ? { minHeight: Math.max(isMobile ? 52 : 100, overlayTop + 4 + rowCount * rowHeight + 8) } : undefined}>
|
||||||
<div className="grid grid-cols-7 h-full">
|
<div className="grid grid-cols-7 h-full">
|
||||||
{week.map((day) => {
|
{week.map((day) => {
|
||||||
const inMonth = isSameMonth(day, selectedDate);
|
const inMonth = checkIsSameMonth(day, selectedDate);
|
||||||
const selected = isSameDay(day, selectedDate);
|
const selected = checkIsSameDay(day, selectedDate);
|
||||||
const today = isToday(day);
|
const today = checkIsToday(day);
|
||||||
const key = format(day, "yyyy-MM-dd");
|
const key = format(day, "yyyy-MM-dd");
|
||||||
const dayEvents = eventsByDate.get(key) || [];
|
const dayEvents = eventsByDate.get(key) || [];
|
||||||
const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
const fullDateLabel = formatFullDate(day);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -181,7 +188,7 @@ export function CalendarMonthView({
|
|||||||
onDragLeave={handleCellDragLeave}
|
onDragLeave={handleCellDragLeave}
|
||||||
onDrop={(e) => handleCellDrop(e, day)}
|
onDrop={(e) => handleCellDrop(e, day)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors touch-manipulation",
|
"border-e border-border last:border-e-0 p-1 cursor-pointer transition-colors touch-manipulation",
|
||||||
!inMonth && "bg-muted/30",
|
!inMonth && "bg-muted/30",
|
||||||
"hover:bg-muted/50",
|
"hover:bg-muted/50",
|
||||||
selected && isMobile && "bg-primary/10",
|
selected && isMobile && "bg-primary/10",
|
||||||
@@ -199,10 +206,10 @@ export function CalendarMonthView({
|
|||||||
inMonth && !selected && !today && "font-medium"
|
inMonth && !selected && !today && "font-medium"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{format(day, "d")}
|
{formatDayNumber(day)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isMobile ? (
|
{isMobile && !showChips ? (
|
||||||
<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 = getPrimaryCalendarId(ev);
|
||||||
@@ -219,7 +226,7 @@ export function CalendarMonthView({
|
|||||||
{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) && (
|
{pendingPreview && checkIsSameDay(pendingPreview.start, day) && (
|
||||||
<span
|
<span
|
||||||
className="w-1.5 h-1.5 rounded-full border border-dashed"
|
className="w-1.5 h-1.5 rounded-full border border-dashed"
|
||||||
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
|
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
|
||||||
@@ -232,25 +239,28 @@ export function CalendarMonthView({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isMobile && pendingPreview && (() => {
|
{showChips && pendingPreview && (() => {
|
||||||
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start));
|
const previewDayIdx = week.findIndex(d => checkIsSameDay(d, pendingPreview.start));
|
||||||
if (previewDayIdx === -1) return null;
|
if (previewDayIdx === -1) return null;
|
||||||
const previewRow = rowCount;
|
const previewRow = rowCount;
|
||||||
const cal = calendarMap.get(pendingPreview.calendarId);
|
const cal = calendarMap.get(pendingPreview.calendarId);
|
||||||
const color = cal?.color || "#3b82f6";
|
const color = cal?.color || "#3b82f6";
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: overlayTop }}>
|
||||||
<div
|
<div
|
||||||
className="absolute px-0.5"
|
className="absolute px-0.5"
|
||||||
style={{
|
style={{
|
||||||
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
|
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
|
||||||
width: `calc(${(1 / 7) * 100}% - 2px)`,
|
width: `calc(${(1 / 7) * 100}% - 2px)`,
|
||||||
top: previewRow * 22,
|
top: previewRow * rowHeight,
|
||||||
height: 20,
|
height: chipHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate border-2 border-dashed"
|
className={cn(
|
||||||
|
"h-full rounded text-[10px] font-medium truncate border-2 border-dashed",
|
||||||
|
isMobile ? "leading-[16px] px-1" : "leading-[20px] px-1.5"
|
||||||
|
)}
|
||||||
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
|
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
|
||||||
>
|
>
|
||||||
{pendingPreview.title}
|
{pendingPreview.title}
|
||||||
@@ -260,8 +270,8 @@ export function CalendarMonthView({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{!isMobile && segments.length > 0 && (
|
{showChips && 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: overlayTop }}>
|
||||||
{segments.map((segment) => {
|
{segments.map((segment) => {
|
||||||
const calId = getPrimaryCalendarId(segment.event);
|
const calId = getPrimaryCalendarId(segment.event);
|
||||||
return (
|
return (
|
||||||
@@ -271,8 +281,8 @@ export function CalendarMonthView({
|
|||||||
style={{
|
style={{
|
||||||
left: `calc(${(segment.startIndex / 7) * 100}% + 1px)`,
|
left: `calc(${(segment.startIndex / 7) * 100}% + 1px)`,
|
||||||
width: `calc(${(segment.span / 7) * 100}% - 2px)`,
|
width: `calc(${(segment.span / 7) * 100}% - 2px)`,
|
||||||
top: segment.row * 22,
|
top: segment.row * rowHeight,
|
||||||
height: 20,
|
height: chipHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
@@ -286,6 +296,7 @@ export function CalendarMonthView({
|
|||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
onContextMenu={onContextMenuEvent}
|
onContextMenu={onContextMenuEvent}
|
||||||
draggable
|
draggable
|
||||||
|
className={isMobile ? "text-[10px] px-1" : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
|
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } 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";
|
||||||
@@ -96,6 +96,7 @@ 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 setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||||
@@ -219,6 +220,16 @@ export function CalendarSidebarPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSetDefault = async (calendarId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
await setDefaultCalendar(client, calendarId);
|
||||||
|
toast.success(tMgmt('default_updated'));
|
||||||
|
} catch {
|
||||||
|
toast.error(tMgmt('error_default'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (calendars.length === 0 && !onSubscribe) return null;
|
if (calendars.length === 0 && !onSubscribe) return null;
|
||||||
|
|
||||||
const renderCalendarItem = (cal: Calendar) => {
|
const renderCalendarItem = (cal: Calendar) => {
|
||||||
@@ -306,12 +317,13 @@ export function CalendarSidebarPanel({
|
|||||||
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
|
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
|
||||||
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
||||||
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
||||||
|
const canSetDefault = !!client && !isBirthday && !cal.isShared && !cal.isDefault;
|
||||||
const canChangeColor = !!onColorChange;
|
const canChangeColor = !!onColorChange;
|
||||||
const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)];
|
const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)];
|
||||||
const canResetColor = !!onResetColor && hasColorOverride;
|
const canResetColor = !!onResetColor && hasColorOverride;
|
||||||
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
||||||
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
||||||
const showSeparator = (canCreate || canShare || canChangeColor || canResetColor) && (canClear || canDelete);
|
const showSeparator = (canCreate || canShare || canSetDefault || canChangeColor || canResetColor) && (canClear || canDelete);
|
||||||
const color = cal.color || "#3b82f6";
|
const color = cal.color || "#3b82f6";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -330,6 +342,13 @@ export function CalendarSidebarPanel({
|
|||||||
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
|
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{canSetDefault && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Star}
|
||||||
|
label={tMgmt('set_default')}
|
||||||
|
onClick={() => { closeContextMenu(); handleSetDefault(cal.id); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{canChangeColor && (
|
{canChangeColor && (
|
||||||
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
|
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
|
||||||
<div className="px-2 py-1.5 w-[200px]">
|
<div className="px-2 py-1.5 w-[200px]">
|
||||||
@@ -378,7 +397,7 @@ export function CalendarSidebarPanel({
|
|||||||
<ListTodo className="w-4 h-4 text-muted-foreground" />
|
<ListTodo className="w-4 h-4 text-muted-foreground" />
|
||||||
<span>{t('tasks.label')}</span>
|
<span>{t('tasks.label')}</span>
|
||||||
{pendingTaskCount > 0 && (
|
{pendingTaskCount > 0 && (
|
||||||
<span className="ml-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
|
<span className="ms-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
|
||||||
)}
|
)}
|
||||||
{overdueTaskCount > 0 && (
|
{overdueTaskCount > 0 && (
|
||||||
<span className="text-xs text-destructive font-medium">{overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()}</span>
|
<span className="text-xs text-destructive font-medium">{overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()}</span>
|
||||||
@@ -418,7 +437,7 @@ export function CalendarSidebarPanel({
|
|||||||
onCreateCalendar();
|
onCreateCalendar();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="ml-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
|
className="ms-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
|
||||||
title={tMgmt('add_calendar')}
|
title={tMgmt('add_calendar')}
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
@@ -426,7 +445,7 @@ export function CalendarSidebarPanel({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="mt-1 pl-3">
|
<div className="mt-1 ps-3">
|
||||||
{owned.length > 0 && (
|
{owned.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
|
||||||
import { addDays, startOfWeek } from "date-fns";
|
import { 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";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
|
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||||
|
|
||||||
interface CalendarToolbarProps {
|
interface CalendarToolbarProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -50,7 +51,14 @@ export function CalendarToolbar({
|
|||||||
onMenuClick,
|
onMenuClick,
|
||||||
}: CalendarToolbarProps) {
|
}: CalendarToolbarProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const formatter = useFormatter();
|
const {
|
||||||
|
weekStartsOn,
|
||||||
|
formatMonthYear,
|
||||||
|
formatMonthYearShort,
|
||||||
|
formatWeekRange,
|
||||||
|
formatWeekRangeShort,
|
||||||
|
formatFullDate,
|
||||||
|
} = useCalendarLocale();
|
||||||
const views: CalendarViewMode[] = enableCalendarTasks
|
const views: CalendarViewMode[] = enableCalendarTasks
|
||||||
? ["month", "week", "day", "agenda", "tasks"]
|
? ["month", "week", "day", "agenda", "tasks"]
|
||||||
: ["month", "week", "day", "agenda"];
|
: ["month", "week", "day", "agenda"];
|
||||||
@@ -72,28 +80,22 @@ export function CalendarToolbar({
|
|||||||
switch (viewMode) {
|
switch (viewMode) {
|
||||||
case "month":
|
case "month":
|
||||||
return isMobile
|
return isMobile
|
||||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
? formatMonthYearShort(selectedDate)
|
||||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
: formatMonthYear(selectedDate);
|
||||||
case "week": {
|
case "week": {
|
||||||
const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 });
|
const ws = startOfWeek(selectedDate, { weekStartsOn });
|
||||||
const we = addDays(ws, 6);
|
return isMobile
|
||||||
if (isMobile) {
|
? formatWeekRangeShort(ws)
|
||||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}`;
|
: formatWeekRange(ws);
|
||||||
}
|
|
||||||
const sameMonth = ws.getMonth() === we.getMonth();
|
|
||||||
if (sameMonth) {
|
|
||||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`;
|
|
||||||
}
|
|
||||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`;
|
|
||||||
}
|
}
|
||||||
case "day":
|
case "day":
|
||||||
return isMobile
|
return isMobile
|
||||||
? formatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" })
|
? formatFullDate(selectedDate)
|
||||||
: formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
: formatFullDate(selectedDate);
|
||||||
case "agenda":
|
case "agenda":
|
||||||
return isMobile
|
return isMobile
|
||||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
? formatMonthYearShort(selectedDate)
|
||||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
: formatMonthYear(selectedDate);
|
||||||
case "tasks":
|
case "tasks":
|
||||||
return t("views.tasks");
|
return t("views.tasks");
|
||||||
}
|
}
|
||||||
@@ -124,7 +126,7 @@ export function CalendarToolbar({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
className="h-8 w-8 -ml-1 mr-1"
|
className="h-8 w-8 -ms-1 me-1"
|
||||||
aria-label={t("nav_open_menu")}
|
aria-label={t("nav_open_menu")}
|
||||||
>
|
>
|
||||||
<Menu className="w-4 h-4" />
|
<Menu className="w-4 h-4" />
|
||||||
@@ -138,7 +140,7 @@ export function CalendarToolbar({
|
|||||||
{onMenuClick && (
|
{onMenuClick && (
|
||||||
<button
|
<button
|
||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
|
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
|
||||||
aria-label={t("nav_open_menu")}
|
aria-label={t("nav_open_menu")}
|
||||||
>
|
>
|
||||||
<Menu className="w-4 h-4" />
|
<Menu className="w-4 h-4" />
|
||||||
@@ -147,7 +149,7 @@ export function CalendarToolbar({
|
|||||||
{onNavigateBack && (
|
{onNavigateBack && (
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateBack}
|
onClick={onNavigateBack}
|
||||||
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
|
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
|
||||||
aria-label={t("back_to_month")}
|
aria-label={t("back_to_month")}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
@@ -162,7 +164,7 @@ export function CalendarToolbar({
|
|||||||
<button onClick={onNext} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
|
<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" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ml-0.5">
|
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ms-0.5">
|
||||||
{t("views.today")}
|
{t("views.today")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +201,7 @@ export function CalendarToolbar({
|
|||||||
<CalendarDays className="w-4 h-4" />
|
<CalendarDays className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
{showCalendarDropdown && (
|
{showCalendarDropdown && (
|
||||||
<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]">
|
<div className="absolute top-full end-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
|
||||||
<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>
|
||||||
@@ -284,7 +286,7 @@ export function CalendarToolbar({
|
|||||||
{/* ── DESKTOP TOOLBAR ── */}
|
{/* ── DESKTOP TOOLBAR ── */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="outline" size="sm" onClick={onToday} className="h-8 mr-1">
|
<Button variant="outline" size="sm" onClick={onToday} className="h-8 me-1">
|
||||||
{t("views.today")}
|
{t("views.today")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
||||||
@@ -293,7 +295,7 @@ export function CalendarToolbar({
|
|||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-base font-semibold ml-2 select-none">
|
<span className="text-base font-semibold ms-2 select-none">
|
||||||
{getDateLabel()}
|
{getDateLabel()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,12 +330,12 @@ export function CalendarToolbar({
|
|||||||
{(onImport || onSubscribe) && !isMobile && (
|
{(onImport || onSubscribe) && !isMobile && (
|
||||||
<div className="relative" ref={importDropdownRef}>
|
<div className="relative" ref={importDropdownRef}>
|
||||||
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
|
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
|
||||||
<Upload className="w-4 h-4 mr-1" />
|
<Upload className="w-4 h-4 me-1" />
|
||||||
{t("import.title")}
|
{t("import.title")}
|
||||||
<ChevronDown className="w-3 h-3 ml-1" />
|
<ChevronDown className="w-3 h-3 ms-1" />
|
||||||
</Button>
|
</Button>
|
||||||
{showImportDropdown && (
|
{showImportDropdown && (
|
||||||
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
|
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
|
||||||
{onImport && (
|
{onImport && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { onImport(); setShowImportDropdown(false); }}
|
onClick={() => { onImport(); setShowImportDropdown(false); }}
|
||||||
@@ -359,7 +361,7 @@ export function CalendarToolbar({
|
|||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
|
<Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 me-1" />
|
||||||
{t("events.create")}
|
{t("events.create")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function CalendarWeekView({
|
|||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6;
|
||||||
|
|
||||||
const weekDays = useMemo(() => {
|
const weekDays = useMemo(() => {
|
||||||
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
|
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
|
||||||
@@ -211,7 +211,7 @@ export function CalendarWeekView({
|
|||||||
<div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
|
<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 sticky left-0 z-10 bg-background" : "w-14")}
|
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-end", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
|
||||||
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||||
>
|
>
|
||||||
{t("events.all_day")}
|
{t("events.all_day")}
|
||||||
@@ -306,7 +306,7 @@ export function CalendarWeekView({
|
|||||||
|
|
||||||
<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 sticky left-0 z-10 bg-background" : "w-14")} />
|
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} />
|
||||||
<div className="flex-1 border-l border-border grid grid-cols-7">
|
<div className="flex-1 border-s 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);
|
||||||
@@ -318,7 +318,7 @@ export function CalendarWeekView({
|
|||||||
role="columnheader"
|
role="columnheader"
|
||||||
aria-label={fullLabel}
|
aria-label={fullLabel}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors touch-manipulation",
|
"text-center py-2 text-sm border-e border-border last:border-e-0 transition-colors touch-manipulation",
|
||||||
"hover:bg-muted/50",
|
"hover:bg-muted/50",
|
||||||
todayCol && "font-bold",
|
todayCol && "font-bold",
|
||||||
)}
|
)}
|
||||||
@@ -345,7 +345,7 @@ export function CalendarWeekView({
|
|||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
className="relative text-muted-foreground text-right pr-2"
|
className="relative text-muted-foreground text-end pe-2"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
{h > 0 && (
|
{h > 0 && (
|
||||||
@@ -357,7 +357,7 @@ export function CalendarWeekView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 border-l border-border relative grid grid-cols-7">
|
<div className="flex-1 border-s 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) || [];
|
||||||
@@ -367,7 +367,7 @@ export function CalendarWeekView({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="relative border-r border-border last:border-r-0"
|
className="relative border-e border-border last:border-e-0"
|
||||||
role="row"
|
role="row"
|
||||||
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" })}
|
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" })}
|
||||||
onPointerDown={(e) => handleGridPointerDown(e, key, day)}
|
onPointerDown={(e) => handleGridPointerDown(e, key, day)}
|
||||||
@@ -448,7 +448,7 @@ 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-destructive -ml-1" />
|
<div className="w-2 h-2 rounded-full bg-destructive -ms-1" />
|
||||||
<div className="flex-1 h-px bg-destructive" />
|
<div className="flex-1 h-px bg-destructive" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -482,7 +482,7 @@ export function CalendarWeekView({
|
|||||||
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-2 h-2 rounded-full bg-primary -ml-1" />
|
<div className="w-2 h-2 rounded-full bg-primary -ms-1" />
|
||||||
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProp
|
|||||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||||
{tCommon("loading")}
|
{tCommon("loading")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
|
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesAfter = false, className, style }: EventCardProps) {
|
||||||
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);
|
||||||
@@ -88,7 +88,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
try { return format(d, fmt); } catch { return "--:--"; }
|
try { return format(d, fmt); } catch { return "--:--"; }
|
||||||
};
|
};
|
||||||
const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`;
|
const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`;
|
||||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
// iTIP CANCEL marks the attendee's copy with status "cancelled" instead of
|
||||||
|
// deleting it (#572) - render it struck through and dimmed.
|
||||||
|
const isCancelled = event.status === "cancelled";
|
||||||
|
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}${isCancelled ? `, ${t("detail.cancelled")}` : ""}`;
|
||||||
|
|
||||||
const handleDragStart = useCallback((e: DragEvent) => {
|
const handleDragStart = useCallback((e: DragEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -131,11 +134,12 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1 w-full text-left text-xs px-1 py-0.5 rounded truncate",
|
"flex items-center gap-1 w-full text-start text-xs px-1 py-0.5 rounded truncate",
|
||||||
"min-h-[44px] sm:min-h-0",
|
"min-h-[44px] sm:min-h-0",
|
||||||
"hover:opacity-80 transition-opacity",
|
"hover:opacity-80 transition-opacity",
|
||||||
isSelected && "ring-2 ring-primary",
|
isSelected && "ring-2 ring-primary",
|
||||||
isBeingDragged && "opacity-50",
|
isBeingDragged && "opacity-50",
|
||||||
|
isCancelled && !isBeingDragged && "opacity-60",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: `${color}20`, color, ...style }}
|
style={{ backgroundColor: `${color}20`, color, ...style }}
|
||||||
@@ -144,7 +148,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{event.title || t("events.no_title")}</span>
|
<span className={cn("truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -159,12 +163,13 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
"w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||||
continuesAfter && "rounded-r-sm",
|
continuesAfter && "rounded-r-sm",
|
||||||
continuesAfter && "pr-2",
|
continuesAfter && "pe-2",
|
||||||
isSelected && "ring-2 ring-primary",
|
isSelected && "ring-2 ring-primary",
|
||||||
isBeingDragged && "opacity-50",
|
isBeingDragged && "opacity-50",
|
||||||
|
isCancelled && !isBeingDragged && "opacity-60",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
||||||
@@ -173,7 +178,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
{showTimeInMonthView && !event.showWithoutTime && (
|
{showTimeInMonthView && !event.showWithoutTime && (
|
||||||
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
|
<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={cn("truncate font-medium", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -189,15 +194,16 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
{...dragProps}
|
{...dragProps}
|
||||||
data-calendar-event
|
data-calendar-event
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
"w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||||
isSelected && "ring-2 ring-primary",
|
isSelected && "ring-2 ring-primary",
|
||||||
isBeingDragged && "opacity-50",
|
isBeingDragged && "opacity-50",
|
||||||
|
isCancelled && !isBeingDragged && "opacity-60",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color, ...style }}
|
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color, ...style }}
|
||||||
>
|
>
|
||||||
<div className="font-medium truncate">{event.title || t("events.no_title")}</div>
|
<div className={cn("font-medium truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</div>
|
||||||
{!event.showWithoutTime && (
|
{!event.showWithoutTime && (
|
||||||
<div className="opacity-80 text-[10px]">
|
<div className="opacity-80 text-[10px]">
|
||||||
{timeString}
|
{timeString}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from "react";
|
import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||||
Pencil, Trash2, Copy, Send, Check,
|
Pencil, Trash2, Copy, Send, Check,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format, isSameDay, parseISO } from "date-fns";
|
import { format, isSameDay } 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 { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||||
|
import { buildRecurrenceSummary } from "./recurrence-editor";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
getUserParticipantId,
|
getUserParticipantId,
|
||||||
@@ -101,16 +102,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
|
||||||
if (!event.recurrenceRules?.length) return null;
|
if (!event.recurrenceRules?.length) return null;
|
||||||
const freq = event.recurrenceRules[0].frequency;
|
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
|
||||||
const labels: Record<string, string> = {
|
|
||||||
daily: t("recurrence.daily"),
|
|
||||||
weekly: t("recurrence.weekly"),
|
|
||||||
monthly: t("recurrence.monthly"),
|
|
||||||
yearly: t("recurrence.yearly"),
|
|
||||||
};
|
|
||||||
return labels[freq] || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventDetailPopover({
|
export function EventDetailPopover({
|
||||||
@@ -130,6 +124,7 @@ export function EventDetailPopover({
|
|||||||
isMobile,
|
isMobile,
|
||||||
}: EventDetailPopoverProps) {
|
}: EventDetailPopoverProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
|
const locale = useLocale();
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const noteInputRef = useRef<HTMLTextAreaElement>(null);
|
const noteInputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
@@ -158,7 +153,7 @@ export function EventDetailPopover({
|
|||||||
}, [event.virtualLocations]);
|
}, [event.virtualLocations]);
|
||||||
|
|
||||||
const participants = useMemo(() => getParticipantList(event), [event]);
|
const participants = useMemo(() => getParticipantList(event), [event]);
|
||||||
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t), [event, t]);
|
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t, locale), [event, t, locale]);
|
||||||
const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]);
|
const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]);
|
||||||
|
|
||||||
const userIsOrganizer = useMemo(() => {
|
const userIsOrganizer = useMemo(() => {
|
||||||
@@ -295,20 +290,23 @@ export function EventDetailPopover({
|
|||||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
/>
|
/>
|
||||||
<h3 className="text-base font-semibold truncate text-foreground">
|
<h3 className={cn(
|
||||||
|
"text-base font-semibold truncate text-foreground",
|
||||||
|
event.status === "cancelled" && "line-through text-muted-foreground"
|
||||||
|
)}>
|
||||||
{event.title || t("events.no_title")}
|
{event.title || t("events.no_title")}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
{calendar && (
|
{calendar && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">
|
<p className="text-xs text-muted-foreground mt-0.5 ps-[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-warning/15 text-warning">
|
<span className="ms-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>
|
||||||
)}
|
)}
|
||||||
{event.status === "cancelled" && (
|
{event.status === "cancelled" && (
|
||||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
|
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
|
||||||
{t("detail.cancelled")}
|
{t("detail.cancelled")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -348,13 +346,13 @@ export function EventDetailPopover({
|
|||||||
<>
|
<>
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
{formatEventDate(startDate)}
|
{formatEventDate(startDate)}
|
||||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
<span className="ms-1.5 font-normal text-muted-foreground">
|
||||||
{formatTime(startDate)}
|
{formatTime(startDate)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
{formatEventDate(endDate)}
|
{formatEventDate(endDate)}
|
||||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
<span className="ms-1.5 font-normal text-muted-foreground">
|
||||||
{formatTime(endDate)}
|
{formatTime(endDate)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,11 +367,11 @@ export function EventDetailPopover({
|
|||||||
{formatEventDate(startDate)}
|
{formatEventDate(startDate)}
|
||||||
</span>
|
</span>
|
||||||
{event.showWithoutTime ? (
|
{event.showWithoutTime ? (
|
||||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{formatTime(startDate)} – {formatTime(endDate)}
|
{formatTime(startDate)} – {formatTime(endDate)}
|
||||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
<span className="ms-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -439,7 +437,7 @@ export function EventDetailPopover({
|
|||||||
<span className="truncate text-foreground">
|
<span className="truncate text-foreground">
|
||||||
{p.name || p.email}
|
{p.name || p.email}
|
||||||
{p.isOrganizer && (
|
{p.isOrganizer && (
|
||||||
<span className="text-muted-foreground ml-1">
|
<span className="text-muted-foreground ms-1">
|
||||||
({t("participants.organizer").toLowerCase()})
|
({t("participants.organizer").toLowerCase()})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -517,7 +515,7 @@ export function EventDetailPopover({
|
|||||||
disabled={!noteText.trim() || isSavingNote}
|
disabled={!noteText.trim() || isSavingNote}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
>
|
>
|
||||||
<Send className="w-3 h-3 mr-1" />
|
<Send className="w-3 h-3 me-1" />
|
||||||
{t("detail.save_note")}
|
{t("detail.save_note")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -551,7 +549,7 @@ export function EventDetailPopover({
|
|||||||
: "text-success border-success/30 hover:bg-success/10"
|
: "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 me-1" />}
|
||||||
{t("participants.accepted")}
|
{t("participants.accepted")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -564,7 +562,7 @@ export function EventDetailPopover({
|
|||||||
: "border border-warning/30 text-warning hover:bg-warning/10"
|
: "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 me-1" />}
|
||||||
{t("participants.tentative")}
|
{t("participants.tentative")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -577,7 +575,7 @@ export function EventDetailPopover({
|
|||||||
: "text-destructive hover:bg-destructive/10"
|
: "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 me-1" />}
|
||||||
{t("participants.declined")}
|
{t("participants.declined")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -611,7 +609,7 @@ export function EventDetailPopover({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button variant="default" size="sm" onClick={onEdit} className="h-7 text-xs">
|
<Button variant="default" size="sm" onClick={onEdit} className="h-7 text-xs">
|
||||||
<Pencil className="w-3.5 h-3.5 mr-1" />
|
<Pencil className="w-3.5 h-3.5 me-1" />
|
||||||
{t("events.edit")}
|
{t("events.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -621,7 +619,7 @@ export function EventDetailPopover({
|
|||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
title={t("events.duplicate")}
|
title={t("events.duplicate")}
|
||||||
>
|
>
|
||||||
<Copy className="w-3.5 h-3.5 mr-1" />
|
<Copy className="w-3.5 h-3.5 me-1" />
|
||||||
{t("events.duplicate")}
|
{t("events.duplicate")}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
||||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||||
|
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||||
@@ -75,7 +76,7 @@ function buildDuration(startDate: Date, endDate: Date): string {
|
|||||||
return dur;
|
return dur;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
|
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "custom";
|
||||||
|
|
||||||
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
|
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
|
||||||
|
|
||||||
@@ -157,16 +158,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
|
|||||||
return labels.join(", ");
|
return labels.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
|
||||||
if (!event.recurrenceRules?.length) return null;
|
if (!event.recurrenceRules?.length) return null;
|
||||||
const freq = event.recurrenceRules[0].frequency;
|
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
|
||||||
const labels: Record<string, string> = {
|
|
||||||
daily: t("recurrence.daily"),
|
|
||||||
weekly: t("recurrence.weekly"),
|
|
||||||
monthly: t("recurrence.monthly"),
|
|
||||||
yearly: t("recurrence.yearly"),
|
|
||||||
};
|
|
||||||
return labels[freq] || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventModal({
|
export function EventModal({
|
||||||
@@ -186,6 +180,7 @@ export function EventModal({
|
|||||||
isMobile = false,
|
isMobile = false,
|
||||||
}: EventModalProps) {
|
}: EventModalProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
|
const locale = useLocale();
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
const isEdit = !!event;
|
const isEdit = !!event;
|
||||||
@@ -270,8 +265,35 @@ export function EventModal({
|
|||||||
});
|
});
|
||||||
const [recurrence, setRecurrence] = useState<RecurrenceOption>(() => {
|
const [recurrence, setRecurrence] = useState<RecurrenceOption>(() => {
|
||||||
if (!event?.recurrenceRules?.length) return "none";
|
if (!event?.recurrenceRules?.length) return "none";
|
||||||
return event.recurrenceRules[0].frequency as RecurrenceOption;
|
const rule = event.recurrenceRules[0];
|
||||||
|
return isSimpleRecurrenceRule(rule) ? (rule.frequency as RecurrenceOption) : "custom";
|
||||||
});
|
});
|
||||||
|
const [customRule, setCustomRule] = useState<CalendarRecurrenceRule | null>(() => {
|
||||||
|
if (!event?.recurrenceRules?.length) return null;
|
||||||
|
const rule = event.recurrenceRules[0];
|
||||||
|
return isSimpleRecurrenceRule(rule) ? null : rule;
|
||||||
|
});
|
||||||
|
const [showRecurrenceEditor, setShowRecurrenceEditor] = useState(false);
|
||||||
|
// Dropdown value to restore when the custom editor is cancelled without a saved rule.
|
||||||
|
const recurrenceBeforeCustomRef = useRef<RecurrenceOption>("none");
|
||||||
|
|
||||||
|
const handleRecurrenceEditorSave = useCallback((rule: CalendarRecurrenceRule) => {
|
||||||
|
setCustomRule(rule);
|
||||||
|
setRecurrence("custom");
|
||||||
|
setShowRecurrenceEditor(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRecurrenceEditorCancel = useCallback(() => {
|
||||||
|
setShowRecurrenceEditor(false);
|
||||||
|
if (!customRule) {
|
||||||
|
setRecurrence(recurrenceBeforeCustomRef.current);
|
||||||
|
}
|
||||||
|
}, [customRule]);
|
||||||
|
|
||||||
|
const customRuleSummary = useMemo(
|
||||||
|
() => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null),
|
||||||
|
[customRule, t, locale]
|
||||||
|
);
|
||||||
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
|
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
|
||||||
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
|
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
|
||||||
if (!event?.alerts) return [];
|
if (!event?.alerts) return [];
|
||||||
@@ -444,7 +466,9 @@ export function EventModal({
|
|||||||
data.virtualLocations = null;
|
data.virtualLocations = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recurrence !== "none") {
|
if (recurrence === "custom" && customRule) {
|
||||||
|
data.recurrenceRules = [customRule];
|
||||||
|
} else if (recurrence !== "none" && recurrence !== "custom") {
|
||||||
data.recurrenceRules = [{
|
data.recurrenceRules = [{
|
||||||
"@type": "RecurrenceRule",
|
"@type": "RecurrenceRule",
|
||||||
frequency: recurrence,
|
frequency: recurrence,
|
||||||
@@ -498,10 +522,16 @@ export function EventModal({
|
|||||||
{ name: organizerName, email: organizerEmail },
|
{ name: organizerName, email: organizerEmail },
|
||||||
effectiveAttendees
|
effectiveAttendees
|
||||||
) as Record<string, CalendarParticipant>;
|
) as Record<string, CalendarParticipant>;
|
||||||
data.replyTo = { imip: `mailto:${organizerEmail}` };
|
// Stalwart (calcard) derives the iCalendar ORGANIZER property solely from
|
||||||
|
// organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP
|
||||||
|
// scheduling is silently skipped (NoSchedulingInfo), so no invites are sent.
|
||||||
|
// The RFC 8984 replyTo property is retired in jscalendarbis and ignored.
|
||||||
|
data.organizerCalendarAddress = `mailto:${organizerEmail}`;
|
||||||
} else if (effectiveAttendees.length === 0 && event?.participants) {
|
} else if (effectiveAttendees.length === 0 && event?.participants) {
|
||||||
data.participants = null;
|
data.participants = null;
|
||||||
|
// Also clear the retired replyTo that older releases (<= 1.7.6) wrote.
|
||||||
data.replyTo = null;
|
data.replyTo = null;
|
||||||
|
data.organizerCalendarAddress = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||||
@@ -511,7 +541,7 @@ export function EventModal({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||||
|
|
||||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||||
if (!event || !userParticipantId || !onRsvp) return;
|
if (!event || !userParticipantId || !onRsvp) return;
|
||||||
@@ -593,7 +623,6 @@ export function EventModal({
|
|||||||
|
|
||||||
if (isAttendeeMode && event) {
|
if (isAttendeeMode && event) {
|
||||||
const startD = getEventStartDate(event);
|
const startD = getEventStartDate(event);
|
||||||
const durMin = parseDuration(event.duration);
|
|
||||||
const endD = getEventEndDate(event);
|
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);
|
||||||
@@ -637,11 +666,11 @@ export function EventModal({
|
|||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||||
<span className="text-muted-foreground ml-2">{format(startD, timeDisplayFmt)}</span>
|
<span className="text-muted-foreground ms-2">{format(startD, timeDisplayFmt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">{formatEventDate(endD)}</span>
|
<span className="font-medium">{formatEventDate(endD)}</span>
|
||||||
<span className="text-muted-foreground ml-2">{format(endD, timeDisplayFmt)}</span>
|
<span className="text-muted-foreground ms-2">{format(endD, timeDisplayFmt)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -650,7 +679,7 @@ export function EventModal({
|
|||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||||
{!event.showWithoutTime && (
|
{!event.showWithoutTime && (
|
||||||
<span className="text-muted-foreground ml-2">
|
<span className="text-muted-foreground ms-2">
|
||||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -672,7 +701,7 @@ export function EventModal({
|
|||||||
<Users className="w-4 h-4" />
|
<Users className="w-4 h-4" />
|
||||||
{t("participants.title")}
|
{t("participants.title")}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 pl-5">
|
<div className="space-y-1 ps-5">
|
||||||
{participants.map(p => (
|
{participants.map(p => (
|
||||||
<div key={p.id} className="flex items-center justify-between text-sm">
|
<div key={p.id} className="flex items-center justify-between text-sm">
|
||||||
<span className="truncate">{p.name || p.email}</span>
|
<span className="truncate">{p.name || p.email}</span>
|
||||||
@@ -697,7 +726,7 @@ export function EventModal({
|
|||||||
? "bg-success hover:bg-success/80 text-success-foreground"
|
? "bg-success hover:bg-success/80 text-success-foreground"
|
||||||
: "text-success border-success/30 hover:bg-success/10"}
|
: "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 me-1" />}
|
||||||
{t("participants.accepted")}
|
{t("participants.accepted")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -708,7 +737,7 @@ export function EventModal({
|
|||||||
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
||||||
: "border border-warning/30 text-warning hover:bg-warning/10"}
|
: "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 me-1" />}
|
||||||
{t("participants.tentative")}
|
{t("participants.tentative")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -719,7 +748,7 @@ export function EventModal({
|
|||||||
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
||||||
: "text-destructive hover:bg-destructive/10"}
|
: "text-destructive hover:bg-destructive/10"}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
|
{userCurrentStatus === "declined" && <Check className="w-4 h-4 me-1" />}
|
||||||
{t("participants.declined")}
|
{t("participants.declined")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -737,7 +766,7 @@ export function EventModal({
|
|||||||
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);
|
||||||
const recurrenceLabel = getRecurrenceLabel(event, t);
|
const recurrenceLabel = getRecurrenceLabel(event, t, locale);
|
||||||
const alertLabel = getAlertLabel(event, t);
|
const alertLabel = getAlertLabel(event, t);
|
||||||
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
|
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
|
||||||
const color = getEventColor(event, eventCalendar);
|
const color = getEventColor(event, eventCalendar);
|
||||||
@@ -755,7 +784,7 @@ export function EventModal({
|
|||||||
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
{eventCalendar && (
|
{eventCalendar && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">{eventCalendar.name}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 ps-[18px]">{eventCalendar.name}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
||||||
@@ -787,13 +816,13 @@ export function EventModal({
|
|||||||
<>
|
<>
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
{formatEventDate(startD)}
|
{formatEventDate(startD)}
|
||||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
<span className="ms-1.5 font-normal text-muted-foreground">
|
||||||
{format(startD, timeDisplayFmt)}
|
{format(startD, timeDisplayFmt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
{formatEventDate(endD)}
|
{formatEventDate(endD)}
|
||||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
<span className="ms-1.5 font-normal text-muted-foreground">
|
||||||
{format(endD, timeDisplayFmt)}
|
{format(endD, timeDisplayFmt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -809,11 +838,11 @@ export function EventModal({
|
|||||||
{formatEventDate(startD)}
|
{formatEventDate(startD)}
|
||||||
</span>
|
</span>
|
||||||
{event.showWithoutTime ? (
|
{event.showWithoutTime ? (
|
||||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
<span className="ms-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -860,7 +889,7 @@ export function EventModal({
|
|||||||
<span className="truncate text-foreground">
|
<span className="truncate text-foreground">
|
||||||
{p.name || p.email}
|
{p.name || p.email}
|
||||||
{p.isOrganizer && (
|
{p.isOrganizer && (
|
||||||
<span className="text-muted-foreground ml-1">({t("participants.organizer").toLowerCase()})</span>
|
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
|
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
|
||||||
@@ -913,21 +942,21 @@ export function EventModal({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-destructive">
|
<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 me-1" />
|
||||||
{t("events.delete")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{onDuplicate && !showDeleteConfirm && (
|
{onDuplicate && !showDeleteConfirm && (
|
||||||
<Button variant="ghost" size="sm" onClick={handleDuplicate} aria-label={t("events.duplicate")}>
|
<Button variant="ghost" size="sm" onClick={handleDuplicate} aria-label={t("events.duplicate")}>
|
||||||
<Copy className="w-4 h-4 mr-1" />
|
<Copy className="w-4 h-4 me-1" />
|
||||||
{t("events.duplicate")}
|
{t("events.duplicate")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!showDeleteConfirm && (
|
{!showDeleteConfirm && (
|
||||||
<Button onClick={() => setMode("edit")}>
|
<Button onClick={() => setMode("edit")}>
|
||||||
<Pencil className="w-4 h-4 mr-1" />
|
<Pencil className="w-4 h-4 me-1" />
|
||||||
{t("events.edit")}
|
{t("events.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -1131,17 +1160,51 @@ export function EventModal({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
|
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
|
||||||
<select
|
<div className="flex items-center gap-2">
|
||||||
value={recurrence}
|
<select
|
||||||
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
|
value={recurrence}
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
onChange={(e) => {
|
||||||
>
|
const value = e.target.value as RecurrenceOption;
|
||||||
<option value="none">{t("recurrence.none")}</option>
|
if (value === "custom") {
|
||||||
<option value="daily">{t("recurrence.daily")}</option>
|
recurrenceBeforeCustomRef.current = recurrence;
|
||||||
<option value="weekly">{t("recurrence.weekly")}</option>
|
setRecurrence("custom");
|
||||||
<option value="monthly">{t("recurrence.monthly")}</option>
|
setShowRecurrenceEditor(true);
|
||||||
<option value="yearly">{t("recurrence.yearly")}</option>
|
} else {
|
||||||
</select>
|
setRecurrence(value);
|
||||||
|
setShowRecurrenceEditor(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="none">{t("recurrence.none")}</option>
|
||||||
|
<option value="daily">{t("recurrence.daily")}</option>
|
||||||
|
<option value="weekly">{t("recurrence.weekly")}</option>
|
||||||
|
<option value="monthly">{t("recurrence.monthly")}</option>
|
||||||
|
<option value="yearly">{t("recurrence.yearly")}</option>
|
||||||
|
<option value="custom">{customRuleSummary || t("recurrence.custom")}</option>
|
||||||
|
</select>
|
||||||
|
{recurrence === "custom" && !showRecurrenceEditor && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowRecurrenceEditor(true)}
|
||||||
|
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
aria-label={t("recurrence.edit_custom")}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showRecurrenceEditor && (
|
||||||
|
<RecurrenceEditor
|
||||||
|
rule={customRule}
|
||||||
|
eventStart={(() => {
|
||||||
|
const d = new Date(`${startDate}T${allDay ? "00:00" : (startTime || "00:00")}:00`);
|
||||||
|
return isNaN(d.getTime()) ? new Date() : d;
|
||||||
|
})()}
|
||||||
|
onSave={handleRecurrenceEditorSave}
|
||||||
|
onCancel={handleRecurrenceEditorCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -1257,7 +1320,7 @@ export function EventModal({
|
|||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
className="text-red-600 dark:text-red-400"
|
className="text-red-600 dark:text-red-400"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
<Trash2 className="w-4 h-4 me-1" />
|
||||||
{t("events.delete")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@@ -1269,7 +1332,7 @@ export function EventModal({
|
|||||||
onClick={handleDuplicate}
|
onClick={handleDuplicate}
|
||||||
aria-label={t("events.duplicate")}
|
aria-label={t("events.duplicate")}
|
||||||
>
|
>
|
||||||
<Copy className="w-4 h-4 mr-1" />
|
<Copy className="w-4 h-4 me-1" />
|
||||||
{t("events.duplicate")}
|
{t("events.duplicate")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useCallback, useRef, useEffect } 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, 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 } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||||
@@ -444,7 +444,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
|||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={selectedIndices.size === 0}
|
disabled={selectedIndices.size === 0}
|
||||||
>
|
>
|
||||||
<Check className="w-4 h-4 mr-1" />
|
<Check className="w-4 h-4 me-1" />
|
||||||
{t("import_button")} ({selectedIndices.size})
|
{t("import_button")} ({selectedIndices.size})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi
|
|||||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||||
{isEdit ? t("saving") : t("subscribing")}
|
{isEdit ? t("saving") : t("subscribing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,25 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, Fragment } from "react";
|
import { useState, useMemo, Fragment } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
|
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
|
||||||
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||||
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
|
getISOWeek, getWeek, 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 { getEventDayBounds } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||||
|
|
||||||
type PickerView = "days" | "months" | "years";
|
type PickerView = "days" | "months" | "years";
|
||||||
|
|
||||||
const MONTH_LABELS = [
|
|
||||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
||||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
||||||
];
|
|
||||||
|
|
||||||
interface MiniCalendarProps {
|
interface MiniCalendarProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
displayMonth: Date;
|
displayMonth: Date;
|
||||||
@@ -40,17 +34,25 @@ export function MiniCalendar({
|
|||||||
showWeekNumbers = false,
|
showWeekNumbers = false,
|
||||||
}: MiniCalendarProps) {
|
}: MiniCalendarProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const {
|
||||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
weekStartsOn,
|
||||||
|
dayHeaderKeys,
|
||||||
|
getMonthGridDays,
|
||||||
|
checkIsToday,
|
||||||
|
checkIsSameMonth,
|
||||||
|
checkIsSameDay,
|
||||||
|
formatDayNumber,
|
||||||
|
formatMonthYear,
|
||||||
|
getMonth,
|
||||||
|
getYear,
|
||||||
|
monthLabelKeys,
|
||||||
|
} = useCalendarLocale();
|
||||||
const [pickerView, setPickerView] = useState<PickerView>("days");
|
const [pickerView, setPickerView] = useState<PickerView>("days");
|
||||||
|
|
||||||
const days = useMemo(() => {
|
const days = useMemo(
|
||||||
const monthStart = startOfMonth(displayMonth);
|
() => getMonthGridDays(displayMonth),
|
||||||
const monthEnd = endOfMonth(displayMonth);
|
[displayMonth, getMonthGridDays],
|
||||||
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
|
);
|
||||||
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
|
|
||||||
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
|
||||||
}, [displayMonth, weekStart]);
|
|
||||||
|
|
||||||
const eventDates = useMemo(() => {
|
const eventDates = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
@@ -67,20 +69,16 @@ export function MiniCalendar({
|
|||||||
return set;
|
return set;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
const dayHeaders = firstDayOfWeek === 0
|
|
||||||
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
|
|
||||||
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
|
||||||
|
|
||||||
// Compute week numbers for each row (one per 7-day chunk)
|
// Compute week numbers for each row (one per 7-day chunk)
|
||||||
const weekNumbers = useMemo(() => {
|
const weekNumbers = useMemo(() => {
|
||||||
if (!showWeekNumbers) return [];
|
if (!showWeekNumbers) return [];
|
||||||
const nums: number[] = [];
|
const nums: number[] = [];
|
||||||
for (let i = 0; i < days.length; i += 7) {
|
for (let i = 0; i < days.length; i += 7) {
|
||||||
// Use the first day of each row to determine the week number
|
// Use the first day of each row to determine the week number
|
||||||
nums.push(weekStart === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
|
nums.push(weekStartsOn === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
|
||||||
}
|
}
|
||||||
return nums;
|
return nums;
|
||||||
}, [days, showWeekNumbers, weekStart]);
|
}, [days, showWeekNumbers, weekStartsOn]);
|
||||||
|
|
||||||
const currentYear = getYear(displayMonth);
|
const currentYear = getYear(displayMonth);
|
||||||
const currentMonth = getMonth(displayMonth);
|
const currentMonth = getMonth(displayMonth);
|
||||||
@@ -116,7 +114,7 @@ export function MiniCalendar({
|
|||||||
|
|
||||||
const headerLabel =
|
const headerLabel =
|
||||||
pickerView === "days"
|
pickerView === "days"
|
||||||
? intlFormatter.dateTime(displayMonth, { month: "long", year: "numeric" })
|
? formatMonthYear(displayMonth)
|
||||||
: pickerView === "months"
|
: pickerView === "months"
|
||||||
? String(currentYear)
|
? String(currentYear)
|
||||||
: `${decadeStart}\u2013${decadeStart + 9}`;
|
: `${decadeStart}\u2013${decadeStart + 9}`;
|
||||||
@@ -160,15 +158,15 @@ export function MiniCalendar({
|
|||||||
{showWeekNumbers && (
|
{showWeekNumbers && (
|
||||||
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
|
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
|
||||||
)}
|
)}
|
||||||
{dayHeaders.map((d) => (
|
{dayHeaderKeys.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, index) => {
|
{days.map((day, index) => {
|
||||||
const inMonth = isSameMonth(day, displayMonth);
|
const inMonth = checkIsSameMonth(day, displayMonth);
|
||||||
const selected = isSameDay(day, selectedDate);
|
const selected = checkIsSameDay(day, selectedDate);
|
||||||
const today = isToday(day);
|
const today = checkIsToday(day);
|
||||||
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
|
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
|
||||||
const isFirstDayOfRow = index % 7 === 0;
|
const isFirstDayOfRow = index % 7 === 0;
|
||||||
|
|
||||||
@@ -193,7 +191,7 @@ export function MiniCalendar({
|
|||||||
selected && "bg-primary text-primary-foreground"
|
selected && "bg-primary text-primary-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{format(day, "d")}
|
{formatDayNumber(day)}
|
||||||
{hasEvent && !selected && (
|
{hasEvent && !selected && (
|
||||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
@@ -206,7 +204,7 @@ export function MiniCalendar({
|
|||||||
|
|
||||||
{pickerView === "months" && (
|
{pickerView === "months" && (
|
||||||
<div className="grid grid-cols-3 gap-1 py-1">
|
<div className="grid grid-cols-3 gap-1 py-1">
|
||||||
{MONTH_LABELS.map((label, i) => {
|
{monthLabelKeys.map((labelKey, i) => {
|
||||||
const isCurrentMonth = i === currentMonth && currentYear === getYear(new Date());
|
const isCurrentMonth = i === currentMonth && currentYear === getYear(new Date());
|
||||||
const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate);
|
const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate);
|
||||||
return (
|
return (
|
||||||
@@ -220,7 +218,7 @@ export function MiniCalendar({
|
|||||||
!isSelected && !isCurrentMonth && "hover:bg-muted"
|
!isSelected && !isCurrentMonth && "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{t(`months.${labelKey}`)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHand
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
|
|
||||||
interface Participant {
|
interface Participant {
|
||||||
@@ -144,14 +145,17 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
|
|||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === activeIndex}
|
aria-selected={i === activeIndex}
|
||||||
onMouseDown={(e) => { e.preventDefault(); addParticipant(s); }}
|
onMouseDown={(e) => { e.preventDefault(); addParticipant(s); }}
|
||||||
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
|
className={`px-3 py-2 text-sm cursor-pointer transition-colors flex items-center gap-2 ${
|
||||||
i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-medium truncate">{s.name || s.email}</div>
|
<Avatar name={s.name} email={s.email} size="sm" className="shrink-0 w-7 h-7 text-[10px]" />
|
||||||
{s.name && (
|
<div className="min-w-0">
|
||||||
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
|
<div className="font-medium truncate">{s.name || s.email}</div>
|
||||||
)}
|
{s.name && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { addYears, format } from "date-fns";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import type { CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||||
|
|
||||||
|
type EditorFrequency = "daily" | "weekly" | "monthly" | "yearly";
|
||||||
|
type MonthlyMode = "day" | "nth";
|
||||||
|
type EndsMode = "never" | "on" | "after";
|
||||||
|
|
||||||
|
const EDITOR_FREQUENCIES: EditorFrequency[] = ["daily", "weekly", "monthly", "yearly"];
|
||||||
|
|
||||||
|
// 2024-01-01 is a Monday - used to render localized weekday names via Intl.
|
||||||
|
const WEEKDAYS: string[] = ["mo", "tu", "we", "th", "fr", "sa", "su"];
|
||||||
|
const DAY_TO_REF_DATE: Record<string, number> = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 };
|
||||||
|
const INDEX_TO_DAY = ["su", "mo", "tu", "we", "th", "fr", "sa"];
|
||||||
|
|
||||||
|
const UNIT_LABEL_KEYS: Record<EditorFrequency, string> = {
|
||||||
|
daily: "recurrence.editor_unit_days",
|
||||||
|
weekly: "recurrence.editor_unit_weeks",
|
||||||
|
monthly: "recurrence.editor_unit_months",
|
||||||
|
yearly: "recurrence.editor_unit_years",
|
||||||
|
};
|
||||||
|
|
||||||
|
type CalendarT = ReturnType<typeof useTranslations>;
|
||||||
|
|
||||||
|
function weekdayName(day: string, locale: string, style: "long" | "short" = "long"): string {
|
||||||
|
const ref = new Date(2024, 0, DAY_TO_REF_DATE[day] ?? 1);
|
||||||
|
return new Intl.DateTimeFormat(locale, { weekday: style }).format(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthName(month: number, locale: string): string {
|
||||||
|
return new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2024, month - 1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nthLabel(nth: number, t: CalendarT): string {
|
||||||
|
if (nth === -1) return t("recurrence.nth_last");
|
||||||
|
if (nth >= 1 && nth <= 4) return t(`recurrence.nth_${nth}`);
|
||||||
|
return String(nth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalize(s: string): string {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract an "nth weekday" pattern from a rule, accepting both the
|
||||||
|
* byDay+nthOfPeriod encoding and the byDay+bySetPosition encoding.
|
||||||
|
*/
|
||||||
|
function getNthDay(rule: CalendarRecurrenceRule): { day: string; nth: number } | null {
|
||||||
|
if (rule.byDay?.length === 1) {
|
||||||
|
const nd = rule.byDay[0];
|
||||||
|
if (nd.nthOfPeriod) return { day: nd.day, nth: nd.nthOfPeriod };
|
||||||
|
if (rule.bySetPosition?.length === 1) return { day: nd.day, nth: rule.bySetPosition[0] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the rule is exactly what the plain Daily/Weekly/Monthly/Yearly
|
||||||
|
* dropdown presets produce, i.e. it needs no custom editor to represent.
|
||||||
|
*/
|
||||||
|
export function isSimpleRecurrenceRule(rule: CalendarRecurrenceRule): boolean {
|
||||||
|
return (
|
||||||
|
(EDITOR_FREQUENCIES as string[]).includes(rule.frequency) &&
|
||||||
|
(!rule.interval || rule.interval === 1) &&
|
||||||
|
!rule.byDay?.length &&
|
||||||
|
!rule.byMonthDay?.length &&
|
||||||
|
!rule.byMonth?.length &&
|
||||||
|
!rule.byYearDay?.length &&
|
||||||
|
!rule.byWeekNo?.length &&
|
||||||
|
!rule.bySetPosition?.length &&
|
||||||
|
!rule.count &&
|
||||||
|
!rule.until
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable summary of a recurrence rule, e.g.
|
||||||
|
* "Every 2 months on the third Thursday · 12 occurrences".
|
||||||
|
* Returns null for frequencies the UI cannot describe (hourly etc.).
|
||||||
|
*/
|
||||||
|
export function buildRecurrenceSummary(
|
||||||
|
rule: CalendarRecurrenceRule,
|
||||||
|
t: CalendarT,
|
||||||
|
locale: string,
|
||||||
|
): string | null {
|
||||||
|
const interval = rule.interval || 1;
|
||||||
|
let base: string;
|
||||||
|
switch (rule.frequency) {
|
||||||
|
case "daily":
|
||||||
|
base = interval > 1 ? t("recurrence.every_n_days", { count: interval }) : t("recurrence.daily");
|
||||||
|
break;
|
||||||
|
case "weekly":
|
||||||
|
base = interval > 1 ? t("recurrence.every_n_weeks", { count: interval }) : t("recurrence.weekly");
|
||||||
|
break;
|
||||||
|
case "monthly":
|
||||||
|
base = interval > 1 ? t("recurrence.every_n_months", { count: interval }) : t("recurrence.monthly");
|
||||||
|
break;
|
||||||
|
case "yearly":
|
||||||
|
base = interval > 1 ? t("recurrence.every_n_years", { count: interval }) : t("recurrence.yearly");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [base];
|
||||||
|
|
||||||
|
if (rule.frequency === "weekly" && rule.byDay?.length) {
|
||||||
|
const days = rule.byDay
|
||||||
|
.filter((d) => WEEKDAYS.includes(d.day))
|
||||||
|
.sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.day))
|
||||||
|
.map((d) => weekdayName(d.day, locale, "short"))
|
||||||
|
.join(", ");
|
||||||
|
if (days) parts.push(t("recurrence.on_days", { days }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.frequency === "monthly" || rule.frequency === "yearly") {
|
||||||
|
if (rule.frequency === "yearly" && rule.byMonth?.length) {
|
||||||
|
const m = parseInt(rule.byMonth[0], 10);
|
||||||
|
if (m >= 1 && m <= 12) parts.push(t("recurrence.in_month", { month: monthName(m, locale) }));
|
||||||
|
}
|
||||||
|
const nthDay = getNthDay(rule);
|
||||||
|
if (nthDay) {
|
||||||
|
parts.push(t("recurrence.on_the_nth", {
|
||||||
|
nth: nthLabel(nthDay.nth, t),
|
||||||
|
day: weekdayName(nthDay.day, locale),
|
||||||
|
}));
|
||||||
|
} else if (rule.byMonthDay?.length) {
|
||||||
|
parts.push(t("recurrence.on_day_n", { day: rule.byMonthDay[0] }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = parts.join(" ");
|
||||||
|
if (rule.count) {
|
||||||
|
summary += ` · ${t("recurrence.occurrences", { count: rule.count })}`;
|
||||||
|
} else if (rule.until) {
|
||||||
|
const d = new Date(rule.until);
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
summary += ` · ${t("recurrence.until")} ${new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(d)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecurrenceEditorProps {
|
||||||
|
rule: CalendarRecurrenceRule | null;
|
||||||
|
eventStart: Date;
|
||||||
|
onSave: (rule: CalendarRecurrenceRule) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const startDay = INDEX_TO_DAY[eventStart.getDay()];
|
||||||
|
const initialNthDay = rule ? getNthDay(rule) : null;
|
||||||
|
|
||||||
|
const [frequency, setFrequency] = useState<EditorFrequency>(() =>
|
||||||
|
rule && (EDITOR_FREQUENCIES as string[]).includes(rule.frequency)
|
||||||
|
? (rule.frequency as EditorFrequency)
|
||||||
|
: "weekly"
|
||||||
|
);
|
||||||
|
const [interval, setIntervalValue] = useState<number>(rule?.interval || 1);
|
||||||
|
const [weekDays, setWeekDays] = useState<string[]>(() => {
|
||||||
|
if (rule?.frequency === "weekly" && rule.byDay?.length) {
|
||||||
|
const days = rule.byDay.map((d) => d.day).filter((d) => WEEKDAYS.includes(d));
|
||||||
|
if (days.length) return days;
|
||||||
|
}
|
||||||
|
return [startDay];
|
||||||
|
});
|
||||||
|
const [monthlyMode, setMonthlyMode] = useState<MonthlyMode>(initialNthDay ? "nth" : "day");
|
||||||
|
const [monthDay, setMonthDay] = useState<number>(() => {
|
||||||
|
const md = rule?.byMonthDay?.[0];
|
||||||
|
return md && md >= 1 && md <= 31 ? md : eventStart.getDate();
|
||||||
|
});
|
||||||
|
const [nth, setNth] = useState<number>(() => {
|
||||||
|
if (initialNthDay && (initialNthDay.nth === -1 || (initialNthDay.nth >= 1 && initialNthDay.nth <= 4))) {
|
||||||
|
return initialNthDay.nth;
|
||||||
|
}
|
||||||
|
return Math.min(4, Math.floor((eventStart.getDate() - 1) / 7) + 1);
|
||||||
|
});
|
||||||
|
const [nthDay, setNthDay] = useState<string>(() =>
|
||||||
|
initialNthDay && WEEKDAYS.includes(initialNthDay.day) ? initialNthDay.day : startDay
|
||||||
|
);
|
||||||
|
const [month, setMonth] = useState<number>(() => {
|
||||||
|
const m = rule?.byMonth?.length ? parseInt(rule.byMonth[0], 10) : NaN;
|
||||||
|
return m >= 1 && m <= 12 ? m : eventStart.getMonth() + 1;
|
||||||
|
});
|
||||||
|
const [endsMode, setEndsMode] = useState<EndsMode>(rule?.count ? "after" : rule?.until ? "on" : "never");
|
||||||
|
const [untilDate, setUntilDate] = useState<string>(() => {
|
||||||
|
if (rule?.until) {
|
||||||
|
const d = new Date(rule.until);
|
||||||
|
if (!isNaN(d.getTime())) return format(d, "yyyy-MM-dd");
|
||||||
|
}
|
||||||
|
return format(addYears(eventStart, 1), "yyyy-MM-dd");
|
||||||
|
});
|
||||||
|
const [count, setCount] = useState<number>(rule?.count ?? 12);
|
||||||
|
|
||||||
|
const toggleWeekDay = (day: string) => {
|
||||||
|
setWeekDays((prev) =>
|
||||||
|
prev.includes(day)
|
||||||
|
? prev.length > 1 ? prev.filter((d) => d !== day) : prev
|
||||||
|
: [...prev, day]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRule = (): CalendarRecurrenceRule => {
|
||||||
|
const built: CalendarRecurrenceRule = {
|
||||||
|
"@type": "RecurrenceRule",
|
||||||
|
frequency,
|
||||||
|
interval: Math.max(1, interval),
|
||||||
|
rscale: "gregorian",
|
||||||
|
skip: "omit",
|
||||||
|
firstDayOfWeek: "mo",
|
||||||
|
byDay: null,
|
||||||
|
byMonthDay: null,
|
||||||
|
byMonth: null,
|
||||||
|
byYearDay: null,
|
||||||
|
byWeekNo: null,
|
||||||
|
byHour: null,
|
||||||
|
byMinute: null,
|
||||||
|
bySecond: null,
|
||||||
|
bySetPosition: null,
|
||||||
|
count: endsMode === "after" ? Math.max(1, count) : null,
|
||||||
|
until: endsMode === "on" && untilDate ? `${untilDate}T23:59:59` : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (frequency === "weekly") {
|
||||||
|
const days = weekDays.length ? weekDays : [startDay];
|
||||||
|
built.byDay = WEEKDAYS.filter((d) => days.includes(d)).map((day) => ({ day }));
|
||||||
|
} else if (frequency === "monthly" || frequency === "yearly") {
|
||||||
|
if (monthlyMode === "nth") {
|
||||||
|
built.byDay = [{ day: nthDay, nthOfPeriod: nth }];
|
||||||
|
} else {
|
||||||
|
built.byMonthDay = [Math.min(31, Math.max(1, monthDay))];
|
||||||
|
}
|
||||||
|
if (frequency === "yearly") {
|
||||||
|
built.byMonth = [String(month)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return built;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => onSave(buildRule());
|
||||||
|
|
||||||
|
const summary = buildRecurrenceSummary(buildRule(), t, locale);
|
||||||
|
|
||||||
|
const selectCls = "rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 rounded-md border border-border bg-muted/20 p-3 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="shrink-0">{t("recurrence.editor_every")}</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={999}
|
||||||
|
value={interval}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10);
|
||||||
|
setIntervalValue(Number.isFinite(n) ? Math.max(1, n) : 1);
|
||||||
|
}}
|
||||||
|
className="w-16 shrink-0"
|
||||||
|
aria-label={t("recurrence.editor_every")}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={frequency}
|
||||||
|
onChange={(e) => setFrequency(e.target.value as EditorFrequency)}
|
||||||
|
className={`${selectCls} flex-1 min-w-0`}
|
||||||
|
aria-label={t("recurrence.title")}
|
||||||
|
>
|
||||||
|
{EDITOR_FREQUENCIES.map((f) => (
|
||||||
|
<option key={f} value={f}>{t(UNIT_LABEL_KEYS[f])}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{frequency === "weekly" && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{WEEKDAYS.map((day) => (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleWeekDay(day)}
|
||||||
|
title={weekdayName(day, locale)}
|
||||||
|
aria-pressed={weekDays.includes(day)}
|
||||||
|
className={
|
||||||
|
weekDays.includes(day)
|
||||||
|
? "flex-1 min-w-0 px-1 py-1.5 text-xs font-medium rounded-md border border-primary text-primary bg-primary/10 transition-colors"
|
||||||
|
: "flex-1 min-w-0 px-1 py-1.5 text-xs font-medium rounded-md border border-input text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{weekdayName(day, locale, "short")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{frequency === "yearly" && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="shrink-0">{capitalize(t("recurrence.editor_in"))}</span>
|
||||||
|
<select
|
||||||
|
value={month}
|
||||||
|
onChange={(e) => setMonth(parseInt(e.target.value, 10))}
|
||||||
|
className={`${selectCls} flex-1 min-w-0`}
|
||||||
|
aria-label={t("recurrence.editor_in")}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
|
||||||
|
<option key={m} value={m}>{capitalize(monthName(m, locale))}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(frequency === "monthly" || frequency === "yearly") && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<select
|
||||||
|
value={monthlyMode}
|
||||||
|
onChange={(e) => setMonthlyMode(e.target.value as MonthlyMode)}
|
||||||
|
className={`${selectCls} shrink-0`}
|
||||||
|
aria-label={t("recurrence.editor_repeats_on")}
|
||||||
|
>
|
||||||
|
<option value="day">{capitalize(t("recurrence.editor_on_day"))}</option>
|
||||||
|
<option value="nth">{capitalize(t("recurrence.editor_on_the"))}</option>
|
||||||
|
</select>
|
||||||
|
{monthlyMode === "day" ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={31}
|
||||||
|
value={monthDay}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10);
|
||||||
|
setMonthDay(Number.isFinite(n) ? Math.min(31, Math.max(1, n)) : 1);
|
||||||
|
}}
|
||||||
|
className="w-16 shrink-0"
|
||||||
|
aria-label={t("recurrence.editor_on_day")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={nth}
|
||||||
|
onChange={(e) => setNth(parseInt(e.target.value, 10))}
|
||||||
|
className={`${selectCls} flex-1 min-w-0`}
|
||||||
|
aria-label={t("recurrence.editor_on_the")}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, -1].map((n) => (
|
||||||
|
<option key={n} value={n}>{capitalize(nthLabel(n, t))}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={nthDay}
|
||||||
|
onChange={(e) => setNthDay(e.target.value)}
|
||||||
|
className={`${selectCls} flex-1 min-w-0`}
|
||||||
|
aria-label={t("recurrence.editor_on_the")}
|
||||||
|
>
|
||||||
|
{WEEKDAYS.map((d) => (
|
||||||
|
<option key={d} value={d}>{capitalize(weekdayName(d, locale))}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="shrink-0">{t("recurrence.editor_ends")}</span>
|
||||||
|
<select
|
||||||
|
value={endsMode}
|
||||||
|
onChange={(e) => setEndsMode(e.target.value as EndsMode)}
|
||||||
|
className={`${selectCls} ${endsMode === "never" ? "flex-1" : "shrink-0"} min-w-0`}
|
||||||
|
aria-label={t("recurrence.editor_ends")}
|
||||||
|
>
|
||||||
|
<option value="never">{t("recurrence.editor_never")}</option>
|
||||||
|
<option value="on">{t("recurrence.until")}</option>
|
||||||
|
<option value="after">{t("recurrence.editor_ends_after")}</option>
|
||||||
|
</select>
|
||||||
|
{endsMode === "on" && (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={untilDate}
|
||||||
|
onChange={(e) => setUntilDate(e.target.value)}
|
||||||
|
className={`${selectCls} flex-1 min-w-0`}
|
||||||
|
aria-label={t("recurrence.editor_ends_on")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{endsMode === "after" && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={999}
|
||||||
|
value={count}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10);
|
||||||
|
setCount(Number.isFinite(n) ? Math.max(1, n) : 1);
|
||||||
|
}}
|
||||||
|
className="w-16 shrink-0"
|
||||||
|
aria-label={t("recurrence.editor_ends_after")}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground truncate">{t("recurrence.editor_occurrences")}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
|
||||||
|
<p className="text-xs text-muted-foreground truncate min-w-0" title={summary ?? undefined}>
|
||||||
|
{summary}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSave}>
|
||||||
|
{t("form.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -302,7 +302,7 @@ export function TaskModal({
|
|||||||
className="text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive"
|
||||||
onClick={() => onDelete(task.id)}
|
onClick={() => onDelete(task.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
<Trash2 className="h-4 w-4 me-1" />
|
||||||
{t("tasks.delete")}
|
{t("tasks.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function TaskToolbar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ml-2">
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ms-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showCompleted}
|
checked={showCompleted}
|
||||||
@@ -57,7 +57,7 @@ export function TaskToolbar({
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<Button size="sm" onClick={onCreateTask}>
|
<Button size="sm" onClick={onCreateTask}>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 me-1" />
|
||||||
{t("tasks.create")}
|
{t("tasks.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,6 +62,27 @@ describe('ContactForm', () => {
|
|||||||
expect(phoneAfter.length).toBe(phoneBefore.length + 1);
|
expect(phoneAfter.length).toBe(phoneBefore.length + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends media: null when an existing photo is removed', async () => {
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const contactWithPhoto: ContactCard = {
|
||||||
|
...existingContact,
|
||||||
|
media: {
|
||||||
|
photo: { kind: 'photo', uri: 'data:image/png;base64,AAAA', mediaType: 'image/png' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(<ContactForm contact={contactWithPhoto} onSave={onSave} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('remove_photo'));
|
||||||
|
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSave).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedData = onSave.mock.calls[0][0];
|
||||||
|
expect(savedData.media).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('submits form data correctly', async () => {
|
it('submits form data correctly', async () => {
|
||||||
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()} />);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ContactsSidebar } from '../contacts-sidebar';
|
||||||
|
import type { ContactCard } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
// next-intl + next/navigation are mocked globally in vitest.setup (t returns the key).
|
||||||
|
vi.mock('@/stores/account-store', () => {
|
||||||
|
const state = { accounts: [], activeAccountId: null };
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
return { useAccountStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
const group = {
|
||||||
|
id: 'g1',
|
||||||
|
kind: 'group',
|
||||||
|
name: { full: 'Team' },
|
||||||
|
members: { '1': true },
|
||||||
|
} as unknown as ContactCard;
|
||||||
|
|
||||||
|
function renderSidebar(onComposeGroup = vi.fn()) {
|
||||||
|
render(
|
||||||
|
<ContactsSidebar
|
||||||
|
groups={[group]}
|
||||||
|
individuals={[]}
|
||||||
|
addressBooks={[]}
|
||||||
|
activeCategory="all"
|
||||||
|
onSelectCategory={vi.fn()}
|
||||||
|
onCreateGroup={vi.fn()}
|
||||||
|
onCreateContact={vi.fn()}
|
||||||
|
onEditGroup={vi.fn()}
|
||||||
|
onDeleteGroup={vi.fn()}
|
||||||
|
onComposeGroup={onComposeGroup}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return onComposeGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ContactsSidebar — compose to group', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it('shows a "Send email to group" submenu in the group context menu', () => {
|
||||||
|
renderSidebar();
|
||||||
|
fireEvent.contextMenu(screen.getByText('Team'));
|
||||||
|
expect(screen.getByText('groups.send_email')).toBeInTheDocument();
|
||||||
|
// and the existing Edit/Delete entries still render
|
||||||
|
expect(screen.getByText('groups.edit')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('form.delete')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onComposeGroup(groupId, field) when a To/Cc/Bcc item is clicked', () => {
|
||||||
|
const onComposeGroup = renderSidebar();
|
||||||
|
fireEvent.contextMenu(screen.getByText('Team'));
|
||||||
|
|
||||||
|
// Open the submenu (hover) then click "Cc".
|
||||||
|
const trigger = screen.getByText('groups.send_email').closest('.relative')!;
|
||||||
|
fireEvent.mouseOver(trigger);
|
||||||
|
fireEvent.mouseEnter(trigger);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('groups.send_email_cc'));
|
||||||
|
expect(onComposeGroup).toHaveBeenCalledWith('g1', 'cc');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -172,7 +172,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
|
|||||||
|
|
||||||
const handleOpenEmail = (email: Email) => {
|
const handleOpenEmail = (email: Email) => {
|
||||||
selectEmail(email);
|
selectEmail(email);
|
||||||
router.push("/mail");
|
router.push("/");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenEvent = (event: CalendarEvent) => {
|
const handleOpenEvent = (event: CalendarEvent) => {
|
||||||
@@ -229,7 +229,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
|
|||||||
key={email.id}
|
key={email.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOpenEmail(email)}
|
onClick={() => handleOpenEmail(email)}
|
||||||
className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
className="w-full text-start flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||||
>
|
>
|
||||||
<Avatar name={sender.name} email={sender.address} size="sm" />
|
<Avatar name={sender.name} email={sender.address} size="sm" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -277,7 +277,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
|
|||||||
key={event.id}
|
key={event.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOpenEvent(event)}
|
onClick={() => handleOpenEvent(event)}
|
||||||
className="w-full text-left flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
className="w-full text-start flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||||
>
|
>
|
||||||
<span className="text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0">
|
<span className="text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0">
|
||||||
{formatEventTime(event)}
|
{formatEventTime(event)}
|
||||||
|
|||||||
@@ -2,16 +2,13 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } 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, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react";
|
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } 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, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
|
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
|
||||||
import { ContactActivity } from "./contact-activity";
|
import { ContactActivity } from "./contact-activity";
|
||||||
import { useSmimeStore } from "@/stores/smime-store";
|
|
||||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
|
||||||
import type { CertificateInfo } from "@/lib/smime/types";
|
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { exportContact } from "./contact-export";
|
import { exportContact } from "./contact-export";
|
||||||
import { printContact } from "./contact-print";
|
import { printContact } from "./contact-print";
|
||||||
@@ -32,6 +29,8 @@ interface ContactDetailProps {
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onAddToGroup?: () => void;
|
onAddToGroup?: () => void;
|
||||||
onDuplicate?: () => void;
|
onDuplicate?: () => void;
|
||||||
|
/** Compose an email to this contact in the app (no OS mailto handoff). */
|
||||||
|
onCompose?: () => void;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -115,51 +114,11 @@ function formatDate(dateInput: AnniversaryDate): string {
|
|||||||
return dateStr;
|
return dateStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) {
|
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
const smimeStore = useSmimeStore();
|
|
||||||
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
|
|
||||||
|
|
||||||
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!contact) return;
|
|
||||||
let cancelled = false;
|
|
||||||
const parseCerts = async () => {
|
|
||||||
const results = new Map<number, CertificateInfo>();
|
|
||||||
for (let i = 0; i < cryptoKeys.length; i++) {
|
|
||||||
const key = cryptoKeys[i];
|
|
||||||
if (typeof key.uri !== 'string') continue;
|
|
||||||
try {
|
|
||||||
let derBytes: ArrayBuffer | string | null = null;
|
|
||||||
if (key.uri.startsWith('data:')) {
|
|
||||||
const commaIdx = key.uri.indexOf(',');
|
|
||||||
if (commaIdx === -1) continue;
|
|
||||||
const b64 = key.uri.substring(commaIdx + 1);
|
|
||||||
const binary = atob(b64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
|
||||||
derBytes = bytes.buffer;
|
|
||||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
|
||||||
derBytes = key.uri;
|
|
||||||
}
|
|
||||||
if (!derBytes) continue;
|
|
||||||
const cert = parseCertificatePemOrDer(derBytes);
|
|
||||||
const der = typeof derBytes === 'string' ? cert.toSchema(true).toBER(false) : derBytes;
|
|
||||||
const info = await extractCertificateInfo(cert, der);
|
|
||||||
if (!cancelled) results.set(i, info);
|
|
||||||
} catch { /* skip unparseable keys */ }
|
|
||||||
}
|
|
||||||
if (!cancelled) setParsedCerts(results);
|
|
||||||
};
|
|
||||||
if (cryptoKeys.length > 0) {
|
|
||||||
parseCerts();
|
|
||||||
} else {
|
|
||||||
setParsedCerts(new Map());
|
|
||||||
}
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
if (!contact) {
|
if (!contact) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
|
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
|
||||||
@@ -201,35 +160,10 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
|||||||
const notes = contact.notes ? Object.values(contact.notes) : [];
|
const notes = contact.notes ? Object.values(contact.notes) : [];
|
||||||
const titles = contact.titles ? Object.values(contact.titles) : [];
|
const titles = contact.titles ? Object.values(contact.titles) : [];
|
||||||
const jobTitles = titles.filter(t => t.kind !== "role");
|
const jobTitles = titles.filter(t => t.kind !== "role");
|
||||||
const roles = titles.filter(t => t.kind === "role");
|
|
||||||
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
|
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
|
||||||
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
|
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
|
||||||
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
|
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
|
||||||
|
|
||||||
const handleImportContactCert = async (keyIndex: number) => {
|
|
||||||
const key = cryptoKeys[keyIndex];
|
|
||||||
if (!key?.uri || typeof key.uri !== 'string') return;
|
|
||||||
try {
|
|
||||||
let derBytes: ArrayBuffer | string;
|
|
||||||
if (key.uri.startsWith('data:')) {
|
|
||||||
const commaIdx = key.uri.indexOf(',');
|
|
||||||
if (commaIdx === -1) return;
|
|
||||||
const b64 = key.uri.substring(commaIdx + 1);
|
|
||||||
const binary = atob(b64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
|
||||||
derBytes = bytes.buffer;
|
|
||||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
|
||||||
derBytes = key.uri;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await smimeStore.importPublicCert(derBytes, 'contact', contact.id);
|
|
||||||
toast.success(t("detail.cert_imported"));
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : t("detail.cert_import_failed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
|
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
|
||||||
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
|
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
|
||||||
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
|
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
|
||||||
@@ -260,26 +194,28 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 flex-shrink-0 flex-wrap">
|
<div className="flex gap-2 flex-shrink-0 flex-wrap">
|
||||||
{email && (
|
{email && onCompose && (
|
||||||
<a
|
<Button
|
||||||
href={`mailto:${email}`}
|
variant="outline"
|
||||||
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
size="sm"
|
||||||
|
onClick={onCompose}
|
||||||
|
className="touch-manipulation"
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4 mr-1" />
|
<Send className="w-4 h-4 me-1" />
|
||||||
{t("detail.compose_email")}
|
{t("detail.compose_email")}
|
||||||
</a>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{phone && (
|
{phone && (
|
||||||
<a
|
<a
|
||||||
href={`tel:${phone}`}
|
href={`tel:${phone}`}
|
||||||
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
||||||
>
|
>
|
||||||
<Phone className="w-4 h-4 mr-1" />
|
<Phone className="w-4 h-4 me-1" />
|
||||||
{t("context_menu.call")}
|
{t("context_menu.call")}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
||||||
<Pencil className="w-4 h-4 mr-1" />
|
<Pencil className="w-4 h-4 me-1" />
|
||||||
{t("form.edit_title")}
|
{t("form.edit_title")}
|
||||||
</Button>
|
</Button>
|
||||||
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
|
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
|
||||||
@@ -490,59 +426,20 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
|||||||
{cryptoKeys.length > 0 && (
|
{cryptoKeys.length > 0 && (
|
||||||
<Section title={t("detail.crypto_keys")}>
|
<Section title={t("detail.crypto_keys")}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{cryptoKeys.map((key, i) => {
|
{cryptoKeys.map((key, i) => (
|
||||||
const certInfo = parsedCerts.get(i);
|
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
|
||||||
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
|
<div className="flex items-start gap-2 text-sm break-all">
|
||||||
const alreadyImported = certInfo?.emailAddresses?.[0]
|
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
? !!smimeStore.getPublicCertForEmail(certInfo.emailAddresses[0])
|
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
||||||
: false;
|
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||||
|
{key.uri}
|
||||||
return (
|
</a>
|
||||||
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
|
|
||||||
{certInfo ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isExpired ? (
|
|
||||||
<ShieldAlert className="w-4 h-4 text-destructive flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<ShieldCheck className="w-4 h-4 text-primary flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium truncate">{certInfo.subject}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground space-y-0.5 pl-6">
|
|
||||||
<p>{t("detail.cert_issuer")}: {certInfo.issuer}</p>
|
|
||||||
<p>
|
|
||||||
{t("detail.cert_expires")}: {new Date(certInfo.notAfter).toLocaleDateString()}
|
|
||||||
{isExpired && <span className="text-destructive ml-1">({t("detail.cert_expired")})</span>}
|
|
||||||
</p>
|
|
||||||
<p>{t("detail.cert_fingerprint")}: {certInfo.fingerprint.substring(0, 20)}...</p>
|
|
||||||
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
|
|
||||||
</div>
|
|
||||||
{!alreadyImported && (
|
|
||||||
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
|
|
||||||
<Download className="w-3 h-3 mr-1" />
|
|
||||||
{t("detail.import_to_smime")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{alreadyImported && (
|
|
||||||
<p className="text-xs text-green-600 pl-6 mt-1">{t("detail.cert_already_imported")}</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-start gap-2 text-sm break-all">
|
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
|
||||||
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
|
||||||
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
|
||||||
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
|
||||||
{key.uri}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
@@ -674,7 +571,7 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string })
|
|||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
role="menu"
|
role="menu"
|
||||||
className="absolute right-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
|
className="absolute end-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
|
||||||
>
|
>
|
||||||
{items.map((item, i) => {
|
{items.map((item, i) => {
|
||||||
if (item.separator) {
|
if (item.separator) {
|
||||||
@@ -690,7 +587,7 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string })
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
|
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
|
||||||
item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950",
|
item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
import { useState, useMemo, useCallback, 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, Camera, Trash2 } from "lucide-react";
|
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -72,7 +72,7 @@ function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = t
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full text-left",
|
"flex items-center gap-2 w-full text-start",
|
||||||
collapsible ? "cursor-pointer" : "cursor-default"
|
collapsible ? "cursor-pointer" : "cursor-default"
|
||||||
)}
|
)}
|
||||||
onClick={() => collapsible && setOpen(!open)}
|
onClick={() => collapsible && setOpen(!open)}
|
||||||
@@ -525,6 +525,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
|
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set mediaVluae to null if we are removing media, so the server removes it
|
||||||
|
const hadMedia = !!contact?.media && Object.keys(contact.media).length > 0;
|
||||||
|
const mediaValue: Record<string, ContactMedia> | null | undefined =
|
||||||
|
Object.keys(mediaMap).length > 0 ? mediaMap : (hadMedia ? null : undefined);
|
||||||
|
|
||||||
const data: Partial<ContactCard> = {
|
const data: Partial<ContactCard> = {
|
||||||
name: { components: nameComponents, isOrdered: true },
|
name: { components: nameComponents, isOrdered: true },
|
||||||
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
||||||
@@ -551,7 +556,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
calendarUri: calendarUri.trim() || undefined,
|
calendarUri: calendarUri.trim() || undefined,
|
||||||
schedulingUri: schedulingUri.trim() || undefined,
|
schedulingUri: schedulingUri.trim() || undefined,
|
||||||
freeBusyUri: freeBusyUri.trim() || undefined,
|
freeBusyUri: freeBusyUri.trim() || undefined,
|
||||||
media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined,
|
media: mediaValue as Record<string, ContactMedia> | undefined,
|
||||||
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
|
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -731,12 +736,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{emailErrors[i] && (
|
{emailErrors[i] && (
|
||||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ml-1">{emailErrors[i]}</p>
|
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ms-1">{emailErrors[i]}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEmails([...emails, { address: "", context: "" }])} className="text-xs">
|
<Button type="button" variant="ghost" size="sm" onClick={() => setEmails([...emails, { address: "", context: "" }])} className="text-xs">
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 me-1" />
|
||||||
{t("add_email")}
|
{t("add_email")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -794,7 +799,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setPhones([...phones, { number: "", context: "", feature: "" }])} className="text-xs">
|
<Button type="button" variant="ghost" size="sm" onClick={() => setPhones([...phones, { number: "", context: "", feature: "" }])} className="text-xs">
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 me-1" />
|
||||||
{t("add_phone")}
|
{t("add_phone")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -850,7 +855,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setAddresses([...addresses, { street: "", locality: "", region: "", postcode: "", country: "", context: "" }])} className="text-xs">
|
<Button type="button" variant="ghost" size="sm" onClick={() => setAddresses([...addresses, { street: "", locality: "", region: "", postcode: "", country: "", context: "" }])} className="text-xs">
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 me-1" />
|
||||||
{t("add_address")}
|
{t("add_address")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -879,7 +884,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
|
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 me-1" />
|
||||||
{t("add_online_service")}
|
{t("add_online_service")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -911,7 +916,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setAnniversaries([...anniversaries, { date: "", kind: "birth" }])} className="text-xs">
|
<Button type="button" variant="ghost" size="sm" onClick={() => setAnniversaries([...anniversaries, { date: "", kind: "birth" }])} className="text-xs">
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 me-1" />
|
||||||
{t("add_anniversary")}
|
{t("add_anniversary")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -952,7 +957,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
|
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 me-1" />
|
||||||
{t("add_personal_info")}
|
{t("add_personal_info")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1140,7 +1145,7 @@ function CategoryComboBox({
|
|||||||
<button
|
<button
|
||||||
key={kw}
|
key={kw}
|
||||||
type="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"
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||||
onClick={() => { addKeyword(kw); inputRef.current?.focus(); }}
|
onClick={() => { addKeyword(kw); inputRef.current?.focus(); }}
|
||||||
>
|
>
|
||||||
<Tag className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
<Tag className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
@@ -1150,7 +1155,7 @@ function CategoryComboBox({
|
|||||||
{canAddNew && (
|
{canAddNew && (
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start text-primary"
|
||||||
onClick={() => { addKeyword(inputValue); inputRef.current?.focus(); }}
|
onClick={() => { addKeyword(inputValue); inputRef.current?.focus(); }}
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
|
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Users, Pencil, Trash2, UserMinus } from "lucide-react";
|
import { Users, Pencil, Trash2, UserMinus, Mail } 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";
|
||||||
@@ -15,6 +15,8 @@ interface ContactGroupDetailProps {
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onRemoveMember: (memberId: string) => void;
|
onRemoveMember: (memberId: string) => void;
|
||||||
onSelectMember: (id: string) => void;
|
onSelectMember: (id: string) => void;
|
||||||
|
/** Compose an email to every member with the recipients placed in `field`. */
|
||||||
|
onComposeGroup?: (field: "to" | "cc" | "bcc") => void;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -26,11 +28,13 @@ export function ContactGroupDetail({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onRemoveMember,
|
onRemoveMember,
|
||||||
onSelectMember,
|
onSelectMember,
|
||||||
|
onComposeGroup,
|
||||||
isMobile,
|
isMobile,
|
||||||
className,
|
className,
|
||||||
}: ContactGroupDetailProps) {
|
}: ContactGroupDetailProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
const groupName = getContactDisplayName(group);
|
const groupName = getContactDisplayName(group);
|
||||||
|
const hasEmailMembers = members.some((m) => getContactPrimaryEmail(m).trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
||||||
@@ -49,7 +53,7 @@ export function ContactGroupDetail({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
||||||
<Pencil className="w-4 h-4 mr-1" />
|
<Pencil className="w-4 h-4 me-1" />
|
||||||
{t("form.edit_title")}
|
{t("form.edit_title")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -62,6 +66,25 @@ export function ContactGroupDetail({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{onComposeGroup && hasEmailMembers && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||||
|
<Mail className="w-4 h-4 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="text-sm text-muted-foreground">{t("groups.send_email")}</span>
|
||||||
|
<div className="inline-flex gap-1">
|
||||||
|
{(["to", "cc", "bcc"] as const).map((field) => (
|
||||||
|
<Button
|
||||||
|
key={field}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onComposeGroup(field)}
|
||||||
|
className="touch-manipulation"
|
||||||
|
>
|
||||||
|
{t(`groups.send_email_${field}`)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4">
|
||||||
@@ -83,7 +106,7 @@ export function ContactGroupDetail({
|
|||||||
className="flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted group transition-colors"
|
className="flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted group transition-colors"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-3 flex-1 min-w-0 text-left"
|
className="flex items-center gap-3 flex-1 min-w-0 text-start"
|
||||||
onClick={() => onSelectMember(member.id)}
|
onClick={() => onSelectMember(member.id)}
|
||||||
>
|
>
|
||||||
<Avatar name={mName} email={mEmail} size="sm" />
|
<Avatar name={mName} email={mEmail} size="sm" />
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function ContactGroupForm({
|
|||||||
placeholder={t("groups.search_members")}
|
placeholder={t("groups.search_members")}
|
||||||
value={memberSearch}
|
value={memberSearch}
|
||||||
onChange={(e) => setMemberSearch(e.target.value)}
|
onChange={(e) => setMemberSearch(e.target.value)}
|
||||||
className="pl-9"
|
className="ps-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ export function ContactGroupForm({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleMember(contact.id)}
|
onClick={() => toggleMember(contact.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors",
|
"w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors",
|
||||||
"hover:bg-muted",
|
"hover:bg-muted",
|
||||||
isSelected && "bg-primary/5"
|
isSelected && "bg-primary/5"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function ContactGroupList({
|
|||||||
<div className={cn("flex flex-col", className)}>
|
<div className={cn("flex flex-col", className)}>
|
||||||
<div className="px-4 py-2 border-b border-border">
|
<div className="px-4 py-2 border-b border-border">
|
||||||
<Button size="sm" variant="outline" onClick={onCreateGroup} className="w-full">
|
<Button size="sm" variant="outline" onClick={onCreateGroup} className="w-full">
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 me-1" />
|
||||||
{t("groups.create")}
|
{t("groups.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +69,7 @@ export function ContactGroupList({
|
|||||||
key={group.id}
|
key={group.id}
|
||||||
onClick={() => onSelectGroup(group.id)}
|
onClick={() => onSelectGroup(group.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center px-4 text-left transition-colors",
|
"w-full flex items-center px-4 text-start transition-colors",
|
||||||
"hover:bg-muted",
|
"hover:bg-muted",
|
||||||
group.id === selectedGroupId && "bg-accent text-accent-foreground"
|
group.id === selectedGroupId && "bg-accent text-accent-foreground"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export function ContactImportDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleSelect(idx)}
|
onClick={() => toggleSelect(idx)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted",
|
"w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors hover:bg-muted",
|
||||||
isSelected && "bg-primary/5"
|
isSelected && "bg-primary/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ export function ContactList({
|
|||||||
placeholder={t("search_placeholder")}
|
placeholder={t("search_placeholder")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
className={cn("ps-9 h-9", searchQuery && "pe-8")}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -359,7 +359,7 @@ export function ContactList({
|
|||||||
onClick={() => setFilters(EMPTY_FILTERS)}
|
onClick={() => setFilters(EMPTY_FILTERS)}
|
||||||
className="h-7 px-2 text-xs"
|
className="h-7 px-2 text-xs"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3 h-3 mr-1" />
|
<RotateCcw className="w-3 h-3 me-1" />
|
||||||
{t("filters.clear")}
|
{t("filters.clear")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -479,11 +479,11 @@ export function ContactList({
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" onClick={onBulkAddToGroup} className="h-7 text-xs">
|
<Button variant="ghost" size="sm" onClick={onBulkAddToGroup} className="h-7 text-xs">
|
||||||
<Users className="w-3.5 h-3.5 mr-1" />
|
<Users className="w-3.5 h-3.5 me-1" />
|
||||||
{t("bulk.add_to_group")}
|
{t("bulk.add_to_group")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={onBulkExport} className="h-7 text-xs">
|
<Button variant="ghost" size="sm" onClick={onBulkExport} className="h-7 text-xs">
|
||||||
<Download className="w-3.5 h-3.5 mr-1" />
|
<Download className="w-3.5 h-3.5 me-1" />
|
||||||
{t("bulk.export")}
|
{t("bulk.export")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -492,7 +492,7 @@ export function ContactList({
|
|||||||
onClick={onBulkDelete}
|
onClick={onBulkDelete}
|
||||||
className="h-7 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
className="h-7 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
<Trash2 className="w-3.5 h-3.5 me-1" />
|
||||||
{t("bulk.delete")}
|
{t("bulk.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
|
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
|
||||||
@@ -526,7 +526,7 @@ export function ContactList({
|
|||||||
)}
|
)}
|
||||||
{activeFilters > 0 && (
|
{activeFilters > 0 && (
|
||||||
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
|
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
|
||||||
<RotateCcw className="w-3.5 h-3.5 mr-1" />
|
<RotateCcw className="w-3.5 h-3.5 me-1" />
|
||||||
{t("filters.clear")}
|
{t("filters.clear")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -538,7 +538,7 @@ export function ContactList({
|
|||||||
<p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
|
<p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
|
<p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
|
||||||
<Button size="sm" className="mt-3" onClick={onCreateNew}>
|
<Button size="sm" className="mt-3" onClick={onCreateNew}>
|
||||||
<UserPlus className="w-4 h-4 mr-1.5" />
|
<UserPlus className="w-4 h-4 me-1.5" />
|
||||||
{t("create_new")}
|
{t("create_new")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { useMemo, useState, useCallback, useEffect, useRef, 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, User, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
|
import { BookUser, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||||
import { useContextMenu } from "@/hooks/use-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";
|
||||||
@@ -22,9 +22,11 @@ interface ContactsSidebarProps {
|
|||||||
onSelectCategory: (category: ContactCategory) => void;
|
onSelectCategory: (category: ContactCategory) => void;
|
||||||
onCreateGroup: () => void;
|
onCreateGroup: () => void;
|
||||||
onCreateContact: () => void;
|
onCreateContact: () => void;
|
||||||
|
onCreateAddressBook?: () => void;
|
||||||
onImport?: () => void;
|
onImport?: () => void;
|
||||||
onEditGroup?: (groupId: string) => void;
|
onEditGroup?: (groupId: string) => void;
|
||||||
onDeleteGroup?: (groupId: string) => void;
|
onDeleteGroup?: (groupId: string) => void;
|
||||||
|
onComposeGroup?: (groupId: string, field: "to" | "cc" | "bcc") => void;
|
||||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||||
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||||
@@ -90,9 +92,11 @@ export function ContactsSidebar({
|
|||||||
onSelectCategory,
|
onSelectCategory,
|
||||||
onCreateGroup,
|
onCreateGroup,
|
||||||
onCreateContact,
|
onCreateContact,
|
||||||
|
onCreateAddressBook,
|
||||||
onImport,
|
onImport,
|
||||||
onEditGroup,
|
onEditGroup,
|
||||||
onDeleteGroup,
|
onDeleteGroup,
|
||||||
|
onComposeGroup,
|
||||||
onDropContacts,
|
onDropContacts,
|
||||||
onDropContactsToCategory,
|
onDropContactsToCategory,
|
||||||
onRenameAddressBook,
|
onRenameAddressBook,
|
||||||
@@ -280,25 +284,34 @@ export function ContactsSidebar({
|
|||||||
{showMenu && (
|
{showMenu && (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
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"
|
className="absolute end-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||||
onClick={() => { setShowMenu(false); onCreateContact(); }}
|
onClick={() => { setShowMenu(false); onCreateContact(); }}
|
||||||
>
|
>
|
||||||
<UserPlus className="w-4 h-4" />
|
<UserPlus className="w-4 h-4" />
|
||||||
{t("create_new")}
|
{t("create_new")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||||
onClick={() => { setShowMenu(false); onCreateGroup(); }}
|
onClick={() => { setShowMenu(false); onCreateGroup(); }}
|
||||||
>
|
>
|
||||||
<UsersRound className="w-4 h-4" />
|
<UsersRound className="w-4 h-4" />
|
||||||
{t("groups.create")}
|
{t("groups.create")}
|
||||||
</button>
|
</button>
|
||||||
|
{onCreateAddressBook && (
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||||
|
onClick={() => { setShowMenu(false); onCreateAddressBook(); }}
|
||||||
|
>
|
||||||
|
<BookPlus className="w-4 h-4" />
|
||||||
|
{t("address_books.create")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onImport && (
|
{onImport && (
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||||
onClick={() => { setShowMenu(false); onImport(); }}
|
onClick={() => { setShowMenu(false); onImport(); }}
|
||||||
>
|
>
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
@@ -325,7 +338,7 @@ 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="ms-auto text-xs text-muted-foreground tabular-nums">
|
||||||
{individuals.length}
|
{individuals.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -342,7 +355,7 @@ export function ContactsSidebar({
|
|||||||
<div className="flex items-center px-3 py-1 group">
|
<div className="flex items-center px-3 py-1 group">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection(sectionKey)}
|
onClick={() => toggleSection(sectionKey)}
|
||||||
className="flex items-center gap-1 flex-1 min-w-0 text-left"
|
className="flex items-center gap-1 flex-1 min-w-0 text-start"
|
||||||
>
|
>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
@@ -356,7 +369,7 @@ export function ContactsSidebar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="pl-2">
|
<div className="ps-2">
|
||||||
{owned.length > 0 && (
|
{owned.length > 0 && (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
||||||
@@ -405,7 +418,7 @@ export function ContactsSidebar({
|
|||||||
<div className="flex items-center px-3 py-1 group">
|
<div className="flex items-center px-3 py-1 group">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection("addressBooks")}
|
onClick={() => toggleSection("addressBooks")}
|
||||||
className="flex items-center gap-1 flex-1 text-left"
|
className="flex items-center gap-1 flex-1 text-start"
|
||||||
>
|
>
|
||||||
{collapsed.addressBooks ? (
|
{collapsed.addressBooks ? (
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
@@ -448,7 +461,7 @@ export function ContactsSidebar({
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection("groups")}
|
onClick={() => toggleSection("groups")}
|
||||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
className="flex items-center gap-1 px-3 py-1 w-full text-start group"
|
||||||
>
|
>
|
||||||
{collapsed.groups ? (
|
{collapsed.groups ? (
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
@@ -470,7 +483,7 @@ export function ContactsSidebar({
|
|||||||
onClick={() => onSelectCategory({ groupId: group.id })}
|
onClick={() => onSelectCategory({ groupId: group.id })}
|
||||||
onContextMenu={(e) => openGroupContextMenu(e, group)}
|
onContextMenu={(e) => openGroupContextMenu(e, group)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
"w-full flex items-center gap-2 ps-5 pe-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"
|
||||||
@@ -479,7 +492,7 @@ export function ContactsSidebar({
|
|||||||
>
|
>
|
||||||
<Users className="w-4 h-4 flex-shrink-0" />
|
<Users className="w-4 h-4 flex-shrink-0" />
|
||||||
<span className="truncate">{getContactDisplayName(group)}</span>
|
<span className="truncate">{getContactDisplayName(group)}</span>
|
||||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
|
||||||
{memberCount}
|
{memberCount}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -492,7 +505,7 @@ export function ContactsSidebar({
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection("categories")}
|
onClick={() => toggleSection("categories")}
|
||||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
className="flex items-center gap-1 px-3 py-1 w-full text-start group"
|
||||||
>
|
>
|
||||||
{collapsed.categories ? (
|
{collapsed.categories ? (
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
@@ -510,7 +523,7 @@ export function ContactsSidebar({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onSelectCategory("uncategorized")}
|
onClick={() => onSelectCategory("uncategorized")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
|
||||||
activeCategory === "uncategorized"
|
activeCategory === "uncategorized"
|
||||||
? "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"
|
||||||
@@ -519,7 +532,7 @@ export function ContactsSidebar({
|
|||||||
>
|
>
|
||||||
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
|
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
|
||||||
<span className="truncate italic">{t("no_category")}</span>
|
<span className="truncate italic">{t("no_category")}</span>
|
||||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
|
||||||
{uncategorizedCount}
|
{uncategorizedCount}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -548,7 +561,7 @@ export function ContactsSidebar({
|
|||||||
<div className="flex items-center px-3 py-1 group">
|
<div className="flex items-center px-3 py-1 group">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
||||||
className="flex items-center gap-1 flex-1 min-w-0 text-left"
|
className="flex items-center gap-1 flex-1 min-w-0 text-start"
|
||||||
>
|
>
|
||||||
{collapsed[`shared-${group.accountId}`] ? (
|
{collapsed[`shared-${group.accountId}`] ? (
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
@@ -684,6 +697,21 @@ export function ContactsSidebar({
|
|||||||
onEditGroup?.(groupContextMenu.data!.id);
|
onEditGroup?.(groupContextMenu.data!.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{onComposeGroup && (
|
||||||
|
<ContextMenuSubMenu icon={Mail} label={t("groups.send_email")}>
|
||||||
|
{(["to", "cc", "bcc"] as const).map((field) => (
|
||||||
|
<ContextMenuItem
|
||||||
|
key={field}
|
||||||
|
label={t(`groups.send_email_${field}`)}
|
||||||
|
onClick={() => {
|
||||||
|
const groupId = groupContextMenu.data!.id;
|
||||||
|
closeGroupContextMenu();
|
||||||
|
onComposeGroup(groupId, field);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ContextMenuSubMenu>
|
||||||
|
)}
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
@@ -751,7 +779,7 @@ function CategoryItem({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
"w-full flex items-center gap-2 ps-5 pe-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",
|
||||||
@@ -761,7 +789,7 @@ function CategoryItem({
|
|||||||
>
|
>
|
||||||
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
|
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
<span className="truncate">{keyword}</span>
|
<span className="truncate">{keyword}</span>
|
||||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -819,7 +847,7 @@ function AddressBookItem({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
"w-full flex items-center gap-2 ps-5 pe-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",
|
||||||
@@ -830,11 +858,11 @@ function AddressBookItem({
|
|||||||
<Book className="w-4 h-4 flex-shrink-0" />
|
<Book className="w-4 h-4 flex-shrink-0" />
|
||||||
<span className="truncate">{book.name}</span>
|
<span className="truncate">{book.name}</span>
|
||||||
{!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
|
{!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
|
||||||
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ml-auto" />
|
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ms-auto" />
|
||||||
)}
|
)}
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-xs text-muted-foreground tabular-nums",
|
"text-xs text-muted-foreground tabular-nums",
|
||||||
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ml-auto"
|
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ms-auto"
|
||||||
)}>
|
)}>
|
||||||
{contactCount}
|
{contactCount}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -540,7 +540,9 @@ describe('CalendarInvitationBanner', () => {
|
|||||||
mocks.clientMock,
|
mocks.clientMock,
|
||||||
'event-8',
|
'event-8',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
replyTo: { imip: 'mailto:organizer@example.com' },
|
// The stored event lacks an ORGANIZER, so the RSVP repair writes
|
||||||
|
// organizerCalendarAddress (replyTo is retired in jscalendarbis).
|
||||||
|
organizerCalendarAddress: 'mailto:organizer@example.com',
|
||||||
participants: expect.objectContaining({
|
participants: expect.objectContaining({
|
||||||
attendee: expect.objectContaining({
|
attendee: expect.objectContaining({
|
||||||
participationStatus: 'accepted',
|
participationStatus: 'accepted',
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ContactSidebarPanel } from '../email-viewer';
|
||||||
|
import type { ContactCard } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
const contact: ContactCard = {
|
||||||
|
id: 'c1',
|
||||||
|
addressBookIds: {},
|
||||||
|
name: {
|
||||||
|
components: [
|
||||||
|
{ kind: 'given', value: 'Alice' },
|
||||||
|
{ kind: 'surname', value: 'Smith' },
|
||||||
|
],
|
||||||
|
isOrdered: true,
|
||||||
|
},
|
||||||
|
emails: { e0: { address: 'alice@example.com' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const unknownEmail = 'unknown@example.com';
|
||||||
|
|
||||||
|
describe('ContactSidebarPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Edit button when contact is known and onEditContact is provided', () => {
|
||||||
|
render(
|
||||||
|
<ContactSidebarPanel
|
||||||
|
email="alice@example.com"
|
||||||
|
contact={contact}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onEditContact={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// useTranslations mock returns the key, so we look for the common.edit key
|
||||||
|
expect(screen.getByTitle('contact_sidebar.action_edit_title')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('edit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onEditContact when Edit button is clicked', () => {
|
||||||
|
const onEditContact = vi.fn();
|
||||||
|
render(
|
||||||
|
<ContactSidebarPanel
|
||||||
|
email="alice@example.com"
|
||||||
|
contact={contact}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onEditContact={onEditContact}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTitle('contact_sidebar.action_edit_title'));
|
||||||
|
expect(onEditContact).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show Edit button when contact is null', () => {
|
||||||
|
render(
|
||||||
|
<ContactSidebarPanel
|
||||||
|
email={unknownEmail}
|
||||||
|
contact={null}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onEditContact={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByTitle('contact_sidebar.action_edit_title')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show Edit button when onEditContact is not provided', () => {
|
||||||
|
render(
|
||||||
|
<ContactSidebarPanel
|
||||||
|
email="alice@example.com"
|
||||||
|
contact={contact}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByTitle('contact_sidebar.action_edit_title')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "not in contacts" message and Add button for unknown email', () => {
|
||||||
|
const onAddToContacts = vi.fn();
|
||||||
|
render(
|
||||||
|
<ContactSidebarPanel
|
||||||
|
email={unknownEmail}
|
||||||
|
contact={null}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onAddToContacts={onAddToContacts}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('contact_sidebar.not_in_contacts')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByText('contact_sidebar.add_to_contacts'));
|
||||||
|
expect(onAddToContacts).toHaveBeenCalledWith(unknownEmail, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose when the close button is clicked', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<ContactSidebarPanel
|
||||||
|
email="alice@example.com"
|
||||||
|
contact={contact}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByLabelText('contact_sidebar.close'));
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -68,10 +68,12 @@ describe('EmailListItem tag badge', () => {
|
|||||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show badge when keyword id not in settings', () => {
|
it('shows a gray fallback badge when keyword id is not in settings', () => {
|
||||||
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
|
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
|
||||||
render(<EmailListItem email={email} />);
|
render(<EmailListItem email={email} />);
|
||||||
expect(screen.queryByText('unknown-tag')).not.toBeInTheDocument();
|
// Unknown tags fall back to the raw id as label with a gray dot
|
||||||
|
// (see email-list-item.tsx: keywordDefs fallback).
|
||||||
|
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows custom keyword label', () => {
|
it('shows custom keyword label', () => {
|
||||||
@@ -119,3 +121,34 @@ describe('EmailListItem tag badge', () => {
|
|||||||
expect(container.querySelector('p')).toBeNull();
|
expect(container.querySelector('p')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('EmailListItem shift-range checkbox', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shift-clicking the checkbox extends the selection from the anchor', () => {
|
||||||
|
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
|
||||||
|
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
|
||||||
|
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
|
||||||
|
// selection mode active (so the checkbox renders), anchor on e1
|
||||||
|
useEmailStore.setState({
|
||||||
|
emails: [e1, e2, e3],
|
||||||
|
selectedEmailIds: new Set(['e1']),
|
||||||
|
lastSelectedEmailId: 'e1',
|
||||||
|
selectedMailbox: 'inbox',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<EmailListItem email={e3} />);
|
||||||
|
// the checkbox is the first button in the row (shown in selection mode)
|
||||||
|
const checkbox = screen.getAllByRole('button')[0];
|
||||||
|
act(() => {
|
||||||
|
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const sel = useEmailStore.getState().selectedEmailIds;
|
||||||
|
expect(sel.has('e1')).toBe(true);
|
||||||
|
expect(sel.has('e2')).toBe(true); // the in-between row got filled in
|
||||||
|
expect(sel.has('e3')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
import { render, screen, act } from '@testing-library/react';
|
||||||
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { EmailComposer } from '../email-composer';
|
||||||
|
|
||||||
|
// ─── Heavy component mocks ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||||
|
RichTextEditor: ({ onChange }: { onChange?: (html: string) => void }) => (
|
||||||
|
React.createElement('div', { 'data-testid': 'rich-text-editor', onClick: () => onChange?.('') })
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||||
|
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||||
|
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||||
|
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||||
|
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||||
|
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||||
|
useFocusTrap: () => ({ ref: { current: null } }),
|
||||||
|
}));
|
||||||
|
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||||
|
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||||
|
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||||
|
// vi.mock factories are hoisted, so all values must be defined inline.
|
||||||
|
|
||||||
|
vi.mock('@/stores/auth-store', () => {
|
||||||
|
const state = {
|
||||||
|
client: null,
|
||||||
|
identities: [],
|
||||||
|
primaryIdentity: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isDemoMode: false,
|
||||||
|
activeAccountId: null,
|
||||||
|
connectionLost: false,
|
||||||
|
getClientForAccount: () => undefined,
|
||||||
|
getAllConnectedClients: () => new Map(),
|
||||||
|
syncIdentities: () => {},
|
||||||
|
refreshIdentities: async () => {},
|
||||||
|
};
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useAuthStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/identity-store', () => {
|
||||||
|
const state = { identities: [], defaultIdentityId: null };
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useIdentityStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/account-store', () => {
|
||||||
|
const state = { accounts: [], getAccountById: () => undefined };
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useAccountStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/email-store', () => {
|
||||||
|
const state = {
|
||||||
|
draftSaveEnabled: false,
|
||||||
|
sendRawEmail: async () => ({ sent: true }),
|
||||||
|
};
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useEmailStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/settings-store', () => {
|
||||||
|
const state = {
|
||||||
|
timeFormat: '24h',
|
||||||
|
plainTextMode: false,
|
||||||
|
subAddressDelimiter: '+',
|
||||||
|
autoSelectReplyIdentity: true,
|
||||||
|
attachmentReminderEnabled: false,
|
||||||
|
attachmentReminderKeywords: [],
|
||||||
|
sendDelaySeconds: 0,
|
||||||
|
signaturePosition: 'above_quote',
|
||||||
|
signatureSeparatorEnabled: false,
|
||||||
|
requestReadReceiptDefault: false,
|
||||||
|
addTrustedSender: () => {},
|
||||||
|
trustedSendersAddressBook: null,
|
||||||
|
};
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useSettingsStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/contact-store', () => {
|
||||||
|
const state = {
|
||||||
|
contacts: [],
|
||||||
|
getAutocomplete: async () => [],
|
||||||
|
addToTrustedSendersBook: async () => {},
|
||||||
|
};
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useContactStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/template-store', () => {
|
||||||
|
const state = { templates: [], addTemplate: async () => {} };
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useTemplateStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('@/stores/toast-store', () => ({
|
||||||
|
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/plugin-hooks', () => ({
|
||||||
|
emailHooks: {
|
||||||
|
onComposerOpen: { call: async () => [] },
|
||||||
|
onRecipientChange: { call: async () => [] },
|
||||||
|
getRecipientSuggestions: { call: async () => [] },
|
||||||
|
onSend: { call: async () => [] },
|
||||||
|
beforeSend: { call: async () => [] },
|
||||||
|
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||||
|
},
|
||||||
|
contactHooks: {
|
||||||
|
search: { call: async () => [] },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/email-sanitization', () => ({
|
||||||
|
sanitizeSignatureHtml: (v: string) => v,
|
||||||
|
sanitizeEmailHtml: (v: string) => v,
|
||||||
|
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/reply-identity', () => ({
|
||||||
|
resolveReplyFrom: () => null,
|
||||||
|
findComposeIdentityId: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/email-threading', () => ({
|
||||||
|
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/signature-utils', () => ({
|
||||||
|
appendPlainTextSignature: (body: string) => body,
|
||||||
|
getPlainTextSignature: () => '',
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||||
|
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
||||||
|
vi.mock('@/components/email/quoted-html', () => ({
|
||||||
|
buildQuotedHtmlBlock: () => '',
|
||||||
|
serializeEditorContent: () => '',
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||||
|
|
||||||
|
// ─── DataTransfer polyfill ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** jsdom's built-in DataTransfer doesn't fully support setData/getData in synthetic drag events. */
|
||||||
|
class MockDataTransfer {
|
||||||
|
private _data: Record<string, string> = {};
|
||||||
|
types: string[] = [];
|
||||||
|
effectAllowed = '';
|
||||||
|
dropEffect = '';
|
||||||
|
|
||||||
|
setData(type: string, data: string) {
|
||||||
|
this._data[type] = data;
|
||||||
|
if (!this.types.includes(type)) this.types.push(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
getData(type: string): string {
|
||||||
|
return this._data[type] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
setDragImage(_image: Element, _x: number, _y: number) {
|
||||||
|
// no-op: jsdom has no rendering, but the chip drag handler calls this.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared test data ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BASE_DATA = {
|
||||||
|
to: 'alice@example.com, ',
|
||||||
|
cc: '',
|
||||||
|
bcc: '',
|
||||||
|
subject: '',
|
||||||
|
body: '',
|
||||||
|
showCc: true,
|
||||||
|
showBcc: true,
|
||||||
|
selectedIdentityId: null,
|
||||||
|
subAddressTag: '',
|
||||||
|
mode: 'compose' as const,
|
||||||
|
draftId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('RecipientChipInput drag and drop', () => {
|
||||||
|
beforeEach(() => { vi.clearAllMocks(); });
|
||||||
|
|
||||||
|
it('renders recipient chips with draggable="true"', async () => {
|
||||||
|
render(<EmailComposer initialData={BASE_DATA} />);
|
||||||
|
|
||||||
|
const chipText = await screen.findByText('alice@example.com');
|
||||||
|
const chipSpan = chipText.closest('[draggable]');
|
||||||
|
expect(chipSpan).not.toBeNull();
|
||||||
|
expect(chipSpan).toHaveAttribute('draggable', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onDragStart encodes the recipient and source field into dataTransfer', async () => {
|
||||||
|
render(<EmailComposer initialData={BASE_DATA} />);
|
||||||
|
|
||||||
|
const chipText = await screen.findByText('alice@example.com');
|
||||||
|
const chipSpan = chipText.closest('[draggable]') as HTMLElement;
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
||||||
|
|
||||||
|
// The enrichment pass may have stamped extra display metadata on the
|
||||||
|
// chip by drag time, so match the essential fields rather than deep-equal.
|
||||||
|
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
||||||
|
expect(payload).toMatchObject({ recipient: { email: 'alice@example.com' }, fromField: 'to', fromIndex: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a display name with a comma in a single chip (array model)', async () => {
|
||||||
|
render(<EmailComposer initialData={{ ...BASE_DATA, to: '"Doo, John" <john@doo.org>, ' }} />);
|
||||||
|
|
||||||
|
// One chip, displayed as "Doo, John (john@doo.org)" — not split on the comma.
|
||||||
|
const chip = await screen.findByText('Doo, John (john@doo.org)');
|
||||||
|
const chipSpan = chip.closest('[draggable]') as HTMLElement;
|
||||||
|
expect(chipSpan).not.toBeNull();
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
||||||
|
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
||||||
|
expect(payload).toMatchObject({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to', fromIndex: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onDragEnd clears the opacity class on the chip', async () => {
|
||||||
|
render(<EmailComposer initialData={BASE_DATA} />);
|
||||||
|
|
||||||
|
const chipText = await screen.findByText('alice@example.com');
|
||||||
|
const chipSpan = chipText.closest('[draggable]') as HTMLElement;
|
||||||
|
|
||||||
|
fireEvent.dragStart(chipSpan, { dataTransfer: new MockDataTransfer() });
|
||||||
|
expect(chipSpan.className).toContain('opacity-50');
|
||||||
|
|
||||||
|
fireEvent.dragEnd(chipSpan);
|
||||||
|
expect(chipSpan.className).not.toContain('opacity-50');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dragOver on a different field container adds ring indicator; dragLeave removes it', async () => {
|
||||||
|
render(<EmailComposer initialData={BASE_DATA} />);
|
||||||
|
|
||||||
|
await screen.findByText('alice@example.com');
|
||||||
|
|
||||||
|
// The flex-wrap containers are the actual drop zones
|
||||||
|
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
|
||||||
|
// To-container has the draggable chip; cc-container doesn't
|
||||||
|
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
|
||||||
|
const ccContainer = allContainers.find(el => el !== toContainer) as HTMLElement;
|
||||||
|
|
||||||
|
if (!ccContainer) return;
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
|
||||||
|
|
||||||
|
fireEvent.dragOver(ccContainer, { dataTransfer: dt });
|
||||||
|
expect(ccContainer.className).toContain('ring-primary');
|
||||||
|
|
||||||
|
fireEvent.dragLeave(ccContainer, { relatedTarget: null });
|
||||||
|
expect(ccContainer.className).not.toContain('ring-primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drop on a different field container moves the chip', async () => {
|
||||||
|
render(<EmailComposer initialData={BASE_DATA} />);
|
||||||
|
await screen.findByText('alice@example.com');
|
||||||
|
|
||||||
|
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
|
||||||
|
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
|
||||||
|
const ccContainer = allContainers.find(el => el !== toContainer) as HTMLElement;
|
||||||
|
|
||||||
|
if (!toContainer || !ccContainer) return;
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
|
||||||
|
fireEvent.dragOver(ccContainer, { dataTransfer: dt });
|
||||||
|
act(() => {
|
||||||
|
fireEvent.drop(ccContainer, { dataTransfer: dt });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chip should still appear exactly once (moved, not duplicated or lost)
|
||||||
|
await screen.findByText('alice@example.com');
|
||||||
|
expect(screen.getAllByText('alice@example.com')).toHaveLength(1);
|
||||||
|
|
||||||
|
// The To container must now be empty
|
||||||
|
expect(toContainer.querySelectorAll('[draggable]')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drop on the same field container is a no-op', async () => {
|
||||||
|
render(<EmailComposer initialData={BASE_DATA} />);
|
||||||
|
await screen.findByText('alice@example.com');
|
||||||
|
|
||||||
|
const allContainers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
|
||||||
|
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
|
||||||
|
fireEvent.dragOver(toContainer, { dataTransfer: dt });
|
||||||
|
act(() => {
|
||||||
|
fireEvent.drop(toContainer, { dataTransfer: dt });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chip stays present exactly once
|
||||||
|
expect(screen.getAllByText('alice@example.com')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dropping a chip onto the hidden Cc button shows the CC field', async () => {
|
||||||
|
render(<EmailComposer initialData={{ ...BASE_DATA, showCc: false, showBcc: false }} />);
|
||||||
|
await screen.findByText('alice@example.com');
|
||||||
|
|
||||||
|
const ccButton = screen.getByRole('button', { name: 'Cc' });
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
|
||||||
|
fireEvent.dragOver(ccButton, { dataTransfer: dt });
|
||||||
|
act(() => {
|
||||||
|
fireEvent.drop(ccButton, { dataTransfer: dt });
|
||||||
|
});
|
||||||
|
|
||||||
|
// cc_label is rendered by the mock translation as its key string
|
||||||
|
const ccLabel = await screen.findByText('cc_label');
|
||||||
|
expect(ccLabel).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Reordering within / across fields (#593) ─────────────────────────────────
|
||||||
|
// jsdom ignores `clientX` in fireEvent's init for drag events (it's a
|
||||||
|
// read-only MouseEvent getter) and gives every element a zero-size rect at
|
||||||
|
// (0,0). So we dispatch events with `clientX` forced via defineProperty; with
|
||||||
|
// the rect midpoint at 0, clientX>0 lands AFTER the hovered chip, <0 BEFORE.
|
||||||
|
|
||||||
|
const THREE = { ...BASE_DATA, to: 'alice@example.com, bob@example.com, carol@example.com, ' };
|
||||||
|
|
||||||
|
const chipByText = async (text: string) =>
|
||||||
|
(await screen.findByText(text)).closest('[draggable]') as HTMLElement;
|
||||||
|
|
||||||
|
/** Dispatch a drag event with a real clientX (fireEvent init drops it). */
|
||||||
|
const fireDnd = (type: 'dragover' | 'drop', el: HTMLElement, dt: MockDataTransfer, clientX: number) => {
|
||||||
|
const e = new Event(type, { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(e, 'clientX', { value: clientX });
|
||||||
|
Object.defineProperty(e, 'dataTransfer', { value: dt });
|
||||||
|
act(() => { fireEvent(el, e); });
|
||||||
|
};
|
||||||
|
const BEFORE = -100;
|
||||||
|
const AFTER = 100;
|
||||||
|
|
||||||
|
/** Ordered chip labels of the field-container that holds `anchorText`. */
|
||||||
|
const orderIn = (anchorText: string) => {
|
||||||
|
const containers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
|
||||||
|
const c = containers.find(el =>
|
||||||
|
Array.from(el.querySelectorAll('[draggable]')).some(d => d.textContent?.includes(anchorText))
|
||||||
|
) as HTMLElement;
|
||||||
|
return Array.from(c.querySelectorAll('[draggable]')).map(el => el.textContent?.trim() ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** All draggable chips (across fields) whose label contains `text`. */
|
||||||
|
const draggableChipsWith = (text: string) =>
|
||||||
|
Array.from(document.querySelectorAll('[draggable]')).filter(el => el.textContent?.includes(text));
|
||||||
|
|
||||||
|
it('reorders a chip to the end of the same field (drop after the last chip)', async () => {
|
||||||
|
render(<EmailComposer initialData={THREE} />);
|
||||||
|
await screen.findByText('alice@example.com');
|
||||||
|
const alice = await chipByText('alice@example.com');
|
||||||
|
const carol = await chipByText('carol@example.com');
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
fireEvent.dragStart(alice, { dataTransfer: dt }); // fromIndex 0
|
||||||
|
fireDnd('dragover', carol, dt, AFTER); // after carol -> index 3
|
||||||
|
fireDnd('drop', carol, dt, AFTER);
|
||||||
|
|
||||||
|
expect(orderIn('bob@example.com')).toEqual([
|
||||||
|
'bob@example.com', 'carol@example.com', 'alice@example.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorders a chip to the front of the same field (drop before the first chip)', async () => {
|
||||||
|
render(<EmailComposer initialData={THREE} />);
|
||||||
|
await screen.findByText('carol@example.com');
|
||||||
|
const carol = await chipByText('carol@example.com');
|
||||||
|
const alice = await chipByText('alice@example.com');
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
fireEvent.dragStart(carol, { dataTransfer: dt }); // fromIndex 2
|
||||||
|
fireDnd('dragover', alice, dt, BEFORE); // before alice -> index 0
|
||||||
|
fireDnd('drop', alice, dt, BEFORE);
|
||||||
|
|
||||||
|
expect(orderIn('alice@example.com')).toEqual([
|
||||||
|
'carol@example.com', 'alice@example.com', 'bob@example.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dropping a chip onto its own position leaves the order unchanged', async () => {
|
||||||
|
render(<EmailComposer initialData={THREE} />);
|
||||||
|
await screen.findByText('bob@example.com');
|
||||||
|
const bob = await chipByText('bob@example.com');
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
fireEvent.dragStart(bob, { dataTransfer: dt }); // fromIndex 1
|
||||||
|
fireDnd('dragover', bob, dt, BEFORE); // before itself -> index 1 (no-op)
|
||||||
|
fireDnd('drop', bob, dt, BEFORE);
|
||||||
|
|
||||||
|
expect(orderIn('bob@example.com')).toEqual([
|
||||||
|
'alice@example.com', 'bob@example.com', 'carol@example.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a chip into another field at the drop position (cross-field reorder)', async () => {
|
||||||
|
render(<EmailComposer initialData={{ ...BASE_DATA, to: 'alice@example.com, ', cc: 'x@example.com, y@example.com, ' }} />);
|
||||||
|
await screen.findByText('alice@example.com');
|
||||||
|
const alice = await chipByText('alice@example.com'); // To
|
||||||
|
const y = await chipByText('y@example.com'); // Cc
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
fireEvent.dragStart(alice, { dataTransfer: dt });
|
||||||
|
fireDnd('dragover', y, dt, BEFORE); // before y -> index 1 in Cc
|
||||||
|
fireDnd('drop', y, dt, BEFORE);
|
||||||
|
|
||||||
|
// alice lands between x and y; To no longer holds it (count only real chips,
|
||||||
|
// not the leftover jsdom drag-preview element)
|
||||||
|
expect(orderIn('x@example.com')).toEqual([
|
||||||
|
'x@example.com', 'alice@example.com', 'y@example.com',
|
||||||
|
]);
|
||||||
|
expect(draggableChipsWith('alice@example.com')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a drop caret only while a chip is dragged over the field', async () => {
|
||||||
|
render(<EmailComposer initialData={THREE} />);
|
||||||
|
await screen.findByText('alice@example.com');
|
||||||
|
const alice = await chipByText('alice@example.com');
|
||||||
|
const bob = await chipByText('bob@example.com');
|
||||||
|
|
||||||
|
const dt = new MockDataTransfer();
|
||||||
|
fireEvent.dragStart(alice, { dataTransfer: dt });
|
||||||
|
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
|
||||||
|
|
||||||
|
fireDnd('dragover', bob, dt, BEFORE);
|
||||||
|
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).not.toBeNull();
|
||||||
|
|
||||||
|
fireEvent.dragEnd(alice);
|
||||||
|
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { EmailComposer } from '../email-composer';
|
||||||
|
|
||||||
|
// ─── Heavy component mocks (mirrors recipient-chip-drag.test.tsx) ──────────────
|
||||||
|
|
||||||
|
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||||
|
RichTextEditor: ({ onChange }: { onChange?: (html: string) => void }) => (
|
||||||
|
React.createElement('div', { 'data-testid': 'rich-text-editor', onClick: () => onChange?.('') })
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||||
|
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||||
|
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||||
|
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||||
|
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||||
|
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||||
|
useFocusTrap: () => ({ ref: { current: null } }),
|
||||||
|
}));
|
||||||
|
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||||
|
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||||
|
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('@/stores/auth-store', () => {
|
||||||
|
const state = {
|
||||||
|
client: null,
|
||||||
|
identities: [],
|
||||||
|
primaryIdentity: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isDemoMode: false,
|
||||||
|
activeAccountId: null,
|
||||||
|
connectionLost: false,
|
||||||
|
getClientForAccount: () => undefined,
|
||||||
|
getAllConnectedClients: () => new Map(),
|
||||||
|
syncIdentities: () => {},
|
||||||
|
refreshIdentities: async () => {},
|
||||||
|
};
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useAuthStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/identity-store', () => {
|
||||||
|
const state = { identities: [], defaultIdentityId: null };
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useIdentityStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/account-store', () => {
|
||||||
|
const state = { accounts: [], getAccountById: () => undefined };
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useAccountStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/email-store', () => {
|
||||||
|
const state = {
|
||||||
|
draftSaveEnabled: false,
|
||||||
|
sendRawEmail: async () => ({ sent: true }),
|
||||||
|
};
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useEmailStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/settings-store', () => {
|
||||||
|
const state = {
|
||||||
|
timeFormat: '24h',
|
||||||
|
plainTextMode: false,
|
||||||
|
subAddressDelimiter: '+',
|
||||||
|
autoSelectReplyIdentity: true,
|
||||||
|
attachmentReminderEnabled: false,
|
||||||
|
attachmentReminderKeywords: [],
|
||||||
|
sendDelaySeconds: 0,
|
||||||
|
signaturePosition: 'above_quote',
|
||||||
|
signatureSeparatorEnabled: false,
|
||||||
|
requestReadReceiptDefault: false,
|
||||||
|
addTrustedSender: () => {},
|
||||||
|
trustedSendersAddressBook: null,
|
||||||
|
};
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useSettingsStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/contact-store', () => {
|
||||||
|
const state = {
|
||||||
|
contacts: [],
|
||||||
|
getAutocomplete: async () => [],
|
||||||
|
addToTrustedSendersBook: async () => {},
|
||||||
|
};
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useContactStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/stores/template-store', () => {
|
||||||
|
const state = { templates: [], addTemplate: async () => {} };
|
||||||
|
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||||
|
typeof sel === 'function' ? sel(state) : state;
|
||||||
|
hook.getState = () => state;
|
||||||
|
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||||
|
return { useTemplateStore: hook };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('@/stores/toast-store', () => ({
|
||||||
|
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/plugin-hooks', () => ({
|
||||||
|
emailHooks: {
|
||||||
|
onComposerOpen: { call: async () => [] },
|
||||||
|
onRecipientChange: { call: async () => [] },
|
||||||
|
getRecipientSuggestions: { call: async () => [] },
|
||||||
|
onSend: { call: async () => [] },
|
||||||
|
beforeSend: { call: async () => [] },
|
||||||
|
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||||
|
},
|
||||||
|
contactHooks: {
|
||||||
|
search: { call: async () => [] },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/email-sanitization', () => ({
|
||||||
|
sanitizeSignatureHtml: (v: string) => v,
|
||||||
|
sanitizeEmailHtml: (v: string) => v,
|
||||||
|
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/reply-identity', () => ({
|
||||||
|
resolveReplyFrom: () => null,
|
||||||
|
findComposeIdentityId: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/email-threading', () => ({
|
||||||
|
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/signature-utils', () => ({
|
||||||
|
appendPlainTextSignature: (body: string) => body,
|
||||||
|
getPlainTextSignature: () => '',
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||||
|
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
||||||
|
vi.mock('@/components/email/quoted-html', () => ({
|
||||||
|
buildQuotedHtmlBlock: () => '',
|
||||||
|
serializeEditorContent: () => '',
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||||
|
|
||||||
|
// ─── Shared test data ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const EMPTY_DATA = {
|
||||||
|
to: '',
|
||||||
|
cc: '',
|
||||||
|
bcc: '',
|
||||||
|
subject: '',
|
||||||
|
body: '',
|
||||||
|
showCc: true,
|
||||||
|
showBcc: true,
|
||||||
|
selectedIdentityId: null,
|
||||||
|
subAddressTag: '',
|
||||||
|
mode: 'compose' as const,
|
||||||
|
draftId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** next-intl is mocked to return the key, so the To placeholder is "to_placeholder". */
|
||||||
|
const toInput = () => screen.getByPlaceholderText('to_placeholder') as HTMLInputElement;
|
||||||
|
const ccInput = () => screen.getByPlaceholderText('cc_placeholder') as HTMLInputElement;
|
||||||
|
|
||||||
|
const paste = (input: HTMLElement, text: string) =>
|
||||||
|
fireEvent.paste(input, { clipboardData: { getData: () => text } });
|
||||||
|
|
||||||
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('RecipientChipInput paste', () => {
|
||||||
|
beforeEach(() => { vi.clearAllMocks(); });
|
||||||
|
|
||||||
|
it('splits a pasted list (comma / semicolon / whitespace) into one chip per address', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
const input = toInput(); // capture once — placeholder disappears once chips exist
|
||||||
|
paste(input, 'a@x.com b@y.com; c@z.com');
|
||||||
|
|
||||||
|
expect(screen.getByText('a@x.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('c@z.com')).toBeInTheDocument();
|
||||||
|
// all consumed → input cleared
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chips the valid addresses and leaves invalid text in the input', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
const input = toInput();
|
||||||
|
paste(input, 'foo bar a@x.com');
|
||||||
|
|
||||||
|
expect(screen.getByText('a@x.com')).toBeInTheDocument();
|
||||||
|
expect(input.value).toBe('foo bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pre-empt a single-address paste (no delimiter → normal editing)', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
paste(toInput(), 'single@x.com');
|
||||||
|
|
||||||
|
// The handler bailed out, so no chip was created from the single token.
|
||||||
|
expect(screen.queryByText('single@x.com')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps display names from a fully-quoted "Name <email>" list', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
const input = toInput();
|
||||||
|
paste(input, '"Alice Smith <alice@x.com>", "Alex Smith <alex@x.com>"');
|
||||||
|
|
||||||
|
// Chips render as "Name (email)" when a display name is present.
|
||||||
|
expect(screen.getByText('Alice Smith (alice@x.com)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Alex Smith (alex@x.com)')).toBeInTheDocument();
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a `Name <email>` pair as a single named chip', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
const input = toInput();
|
||||||
|
paste(input, 'John Doe <j@x.com>, jane@y.com');
|
||||||
|
|
||||||
|
expect(screen.getByText('John Doe (j@x.com)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('jane@y.com')).toBeInTheDocument();
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a comma inside a quoted display name intact', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
const input = toInput();
|
||||||
|
paste(input, '"Doe, John" <j@x.com>; bob@z.com');
|
||||||
|
|
||||||
|
expect(screen.getByText('Doe, John (j@x.com)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('bob@z.com')).toBeInTheDocument();
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits a newline-separated block (e.g. a spreadsheet column)', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
const input = toInput();
|
||||||
|
paste(input, 'a@x.com\nb@y.com\nc@z.com');
|
||||||
|
|
||||||
|
expect(screen.getByText('a@x.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('c@z.com')).toBeInTheDocument();
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes case-insensitively within the paste and against existing chips', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
const input = toInput(); // DOM node persists across pastes (placeholder just clears)
|
||||||
|
paste(input, 'a@x.com, A@X.com, b@y.com'); // within-paste dup collapses
|
||||||
|
paste(input, 'A@X.COM, c@z.com'); // dup of an existing chip is dropped
|
||||||
|
|
||||||
|
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('c@z.com')).toBeInTheDocument();
|
||||||
|
// a@x.com appears exactly once despite three case variants across two pastes.
|
||||||
|
expect(screen.getAllByText('a@x.com')).toHaveLength(1);
|
||||||
|
expect(screen.queryByText('A@X.COM')).not.toBeInTheDocument();
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chips the valid (named) entries and leaves a non-address token behind', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
const input = toInput();
|
||||||
|
paste(input, '"VIP" <vip@x.com>, not-an-email, b@y.com');
|
||||||
|
|
||||||
|
expect(screen.getByText('VIP (vip@x.com)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('b@y.com')).toBeInTheDocument();
|
||||||
|
expect(input.value).toBe('not-an-email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works on the Cc field (shared handler)', () => {
|
||||||
|
render(<EmailComposer initialData={EMPTY_DATA} />);
|
||||||
|
paste(ccInput(), 'x@a.com; y@b.com');
|
||||||
|
|
||||||
|
expect(screen.getByText('x@a.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('y@b.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { SelectableAvatar } from '../selectable-avatar';
|
||||||
|
|
||||||
|
// Isolate from the real Avatar (image fetching, libravatar hashing) — we only
|
||||||
|
// care about the selection wrapper behaviour here.
|
||||||
|
vi.mock('@/components/ui/avatar', () => ({
|
||||||
|
Avatar: (props: { name?: string }) => <span data-testid="avatar">{props.name}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('SelectableAvatar', () => {
|
||||||
|
it('renders the wrapped avatar', () => {
|
||||||
|
render(<SelectableAvatar name="Marta" checked={false} onToggle={() => {}} selectLabel="Select" />);
|
||||||
|
expect(screen.getByTestId('avatar')).toHaveTextContent('Marta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onToggle and stops propagation when the avatar is clicked', () => {
|
||||||
|
const onToggle = vi.fn();
|
||||||
|
const onRowClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<div onClick={onRowClick}>
|
||||||
|
<SelectableAvatar name="Marta" checked={false} onToggle={onToggle} selectLabel="Select" />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('checkbox'));
|
||||||
|
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||||
|
// Clicking the avatar must not bubble up to open/select the row.
|
||||||
|
expect(onRowClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects the checked state via aria-checked', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<SelectableAvatar name="Marta" checked={false} onToggle={() => {}} selectLabel="Select" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false');
|
||||||
|
rerender(<SelectableAvatar name="Marta" checked onToggle={() => {}} selectLabel="Select" />);
|
||||||
|
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Editor } from '@tiptap/core';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
|
||||||
|
import { SignatureBlock, buildSignatureBlock, SIGNATURE_BLOCK_MARKER } from '../signature-block';
|
||||||
|
import { serializeEditorContent } from '../quoted-html';
|
||||||
|
|
||||||
|
describe('signature-block', () => {
|
||||||
|
it('buildSignatureBlock wraps html in the marker div', () => {
|
||||||
|
expect(buildSignatureBlock('<b>x</b>')).toBe(`<div ${SIGNATURE_BLOCK_MARKER}><b>x</b></div>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an inline-styled signature through parse + serialize (no schema flattening)', () => {
|
||||||
|
const styled =
|
||||||
|
'<table style="background:#0a0e16;border-radius:8px"><tbody><tr>' +
|
||||||
|
'<td style="color:#c6f24e;font-family:\'Courier New\'">MV</td>' +
|
||||||
|
'</tr></tbody></table>';
|
||||||
|
const editor = new Editor({
|
||||||
|
element: document.createElement('div'),
|
||||||
|
extensions: [StarterKit, SignatureBlock],
|
||||||
|
content: `<p>Hello</p>${buildSignatureBlock(styled)}`,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const out = serializeEditorContent(editor);
|
||||||
|
// Original inline styling survives - it is NOT re-parsed into the schema.
|
||||||
|
expect(out).toContain('background:#0a0e16');
|
||||||
|
expect(out).toContain('border-radius:8px');
|
||||||
|
expect(out).toContain('color:#c6f24e');
|
||||||
|
expect(out).toContain(SIGNATURE_BLOCK_MARKER);
|
||||||
|
// Surrounding body is preserved.
|
||||||
|
expect(out).toContain('Hello');
|
||||||
|
// The signature did not get the editor's generic table styling.
|
||||||
|
expect(out).not.toContain('rgb(204, 204, 204)');
|
||||||
|
} finally {
|
||||||
|
editor.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTranslations, useFormatter } from 'next-intl';
|
import { useTranslations, useFormatter } from 'next-intl';
|
||||||
import { useRouter } from '@/i18n/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
|
import { isDocumentRTL } from '@/i18n/direction';
|
||||||
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 { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
@@ -374,7 +375,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
|
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
|
||||||
const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number } | null>(null);
|
const [pickerPosition, setPickerPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
|
||||||
const pickerTriggerRef = useRef<HTMLButtonElement>(null);
|
const pickerTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
|
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
|
||||||
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
|
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
|
||||||
@@ -553,40 +554,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
const replyToForRsvp = parsedEvent.replyTo
|
const replyToForRsvp = parsedEvent.replyTo
|
||||||
|| (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null);
|
|| (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null);
|
||||||
|
|
||||||
const imipStatus = status.toUpperCase() as 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
|
// The iMIP REPLY to the organizer is sent by the server: rsvpEvent /
|
||||||
const organizerEmail = parsedEvent?.replyTo?.imip?.replace('mailto:', '')
|
// updateEvent below pass sendSchedulingMessages=true to CalendarEvent/set
|
||||||
|| parsedEvent?.organizerCalendarAddress?.replace('mailto:', '')
|
// and Stalwart queues the iTIP REPLY itself. A manual client-side reply
|
||||||
|| summary?.organizerEmail
|
// here produced duplicate emails.
|
||||||
|| null;
|
|
||||||
|
|
||||||
// Send the iMIP REPLY email to the organizer (client-side scheduling).
|
|
||||||
// Called after updating the local calendar event. Best-effort - if it
|
|
||||||
// fails we still report the RSVP as sent since the calendar was updated.
|
|
||||||
const sendImipReply = async () => {
|
|
||||||
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.sendImipReply({
|
|
||||||
organizerEmail,
|
|
||||||
organizerName: summary?.organizer || undefined,
|
|
||||||
attendeeEmail: currentUserEmail,
|
|
||||||
attendeeName: myParticipant?.participant.name || undefined,
|
|
||||||
uid: parsedEvent.uid,
|
|
||||||
summary: parsedEvent.title,
|
|
||||||
dtStart: parsedEvent.start || undefined,
|
|
||||||
dtEnd: summary?.end || undefined,
|
|
||||||
timeZone: parsedEvent.timeZone || undefined,
|
|
||||||
isAllDay: parsedEvent.showWithoutTime || false,
|
|
||||||
sequence: parsedEvent.sequence,
|
|
||||||
status: imipStatus,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Best-effort: don't block the RSVP success notification
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const eventForRsvp = existingEvent
|
const eventForRsvp = existingEvent
|
||||||
? await resolveExistingEventForRsvp()
|
? await resolveExistingEventForRsvp()
|
||||||
@@ -601,7 +572,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
);
|
);
|
||||||
if (eventForRsvp && existingEventParticipant) {
|
if (eventForRsvp && existingEventParticipant) {
|
||||||
await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp);
|
await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp);
|
||||||
await sendImipReply();
|
|
||||||
setRsvpStatus(status);
|
setRsvpStatus(status);
|
||||||
setActionNotice(t('rsvp_sent'));
|
setActionNotice(t('rsvp_sent'));
|
||||||
setState('parsed');
|
setState('parsed');
|
||||||
@@ -613,9 +583,13 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
} else {
|
} else {
|
||||||
await updateEvent(client, eventForRsvp.id, {
|
await updateEvent(client, eventForRsvp.id, {
|
||||||
participants: repairedParticipants,
|
participants: repairedParticipants,
|
||||||
replyTo: replyToForRsvp ?? undefined,
|
// Stalwart routes the iTIP REPLY via the stored ORGANIZER
|
||||||
|
// (organizerCalendarAddress; the RFC 8984 replyTo is retired).
|
||||||
|
// Only repair a missing organizer - attendees may not modify it.
|
||||||
|
...(replyToForRsvp?.imip && !eventForRsvp.organizerCalendarAddress
|
||||||
|
? { organizerCalendarAddress: replyToForRsvp.imip }
|
||||||
|
: {}),
|
||||||
}, true);
|
}, true);
|
||||||
await sendImipReply();
|
|
||||||
setRsvpStatus(status);
|
setRsvpStatus(status);
|
||||||
setActionNotice(t('rsvp_sent'));
|
setActionNotice(t('rsvp_sent'));
|
||||||
setState('parsed');
|
setState('parsed');
|
||||||
@@ -632,7 +606,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
|
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
|
||||||
if (newEvent && participant) {
|
if (newEvent && participant) {
|
||||||
await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp);
|
await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp);
|
||||||
await sendImipReply();
|
|
||||||
setRsvpStatus(status);
|
setRsvpStatus(status);
|
||||||
setActionNotice(t('rsvp_sent'));
|
setActionNotice(t('rsvp_sent'));
|
||||||
} else {
|
} else {
|
||||||
@@ -1054,7 +1027,11 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
}
|
}
|
||||||
if (pickerTriggerRef.current) {
|
if (pickerTriggerRef.current) {
|
||||||
const rect = pickerTriggerRef.current.getBoundingClientRect();
|
const rect = pickerTriggerRef.current.getBoundingClientRect();
|
||||||
setPickerPosition({ top: rect.bottom + 4, left: rect.left });
|
setPickerPosition(
|
||||||
|
isDocumentRTL()
|
||||||
|
? { top: rect.bottom + 4, right: window.innerWidth - rect.right }
|
||||||
|
: { top: rect.bottom + 4, left: rect.left }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setShowCalendarPicker(true);
|
setShowCalendarPicker(true);
|
||||||
}}
|
}}
|
||||||
@@ -1069,7 +1046,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
{showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal(
|
{showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed w-52 bg-background rounded-lg shadow-lg border border-border z-50 py-1"
|
className="fixed w-52 bg-background rounded-lg shadow-lg border border-border z-50 py-1"
|
||||||
style={{ top: pickerPosition.top, left: pickerPosition.left }}
|
style={{ top: pickerPosition.top, left: pickerPosition.left, right: pickerPosition.right }}
|
||||||
>
|
>
|
||||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
{t('select_calendar')}
|
{t('select_calendar')}
|
||||||
@@ -1081,7 +1058,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
setShowCalendarPicker(false);
|
setShowCalendarPicker(false);
|
||||||
handleImport(cal.id);
|
handleImport(cal.id);
|
||||||
}}
|
}}
|
||||||
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
|
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
@@ -1123,7 +1100,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
|
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ms-auto" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+1063
-512
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,8 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
MailOpen,
|
MailOpen,
|
||||||
Star,
|
Star,
|
||||||
|
Pin,
|
||||||
|
PinOff,
|
||||||
Trash2,
|
Trash2,
|
||||||
Archive,
|
Archive,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
@@ -34,6 +36,7 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
|
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface Position {
|
interface Position {
|
||||||
@@ -58,6 +61,7 @@ interface EmailContextMenuProps {
|
|||||||
onForward?: () => void;
|
onForward?: () => void;
|
||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
|
onTogglePinned?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onSetColorTag?: (color: string | null) => void;
|
onSetColorTag?: (color: string | null) => void;
|
||||||
@@ -125,6 +129,7 @@ export function EmailContextMenu({
|
|||||||
onForward,
|
onForward,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
|
onTogglePinned,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetColorTag,
|
||||||
@@ -143,14 +148,19 @@ export function EmailContextMenu({
|
|||||||
onRescheduleScheduled,
|
onRescheduleScheduled,
|
||||||
}: EmailContextMenuProps) {
|
}: EmailContextMenuProps) {
|
||||||
const t = useTranslations("context_menu");
|
const t = useTranslations("context_menu");
|
||||||
|
const tSidebar = useTranslations("sidebar");
|
||||||
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;
|
||||||
|
const isPinned = email.keywords?.['$pinned'] === true;
|
||||||
const isDraft = email.keywords?.['$draft'] === true;
|
const isDraft = email.keywords?.['$draft'] === true;
|
||||||
const currentColors = getCurrentColors(email.keywords);
|
const currentColors = getCurrentColors(email.keywords);
|
||||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
|
// Marking your own outgoing mail as spam makes no sense - hide the action
|
||||||
|
// in Sent, Drafts and Scheduled.
|
||||||
|
const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
|
||||||
const isScheduled = email.isScheduled === true;
|
const isScheduled = email.isScheduled === true;
|
||||||
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
||||||
|
|
||||||
@@ -285,6 +295,7 @@ export function EmailContextMenu({
|
|||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
label={t("delete")}
|
label={t("delete")}
|
||||||
|
testId="ctx-delete"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
|
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
|
||||||
}
|
}
|
||||||
@@ -296,18 +307,20 @@ export function EmailContextMenu({
|
|||||||
|
|
||||||
{/* Move to submenu */}
|
{/* Move to submenu */}
|
||||||
{moveTree.length > 0 && (
|
{moveTree.length > 0 && (
|
||||||
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")}>
|
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to">
|
||||||
{(() => {
|
{(() => {
|
||||||
const renderNodes = (nodes: MailboxNode[]) => {
|
const renderNodes = (nodes: MailboxNode[]) => {
|
||||||
return nodes.map((node) => {
|
return nodes.map((node) => {
|
||||||
const Icon = getMailboxIcon(node.role);
|
const Icon = getMailboxIcon(node.role);
|
||||||
const isTarget = moveTargetIds.has(node.id);
|
const isTarget = moveTargetIds.has(node.id);
|
||||||
|
const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
|
||||||
return (
|
return (
|
||||||
<div key={node.id}>
|
<div key={node.id}>
|
||||||
{isTarget ? (
|
{isTarget ? (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Icon}
|
icon={Icon}
|
||||||
label={node.name}
|
label={nodeLabel}
|
||||||
|
testId={`move-to:${node.id}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(() =>
|
handleAction(() =>
|
||||||
showBatchActions
|
showBatchActions
|
||||||
@@ -319,11 +332,11 @@ export function EmailContextMenu({
|
|||||||
) : (
|
) : (
|
||||||
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
|
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
|
||||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||||
<span>{node.name}</span>
|
<span>{nodeLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{node.children.length > 0 && (
|
{node.children.length > 0 && (
|
||||||
<div className="pl-4">
|
<div className="ps-4">
|
||||||
{renderNodes(node.children)}
|
{renderNodes(node.children)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -346,6 +359,15 @@ export function EmailContextMenu({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pin/Unpin - only for single email; pinned mails float to the top of the list */}
|
||||||
|
{!showBatchActions && onTogglePinned && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={isPinned ? PinOff : Pin}
|
||||||
|
label={isPinned ? t("unpin") : t("pin")}
|
||||||
|
onClick={() => handleAction(onTogglePinned)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Set tag submenu - only for single email */}
|
{/* Set tag submenu - only for single email */}
|
||||||
{!showBatchActions && (
|
{!showBatchActions && (
|
||||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||||
@@ -357,7 +379,7 @@ export function EmailContextMenu({
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
|
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||||
isActive && "bg-accent font-medium"
|
isActive && "bg-accent font-medium"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -382,22 +404,27 @@ export function EmailContextMenu({
|
|||||||
</ContextMenuSubMenu>
|
</ContextMenuSubMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ContextMenuSeparator />
|
{/* Spam - contextual based on folder; pointless on own outgoing mail */}
|
||||||
|
{spamApplicable && (
|
||||||
|
<>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
{/* Spam - contextual based on folder */}
|
<ContextMenuItem
|
||||||
<ContextMenuItem
|
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
|
||||||
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
|
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
|
||||||
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
|
testId={isInJunkFolder ? "ctx-not-spam" : "ctx-spam"}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(
|
handleAction(
|
||||||
showBatchActions
|
showBatchActions
|
||||||
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
|
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
|
||||||
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
|
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
|
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
|
||||||
destructive={!isInJunkFolder}
|
destructive={!isInJunkFolder}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
@@ -405,6 +432,7 @@ export function EmailContextMenu({
|
|||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={isUnread ? MailOpen : Mail}
|
icon={isUnread ? MailOpen : Mail}
|
||||||
label={isUnread ? t("mark_read") : t("mark_unread")}
|
label={isUnread ? t("mark_read") : t("mark_unread")}
|
||||||
|
testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(() =>
|
handleAction(() =>
|
||||||
showBatchActions
|
showBatchActions
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Email } from "@/lib/jmap/types";
|
|||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import type { HoverAction } from "@/stores/settings-store";
|
import type { HoverAction } from "@/stores/settings-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
|
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
|
|
||||||
@@ -17,6 +17,12 @@ interface EmailHoverActionsProps {
|
|||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onSetColorTag?: (color: string | null) => void;
|
onSetColorTag?: (color: string | null) => void;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
|
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
|
||||||
|
// the spam quick-action flips to "not spam".
|
||||||
|
isInJunk?: boolean;
|
||||||
|
onUndoSpam?: () => void;
|
||||||
|
// Hidden where marking spam is meaningless for self-authored mail (Drafts, Sent).
|
||||||
|
spamApplicable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_CONFIG: Record<HoverAction, {
|
const ACTION_CONFIG: Record<HoverAction, {
|
||||||
@@ -72,6 +78,9 @@ export function EmailHoverActions({
|
|||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetColorTag,
|
||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
|
isInJunk = false,
|
||||||
|
onUndoSpam,
|
||||||
|
spamApplicable = true,
|
||||||
}: EmailHoverActionsProps) {
|
}: EmailHoverActionsProps) {
|
||||||
const hoverActions = useSettingsStore((state) => state.hoverActions);
|
const hoverActions = useSettingsStore((state) => state.hoverActions);
|
||||||
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
|
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
|
||||||
@@ -106,7 +115,8 @@ export function EmailHoverActions({
|
|||||||
onSetColorTag?.(null);
|
onSetColorTag?.(null);
|
||||||
break;
|
break;
|
||||||
case "spam":
|
case "spam":
|
||||||
onMarkAsSpam?.();
|
if (isInJunk) onUndoSpam?.();
|
||||||
|
else onMarkAsSpam?.();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -114,22 +124,31 @@ export function EmailHoverActions({
|
|||||||
const actionButtons = hoverActions.map((actionId) => {
|
const actionButtons = hoverActions.map((actionId) => {
|
||||||
const config = ACTION_CONFIG[actionId];
|
const config = ACTION_CONFIG[actionId];
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
|
if (actionId === "spam" && !spamApplicable) return null;
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
// In a junk context the spam action becomes "not spam".
|
||||||
|
const isNotSpam = actionId === "spam" && isInJunk;
|
||||||
const DisplayIcon = actionId === "markRead"
|
const DisplayIcon = actionId === "markRead"
|
||||||
? (isUnread ? MailOpen : Mail)
|
? (isUnread ? MailOpen : Mail)
|
||||||
: actionId === "star" && isStarred
|
: actionId === "star" && isStarred
|
||||||
? Star
|
? Star
|
||||||
: Icon;
|
: isNotSpam
|
||||||
|
? ShieldCheck
|
||||||
|
: Icon;
|
||||||
|
const title = isNotSpam ? t("not_spam") : t(config.titleKey);
|
||||||
|
const className = isNotSpam
|
||||||
|
? "hover:text-green-600 dark:hover:text-green-400"
|
||||||
|
: config.className;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={actionId}
|
key={actionId}
|
||||||
onClick={(e) => handleAction(e, actionId)}
|
onClick={(e) => handleAction(e, actionId)}
|
||||||
title={t(config.titleKey)}
|
title={title}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
|
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
|
||||||
config.className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DisplayIcon
|
<DisplayIcon
|
||||||
@@ -162,16 +181,17 @@ export function EmailHoverActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
className="absolute end-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn("w-8 h-full", hoverBackgroundClassName)}
|
className={cn(
|
||||||
style={{
|
"w-8 h-full",
|
||||||
WebkitMaskImage: "linear-gradient(to right, transparent, black)",
|
"[mask-image:linear-gradient(to_right,transparent,black)] [-webkit-mask-image:linear-gradient(to_right,transparent,black)]",
|
||||||
maskImage: "linear-gradient(to right, transparent, black)",
|
"rtl:[mask-image:linear-gradient(to_left,transparent,black)] rtl:[-webkit-mask-image:linear-gradient(to_left,transparent,black)]",
|
||||||
}}
|
hoverBackgroundClassName,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<div className={cn("flex items-center gap-0.5 h-full pr-3 pl-0.5", hoverBackgroundClassName)}>
|
<div className={cn("flex items-center gap-0.5 h-full pe-3 ps-0.5", hoverBackgroundClassName)}>
|
||||||
{actionButtons}
|
{actionButtons}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useCallback } from "react";
|
|||||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
import { formatDate, stripInvisibleLeading } 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 { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||||
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
import { Paperclip, Star, Pin, 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";
|
||||||
@@ -29,25 +29,32 @@ interface EmailListItemProps {
|
|||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onSetColorTag?: (color: string | null) => void;
|
onSetColorTag?: (color: string | null) => void;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
|
onUndoSpam?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) {
|
||||||
const t = useTranslations('email_viewer');
|
const t = useTranslations('email_viewer');
|
||||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
|
const tBatch = useTranslations('email_list.batch_actions');
|
||||||
|
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = 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 mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
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 isPinned = email.keywords?.['$pinned'] === true;
|
||||||
const isImportant = email.keywords?.["$important"];
|
const isImportant = email.keywords?.["$important"];
|
||||||
const isAnswered = email.keywords?.$answered;
|
const isAnswered = email.keywords?.$answered;
|
||||||
const isForwarded = email.keywords?.$forwarded;
|
const isForwarded = email.keywords?.$forwarded;
|
||||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
// In Sent/Drafts folders, show recipient instead of sender (which is always "me").
|
||||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
// In aggregate role-views the selected mailbox is virtual → fall back to the
|
||||||
|
// unified role so junk-contextual UI (spam ↔ not-spam) and avatar hiding work.
|
||||||
|
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
|
||||||
|
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
@@ -62,7 +69,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||||
// Use first tag for background coloring
|
// Use first tag for background coloring
|
||||||
const keywordDef = keywordDefs[0] ?? null;
|
const keywordDef = keywordDefs[0] ?? null;
|
||||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||||
|
|
||||||
// Drag and drop functionality
|
// Drag and drop functionality
|
||||||
const { dragHandlers, isDragging } = useEmailDrag({
|
const { dragHandlers, isDragging } = useEmailDrag({
|
||||||
@@ -83,7 +90,14 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
|
|
||||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleEmailSelection(email.id);
|
if (e.shiftKey) {
|
||||||
|
// Shift-click extends the selection from the anchor to here, like
|
||||||
|
// shift-clicking the row (the checkbox stops propagation, so the
|
||||||
|
// row's shift handler never runs — replicate it here).
|
||||||
|
selectRangeEmails(email.id);
|
||||||
|
} else {
|
||||||
|
toggleEmailSelection(email.id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContextMenu = (e: React.MouseEvent) => {
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
@@ -162,19 +176,22 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
|
|
||||||
{/* Unread indicator */}
|
{/* Unread indicator */}
|
||||||
{isUnread && (
|
{isUnread && (
|
||||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
|
||||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
{density !== 'extra-compact' && (
|
{density !== 'extra-compact' && (
|
||||||
<Avatar
|
<SelectableAvatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
size={isFocusedMailLayout ? "sm" : "md"}
|
size={isFocusedMailLayout ? "sm" : "md"}
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
disableImages={hideJunkAvatarImages}
|
disableImages={hideJunkAvatarImages}
|
||||||
|
checked={isChecked}
|
||||||
|
onToggle={() => toggleEmailSelection(email.id)}
|
||||||
|
selectLabel={tBatch('select')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -191,17 +208,18 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'shrink-0 truncate',
|
'min-w-0 truncate',
|
||||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
)}>
|
)}>
|
||||||
{email.subject || t('no_subject')}
|
{email.subject || t('no_subject')}
|
||||||
</span>
|
</span>
|
||||||
{inlinePreview && (
|
{inlinePreview && (
|
||||||
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5 shrink-0">
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
|
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
|
||||||
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
{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" />}
|
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
|
||||||
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
@@ -238,6 +256,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
{sender?.name || sender?.email || "Unknown"}
|
{sender?.name || sender?.email || "Unknown"}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isPinned && (
|
||||||
|
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||||
|
)}
|
||||||
{isStarred && (
|
{isStarred && (
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
)}
|
)}
|
||||||
@@ -321,6 +342,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
onArchive={onArchive}
|
onArchive={onArchive}
|
||||||
onSetColorTag={onSetColorTag}
|
onSetColorTag={onSetColorTag}
|
||||||
onMarkAsSpam={onMarkAsSpam}
|
onMarkAsSpam={onMarkAsSpam}
|
||||||
|
onUndoSpam={onUndoSpam}
|
||||||
|
isInJunk={currentMailboxRole === 'junk'}
|
||||||
|
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
|||||||
import { ThreadListItem } from "./thread-list-item";
|
import { ThreadListItem } from "./thread-list-item";
|
||||||
import { EmailContextMenu } from "./email-context-menu";
|
import { EmailContextMenu } from "./email-context-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock } from "lucide-react";
|
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck } from "lucide-react";
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo } from "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";
|
||||||
@@ -35,6 +35,7 @@ interface EmailListProps {
|
|||||||
onForward?: (email: Email) => void;
|
onForward?: (email: Email) => void;
|
||||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||||
onToggleStar?: (email: Email) => void;
|
onToggleStar?: (email: Email) => void;
|
||||||
|
onTogglePinned?: (email: Email) => void;
|
||||||
onDelete?: (email: Email) => void;
|
onDelete?: (email: Email) => void;
|
||||||
onArchive?: (email: Email) => void;
|
onArchive?: (email: Email) => void;
|
||||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||||
@@ -64,6 +65,7 @@ export function EmailList({
|
|||||||
onForward,
|
onForward,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
|
onTogglePinned,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetColorTag,
|
||||||
@@ -100,19 +102,32 @@ export function EmailList({
|
|||||||
isLoadingThread,
|
isLoadingThread,
|
||||||
toggleThreadExpansion,
|
toggleThreadExpansion,
|
||||||
fetchThreadEmails,
|
fetchThreadEmails,
|
||||||
|
markThreadAsRead,
|
||||||
|
collapseAllThreads,
|
||||||
|
threadEmailCounts,
|
||||||
searchFilters,
|
searchFilters,
|
||||||
setSearchFilters,
|
setSearchFilters,
|
||||||
clearSearchFilters,
|
clearSearchFilters,
|
||||||
advancedSearch,
|
advancedSearch,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
isUnifiedView,
|
||||||
|
unifiedRole,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
|
// In aggregate role-views (e.g. "All Junk") the selected mailbox is virtual, so
|
||||||
|
// there is no concrete mailbox to read the role from. Fall back to the unified
|
||||||
|
// role so contextual actions (e.g. mark-as-spam ↔ not-spam) behave as if inside
|
||||||
|
// that role's folder.
|
||||||
|
const effectiveMailboxRole =
|
||||||
|
mailboxes.find(m => m.id === selectedMailbox)?.role
|
||||||
|
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||||
|
|
||||||
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
||||||
|
|
||||||
const threadGroups = useMemo(() => {
|
const threadGroups = useMemo(() => {
|
||||||
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
|
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts);
|
||||||
return sortThreadGroups(groups);
|
return sortThreadGroups(groups);
|
||||||
}, [emails, disableThreading, isScheduledView]);
|
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
|
||||||
|
|
||||||
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();
|
||||||
@@ -176,6 +191,22 @@ export function EmailList({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBatchUndoSpam = async () => {
|
||||||
|
if (!client || isProcessing) return;
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
const emailIds = Array.from(selectedEmailIds);
|
||||||
|
await batchUndoSpam(client, emailIds);
|
||||||
|
const { toast } = await import('sonner');
|
||||||
|
toast.success(t('../email_viewer.spam.toast_not_spam_batch', { count: emailIds.length }));
|
||||||
|
} catch {
|
||||||
|
const { toast } = await import('sonner');
|
||||||
|
toast.error(t('../email_viewer.spam.error_not_spam'));
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => setIsProcessing(false), 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleBatchDelete = async () => {
|
const handleBatchDelete = async () => {
|
||||||
if (!client || isProcessing) return;
|
if (!client || isProcessing) return;
|
||||||
|
|
||||||
@@ -250,10 +281,12 @@ export function EmailList({
|
|||||||
if (!isExpanded && client) {
|
if (!isExpanded && client) {
|
||||||
toggleThreadExpansion(threadId);
|
toggleThreadExpansion(threadId);
|
||||||
await fetchThreadEmails(client, threadId);
|
await fetchThreadEmails(client, threadId);
|
||||||
|
// Mark all unread emails in this thread as read
|
||||||
|
void markThreadAsRead(client, threadId);
|
||||||
} else {
|
} else {
|
||||||
toggleThreadExpansion(threadId);
|
toggleThreadExpansion(threadId);
|
||||||
}
|
}
|
||||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]);
|
||||||
|
|
||||||
// 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
|
// Debounce to prevent rapid cascade when thread grouping reduces item
|
||||||
@@ -340,6 +373,22 @@ export function EmailList({
|
|||||||
<Mail className="w-4 h-4" />
|
<Mail className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{effectiveMailboxRole === 'junk' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBatchUndoSpam}
|
||||||
|
title={t('../context_menu.not_spam')}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100/50 dark:hover:bg-emerald-950/30 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ShieldCheck className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -400,9 +449,9 @@ export function EmailList({
|
|||||||
className="text-destructive border-destructive/30 hover:bg-destructive/10 text-xs"
|
className="text-destructive border-destructive/30 hover:bg-destructive/10 text-xs"
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />
|
<Loader2 className="w-3 h-3 animate-spin me-1" />
|
||||||
) : (
|
) : (
|
||||||
<Trash2 className="w-3 h-3 mr-1" />
|
<Trash2 className="w-3 h-3 me-1" />
|
||||||
)}
|
)}
|
||||||
{t('empty_folder.button')}
|
{t('empty_folder.button')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -473,7 +522,18 @@ export function EmailList({
|
|||||||
isLoading={isLoadingThread === thread.threadId}
|
isLoading={isLoadingThread === thread.threadId}
|
||||||
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
||||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
||||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
onCollapseAllThreads={collapseAllThreads}
|
||||||
|
onEmailSelect={(email) => {
|
||||||
|
// Collapse expanded threads when selecting an email outside the expanded thread
|
||||||
|
const currentExpanded = useEmailStore.getState().expandedThreadIds;
|
||||||
|
if (currentExpanded.size > 0) {
|
||||||
|
// Check if the selected email belongs to any expanded thread via its threadId
|
||||||
|
if (!email.threadId || !currentExpanded.has(email.threadId)) {
|
||||||
|
collapseAllThreads();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onEmailSelect?.(email);
|
||||||
|
}}
|
||||||
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
||||||
onContextMenu={openContextMenu}
|
onContextMenu={openContextMenu}
|
||||||
onOpenConversation={onOpenConversation}
|
onOpenConversation={onOpenConversation}
|
||||||
@@ -483,6 +543,7 @@ export function EmailList({
|
|||||||
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
||||||
onSetColorTag={onSetColorTag}
|
onSetColorTag={onSetColorTag}
|
||||||
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
||||||
|
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -516,7 +577,7 @@ export function EmailList({
|
|||||||
menuRef={menuRef}
|
menuRef={menuRef}
|
||||||
mailboxes={mailboxes}
|
mailboxes={mailboxes}
|
||||||
selectedMailbox={selectedMailbox}
|
selectedMailbox={selectedMailbox}
|
||||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
currentMailboxRole={effectiveMailboxRole}
|
||||||
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
||||||
selectedCount={selectedEmailIds.size}
|
selectedCount={selectedEmailIds.size}
|
||||||
onReply={() => onReply?.(contextMenu.data!)}
|
onReply={() => onReply?.(contextMenu.data!)}
|
||||||
@@ -524,6 +585,7 @@ export function EmailList({
|
|||||||
onForward={() => onForward?.(contextMenu.data!)}
|
onForward={() => onForward?.(contextMenu.data!)}
|
||||||
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
||||||
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
||||||
|
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
|
||||||
onDelete={() => onDelete?.(contextMenu.data!)}
|
onDelete={() => onDelete?.(contextMenu.data!)}
|
||||||
onArchive={() => onArchive?.(contextMenu.data!)}
|
onArchive={() => onArchive?.(contextMenu.data!)}
|
||||||
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
|
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
|
||||||
|
|||||||
+694
-1343
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Native tab strip for plugin-registered message-list category tabs
|
||||||
|
// (Gmail-style Primary / Promotions / Social / Updates). Renders above the
|
||||||
|
// email list; the active tab's resolved JMAP filter is ANDed into the
|
||||||
|
// mailbox query by email-store.fetchEmails. Plugins only contribute tab
|
||||||
|
// DEFINITIONS (stores/message-list-tabs-store.ts) - no plugin iframe here.
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
||||||
|
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
|
||||||
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function MessageListTabs() {
|
||||||
|
const tabs = useMessageListTabsStore((s) => s.tabs);
|
||||||
|
const mailboxRoles = useMessageListTabsStore((s) => s.mailboxRoles);
|
||||||
|
const activeTabId = useMessageListTabsStore((s) => s.activeTabId);
|
||||||
|
const tabCounts = useMessageListTabsStore((s) => s.tabCounts);
|
||||||
|
|
||||||
|
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
|
||||||
|
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||||
|
const selectedKeyword = useEmailStore((s) => s.selectedKeyword);
|
||||||
|
const searchQuery = useEmailStore((s) => s.searchQuery);
|
||||||
|
const isUnifiedView = useEmailStore((s) => s.isUnifiedView);
|
||||||
|
const client = useAuthStore((s) => s.client);
|
||||||
|
|
||||||
|
const mailbox = mailboxes.find((mb) => mb.id === selectedMailbox);
|
||||||
|
const role = mailbox?.role?.toLowerCase() ?? null;
|
||||||
|
// Tabs only make sense on a plain mailbox view: tag views, searches and
|
||||||
|
// unified fan-outs bypass the category filter in fetchEmails, so the strip
|
||||||
|
// must disappear rather than lie about what's being shown.
|
||||||
|
const visible =
|
||||||
|
tabs.length > 0 &&
|
||||||
|
!!role &&
|
||||||
|
mailboxRoles.includes(role) &&
|
||||||
|
!selectedKeyword &&
|
||||||
|
!searchQuery &&
|
||||||
|
!isUnifiedView;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !client || !mailbox) return;
|
||||||
|
const jmapMailboxId = mailbox.originalId || mailbox.id;
|
||||||
|
const accountId = mailbox.isShared ? mailbox.accountId : undefined;
|
||||||
|
void useMessageListTabsStore.getState().refreshCounts(client, jmapMailboxId, accountId);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [visible, client, selectedMailbox, tabs]);
|
||||||
|
|
||||||
|
// A plugin registering (or clearing) tabs after the list was fetched leaves
|
||||||
|
// the visible list out of sync with the strip's active-tab filter - refetch
|
||||||
|
// exactly when the merged tab set changes, never on ordinary view switches
|
||||||
|
// (those already refetch through their own flows).
|
||||||
|
const prevTabsRef = useRef(tabs);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevTabsRef.current === tabs) return;
|
||||||
|
prevTabsRef.current = tabs;
|
||||||
|
if (client) void useEmailStore.getState().fetchEmails(client);
|
||||||
|
}, [tabs, client]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const handleSelect = (tabId: string) => {
|
||||||
|
if (tabId === activeTabId) return;
|
||||||
|
useMessageListTabsStore.getState().setActiveTab(tabId, selectedMailbox);
|
||||||
|
if (client) void useEmailStore.getState().fetchEmails(client);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-stretch gap-1 px-2 border-b border-border overflow-x-auto shrink-0"
|
||||||
|
style={{ scrollbarWidth: 'none' }}
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Inbox categories"
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const Icon = tab.icon
|
||||||
|
? (lucideIcons[tab.icon as keyof typeof lucideIcons] as LucideIcon | undefined)
|
||||||
|
: undefined;
|
||||||
|
const unread = tabCounts[tab.id] ?? 0;
|
||||||
|
const isActive = tab.id === activeTabId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onClick={() => handleSelect(tab.id)}
|
||||||
|
className={cn(
|
||||||
|
'relative flex items-center gap-1.5 px-3.5 py-2.5 text-sm whitespace-nowrap select-none',
|
||||||
|
'border-b-2 -mb-px rounded-t-md transition-colors duration-150',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||||
|
isActive
|
||||||
|
? 'border-primary text-foreground font-medium'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/40',
|
||||||
|
)}
|
||||||
|
style={isActive && tab.color ? { borderBottomColor: tab.color } : undefined}
|
||||||
|
>
|
||||||
|
{Icon && (
|
||||||
|
<Icon
|
||||||
|
className={cn('h-4 w-4 flex-shrink-0', !isActive && 'opacity-70')}
|
||||||
|
style={isActive && tab.color ? { color: tab.color } : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
{tab.showUnreadBadge !== false && unread > 0 && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs font-semibold tabular-nums',
|
||||||
|
isActive ? 'text-foreground' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
title={`${unread} unread`}
|
||||||
|
>
|
||||||
|
{unread > 99 ? '99+' : unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,10 +4,16 @@ import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
|
|||||||
import { DOMSerializer } from "@tiptap/pm/model";
|
import { DOMSerializer } from "@tiptap/pm/model";
|
||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
|
|
||||||
|
import { buildSignatureBlock } from "@/components/email/signature-block";
|
||||||
|
|
||||||
// Marker attribute that identifies the quoted-original wrapper in serialized
|
// Marker attribute that identifies the quoted-original wrapper in serialized
|
||||||
// HTML, so parseHTML can recognise it on the way back in.
|
// HTML, so parseHTML can recognise it on the way back in.
|
||||||
export const QUOTED_HTML_MARKER = "data-quoted-html";
|
export const QUOTED_HTML_MARKER = "data-quoted-html";
|
||||||
|
|
||||||
|
// Reusable style for the quote bar when quoting email text (like in a reply).
|
||||||
|
const QUOTE_BAR_STYLE =
|
||||||
|
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QuotedHtml — an atomic block node that carries the *verbatim* HTML of a
|
* QuotedHtml — an atomic block node that carries the *verbatim* HTML of a
|
||||||
* quoted/forwarded original email. The HTML is stored in the `html` attribute
|
* quoted/forwarded original email. The HTML is stored in the `html` attribute
|
||||||
@@ -66,8 +72,7 @@ export const QuotedHtml = TiptapNode.create({
|
|||||||
const dom = document.createElement("div");
|
const dom = document.createElement("div");
|
||||||
dom.setAttribute(QUOTED_HTML_MARKER, "");
|
dom.setAttribute(QUOTED_HTML_MARKER, "");
|
||||||
dom.className = "quoted-html-island";
|
dom.className = "quoted-html-island";
|
||||||
dom.style.cssText =
|
dom.style.cssText = QUOTE_BAR_STYLE;
|
||||||
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
|
|
||||||
|
|
||||||
// CRITICAL: render the quoted email inside a Shadow Root. The app's
|
// CRITICAL: render the quoted email inside a Shadow Root. The app's
|
||||||
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
|
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
|
||||||
@@ -166,6 +171,13 @@ export function serializeEditorContent(editor: Editor): string {
|
|||||||
parts.push(buildQuotedHtmlBlock((node.attrs.html as string) || ""));
|
parts.push(buildQuotedHtmlBlock((node.attrs.html as string) || ""));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (node.type.name === "signatureBlock") {
|
||||||
|
// Same rationale as quotedHtml: inline the verbatim signature HTML so the
|
||||||
|
// styled signature reaches the recipient (and a saved draft round-trips)
|
||||||
|
// instead of the schema-flattened version.
|
||||||
|
parts.push(buildSignatureBlock((node.attrs.html as string) || ""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fragment = serializer.serializeNode(node);
|
const fragment = serializer.serializeNode(node);
|
||||||
const tmp = document.createElement("div");
|
const tmp = document.createElement("div");
|
||||||
tmp.appendChild(fragment);
|
tmp.appendChild(fragment);
|
||||||
@@ -183,5 +195,5 @@ export function serializeEditorContent(editor: Editor): string {
|
|||||||
* must be what serializeEditorContent emits too (round-trip consistency).
|
* must be what serializeEditorContent emits too (round-trip consistency).
|
||||||
*/
|
*/
|
||||||
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
|
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
|
||||||
return `<div ${QUOTED_HTML_MARKER}>${sanitizedInnerHtml}</div>`;
|
return `<div ${QUOTED_HTML_MARKER} style="${QUOTE_BAR_STYLE}">${sanitizedInnerHtml}</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { MailCheck, Loader2, CheckCircle } from 'lucide-react';
|
import { MailCheck, Loader2, CheckCircle, X } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
interface ReadReceiptBannerProps {
|
interface ReadReceiptBannerProps {
|
||||||
@@ -17,43 +17,61 @@ export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceipt
|
|||||||
const t = useTranslations('email_viewer.read_receipt');
|
const t = useTranslations('email_viewer.read_receipt');
|
||||||
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
||||||
|
|
||||||
|
// Matches the host's "External Content" banner row: a round tinted icon chip,
|
||||||
|
// an uppercase eyebrow, a foreground message, and neutral bordered actions.
|
||||||
if (state === 'sent') {
|
if (state === 'sent') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-3 py-1">
|
||||||
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
|
<div className="w-10 h-10 rounded-full bg-success/15 text-success flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<span>{t('sent')}</span>
|
<CheckCircle className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">{t('sent')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm dark:border-amber-700/50 dark:bg-amber-950/30">
|
<div className="flex items-start gap-3 py-1">
|
||||||
<MailCheck className="w-4 h-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<span className="text-foreground">{t('prompt')}</span>
|
<MailCheck className="w-5 h-5" />
|
||||||
<span className="break-all text-muted-foreground">{requestedBy}</span>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
<button
|
<div>
|
||||||
onClick={async () => {
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
setState('sending');
|
Read receipt
|
||||||
try {
|
</div>
|
||||||
await onSend();
|
<div className="text-sm font-medium text-foreground break-words">
|
||||||
setState('sent');
|
{t('prompt')}
|
||||||
} catch {
|
</div>
|
||||||
setState('idle');
|
<div className="text-xs text-muted-foreground break-all">
|
||||||
}
|
Requested by <span className="text-foreground/80">{requestedBy}</span>
|
||||||
}}
|
</div>
|
||||||
disabled={state === 'sending'}
|
</div>
|
||||||
className="inline-flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||||
>
|
<button
|
||||||
{state === 'sending' && <Loader2 className="w-3 h-3 animate-spin" />}
|
onClick={async () => {
|
||||||
{t('send')}
|
setState('sending');
|
||||||
</button>
|
try {
|
||||||
<button
|
await onSend();
|
||||||
onClick={onIgnore}
|
setState('sent');
|
||||||
className="rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-700"
|
} catch {
|
||||||
>
|
setState('idle');
|
||||||
{t('ignore')}
|
}
|
||||||
</button>
|
}}
|
||||||
|
disabled={state === 'sending'}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{state === 'sending' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <MailCheck className="w-3.5 h-3.5" />}
|
||||||
|
{t('send')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onIgnore}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px]"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
{t('ignore')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{displayLabel || name || email}
|
<bdi>{displayLabel || name || email}</bdi>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen &&
|
{isOpen &&
|
||||||
@@ -221,7 +221,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
|||||||
{onViewContact && (
|
{onViewContact && (
|
||||||
<button
|
<button
|
||||||
onClick={handleViewContact}
|
onClick={handleViewContact}
|
||||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors ml-auto"
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors ms-auto"
|
||||||
title={contact ? "View contact" : "View details"}
|
title={contact ? "View contact" : "View details"}
|
||||||
>
|
>
|
||||||
{contact ? <ExternalLink className="w-3.5 h-3.5" /> : <UserPlus className="w-3.5 h-3.5" />}
|
{contact ? <ExternalLink className="w-3.5 h-3.5" /> : <UserPlus className="w-3.5 h-3.5" />}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Heading from "@tiptap/extension-heading";
|
|||||||
import Underline from "@tiptap/extension-underline";
|
import Underline from "@tiptap/extension-underline";
|
||||||
import Link from "@tiptap/extension-link";
|
import Link from "@tiptap/extension-link";
|
||||||
import TextAlign from "@tiptap/extension-text-align";
|
import TextAlign from "@tiptap/extension-text-align";
|
||||||
|
import { TextDirection } from "@/components/email/text-direction";
|
||||||
import { TextStyle } from "@tiptap/extension-text-style";
|
import { TextStyle } from "@tiptap/extension-text-style";
|
||||||
import Color from "@tiptap/extension-color";
|
import Color from "@tiptap/extension-color";
|
||||||
import { ResizableImage } from "@/components/email/resizable-image";
|
import { ResizableImage } from "@/components/email/resizable-image";
|
||||||
@@ -17,7 +18,9 @@ import { TableRow } from "@tiptap/extension-table-row";
|
|||||||
import { TableHeader } from "@tiptap/extension-table-header";
|
import { TableHeader } from "@tiptap/extension-table-header";
|
||||||
import { TableCell } from "@tiptap/extension-table-cell";
|
import { TableCell } from "@tiptap/extension-table-cell";
|
||||||
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
|
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
|
||||||
|
import { SignatureBlock } from "@/components/email/signature-block";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import {
|
import {
|
||||||
Bold,
|
Bold,
|
||||||
Italic,
|
Italic,
|
||||||
@@ -28,6 +31,7 @@ import {
|
|||||||
AlignLeft,
|
AlignLeft,
|
||||||
AlignCenter,
|
AlignCenter,
|
||||||
AlignRight,
|
AlignRight,
|
||||||
|
ArrowLeftRight,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
Undo,
|
Undo,
|
||||||
Redo,
|
Redo,
|
||||||
@@ -37,6 +41,7 @@ import {
|
|||||||
Heading1,
|
Heading1,
|
||||||
Heading2,
|
Heading2,
|
||||||
Table as TableIcon,
|
Table as TableIcon,
|
||||||
|
Baseline,
|
||||||
Trash2,
|
Trash2,
|
||||||
Rows3,
|
Rows3,
|
||||||
Columns3,
|
Columns3,
|
||||||
@@ -139,6 +144,14 @@ function ToolbarSeparator() {
|
|||||||
const TABLE_PICKER_ROWS = 6;
|
const TABLE_PICKER_ROWS = 6;
|
||||||
const TABLE_PICKER_COLS = 8;
|
const TABLE_PICKER_COLS = 8;
|
||||||
|
|
||||||
|
// Preset text colours (2 x 8). Inline `style="color: …"` survives email
|
||||||
|
// round-trips; the TextStyle/Color extensions are already registered to
|
||||||
|
// preserve pasted colours - this palette just adds a UI to set them.
|
||||||
|
const TEXT_COLORS = [
|
||||||
|
"#000000", "#5f6368", "#9aa0a6", "#c5221f", "#e8710a", "#f9ab00", "#188038", "#1967d2",
|
||||||
|
"#7627bb", "#c2185b", "#795548", "#fa5252", "#fd7e14", "#40c057", "#4dabf7", "#e64980",
|
||||||
|
];
|
||||||
|
|
||||||
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
|
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
|
||||||
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
|
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
|
||||||
return (
|
return (
|
||||||
@@ -182,6 +195,7 @@ export function RichTextEditor({
|
|||||||
hasError,
|
hasError,
|
||||||
onEditorReady,
|
onEditorReady,
|
||||||
}: RichTextEditorProps) {
|
}: RichTextEditorProps) {
|
||||||
|
const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport);
|
||||||
const onImageUploadRef = React.useRef(onImageUpload);
|
const onImageUploadRef = React.useRef(onImageUpload);
|
||||||
onImageUploadRef.current = onImageUpload;
|
onImageUploadRef.current = onImageUpload;
|
||||||
const onEditorReadyRef = React.useRef(onEditorReady);
|
const onEditorReadyRef = React.useRef(onEditorReady);
|
||||||
@@ -235,6 +249,11 @@ export function RichTextEditor({
|
|||||||
// Quoted/forwarded original email body - held verbatim as an atomic
|
// Quoted/forwarded original email body - held verbatim as an atomic
|
||||||
// node so layout-heavy HTML survives 1:1 (see quoted-html.ts).
|
// node so layout-heavy HTML survives 1:1 (see quoted-html.ts).
|
||||||
QuotedHtml,
|
QuotedHtml,
|
||||||
|
// Identity signature - held verbatim as a non-editable atomic node so
|
||||||
|
// rich/branded signatures keep their inline styling in the editor and
|
||||||
|
// in the sent mail (see signature-block.ts).
|
||||||
|
SignatureBlock,
|
||||||
|
TextDirection,
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
@@ -326,6 +345,19 @@ export function RichTextEditor({
|
|||||||
|
|
||||||
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
||||||
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [colorMenuOpen, setColorMenuOpen] = useState(false);
|
||||||
|
const colorWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!colorMenuOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (colorWrapperRef.current && !colorWrapperRef.current.contains(e.target as Node)) {
|
||||||
|
setColorMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [colorMenuOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tableMenuOpen) return;
|
if (!tableMenuOpen) return;
|
||||||
@@ -376,6 +408,49 @@ export function RichTextEditor({
|
|||||||
>
|
>
|
||||||
<Strikethrough className="w-4 h-4" />
|
<Strikethrough className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
<div ref={colorWrapperRef} className="relative">
|
||||||
|
<ToolbarButton
|
||||||
|
active={!!editor.getAttributes("textStyle").color}
|
||||||
|
onClick={() => setColorMenuOpen((v) => !v)}
|
||||||
|
title="Text color"
|
||||||
|
>
|
||||||
|
{/* The icon itself previews the active colour - no layout shift. */}
|
||||||
|
<Baseline className="w-4 h-4" style={{ color: editor.getAttributes("textStyle").color || undefined }} />
|
||||||
|
</ToolbarButton>
|
||||||
|
{colorMenuOpen && (
|
||||||
|
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2">
|
||||||
|
<div className="grid gap-0.5" style={{ gridTemplateColumns: "repeat(8, 1fr)" }}>
|
||||||
|
{TEXT_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
title={color}
|
||||||
|
onClick={() => {
|
||||||
|
editor.chain().focus().setColor(color).run();
|
||||||
|
setColorMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4 border border-border/60 rounded-[2px] transition-transform hover:scale-110",
|
||||||
|
editor.getAttributes("textStyle").color === color && "ring-1 ring-ring ring-offset-1"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-border my-1.5" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1 text-sm rounded hover:bg-accent text-start w-full"
|
||||||
|
onClick={() => {
|
||||||
|
editor.chain().focus().unsetColor().run();
|
||||||
|
setColorMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RemoveFormatting className="w-4 h-4" /> Remove color
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ToolbarSeparator />
|
<ToolbarSeparator />
|
||||||
|
|
||||||
@@ -449,6 +524,22 @@ export function RichTextEditor({
|
|||||||
<AlignRight className="w-4 h-4" />
|
<AlignRight className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
||||||
|
{rtlEditingSupport && (
|
||||||
|
<ToolbarButton
|
||||||
|
active={
|
||||||
|
(editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir) === "rtl"
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const cur =
|
||||||
|
editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir;
|
||||||
|
editor.chain().focus().setTextDirection(cur === "rtl" ? "ltr" : "rtl").run();
|
||||||
|
}}
|
||||||
|
title="Text direction (RTL/LTR)"
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
)}
|
||||||
|
|
||||||
<ToolbarSeparator />
|
<ToolbarSeparator />
|
||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
@@ -468,33 +559,33 @@ export function RichTextEditor({
|
|||||||
<TableIcon className="w-4 h-4" />
|
<TableIcon className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
{tableMenuOpen && (
|
{tableMenuOpen && (
|
||||||
<div className="absolute z-50 top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
|
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
|
||||||
{editor.isActive("table") ? (
|
{editor.isActive("table") ? (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||||
onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
|
onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<Rows3 className="w-4 h-4" /> Add row above
|
<Rows3 className="w-4 h-4" /> Add row above
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||||
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
|
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<Rows3 className="w-4 h-4" /> Add row below
|
<Rows3 className="w-4 h-4" /> Add row below
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||||
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
|
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<Columns3 className="w-4 h-4" /> Add column before
|
<Columns3 className="w-4 h-4" /> Add column before
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||||
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
|
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<Columns3 className="w-4 h-4" /> Add column after
|
<Columns3 className="w-4 h-4" /> Add column after
|
||||||
@@ -502,21 +593,21 @@ export function RichTextEditor({
|
|||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||||
onClick={() => { editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
|
onClick={() => { editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" /> Delete row
|
<Trash2 className="w-4 h-4" /> Delete row
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||||
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
|
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" /> Delete column
|
<Trash2 className="w-4 h-4" /> Delete column
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||||
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
|
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<Rows3 className="w-4 h-4" /> Toggle header row
|
<Rows3 className="w-4 h-4" /> Toggle header row
|
||||||
@@ -524,7 +615,7 @@ export function RichTextEditor({
|
|||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left text-red-600 dark:text-red-400"
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start text-red-600 dark:text-red-400"
|
||||||
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
|
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" /> Delete table
|
<Trash2 className="w-4 h-4" /> Delete table
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type SelectableAvatarProps = ComponentProps<typeof Avatar> & {
|
||||||
|
/** Whether the underlying message/thread is currently selected. */
|
||||||
|
checked: boolean;
|
||||||
|
/** Toggle selection. The wrapper stops propagation so the row is not opened. */
|
||||||
|
onToggle: () => void;
|
||||||
|
/** Accessible label for the selection control. */
|
||||||
|
selectLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avatar that doubles as a selection control, Thunderbird-style: clicking the
|
||||||
|
* avatar toggles the message/thread into the current selection instead of
|
||||||
|
* opening it. A check overlay appears on hover (hinting it is clickable) and
|
||||||
|
* stays visible while the row is selected.
|
||||||
|
*/
|
||||||
|
export function SelectableAvatar({
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
|
selectLabel,
|
||||||
|
className,
|
||||||
|
...avatarProps
|
||||||
|
}: SelectableAvatarProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={selectLabel}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"group/select relative shrink-0 rounded-full outline-none",
|
||||||
|
"focus-visible:ring-2 focus-visible:ring-primary/60",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar {...avatarProps} />
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 flex items-center justify-center rounded-full",
|
||||||
|
"bg-primary text-primary-foreground transition-opacity duration-150",
|
||||||
|
checked ? "opacity-100" : "opacity-0 group-hover/select:opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
|
||||||
|
|
||||||
|
// Marker attribute that identifies the signature wrapper in serialized HTML,
|
||||||
|
// so parseHTML can recognise it on the way back in (initial content, drafts).
|
||||||
|
export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force every link in the rendered signature to open in a new tab.
|
||||||
|
*
|
||||||
|
* Applied to the NodeView's DOM only, never to `attrs.html` — that attribute is
|
||||||
|
* what serializeEditorContent emits into the sent message, and the recipient's
|
||||||
|
* copy should stay exactly as the user wrote it. Without this the composer's
|
||||||
|
* signature is a set of live, target-less anchors in the main document (the
|
||||||
|
* message body gets a sandboxed iframe; this does not), so one stray click
|
||||||
|
* navigates the whole app away and takes the unsent draft with it.
|
||||||
|
*/
|
||||||
|
function forceLinksToNewTab(root: HTMLElement): void {
|
||||||
|
root.querySelectorAll("a[href]").forEach((a) => {
|
||||||
|
a.setAttribute("target", "_blank");
|
||||||
|
a.setAttribute("rel", "noopener noreferrer");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SignatureBlock — an atomic, NON-editable block node that carries the
|
||||||
|
* *verbatim* HTML of the user's identity signature in its `html` attribute.
|
||||||
|
*
|
||||||
|
* Why: the signature is embedded into the composer so it stays in the body,
|
||||||
|
* but parsing rich, table-based "brand" signatures into the ProseMirror schema
|
||||||
|
* strips their inline CSS (background/text colors, fonts, border-radius). By
|
||||||
|
* holding the signature as an atom it is never parsed into the schema, so the
|
||||||
|
* styling survives 1:1 — both in the editor (rendered by the NodeView below)
|
||||||
|
* and in the sent mail (emitted by serializeEditorContent in quoted-html.ts).
|
||||||
|
*
|
||||||
|
* Mirrors QuotedHtml, but the inner region is read-only: a signature is meant
|
||||||
|
* to be inserted/removed as a unit, not edited inline. Select the node and
|
||||||
|
* press Backspace/Delete to drop the whole signature.
|
||||||
|
*/
|
||||||
|
export const SignatureBlock = TiptapNode.create({
|
||||||
|
name: "signatureBlock",
|
||||||
|
group: "block",
|
||||||
|
atom: true,
|
||||||
|
selectable: true,
|
||||||
|
draggable: false,
|
||||||
|
// Isolating keeps selection/gapcursor behaviour sane at the boundary.
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
html: {
|
||||||
|
default: "",
|
||||||
|
// Capture the verbatim inner HTML when parsing. Because the node is an
|
||||||
|
// atom, ProseMirror does NOT descend into the children, so the rich
|
||||||
|
// signature markup never hits (and is never mangled by) the schema.
|
||||||
|
parseHTML: (el) => el.innerHTML,
|
||||||
|
// The real content round-trips via the custom serializer
|
||||||
|
// (serializeEditorContent); renderHTML below only needs the wrapper.
|
||||||
|
renderHTML: () => ({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: `div[${SIGNATURE_BLOCK_MARKER}]` }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
// Only used for ProseMirror's internal/clipboard round-trip. The send /
|
||||||
|
// draft path uses serializeEditorContent() which inlines attrs.html.
|
||||||
|
return ["div", mergeAttributes(HTMLAttributes, { [SIGNATURE_BLOCK_MARKER]: "" })];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ({ node }) => {
|
||||||
|
const dom = document.createElement("div");
|
||||||
|
dom.setAttribute(SIGNATURE_BLOCK_MARKER, "");
|
||||||
|
dom.className = "signature-block-island";
|
||||||
|
|
||||||
|
|
||||||
|
// CRITICAL: render the signature inside a Shadow Root. The app's global
|
||||||
|
// CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets)
|
||||||
|
// would otherwise cascade INTO the signature and destroy its layout -
|
||||||
|
// exactly the corruption we are fixing. Shadow DOM isolates both
|
||||||
|
// directions, so only the browser's UA defaults + the signature's own
|
||||||
|
// inline styles apply and the in-editor preview matches the sent mail.
|
||||||
|
const shadow = dom.attachShadow({ mode: "open" });
|
||||||
|
const inner = document.createElement("div");
|
||||||
|
// Read-only: a signature is inserted/removed as a unit, not edited inline.
|
||||||
|
inner.contentEditable = "false";
|
||||||
|
// Track what we were given, not what's in the DOM: forceLinksToNewTab
|
||||||
|
// rewrites the markup, so inner.innerHTML no longer round-trips against
|
||||||
|
// attrs.html and comparing the two would rewrite on every transaction.
|
||||||
|
let appliedHtml = node.attrs.html || "";
|
||||||
|
inner.innerHTML = appliedHtml;
|
||||||
|
forceLinksToNewTab(inner);
|
||||||
|
shadow.appendChild(inner);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dom,
|
||||||
|
// ProseMirror must not try to reconcile the foreign shadow content.
|
||||||
|
ignoreMutation: () => true,
|
||||||
|
// Let ProseMirror handle all events so clicking selects the atom and
|
||||||
|
// Backspace/Delete removes the whole signature.
|
||||||
|
stopEvent: () => false,
|
||||||
|
update: (updatedNode) => {
|
||||||
|
if (updatedNode.type.name !== "signatureBlock") return false;
|
||||||
|
const nextHtml = updatedNode.attrs.html || "";
|
||||||
|
if (nextHtml !== appliedHtml) {
|
||||||
|
appliedHtml = nextHtml;
|
||||||
|
inner.innerHTML = nextHtml;
|
||||||
|
forceLinksToNewTab(inner);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the editor-content wrapper that embeds the signature as a single
|
||||||
|
* SignatureBlock node. The inner HTML must be pre-sanitized
|
||||||
|
* (sanitizeSignatureHtml). The `data-signature-block-node` marker is what
|
||||||
|
* parseHTML keys on, so this exact form must be what serializeEditorContent
|
||||||
|
* emits too (round-trip consistency).
|
||||||
|
*/
|
||||||
|
export function buildSignatureBlock(sanitizedInnerHtml: string): string {
|
||||||
|
return `<div ${SIGNATURE_BLOCK_MARKER}>${sanitizedInnerHtml}</div>`;
|
||||||
|
}
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { ShieldCheck, ShieldAlert, ShieldX, Lock, LockOpen, AlertTriangle, Info } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import type { SmimeStatus } from "@/lib/smime/types";
|
|
||||||
|
|
||||||
interface SmimeStatusBannerProps {
|
|
||||||
status: SmimeStatus;
|
|
||||||
onUnlockKey?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SmimeVariant = 'success' | 'warning' | 'error' | 'info';
|
|
||||||
|
|
||||||
const variantTone: Record<SmimeVariant, string> = {
|
|
||||||
success: 'bg-success/15 text-success',
|
|
||||||
warning: 'bg-warning/15 text-warning',
|
|
||||||
error: 'bg-destructive/15 text-destructive',
|
|
||||||
info: 'bg-info/15 text-info',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatusBannerProps) {
|
|
||||||
const t = useTranslations('smime');
|
|
||||||
|
|
||||||
const items: Array<{
|
|
||||||
icon: React.ReactNode;
|
|
||||||
text: string;
|
|
||||||
variant: SmimeVariant;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
// Encryption status
|
|
||||||
if (status.isEncrypted) {
|
|
||||||
if (status.decryptionError) {
|
|
||||||
if (status.decryptionError === 'locked') {
|
|
||||||
items.push({
|
|
||||||
icon: <Lock className="w-5 h-5" />,
|
|
||||||
text: t('unlock_key_desc'),
|
|
||||||
variant: 'warning',
|
|
||||||
});
|
|
||||||
} else if (status.decryptionError === 'no-key') {
|
|
||||||
items.push({
|
|
||||||
icon: <Lock className="w-5 h-5" />,
|
|
||||||
text: t('status_encrypted_no_key'),
|
|
||||||
variant: 'warning',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
items.push({
|
|
||||||
icon: <ShieldX className="w-5 h-5" />,
|
|
||||||
text: t('status_encrypted_failed'),
|
|
||||||
variant: 'error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
items.push({
|
|
||||||
icon: <LockOpen className="w-5 h-5" />,
|
|
||||||
text: t('status_encrypted_ok'),
|
|
||||||
variant: 'success',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signature status
|
|
||||||
if (status.isSigned) {
|
|
||||||
if (status.signatureValid === true) {
|
|
||||||
if (status.selfSigned) {
|
|
||||||
items.push({
|
|
||||||
icon: <AlertTriangle className="w-5 h-5" />,
|
|
||||||
text: t('status_signed_self_signed'),
|
|
||||||
variant: 'warning',
|
|
||||||
});
|
|
||||||
} else if (status.signerEmailMatch === false) {
|
|
||||||
items.push({
|
|
||||||
icon: <AlertTriangle className="w-5 h-5" />,
|
|
||||||
text: t('status_signed_mismatch'),
|
|
||||||
variant: 'warning',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
items.push({
|
|
||||||
icon: <ShieldCheck className="w-5 h-5" />,
|
|
||||||
text: t('status_signed_valid'),
|
|
||||||
variant: 'success',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (status.signatureValid === false) {
|
|
||||||
items.push({
|
|
||||||
icon: <ShieldAlert className="w-5 h-5" />,
|
|
||||||
text: status.signatureError || t('status_signed_invalid'),
|
|
||||||
variant: 'error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unsupported S/MIME
|
|
||||||
if (status.unsupportedReason) {
|
|
||||||
items.push({
|
|
||||||
icon: <Info className="w-5 h-5" />,
|
|
||||||
text: t('status_unsupported'),
|
|
||||||
variant: 'info',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex flex-col gap-3 py-1", className)}>
|
|
||||||
{items.map((item, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-3">
|
|
||||||
<div className={cn(
|
|
||||||
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
|
|
||||||
variantTone[item.variant],
|
|
||||||
)}>
|
|
||||||
{item.icon}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0 flex items-center justify-between gap-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
S/MIME
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-medium text-foreground break-words">
|
|
||||||
{item.text}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{item.variant === 'warning' && status.decryptionError === 'locked' && onUnlockKey && (
|
|
||||||
<button
|
|
||||||
onClick={onUnlockKey}
|
|
||||||
className="text-xs font-medium underline hover:no-underline flex-shrink-0"
|
|
||||||
>
|
|
||||||
{t('unlock_key')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user