Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a8ad525f1 | ||
|
|
31d17098d6 | ||
|
|
3f97e6ed8d | ||
|
|
2dea33e698 | ||
|
|
123764f8b8 | ||
|
|
ec0f355c13 | ||
|
|
c555973b6b | ||
|
|
a8db02e881 | ||
|
|
7dc5984359 | ||
|
|
1c3003421e | ||
|
|
f3d9115ecd | ||
|
|
4400a7abba | ||
|
|
45a4db1c22 | ||
|
|
65eef4b2b8 | ||
|
|
25de7d996c | ||
|
|
c406fbb73e | ||
|
|
31024396e3 | ||
|
|
f0967f90eb | ||
|
|
a4bb8e0c28 | ||
|
|
7188abc9bc | ||
|
|
4a91cd0c44 | ||
|
|
6abf8a5dd8 | ||
|
|
3667c842c6 | ||
|
|
b64721b43c | ||
|
|
6b5ca2cb89 | ||
|
|
0f6e4f995f | ||
|
|
0b6fdcabfb | ||
|
|
fc49fe0687 | ||
|
|
419382d25d | ||
|
|
8935b81f12 | ||
|
|
ec581ce53e | ||
|
|
81d8465a79 | ||
|
|
0f3b506604 | ||
|
|
1b84547211 | ||
|
|
dafc8ace3c | ||
|
|
2c419cc4fe | ||
|
|
90acf181f3 | ||
|
|
54af07f2af | ||
|
|
68f1fabc4b | ||
|
|
27451807db | ||
|
|
55099bdcbb | ||
|
|
71eb720065 | ||
|
|
8abb0c8717 | ||
|
|
3043639d2d | ||
|
|
e9b3eacbb7 | ||
|
|
f37e55e285 | ||
|
|
c0af2dbdd1 | ||
|
|
ae517732f7 | ||
|
|
3e1de10213 | ||
|
|
511740bb6d | ||
|
|
4a24d2a11d | ||
|
|
aadf56c27b | ||
|
|
9f8588eadc | ||
|
|
d657aec391 | ||
|
|
e683c90404 | ||
|
|
cfb4a23c9d | ||
|
|
fe1d4861bb | ||
|
|
5aa9b1d5f9 | ||
|
|
0913dbd3e4 | ||
|
|
29197ea355 | ||
|
|
4788e8a91a | ||
|
|
b80678b00f | ||
|
|
4f7c9c332b | ||
|
|
da103ff06f | ||
|
|
2111c77870 | ||
|
|
e5083ec1df | ||
|
|
df8d04e233 | ||
|
|
9a11a18a44 | ||
|
|
ce9f7af330 | ||
|
|
081e8a0310 | ||
|
|
6c3529b368 | ||
|
|
077a4f03a7 | ||
|
|
b04dfaf252 | ||
|
|
9c7452e7fd | ||
|
|
ec5593f567 | ||
|
|
c30c38a7af | ||
|
|
7494fc1776 | ||
|
|
16fe92d2e9 | ||
|
|
3544e6a9e7 | ||
|
|
dd1f3e11e6 | ||
|
|
6503482b55 | ||
|
|
cab57f6cd7 | ||
|
|
27f4fbdce4 | ||
|
|
39a228b20e | ||
|
|
4b069808d6 | ||
|
|
468851ff25 | ||
|
|
b7374570c8 | ||
|
|
3c9fa5dc25 | ||
|
|
3ade1c6473 | ||
|
|
1810a474a2 | ||
|
|
40982bc37b | ||
|
|
76f6149841 | ||
|
|
7d6a3c8c76 | ||
|
|
361ad49f5f | ||
|
|
f9aa5cbaee | ||
|
|
3f36045990 | ||
|
|
9a44babcf1 | ||
|
|
f032758303 | ||
|
|
92c7f74420 | ||
|
|
c2e4518cfa | ||
|
|
00f33afdf9 | ||
|
|
e566cfe687 | ||
|
|
6b7c849332 | ||
|
|
30c4afb977 | ||
|
|
1f60671886 | ||
|
|
794001fdbd | ||
|
|
9ad2facad3 | ||
|
|
89d8282846 | ||
|
|
c3960a99be | ||
|
|
e73ffa7449 | ||
|
|
37bd490072 | ||
|
|
24c53e5ce7 | ||
|
|
aa7f886795 | ||
|
|
00dec8c5a0 | ||
|
|
578e60c0bc | ||
|
|
8b21851353 | ||
|
|
15006086d2 | ||
|
|
bc3b923945 | ||
|
|
76ba9e5f85 | ||
|
|
44eb5fced2 | ||
|
|
172d8267ef | ||
|
|
f162f1e3d4 | ||
|
|
6fa0029d0b | ||
|
|
028e78a0c9 | ||
|
|
440a4e919a | ||
|
|
b8f39198e1 | ||
|
|
966bbe3957 | ||
|
|
522bf6a019 | ||
|
|
1689315c3a | ||
|
|
d4f7ae522e | ||
|
|
f05f70a9e5 | ||
|
|
850ee73048 | ||
|
|
5842f3f914 | ||
|
|
f303478850 | ||
|
|
8bdadc7ba3 | ||
|
|
6b57118add | ||
|
|
ffb645671c | ||
|
|
e63ce25f5f | ||
|
|
d31b30ba4a | ||
|
|
31eff96614 | ||
|
|
2ea8054240 | ||
|
|
5a2e141ed6 | ||
|
|
fa343e0768 | ||
|
|
8969338b2a | ||
|
|
4c720d6855 | ||
|
|
ad175d20e3 | ||
|
|
fb2f0c9158 | ||
|
|
7daa46e73e | ||
|
|
195185dc52 | ||
|
|
8a9dce1a99 | ||
|
|
c690e8eb76 | ||
|
|
9d867cbff6 | ||
|
|
f22699fe20 | ||
|
|
a7db3883aa | ||
|
|
7fcefa53c9 | ||
|
|
bdb76c3d90 | ||
|
|
168b36d419 | ||
|
|
a4f57e7a5c | ||
|
|
6678501501 | ||
|
|
fa0045e01b | ||
|
|
1b816d3185 | ||
|
|
24949e183f |
+3
-3
@@ -1,11 +1,11 @@
|
|||||||
# Bulwark Webmail — Development Configuration
|
# Bulwark Webmail - Development Configuration
|
||||||
# Copy this file to .env.local to run with the built-in mock JMAP server.
|
# Copy this file to .env.local to run with the built-in mock JMAP server.
|
||||||
# No external mail server required — great for UI development and testing.
|
# No external mail server required - great for UI development and testing.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# cp .env.dev.example .env.local
|
# cp .env.dev.example .env.local
|
||||||
# npm run dev
|
# npm run dev
|
||||||
# Open http://localhost:3000 — log in with any username/password.
|
# Open http://localhost:3000 - log in with any username/password.
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Mock JMAP Server
|
# Mock JMAP Server
|
||||||
|
|||||||
+86
-25
@@ -1,4 +1,4 @@
|
|||||||
# Bulwark Webmail — Production Configuration
|
# Bulwark Webmail - Production Configuration
|
||||||
# Copy this file to .env.local and fill in your values.
|
# Copy this file to .env.local and fill in your values.
|
||||||
# For development with the built-in mock server, see .env.dev.example instead.
|
# For development with the built-in mock server, see .env.dev.example instead.
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
# JMAP Server (required)
|
# JMAP Server (required)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# App name displayed in the UI
|
# App name displayed in the UI, browser tab title, and PWA manifest.
|
||||||
APP_NAME=Bulwark Webmail
|
APP_NAME=Bulwark Webmail
|
||||||
|
|
||||||
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
|
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
|
||||||
@@ -27,11 +27,6 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Set to "false" to disable if using a non-Stalwart JMAP server.
|
# Set to "false" to disable if using a non-Stalwart JMAP server.
|
||||||
# STALWART_FEATURES=true
|
# STALWART_FEATURES=true
|
||||||
|
|
||||||
# If your reverse proxy doesn't forward Stalwart management API paths
|
|
||||||
# (/api/account/*, /api/principal/*), set this to the URL where Stalwart's
|
|
||||||
# HTTP listener is directly reachable. Defaults to JMAP_SERVER_URL if not set.
|
|
||||||
# STALWART_API_URL=https://admin.example.com
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# OAuth / OpenID Connect (optional)
|
# OAuth / OpenID Connect (optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -76,11 +71,36 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
|
|
||||||
# Directory for storing encrypted settings files (default: ./data/settings).
|
# Directory for storing encrypted settings files (default: ./data/settings).
|
||||||
# For Docker, the working directory is /app, so the default resolves to
|
# For Docker, the working directory is /app, so the default resolves to
|
||||||
# /app/data/settings — mount a persistent volume there:
|
# /app/data/settings - mount a persistent volume there (see docker-compose.yml).
|
||||||
# volumes:
|
|
||||||
# - bulwark-settings:/app/data/settings
|
|
||||||
# SETTINGS_DATA_DIR=./data/settings
|
# SETTINGS_DATA_DIR=./data/settings
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Admin Dashboard Data
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Directory for admin dashboard state: config overrides, admin password hash,
|
||||||
|
# installed plugins/themes, and audit logs (default: ./data/admin).
|
||||||
|
# For Docker, the default resolves to /app/data/admin - mount a persistent
|
||||||
|
# volume there (see docker-compose.yml).
|
||||||
|
# ADMIN_DATA_DIR=./data/admin
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Anonymous Telemetry
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Anonymous instance telemetry is enabled by default. Heartbeats contain no PII:
|
||||||
|
# version, platform, bucketed account counts, and feature toggles only. See
|
||||||
|
# https://bulwarkmail.org/docs/legal/privacy/telemetry for the full schema.
|
||||||
|
#
|
||||||
|
# Disable telemetry entirely (overrides the admin UI):
|
||||||
|
# BULWARK_TELEMETRY=off
|
||||||
|
|
||||||
|
# Directory for telemetry state: instance id, consent, login HMACs
|
||||||
|
# (default: ./data/telemetry). For Docker, the default resolves to
|
||||||
|
# /app/data/telemetry - mount a persistent volume there (see docker-compose.yml)
|
||||||
|
# so the instance id and consent choice survive upgrades.
|
||||||
|
# TELEMETRY_DATA_DIR=./data/telemetry
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Server Listen Address
|
# Server Listen Address
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -106,41 +126,81 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Branding (all optional)
|
# Branding (all optional)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Custom favicon for the browser tab.
|
# ---------------------------------------------------------------------------
|
||||||
|
# App identity
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Short name for the app, used in contexts where space is limited
|
||||||
|
# (e.g. home screen label on mobile). Defaults to APP_NAME if not set.
|
||||||
|
# APP_SHORT_NAME=Bulwark
|
||||||
|
|
||||||
|
# Description shown in the PWA manifest (displayed by the OS during install).
|
||||||
|
# Defaults to a generic Bulwark description if not set.
|
||||||
|
# APP_DESCRIPTION=Your personal webmail
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Icons & favicon
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Custom favicon shown in the browser tab.
|
||||||
# Supported formats: SVG (recommended), PNG, ICO.
|
# Supported formats: SVG (recommended), PNG, ICO.
|
||||||
# Recommended size: 32×32px minimum, 512×512px maximum (or SVG for best scaling).
|
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
|
||||||
# Can be an absolute URL or a path relative to the public/ directory.
|
|
||||||
# Defaults to the Bulwark favicon if not set.
|
# Defaults to the Bulwark favicon if not set.
|
||||||
# FAVICON_URL=/branding/my-favicon.svg
|
# FAVICON_URL=/branding/my-favicon.svg
|
||||||
|
|
||||||
# Custom logos for the sidebar (shown in the main app after login).
|
# Source image used to auto-generate PWA icons (192×192 and 512×512 PNG).
|
||||||
|
# Supported formats: SVG (recommended for best quality) or PNG (≥512×512px recommended).
|
||||||
|
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
|
||||||
|
# Falls back to FAVICON_URL if not set, and to the default Bulwark icons if neither is set.
|
||||||
|
# PWA_ICON_URL=/branding/my-icon.svg
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PWA appearance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Color applied to the browser UI chrome when the app is installed as a PWA
|
||||||
|
# (address bar, status bar on Android). Default: #ffffff
|
||||||
|
# PWA_THEME_COLOR=#3b82f6
|
||||||
|
|
||||||
|
# Background color shown on the PWA splash screen while the app is loading.
|
||||||
|
# Should match your app's main background color. Default: #ffffff
|
||||||
|
# PWA_BACKGROUND_COLOR=#ffffff
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logos
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Logos shown in the sidebar (main app, after login).
|
||||||
# Supported formats: SVG (recommended), PNG, WebP.
|
# Supported formats: SVG (recommended), PNG, WebP.
|
||||||
# Recommended size: min 24×24px, max 128×128px
|
# Recommended size: min 24×24px, max 128×128px.
|
||||||
# Can be absolute URLs or paths relative to the public/ directory.
|
# Can be absolute URLs or paths relative to the public/ directory.
|
||||||
# If not set, no logo is shown in the sidebar.
|
# If not set, no logo is shown in the sidebar.
|
||||||
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
|
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
|
||||||
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg
|
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg
|
||||||
|
|
||||||
# Custom logo images for the login page.
|
# Logos shown on the login page.
|
||||||
# Supported formats: SVG (recommended), PNG, WebP.
|
# Supported formats: SVG (recommended), PNG, WebP.
|
||||||
# Recommended size: min 32×32px, max 512×512px
|
# Recommended size: min 32×32px, max 512×512px.
|
||||||
# Can be absolute URLs or paths relative to the public/ directory.
|
# Can be absolute URLs or paths relative to the public/ directory.
|
||||||
# Light mode logo (shown on light backgrounds), defaults to Bulwark logo.
|
# Light mode logo (shown on light backgrounds). Defaults to the Bulwark logo.
|
||||||
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
|
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
|
||||||
#
|
# Dark mode logo (shown on dark backgrounds). Defaults to the Bulwark white logo.
|
||||||
# Dark mode logo (shown on dark backgrounds), defaults to Bulwark white logo.
|
|
||||||
LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg
|
LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg
|
||||||
|
|
||||||
# Company or organization name displayed above the version on the login page
|
# ---------------------------------------------------------------------------
|
||||||
|
# Login page
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Company name shown above the version number on the login page.
|
||||||
LOGIN_COMPANY_NAME=Bulwark Webmail
|
LOGIN_COMPANY_NAME=Bulwark Webmail
|
||||||
|
|
||||||
# URL for the imprint/legal notice link on the login page
|
# URL for the imprint / legal notice link on the login page.
|
||||||
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||||
|
|
||||||
# URL for the privacy policy link on the login page
|
# URL for the privacy policy link on the login page.
|
||||||
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||||
|
|
||||||
# URL for the company website link on the login page
|
# URL for the company website link on the login page.
|
||||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -148,7 +208,8 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# URL of the BulwarkMail extension directory for the admin marketplace.
|
# URL of the BulwarkMail extension directory for the admin marketplace.
|
||||||
# Set this to enable browsing and installing plugins/themes from the directory.
|
# Defaults to https://extensions.bulwarkmail.org. Override only if you run
|
||||||
|
# your own directory (e.g. http://localhost:3001 for local development).
|
||||||
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
+173
-2
@@ -1,14 +1,183 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.5.4 (2026-05-01)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **PWA**: Web push notifications for new inbox mail (#233), with click-through to open the message
|
||||||
|
- **Composer**: Insert and edit tables in rich-text emails (#236)
|
||||||
|
- **Mail**: Configurable sub-addressing delimiter character (#239)
|
||||||
|
- **i18n**: Turkish localization
|
||||||
|
- **i18n**: Missing keys filled in across 15 locales
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mail**: Set In-Reply-To and References headers on replies (#234)
|
||||||
|
- **Mail**: Persist htmlBody in drafts to preserve rich formatting (#236)
|
||||||
|
- **Auth**: Pin JMAP auth verification to the configured server URL (#237)
|
||||||
|
- **Auth**: Evict unrecoverable basic-auth accounts on reload
|
||||||
|
- **Notifications**: Scope new-mail notifications to genuine inbox deliveries
|
||||||
|
- **Notifications**: Extend PushVerification timeout and clean up leftover subscriptions
|
||||||
|
- **Viewer**: Smooth out body load to prevent flicker on first render
|
||||||
|
- **Viewer**: Prevent iframe flash when loading images or trusting the sender
|
||||||
|
- **Viewer**: Pad bare HTML emails like plain-text mails for consistent layout
|
||||||
|
- **Viewer**: Light-mode override now only affects body content
|
||||||
|
- **Viewer**: Detect `<style>` tag when applying padding
|
||||||
|
- **Viewer**: Drop iframe border-radius
|
||||||
|
- **Calendar**: Localize event start date in detail popover and event modal
|
||||||
|
- **Dev**: Include http protocol in connect-src for development mode CSP
|
||||||
|
|
||||||
|
## 1.5.3 (2026-04-28)
|
||||||
|
|
||||||
|
> **New:** Help shape Bulwark Webmail. Each instance now sends a lightweight daily heartbeat (version, platform, bucketed account counts, feature toggles - never message data or PII) so we can see which platforms and features actually get used and prioritize fixes where they matter most. You're in control: opt out any time from **Admin → Telemetry** or by setting `BULWARK_TELEMETRY=off`. Full schema in the [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Telemetry**: Anonymous instance telemetry, on by default. Reports schema version, platform, bucketed account counts, and feature toggles only - disable from the admin UI, with `BULWARK_TELEMETRY=off`, or by clearing the endpoint
|
||||||
|
- **Telemetry**: Track unique logins (HMAC'd per instance, 90-day retention) so the heartbeat can report bucketed account totals without storing usernames
|
||||||
|
- **Plugins**: Theme API v2 with token compiler and skin slot
|
||||||
|
- **Plugins**: Extension preview page and detailed extension info API
|
||||||
|
- **Calendar**: Right-click context menu on empty calendar space
|
||||||
|
- **Docker**: Persistent named volume for telemetry data so the instance id and admin's consent choice survive container upgrades
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Security**: Block telemetry endpoint from pointing at internal/loopback hosts (validation + DNS-rebind re-check at fetch time)
|
||||||
|
- **Security**: Harden plugin config, TOTP token exchange, and branding file serving
|
||||||
|
- **Mail**: Batch shortcuts now act on the multi-selection when one is present (#228)
|
||||||
|
|
||||||
|
## 1.5.2 (2026-04-27)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission - plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example
|
||||||
|
- **Plugins**: Manifests can declare `frameOrigins` - a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
|
||||||
|
- **Calendar/Contacts**: JMAP sharing for calendars and address books
|
||||||
|
- **i18n**: Czech language support
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Security**: Validate URLs before outbound fetch
|
||||||
|
- **Calendar**: Prevent drag creation on touch events in the time grid
|
||||||
|
- **Contacts**: Emit RFC 9553 name kinds and decode QUOTED-PRINTABLE in vCard import (#224, #187)
|
||||||
|
- **Mail**: Hide preview line in compact density to match settings preview (#223)
|
||||||
|
- **Proxy**: Inline matcher for Next.js proxy and drop unnecessary Node.js runtime config
|
||||||
|
- **i18n**: Portuguese fixes for "ficheiro" and "contactos" variants
|
||||||
|
|
||||||
|
## 1.5.1 (2026-04-25)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Stalwart**: OAuth auto-setup with dialog and validation for origin and issuer URLs
|
||||||
|
- **Mail**: Right-click context menu on the folders sidebar
|
||||||
|
- **Mail**: Replace folder `prompt()` calls with a proper modal dialog
|
||||||
|
- **Calendar**: Add 'Today' button to the desktop calendar toolbar
|
||||||
|
- **Junk**: Setting to show avatars in the Junk folder (off by default)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Admin**: Restore admin panel after Stalwart v0.16 REST API removal
|
||||||
|
- **Viewer**: Restore broken viewer toolbar actions and improve the mobile menu (#220)
|
||||||
|
- **Folders**: Stop flicker on background folder refresh
|
||||||
|
- **Email**: Preserve search/filter on batch move and archive
|
||||||
|
- **Email**: Preserve search/filter when moving emails via drag-drop
|
||||||
|
- **i18n**: Improve Korean flag
|
||||||
|
|
||||||
|
## 1.5.0 (2026-04-22)
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **Self-service portal now needs Stalwart 0.16+**: Stalwart dropped its self-service HTTP API in 0.16.0 and replaced it with JMAP. Bulwark Webmail only talks to the new JMAP endpoint, so the self-service portal (account settings, app passwords, API keys) requires Stalwart 0.16 or newer. `STALWART_API_URL` is deprecated, these actions go through the normal JMAP session.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Stalwart**: Migrate Stalwart management API to JMAP `x:` methods for Stalwart 0.16
|
||||||
|
- **Admin**: Add API Keys management and IP allowlist for App Passwords
|
||||||
|
- **Contacts**: Revamp contact detail view with filters, photo, print, and duplicate actions
|
||||||
|
- **Contacts**: Add contact activity component showing recent emails and upcoming events
|
||||||
|
- **Contacts**: Add right-click context menu
|
||||||
|
- **Contacts**: Group contacts by first letter with sticky section headers, toggleable in settings
|
||||||
|
- **Calendar**: Support resizing events from the top edge
|
||||||
|
- **Calendar**: Add timezone-aware formatting for event start times and update `utcEnd` on duration change
|
||||||
|
- **Calendar**: Optimize layout of overlapping events
|
||||||
|
- **Calendar**: Add collapsible details to calendar invitation banner
|
||||||
|
- **Email**: Implement batch archiving and bulk moving of emails
|
||||||
|
- **Email**: Show full folder path in move/drop toast
|
||||||
|
- **Settings**: Reorganize settings into 6 groups with clearer tabs
|
||||||
|
- **Navigation**: Add account-addition button to the navigation rail
|
||||||
|
- **Mobile**: Streamline email viewer header layout
|
||||||
|
- **Mobile**: Pass `isMobile` through calendar views and time-grid interactions
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mailbox**: Retry mailbox fetch on first login to handle lazy provisioning (#217)
|
||||||
|
- **Mailbox**: Use fresh state in archive handling to avoid stale mailbox data
|
||||||
|
- **Mailbox**: Improve error message on mailbox creation failure
|
||||||
|
- **Auth**: Skip `checkAuth` on route change when already authenticated
|
||||||
|
- **Auth**: Clean up unused imports and improve TOTP QR code rendering
|
||||||
|
- **UI**: Align hover styles and selection-toggle target with focused item
|
||||||
|
- **UI**: Read `matchMedia` synchronously on client to prevent layout flicker
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **Settings**: Remove Stalwart API URL configuration (now derived via JMAP)
|
||||||
|
|
||||||
|
### Chore
|
||||||
|
|
||||||
|
- **i18n**: Add missing translation keys
|
||||||
|
- **Deps**: Bump dependencies to latest compatible versions
|
||||||
|
|
||||||
|
## 1.4.14 (2026-04-16)
|
||||||
|
|
||||||
|
Thank you for your donations:
|
||||||
|
|
||||||
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
|
**One-time**
|
||||||
|
|
||||||
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
- [@boris22100](https://github.com/boris22100)
|
||||||
|
|
||||||
|
**Monthly**
|
||||||
|
|
||||||
|
- [@pr0ton11](https://github.com/pr0ton11)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Email**: Add unified mailbox across accounts and sidebar icons toggle
|
||||||
|
- **Email**: Enhance email deletion and spam handling with improved parameterization
|
||||||
|
- **Sieve**: Enhance external rule handling in parser and store (#201)
|
||||||
|
- **Plugins**: Add i18n API, render hooks, and new intercept hooks to plugin system
|
||||||
|
- **PWA**: Dynamic PWA manifest with configurable name, description, and icons
|
||||||
|
- **PWA**: Show app name and logo in install prompt
|
||||||
|
- **i18n**: Add Ukrainian language with flags and missing translation keys
|
||||||
|
- **i18n**: Configurable locale prefix via `NEXT_PUBLIC_LOCALE_PREFIX`
|
||||||
|
- **API**: Add `apiFetch` helper for mount-prefix-aware API calls
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Calendar**: Send iMIP invitation emails when creating or updating calendar events (#192)
|
||||||
|
- **Calendar**: RFC 5545/6047 compliance for outgoing iMIP calendar emails
|
||||||
|
- **Calendar**: Add `calendarAddress` and `replyTo` to participants for Stalwart compatibility (#189, #192)
|
||||||
|
- **Calendar**: Improve CalDAV task detection for external clients like Thunderbird (#84)
|
||||||
|
- **Email**: Hide ICS attachments from attachment list when invitation banner is shown
|
||||||
|
- **Email**: Send before storing in Sent via `onSuccessUpdateEmail` (#188)
|
||||||
|
- **Email**: Standardize tag naming and fix unknown keyword display (#184, #185)
|
||||||
|
- **i18n**: Skip intl middleware for paths already containing a locale prefix
|
||||||
|
- **Docs**: Document PWA and branding env vars in `.env.example`
|
||||||
|
- **Docs**: Use `company` consistently in `.env.example` branding comments
|
||||||
|
|
||||||
## 1.4.13 (2026-04-12)
|
## 1.4.13 (2026-04-12)
|
||||||
|
|
||||||
Thank you for your donations:
|
Thank you for your donations:
|
||||||
|
|
||||||
**One-time**
|
**One-time**
|
||||||
|
|
||||||
- [@boris22100](https://github.com/boris22100)
|
- [@boris22100](https://github.com/boris22100)
|
||||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
|
||||||
**Monthly**
|
**Monthly**
|
||||||
|
|
||||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -38,9 +207,11 @@ Thank you for your donations:
|
|||||||
Thank you for your donations:
|
Thank you for your donations:
|
||||||
|
|
||||||
**One-time**
|
**One-time**
|
||||||
|
|
||||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||||
|
|
||||||
**Monthly**
|
**Monthly**
|
||||||
|
|
||||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -436,7 +607,7 @@ Thank you for your donations:
|
|||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19)
|
- **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19)
|
||||||
- **Move to folder**: Fix emails not actually moving on the server — JMAP response errors were silently ignored and shared account IDs were not resolved correctly
|
- **Move to folder**: Fix emails not actually moving on the server - JMAP response errors were silently ignored and shared account IDs were not resolved correctly
|
||||||
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node
|
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node
|
||||||
|
|
||||||
## 1.1.1 (2026-02-28)
|
## 1.1.1 (2026-02-28)
|
||||||
@@ -446,7 +617,7 @@ Thank you for your donations:
|
|||||||
- **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18)
|
- **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18)
|
||||||
- **Email viewer**: Details toggle text is now properly translated (was hardcoded in English)
|
- **Email viewer**: Details toggle text is now properly translated (was hardcoded in English)
|
||||||
- **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module
|
- **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module
|
||||||
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) — upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) - upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
||||||
|
|
||||||
## 1.1.0 (2026-02-28)
|
## 1.1.0 (2026-02-28)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ RUN apk upgrade --no-cache && \
|
|||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
RUN mkdir -p /app/data/settings /app/data/admin && chown -R nextjs:nodejs /app/data
|
RUN mkdir -p /app/data/settings /app/data/admin /app/data/telemetry && chown -R nextjs:nodejs /app/data
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
# Features
|
||||||
|
|
||||||
|
## Mail
|
||||||
|
|
||||||
|
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding)
|
||||||
|
- Gmail-style threading with inline expansion and an optional conversation toggle
|
||||||
|
- Unified mailbox view across all connected accounts
|
||||||
|
- Draft auto-save with identity preservation
|
||||||
|
- Attachment upload, download, and inline preview; forgotten-attachment warning
|
||||||
|
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||||
|
- Batch operations – multi-select, archive, delete, move, tag
|
||||||
|
- Archive modes – direct, by year, or by month
|
||||||
|
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
|
||||||
|
- Star/unstar with configurable mark-as-read delay
|
||||||
|
- Virtual scrolling for large mailboxes
|
||||||
|
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
|
||||||
|
- Plain-text composer mode and Reply-To support
|
||||||
|
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
|
||||||
|
- Folder management with icon picker, subfolders, and sidebar counts
|
||||||
|
- Print directly from the viewer
|
||||||
|
- Browser history sync for back/forward navigation
|
||||||
|
|
||||||
|
## Calendar
|
||||||
|
|
||||||
|
- Month, week, day, and agenda views with a mini-calendar sidebar and task list
|
||||||
|
- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap
|
||||||
|
- Recurring events with scoped edit/delete (this / this and following / all)
|
||||||
|
- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment
|
||||||
|
- Inline calendar invitations in the email viewer – auto-detect `.ics`, RSVP, import
|
||||||
|
- iCalendar import with preview, bulk create, and UID deduplication
|
||||||
|
- iCal / webcal subscriptions with editing and batch import
|
||||||
|
- Auto-generated birthday calendar from contacts
|
||||||
|
- Virtual locations (video conference URLs) as first-class event fields
|
||||||
|
- Task management with due dates, priority, and completion status
|
||||||
|
- Shared calendars with CalDAV discovery and multi-account home resolution
|
||||||
|
- Week numbers, event hover preview, notifications with sound picker
|
||||||
|
- Real-time sync via JMAP push
|
||||||
|
|
||||||
|
## Contacts
|
||||||
|
|
||||||
|
- JMAP sync (RFC 9553 / 9610) with local fallback
|
||||||
|
- Multiple address books with drag-and-drop between books
|
||||||
|
- Contact groups with member management
|
||||||
|
- vCard import/export (RFC 6350) with duplicate detection
|
||||||
|
- Trusted senders stored in a dedicated JMAP address book
|
||||||
|
- Autocomplete in the composer (To / Cc / Bcc)
|
||||||
|
|
||||||
|
## Filters & Templates
|
||||||
|
|
||||||
|
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
||||||
|
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
||||||
|
- Preserves rules authored in other clients
|
||||||
|
- Raw Sieve editor with syntax validation
|
||||||
|
- Vacation responder with date range scheduling
|
||||||
|
- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- JMAP FileNode browser (Stalwart native cloud storage)
|
||||||
|
- Streamed WebDAV PUT upload and folder upload with progress tracking
|
||||||
|
- Dynamic upload limits based on server configuration
|
||||||
|
- Grid and list views with sorting by name, size, or date
|
||||||
|
- Previews for images, text, audio, and video
|
||||||
|
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
||||||
|
|
||||||
|
## Security & Privacy
|
||||||
|
|
||||||
|
- External content blocked by default, with a trusted senders list
|
||||||
|
- HTML sanitization via DOMPurify
|
||||||
|
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
||||||
|
- SPF / DKIM / DMARC status indicators
|
||||||
|
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
||||||
|
- TOTP two-factor authentication
|
||||||
|
- Account security panel for password and 2FA management via the Stalwart admin API
|
||||||
|
- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie
|
||||||
|
- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention
|
||||||
|
- Plugin hardening with dangerous-pattern detection and admin approval
|
||||||
|
- Newsletter unsubscribe (RFC 2369)
|
||||||
|
|
||||||
|
## Interface
|
||||||
|
|
||||||
|
- Three-pane layout with resizable columns
|
||||||
|
- Dark and light themes with intelligent email color transformation
|
||||||
|
- Responsive desktop, tablet, and mobile layouts
|
||||||
|
- Full keyboard navigation
|
||||||
|
- Drag-and-drop email organization and tag assignment
|
||||||
|
- Interactive guided tour for new users
|
||||||
|
- Right-click context menus, toast notifications with undo
|
||||||
|
- Customizable toolbar position, favicon, and login branding
|
||||||
|
- Pinnable sidebar apps with drag-and-drop reordering
|
||||||
|
- Encrypted settings sync across devices
|
||||||
|
- Storage quota display
|
||||||
|
- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions
|
||||||
|
|
||||||
|
## Internationalization
|
||||||
|
|
||||||
|
15 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
||||||
|
|
||||||
|
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||||
|
|
||||||
|
## Identity & Multi-Account
|
||||||
|
|
||||||
|
- Up to 5 simultaneous accounts with instant switching and per-account session persistence
|
||||||
|
- Account switcher with connection status and default account selection
|
||||||
|
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
|
||||||
|
- Sub-addressing (`user+tag@domain.com`) with contextual tag suggestions
|
||||||
|
- Shared folders across accounts
|
||||||
|
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||||
|
|
||||||
|
## Admin & Extensibility
|
||||||
|
|
||||||
|
- Stalwart admin dashboard with dedicated policy sections
|
||||||
|
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event slots, and managed policy enforcement
|
||||||
|
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
||||||
|
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
|
||||||
|
- Bundled plugins including Jitsi Meet calendar integration
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
- Progressive Web App with service worker, install prompt, and dynamic manifest
|
||||||
|
- Automatic update check with server-side logging of new releases
|
||||||
|
- Structured logging (`text` or `json`) with category-based levels
|
||||||
|
- Release (`main`) and development (`dev`) Docker images on GHCR
|
||||||
|
- Demo mode with fixture data – no mail server required
|
||||||
@@ -8,12 +8,11 @@
|
|||||||
|
|
||||||
# Bulwark Webmail
|
# Bulwark Webmail
|
||||||
|
|
||||||
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/).<br/>
|
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
|
||||||
Built with Next.js and the JMAP protocol.
|
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -22,190 +21,58 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="screenshots/mail-dark.png" />
|
||||||
|
<img src="screenshots/mail-white.png" alt="Mail view" width="100%" />
|
||||||
|
</picture>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%">
|
<td width="50%"><img src="screenshots/calendar.png" alt="Calendar" /></td>
|
||||||
|
<td width="50%"><img src="screenshots/contacts.png" alt="Contacts" /></td>
|
||||||
<img src="screenshots/inbox.png" width="100%" alt="Inbox — three-pane layout with sidebar, email list, and viewer (dark mode)">
|
|
||||||
|
|
||||||
**Mail** — Three-pane layout with sidebar, email list, and viewer
|
|
||||||
|
|
||||||
</td>
|
|
||||||
<td width="50%">
|
|
||||||
|
|
||||||
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
|
|
||||||
|
|
||||||
**Calendar** — Month, week, day, and agenda views with event management
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%">
|
<td><sub><b>Calendar</b> – month, week, day, and agenda views with drag-to-reschedule, iMIP invitations, and CalDAV subscriptions.</sub></td>
|
||||||
|
<td><sub><b>Contacts</b> – multiple address books, groups, vCard import/export, and autocomplete in the composer.</sub></td>
|
||||||
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
</tr>
|
||||||
|
<tr>
|
||||||
**Contacts** — Contact management with groups and vCard support
|
<td><img src="screenshots/theme.png" alt="Themes" /></td>
|
||||||
|
<td><img src="screenshots/plugins.png" alt="Plugins" /></td>
|
||||||
</td>
|
</tr>
|
||||||
<td width="50%">
|
<tr>
|
||||||
|
<td><sub><b>Themes</b> – bundled color themes or upload your own as ZIP bundles; admins can enforce presets.</sub></td>
|
||||||
<img src="screenshots/files.png" width="100%" alt="File browser">
|
<td><sub><b>Plugins</b> – extend the client with bundled or third-party plugins installed from a .zip file.</sub></td>
|
||||||
|
</tr>
|
||||||
**Files** — Cloud file browser with upload, preview, and folder navigation
|
<tr>
|
||||||
|
<td><img src="screenshots/mail-white.png" alt="Light mode" /></td>
|
||||||
</td>
|
<td><img src="screenshots/settings.png" alt="Settings" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><sub><b>Light mode</b> – full theme support with intelligent color transformation for HTML emails.</sub></td>
|
||||||
|
<td><sub><b>Settings</b> – appearance, identities, filters, templates, security, and more.</sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<details>
|
> **Anonymous telemetry is on by default** since 1.5.3. Each instance sends a daily heartbeat (version, platform, bucketed account counts, feature toggles - no message data, no PII). Disable from **Admin → Telemetry**, by setting `BULWARK_TELEMETRY=off`, or by clearing the endpoint. Full schema: [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
|
||||||
<summary>More screenshots</summary>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td width="50%">
|
|
||||||
|
|
||||||
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox — light mode">
|
## Overview
|
||||||
|
|
||||||
**Light mode** — Full theme support with intelligent color transformation
|
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:
|
||||||
|
|
||||||
</td>
|
- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
|
||||||
<td width="50%">
|
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||||
|
- **Contacts** – multiple address books, groups, vCard import/export
|
||||||
|
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||||
|
|
||||||
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard.
|
||||||
|
|
||||||
**Settings** — Appearance, identities, filters, templates, and more
|
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td width="50%">
|
|
||||||
|
|
||||||
<img src="screenshots/login.png" width="100%" alt="Login page">
|
|
||||||
|
|
||||||
**Login** — Configurable branding with OAuth2/OIDC and 2FA support
|
|
||||||
|
|
||||||
</td>
|
|
||||||
<td width="50%">
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Mail
|
|
||||||
|
|
||||||
- **Read, compose, reply, reply-all, forward** with rich HTML rendering
|
|
||||||
- **Threading** — Gmail-style inline expansion with thread navigation
|
|
||||||
- **Draft auto-save** with discard confirmation
|
|
||||||
- **Attachments** — upload, download, and inline preview
|
|
||||||
- **Search** — full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
|
|
||||||
- **Batch operations** — multi-select with checkboxes, archive, delete, move, tag
|
|
||||||
- **Archive modes** — archive directly or organize archived mail by year or month
|
|
||||||
- **Print** emails directly from the viewer
|
|
||||||
- **Answered/forwarded status icons** in email list and thread views
|
|
||||||
- **Color tags/labels** and star/unstar
|
|
||||||
- **Virtual scrolling** for large mailboxes
|
|
||||||
- **Quick reply** from the viewer
|
|
||||||
- **Sender avatars** — favicon-based with negative caching for performance
|
|
||||||
- **Recipient popover** for quick contact interaction
|
|
||||||
- **TNEF support** — extract Outlook `winmail.dat` message bodies and attachments automatically
|
|
||||||
- **Folder management** — create, rename, delete folders with icon picker and subfolder support
|
|
||||||
- **Tag counts** — unread and total counts displayed in sidebar
|
|
||||||
|
|
||||||
### Calendar
|
|
||||||
|
|
||||||
- **Month, week, day, and agenda views** with mini-calendar sidebar
|
|
||||||
- **Event hover preview** popover with details
|
|
||||||
- **Drag-and-drop rescheduling**, click-drag creation, edge-resize (15-min snap)
|
|
||||||
- **Recurring events** with edit/delete scope (this / this and following / all)
|
|
||||||
- **Participant scheduling** — iTIP invitations, organizer/attendee UI, RSVP
|
|
||||||
- **Inline calendar invitations** in email viewer — auto-detect `.ics`, RSVP, import
|
|
||||||
- **iCalendar import** with preview and bulk create
|
|
||||||
- **Task management** — create, edit, and track tasks with due dates, priority, and completion status
|
|
||||||
- **Week numbers** in mini-calendar sidebar
|
|
||||||
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
|
|
||||||
- **Real-time sync** via JMAP push
|
|
||||||
|
|
||||||
### Contacts
|
|
||||||
|
|
||||||
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
|
|
||||||
- **Contact groups** with group expansion and member management
|
|
||||||
- **vCard import/export** (RFC 6350) with duplicate detection
|
|
||||||
- **Autocomplete** in composer (To/Cc/Bcc)
|
|
||||||
- **Bulk operations** — multi-select, delete, group add, export
|
|
||||||
|
|
||||||
### Filters & Automation
|
|
||||||
|
|
||||||
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
|
|
||||||
- **Visual rule builder** — conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
|
||||||
- **Raw Sieve editor** with syntax validation
|
|
||||||
- **Vacation responder** with date range scheduling and sidebar indicator
|
|
||||||
- **Email templates** — reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
|
|
||||||
|
|
||||||
### Files
|
|
||||||
|
|
||||||
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
|
|
||||||
- **Upload and download** files with progress tracking and folder upload support
|
|
||||||
- **Folder navigation** with breadcrumb path and tree sidebar
|
|
||||||
- **Grid and list views** with sorting by name, size, or date
|
|
||||||
- **Clipboard operations** — cut, copy, paste, duplicate files
|
|
||||||
- **File preview** for images, text, audio, video, and more
|
|
||||||
- **Favorites and recent files** for quick access
|
|
||||||
- **Bulk operations** — multi-select, delete, move, download
|
|
||||||
|
|
||||||
### Security & Privacy
|
|
||||||
|
|
||||||
- **External content blocked** by default — trusted senders list for auto-load
|
|
||||||
- **HTML sanitization** via DOMPurify with XSS prevention
|
|
||||||
- **S/MIME** — manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures
|
|
||||||
- **SPF/DKIM/DMARC** status indicators
|
|
||||||
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode and non-interactive SSO for embedded/iframe deployments
|
|
||||||
- **TOTP two-factor authentication**
|
|
||||||
- **Account security panel** — manage passwords and 2FA via Stalwart admin API
|
|
||||||
- **"Remember me"** — AES-256-GCM encrypted httpOnly cookie (opt-in)
|
|
||||||
- **Security headers** — CSP with per-request nonce, X-Frame-Options, Referrer-Policy
|
|
||||||
- **Newsletter unsubscribe** (RFC 2369)
|
|
||||||
|
|
||||||
### Interface
|
|
||||||
|
|
||||||
- **Three-pane layout** — sidebar, email list, viewer with resizable columns
|
|
||||||
- **Dark and light themes** with intelligent email color transformation
|
|
||||||
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
|
||||||
- **Responsive** — desktop sidebar + mobile bottom tab bar with tablet support
|
|
||||||
- **Keyboard shortcuts** — full navigation without a mouse
|
|
||||||
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
|
||||||
- **Interactive guided tour** — onboarding walkthrough for new users
|
|
||||||
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
|
||||||
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
|
||||||
- **Sidebar apps** — pin custom tools to the navigation rail and open them inline or in a new tab
|
|
||||||
- **Settings sync** — preferences synchronized with the server (encrypted)
|
|
||||||
- **Storage quota** display
|
|
||||||
- **Shared folders** — multi-account access
|
|
||||||
- **Accessibility** — WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
|
||||||
|
|
||||||
### Internationalization
|
|
||||||
|
|
||||||
8 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português
|
|
||||||
|
|
||||||
Automatic browser detection with persistent preference.
|
|
||||||
|
|
||||||
### Identity Management
|
|
||||||
|
|
||||||
- **Multiple sender identities** with per-identity signatures
|
|
||||||
- **Identity refresh** — keep the identity manager aligned with server-side changes after edits
|
|
||||||
- **Sub-addressing** — `user+tag@domain.com` with contextual tag suggestions
|
|
||||||
- **Identity badges** in viewer and email list
|
|
||||||
|
|
||||||
### Operations
|
|
||||||
|
|
||||||
- **Automatic update check** — server logs when a newer release is available
|
|
||||||
- **Demo mode** — try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes — no mail server required
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Docker (recommended)
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 3000:3000 \
|
docker run -d -p 3000:3000 \
|
||||||
@@ -217,7 +84,7 @@ Or with Docker Compose:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edit .env.local — set JMAP_SERVER_URL
|
# Edit .env.local – set JMAP_SERVER_URL
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -228,21 +95,21 @@ git clone https://github.com/bulwarkmail/webmail.git
|
|||||||
cd webmail
|
cd webmail
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
# Edit .env.local — set JMAP_SERVER_URL
|
# Edit .env.local – set JMAP_SERVER_URL
|
||||||
npm run build && npm start
|
npm run build && npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Start dev server (mock JMAP server included)
|
npm run dev # Dev server with a mock JMAP server
|
||||||
npm run typecheck # Type checking
|
npm run typecheck
|
||||||
npm run lint # Linting
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Edit `.env.local`:
|
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Required
|
# Required
|
||||||
@@ -252,27 +119,25 @@ JMAP_SERVER_URL=https://mail.example.com
|
|||||||
APP_NAME=My Webmail
|
APP_NAME=My Webmail
|
||||||
```
|
```
|
||||||
|
|
||||||
All variables are **runtime** — Docker deployments can be configured without rebuilding.
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Server Listen Address</summary>
|
<summary>Server listen address</summary>
|
||||||
|
|
||||||
```env
|
```env
|
||||||
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
|
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
|
||||||
PORT=3000 # Default listen port
|
PORT=3000
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>OAuth2/OIDC (SSO)</summary>
|
<summary>OAuth2 / OIDC</summary>
|
||||||
|
|
||||||
```env
|
```env
|
||||||
OAUTH_ENABLED=true
|
OAUTH_ENABLED=true
|
||||||
OAUTH_CLIENT_ID=webmail
|
OAUTH_CLIENT_ID=webmail
|
||||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||||
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
|
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
|
||||||
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
|
OAUTH_ISSUER_URL= # optional, for external IdPs
|
||||||
```
|
```
|
||||||
|
|
||||||
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
|
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
|
||||||
@@ -280,14 +145,77 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Remember Me</summary>
|
<summary>Session & settings sync</summary>
|
||||||
|
|
||||||
```env
|
```env
|
||||||
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
|
SESSION_SECRET= # openssl rand -base64 32
|
||||||
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret
|
SESSION_SECRET_FILE=/session-secret # path to a file containing the secret
|
||||||
|
|
||||||
|
SETTINGS_SYNC_ENABLED=true
|
||||||
|
SETTINGS_DATA_DIR=./data/settings # mount as a volume in Docker
|
||||||
```
|
```
|
||||||
|
|
||||||
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
|
Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Custom JMAP endpoint</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows a "JMAP Server" field on the login form. External servers must CORS-allow the webmail origin.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Branding & PWA</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_NAME=My Webmail
|
||||||
|
APP_SHORT_NAME=Webmail
|
||||||
|
APP_DESCRIPTION=Your personal mail
|
||||||
|
|
||||||
|
FAVICON_URL=/branding/favicon.svg
|
||||||
|
PWA_ICON_URL=/branding/icon.svg # falls back to FAVICON_URL
|
||||||
|
PWA_THEME_COLOR=#3b82f6
|
||||||
|
PWA_BACKGROUND_COLOR=#ffffff
|
||||||
|
|
||||||
|
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
|
||||||
|
APP_LOGO_DARK_URL=/branding/logo-dark.svg
|
||||||
|
LOGIN_LOGO_LIGHT_URL=/branding/login-light.svg
|
||||||
|
LOGIN_LOGO_DARK_URL=/branding/login-dark.svg
|
||||||
|
|
||||||
|
LOGIN_COMPANY_NAME=My Company
|
||||||
|
LOGIN_WEBSITE_URL=https://example.com
|
||||||
|
LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||||
|
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Extension directory</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||||
|
```
|
||||||
|
|
||||||
|
Enables the admin marketplace for browsing and installing plugins and themes.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Stalwart integration & logging</summary>
|
||||||
|
|
||||||
|
```env
|
||||||
|
STALWART_FEATURES=true # password change, Sieve filters, etc.
|
||||||
|
|
||||||
|
LOG_FORMAT=text # "text" or "json"
|
||||||
|
LOG_LEVEL=info # error | warn | info | debug
|
||||||
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -321,19 +249,16 @@ Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day exp
|
|||||||
|
|
||||||
## Why Stalwart?
|
## Why Stalwart?
|
||||||
|
|
||||||
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** — not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
|
[Stalwart](https://github.com/stalwartlabs/mail-server) is a Rust mail server with native JMAP support – not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single self-hosted binary with no third-party dependencies.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[GNU AGPL v3](LICENSE)
|
[GNU AGPL v3](LICENSE). This repository preserves the original MIT attribution for the fork lineage in [NOTICE](NOTICE).
|
||||||
|
|
||||||
This repository also preserves the original MIT attribution notice for the
|
|
||||||
fork lineage in [NOTICE](NOTICE).
|
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for doing most of the groundwork that this project builds upon.
|
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for the groundwork this project builds upon.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function OAuthCallbackInner() {
|
|||||||
const savedState = sessionStorage.getItem("oauth_state");
|
const savedState = sessionStorage.getItem("oauth_state");
|
||||||
|
|
||||||
if (savedState) {
|
if (savedState) {
|
||||||
// Classic flow — sessionStorage has the PKCE state (same-tab OAuth)
|
// Classic flow - sessionStorage has the PKCE state (same-tab OAuth)
|
||||||
if (!state || state !== savedState) {
|
if (!state || state !== savedState) {
|
||||||
setError("invalid_state");
|
setError("invalid_state");
|
||||||
return;
|
return;
|
||||||
@@ -76,7 +76,7 @@ function OAuthCallbackInner() {
|
|||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
});
|
});
|
||||||
} else if (state) {
|
} else if (state) {
|
||||||
// Server-side SSO flow — state was stored in encrypted httpOnly cookie
|
// Server-side SSO flow - state was stored in encrypted httpOnly cookie
|
||||||
const ssoPrefix = getPathPrefix(params.locale as string);
|
const ssoPrefix = getPathPrefix(params.locale as string);
|
||||||
loginWithServerSso(code, state)
|
loginWithServerSso(code, state)
|
||||||
.then((success) => {
|
.then((success) => {
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
|||||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||||
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
||||||
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||||
|
import { EventContextMenu } from "@/components/calendar/event-context-menu";
|
||||||
|
import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu";
|
||||||
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
|
import { downloadEventICS } from "@/lib/calendar-ics-export";
|
||||||
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
||||||
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
|
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
|
||||||
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
|
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
|
||||||
@@ -43,7 +48,11 @@ import { getEventStartDate } from "@/lib/calendar-utils";
|
|||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from "@/lib/jmap/types";
|
||||||
|
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
|
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
||||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
@@ -68,7 +77,8 @@ export default function CalendarPage() {
|
|||||||
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
||||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
|
||||||
|
removeCalendar, clearCalendarEvents,
|
||||||
refreshAllSubscriptions, icalSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||||
@@ -87,9 +97,15 @@ export default function CalendarPage() {
|
|||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||||
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||||
|
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
|
||||||
|
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
|
||||||
|
const [showCreateCalendar, setShowCreateCalendar] = useState(false);
|
||||||
|
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useConfirmDialog();
|
||||||
|
const tMgmt = useTranslations("calendar.management");
|
||||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||||
|
const [defaultModalAllDay, setDefaultModalAllDay] = useState(false);
|
||||||
const [miniMonth, setMiniMonth] = useState(new Date());
|
const [miniMonth, setMiniMonth] = useState(new Date());
|
||||||
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
||||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
||||||
@@ -112,8 +128,25 @@ export default function CalendarPage() {
|
|||||||
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
|
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
|
||||||
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||||
|
|
||||||
// Check auth on mount
|
// Keep detailEvent in sync with store events (e.g. after update + refetch)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (detailEvent) {
|
||||||
|
const updated = events.find(e => e.id === detailEvent.id);
|
||||||
|
if (updated && updated !== detailEvent) {
|
||||||
|
setDetailEvent(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [events, detailEvent]);
|
||||||
|
|
||||||
|
// Check auth on mount – skip when already authenticated so that navigating
|
||||||
|
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||||
|
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||||
|
useEffect(() => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (state.isAuthenticated && state.client) {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkAuth().finally(() => {
|
checkAuth().finally(() => {
|
||||||
setInitialCheckDone(true);
|
setInitialCheckDone(true);
|
||||||
});
|
});
|
||||||
@@ -151,9 +184,14 @@ export default function CalendarPage() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [client, refreshAllSubscriptions]);
|
}, [client, refreshAllSubscriptions]);
|
||||||
|
|
||||||
// Auto-add birthday calendar to selected IDs when enabled
|
// Auto-add birthday calendar to selected IDs only when the setting flips
|
||||||
|
// off→on. Firing on every mount would undo a user's manual hide via the
|
||||||
|
// sidebar each time they navigate back to the calendar (see #204).
|
||||||
|
const prevShowBirthdayRef = useRef(showBirthdayCalendar);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
|
const wasShown = prevShowBirthdayRef.current;
|
||||||
|
prevShowBirthdayRef.current = showBirthdayCalendar;
|
||||||
|
if (!wasShown && showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
|
||||||
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
|
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
|
||||||
}
|
}
|
||||||
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
@@ -290,11 +328,12 @@ export default function CalendarPage() {
|
|||||||
setSelectedDate(date);
|
setSelectedDate(date);
|
||||||
}, [setSelectedDate]);
|
}, [setSelectedDate]);
|
||||||
|
|
||||||
const openCreateModal = useCallback((date?: Date, endDate?: Date) => {
|
const openCreateModal = useCallback((date?: Date, endDate?: Date, allDay?: boolean) => {
|
||||||
setEditEvent(null);
|
setEditEvent(null);
|
||||||
const d = date || selectedDate;
|
const d = date || selectedDate;
|
||||||
setDefaultModalDate(d);
|
setDefaultModalDate(d);
|
||||||
setDefaultModalEndDate(endDate);
|
setDefaultModalEndDate(endDate);
|
||||||
|
setDefaultModalAllDay(allDay ?? false);
|
||||||
setSelectedDate(d);
|
setSelectedDate(d);
|
||||||
setShowEventModal(true);
|
setShowEventModal(true);
|
||||||
}, [selectedDate, setSelectedDate]);
|
}, [selectedDate, setSelectedDate]);
|
||||||
@@ -347,6 +386,33 @@ export default function CalendarPage() {
|
|||||||
openEditModal(event);
|
openEditModal(event);
|
||||||
}, [closeDetail, openEditModal]);
|
}, [closeDetail, openEditModal]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
contextMenu: eventContextMenu,
|
||||||
|
openContextMenu: openEventContextMenu,
|
||||||
|
closeContextMenu: closeEventContextMenu,
|
||||||
|
menuRef: eventContextMenuRef,
|
||||||
|
} = useContextMenu<CalendarEvent>();
|
||||||
|
|
||||||
|
const handleContextMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => {
|
||||||
|
closeDetail();
|
||||||
|
openEventContextMenu(e, event);
|
||||||
|
}, [closeDetail, openEventContextMenu]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
contextMenu: emptyContextMenu,
|
||||||
|
openContextMenu: openEmptyContextMenu,
|
||||||
|
closeContextMenu: closeEmptyContextMenu,
|
||||||
|
menuRef: emptyContextMenuRef,
|
||||||
|
} = useContextMenu<{ date: Date; hour?: number; allDayArea?: boolean }>();
|
||||||
|
|
||||||
|
const handleContextMenuEmpty = useCallback(
|
||||||
|
(e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => {
|
||||||
|
closeDetail();
|
||||||
|
openEmptyContextMenu(e, { date, hour, allDayArea });
|
||||||
|
},
|
||||||
|
[closeDetail, openEmptyContextMenu],
|
||||||
|
);
|
||||||
|
|
||||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||||
if (isMobile) return;
|
if (isMobile) return;
|
||||||
if (calendarHoverPreview === 'off') return;
|
if (calendarHoverPreview === 'off') return;
|
||||||
@@ -407,6 +473,20 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}, [client, fetchEvents]);
|
}, [client, fetchEvents]);
|
||||||
|
|
||||||
|
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||||
|
// and refresh calendar data via JMAP instead of reloading the page.
|
||||||
|
useRefreshGesture({
|
||||||
|
enabled: isAuthenticated && !!client,
|
||||||
|
onRefresh: async () => {
|
||||||
|
if (!client) return;
|
||||||
|
await Promise.all([
|
||||||
|
fetchCalendars(client),
|
||||||
|
refetchCurrentRange(),
|
||||||
|
refreshAllSubscriptions(client),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
|
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
|
||||||
if (!event.start) {
|
if (!event.start) {
|
||||||
return;
|
return;
|
||||||
@@ -722,6 +802,73 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}, [detailEvent, client, updateEvent, t]);
|
}, [detailEvent, client, updateEvent, t]);
|
||||||
|
|
||||||
|
const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => {
|
||||||
|
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||||
|
const start = parseISO(event.start);
|
||||||
|
const newStart = addDays(start, 1);
|
||||||
|
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
|
||||||
|
title: event.title,
|
||||||
|
description: event.description,
|
||||||
|
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||||
|
duration: event.duration,
|
||||||
|
timeZone: event.timeZone,
|
||||||
|
showWithoutTime: event.showWithoutTime,
|
||||||
|
calendarIds: { ...event.calendarIds },
|
||||||
|
status: "confirmed",
|
||||||
|
freeBusyStatus: event.freeBusyStatus,
|
||||||
|
privacy: event.privacy,
|
||||||
|
});
|
||||||
|
if (event.locations) data.locations = structuredClone(event.locations);
|
||||||
|
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||||
|
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||||
|
if (event.participants) data.participants = structuredClone(event.participants);
|
||||||
|
try {
|
||||||
|
const created = await createEvent(client, data);
|
||||||
|
if (created) {
|
||||||
|
toast.success(t("notifications.event_duplicated"));
|
||||||
|
openEditModal(created);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
}
|
||||||
|
}, [client, createEvent, openEditModal, t]);
|
||||||
|
|
||||||
|
const handleExportICS = useCallback((event: CalendarEvent) => {
|
||||||
|
try {
|
||||||
|
downloadEventICS(event);
|
||||||
|
toast.success(t("notifications.event_exported"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleCopyTitle = useCallback(async (event: CalendarEvent) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(event.title || "");
|
||||||
|
toast.success(t("notifications.title_copied"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleCopyMeetingLink = useCallback(async (event: CalendarEvent) => {
|
||||||
|
const uri = event.virtualLocations
|
||||||
|
? Object.values(event.virtualLocations).find((v) => v.uri)?.uri
|
||||||
|
: undefined;
|
||||||
|
if (!uri) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(uri);
|
||||||
|
toast.success(t("notifications.link_copied"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleDeleteContextMenu = useCallback((event: CalendarEvent) => {
|
||||||
|
const hasParticipants = event.participants && Object.keys(event.participants).length > 0;
|
||||||
|
handleDeleteEvent(event.id, hasParticipants || undefined);
|
||||||
|
}, [handleDeleteEvent]);
|
||||||
|
|
||||||
const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => {
|
const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => {
|
||||||
if (!detailEvent || !client) return;
|
if (!detailEvent || !client) return;
|
||||||
const participantId = getUserParticipantId(detailEvent, currentUserEmails);
|
const participantId = getUserParticipantId(detailEvent, currentUserEmails);
|
||||||
@@ -831,6 +978,8 @@ export default function CalendarPage() {
|
|||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
|
onContextMenuEvent={handleContextMenuEvent}
|
||||||
|
onContextMenuEmpty={handleContextMenuEmpty}
|
||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -847,6 +996,8 @@ export default function CalendarPage() {
|
|||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
|
onContextMenuEvent={handleContextMenuEvent}
|
||||||
|
onContextMenuEmpty={handleContextMenuEmpty}
|
||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
@@ -865,6 +1016,8 @@ export default function CalendarPage() {
|
|||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
|
onContextMenuEvent={handleContextMenuEvent}
|
||||||
|
onContextMenuEmpty={handleContextMenuEmpty}
|
||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -882,6 +1035,7 @@ export default function CalendarPage() {
|
|||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onHoverEvent={handleHoverEvent}
|
onHoverEvent={handleHoverEvent}
|
||||||
onHoverLeave={handleHoverLeave}
|
onHoverLeave={handleHoverLeave}
|
||||||
|
onContextMenuEvent={handleContextMenuEvent}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -979,6 +1133,42 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
updateCalendar(client, calendarId, { color });
|
updateCalendar(client, calendarId, { color });
|
||||||
} : undefined}
|
} : undefined}
|
||||||
|
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
||||||
|
onCreateEvent={(cal: Calendar) => {
|
||||||
|
setDefaultCalendarIdForCreate(cal.id);
|
||||||
|
openCreateModal();
|
||||||
|
}}
|
||||||
|
onClearCalendar={client ? async (cal: Calendar) => {
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: tMgmt("clear_events"),
|
||||||
|
message: tMgmt("confirm_clear", { name: cal.name }),
|
||||||
|
variant: "destructive",
|
||||||
|
confirmText: tMgmt("clear_events"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
const count = await clearCalendarEvents(client, cal.id);
|
||||||
|
toast.success(tMgmt("events_cleared", { count }));
|
||||||
|
} catch {
|
||||||
|
toast.error(tMgmt("error_clear"));
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
|
onDeleteCalendar={client ? async (cal: Calendar) => {
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: tMgmt("delete"),
|
||||||
|
message: tMgmt("confirm_delete", { name: cal.name }),
|
||||||
|
variant: "destructive",
|
||||||
|
confirmText: tMgmt("delete"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await removeCalendar(client, cal.id);
|
||||||
|
toast.success(tMgmt("calendar_deleted"));
|
||||||
|
} catch {
|
||||||
|
toast.error(tMgmt("error_delete"));
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
|
onCreateCalendar={client ? () => setShowCreateCalendar(true) : undefined}
|
||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||||
client={client}
|
client={client}
|
||||||
@@ -1042,11 +1232,13 @@ export default function CalendarPage() {
|
|||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
|
defaultAllDay={defaultModalAllDay}
|
||||||
|
defaultCalendarId={defaultCalendarIdForCreate}
|
||||||
onSave={handleSaveEvent}
|
onSave={handleSaveEvent}
|
||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||||
onPreviewChange={setPendingPreview}
|
onPreviewChange={setPendingPreview}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
@@ -1096,6 +1288,54 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{eventContextMenu.data && (
|
||||||
|
<EventContextMenu
|
||||||
|
event={eventContextMenu.data}
|
||||||
|
position={eventContextMenu.position}
|
||||||
|
isOpen={eventContextMenu.isOpen}
|
||||||
|
onClose={closeEventContextMenu}
|
||||||
|
menuRef={eventContextMenuRef}
|
||||||
|
onEdit={() => openEditModal(eventContextMenu.data!)}
|
||||||
|
onDuplicate={() => handleDuplicateContextMenu(eventContextMenu.data!)}
|
||||||
|
onExportICS={() => handleExportICS(eventContextMenu.data!)}
|
||||||
|
onCopyTitle={() => handleCopyTitle(eventContextMenu.data!)}
|
||||||
|
onCopyMeetingLink={() => handleCopyMeetingLink(eventContextMenu.data!)}
|
||||||
|
onDelete={() => handleDeleteContextMenu(eventContextMenu.data!)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{emptyContextMenu.data && (() => {
|
||||||
|
const { date, hour } = emptyContextMenu.data;
|
||||||
|
return (
|
||||||
|
<EmptySpaceContextMenu
|
||||||
|
position={emptyContextMenu.position}
|
||||||
|
isOpen={emptyContextMenu.isOpen}
|
||||||
|
onClose={closeEmptyContextMenu}
|
||||||
|
menuRef={emptyContextMenuRef}
|
||||||
|
onNewEvent={() => {
|
||||||
|
const d = new Date(date);
|
||||||
|
if (typeof hour === "number") {
|
||||||
|
d.setHours(hour, 0, 0, 0);
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
d.setHours(now.getHours() + 1, 0, 0, 0);
|
||||||
|
}
|
||||||
|
openCreateModal(d);
|
||||||
|
}}
|
||||||
|
onNewAllDayEvent={() => {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
openCreateModal(d, undefined, true);
|
||||||
|
}}
|
||||||
|
onNewTask={enableCalendarTasks ? () => {
|
||||||
|
setEditTask(null);
|
||||||
|
setShowTaskModal(true);
|
||||||
|
} : undefined}
|
||||||
|
onGoToToday={goToToday}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{detailEvent && detailAnchorRect && (
|
{detailEvent && detailAnchorRect && (
|
||||||
<EventDetailPopover
|
<EventDetailPopover
|
||||||
event={detailEvent}
|
event={detailEvent}
|
||||||
@@ -1122,11 +1362,13 @@ export default function CalendarPage() {
|
|||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
defaultEndDate={defaultModalEndDate}
|
defaultEndDate={defaultModalEndDate}
|
||||||
|
defaultAllDay={defaultModalAllDay}
|
||||||
|
defaultCalendarId={defaultCalendarIdForCreate}
|
||||||
onSave={handleSaveEvent}
|
onSave={handleSaveEvent}
|
||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={true}
|
isMobile={true}
|
||||||
/>
|
/>
|
||||||
@@ -1166,6 +1408,33 @@ export default function CalendarPage() {
|
|||||||
onSelect={handleScopeSelect}
|
onSelect={handleScopeSelect}
|
||||||
onClose={() => setPendingScopeAction(null)}
|
onClose={() => setPendingScopeAction(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
|
||||||
|
{showCreateCalendar && client && (
|
||||||
|
<CreateCalendarModal
|
||||||
|
client={client}
|
||||||
|
onClose={() => setShowCreateCalendar(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sharingCalendarId && client && (() => {
|
||||||
|
const cal = allCalendars.find((c) => c.id === sharingCalendarId);
|
||||||
|
if (!cal) return null;
|
||||||
|
return (
|
||||||
|
<ShareCollectionDialog
|
||||||
|
client={client}
|
||||||
|
kind="calendar"
|
||||||
|
collectionName={cal.name}
|
||||||
|
shareWith={cal.shareWith}
|
||||||
|
ownAccountId={client.getAccountId()}
|
||||||
|
onShare={async (principalId, rights) => {
|
||||||
|
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
|
||||||
|
}}
|
||||||
|
onClose={() => setSharingCalendarId(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-28
@@ -26,7 +26,9 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
|
|||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
|
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||||
|
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
| "list"
|
| "list"
|
||||||
@@ -74,6 +76,8 @@ export default function ContactsPage() {
|
|||||||
bulkAddToGroup,
|
bulkAddToGroup,
|
||||||
moveContactToAddressBook,
|
moveContactToAddressBook,
|
||||||
renameAddressBook,
|
renameAddressBook,
|
||||||
|
removeAddressBook,
|
||||||
|
shareAddressBook,
|
||||||
renameKeyword,
|
renameKeyword,
|
||||||
importContacts,
|
importContacts,
|
||||||
} = useContactStore();
|
} = useContactStore();
|
||||||
@@ -82,6 +86,8 @@ 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 [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||||
|
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
||||||
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
@@ -97,13 +103,20 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
// Panel resize state - contact list
|
// Panel resize state - contact list
|
||||||
const [listWidth, setListWidth] = useState(() => {
|
const [listWidth, setListWidth] = useState(() => {
|
||||||
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 320; } catch { return 320; }
|
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 384; } catch { return 384; }
|
||||||
});
|
});
|
||||||
const [isListResizing, setIsListResizing] = useState(false);
|
const [isListResizing, setIsListResizing] = useState(false);
|
||||||
const listDragStartWidth = useRef(320);
|
const listDragStartWidth = useRef(384);
|
||||||
|
|
||||||
// Check auth on mount
|
// Check auth on mount – skip when already authenticated so that navigating
|
||||||
|
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||||
|
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (state.isAuthenticated && state.client) {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkAuth().finally(() => {
|
checkAuth().finally(() => {
|
||||||
setInitialCheckDone(true);
|
setInitialCheckDone(true);
|
||||||
});
|
});
|
||||||
@@ -123,6 +136,16 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
}, [client, supportsSync, fetchContacts]);
|
}, [client, supportsSync, fetchContacts]);
|
||||||
|
|
||||||
|
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||||
|
// and refresh contacts via JMAP instead of reloading the page.
|
||||||
|
useRefreshGesture({
|
||||||
|
enabled: isAuthenticated && !!client && supportsSync,
|
||||||
|
onRefresh: async () => {
|
||||||
|
if (!client) return;
|
||||||
|
await fetchContacts(client);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
|
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
|
||||||
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
|
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
|
||||||
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
||||||
@@ -161,21 +184,6 @@ export default function ContactsPage() {
|
|||||||
return getGroupMembers(activeCategory.groupId);
|
return getGroupMembers(activeCategory.groupId);
|
||||||
}, [activeCategory, individuals, getGroupMembers]);
|
}, [activeCategory, individuals, getGroupMembers]);
|
||||||
|
|
||||||
// Label for the current category
|
|
||||||
const categoryLabel = useMemo(() => {
|
|
||||||
if (activeCategory === "all") return t("tabs.all");
|
|
||||||
if (activeCategory === "uncategorized") return t("no_category");
|
|
||||||
if ("addressBookId" in activeCategory) {
|
|
||||||
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
|
|
||||||
return book?.name || t("tabs.all");
|
|
||||||
}
|
|
||||||
if ("keyword" in activeCategory) {
|
|
||||||
return activeCategory.keyword;
|
|
||||||
}
|
|
||||||
const group = contacts.find(c => c.id === activeCategory.groupId);
|
|
||||||
return group ? getContactDisplayName(group) : t("tabs.all");
|
|
||||||
}, [activeCategory, contacts, addressBooks, t]);
|
|
||||||
|
|
||||||
const handleSelectCategory = useCallback((category: ContactCategory) => {
|
const handleSelectCategory = useCallback((category: ContactCategory) => {
|
||||||
setActiveCategory(category);
|
setActiveCategory(category);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -248,9 +256,7 @@ export default function ContactsPage() {
|
|||||||
setView("edit");
|
setView("edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const deleteContactById = useCallback(async (contactId: string) => {
|
||||||
if (!selectedContact) return;
|
|
||||||
|
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
title: t("delete_confirm_title"),
|
title: t("delete_confirm_title"),
|
||||||
message: t("delete_confirm"),
|
message: t("delete_confirm"),
|
||||||
@@ -261,18 +267,60 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (supportsSync && client) {
|
if (supportsSync && client) {
|
||||||
await deleteContact(client, selectedContact.id);
|
await deleteContact(client, contactId);
|
||||||
} else {
|
} else {
|
||||||
deleteLocalContact(selectedContact.id);
|
deleteLocalContact(contactId);
|
||||||
}
|
}
|
||||||
toast.success(t("toast.deleted"));
|
toast.success(t("toast.deleted"));
|
||||||
setView("list");
|
if (selectedContactId === contactId) setView("list");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete contact:', error);
|
console.error('Failed to delete contact:', error);
|
||||||
toast.error(t("toast.error_delete"));
|
toast.error(t("toast.error_delete"));
|
||||||
}
|
}
|
||||||
|
}, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!selectedContact) return;
|
||||||
|
await deleteContactById(selectedContact.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditContact = useCallback((id: string) => {
|
||||||
|
setSelectedContact(id);
|
||||||
|
setView("edit");
|
||||||
|
}, [setSelectedContact]);
|
||||||
|
|
||||||
|
const handleDeleteContact = useCallback((contact: ContactCard) => {
|
||||||
|
void deleteContactById(contact.id);
|
||||||
|
}, [deleteContactById]);
|
||||||
|
|
||||||
|
const handleAddContactToGroup = useCallback((id: string) => {
|
||||||
|
clearSelection();
|
||||||
|
toggleContactSelection(id);
|
||||||
|
if (groups.length === 0) {
|
||||||
|
setView("group-create");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setView("bulk-add-to-group");
|
||||||
|
}, [clearSelection, toggleContactSelection, groups.length]);
|
||||||
|
|
||||||
|
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
|
||||||
|
const { id: _id, created: _created, updated: _updated, ...rest } = source;
|
||||||
|
void _id; void _created; void _updated;
|
||||||
|
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
|
||||||
|
if (supportsSync && client) {
|
||||||
|
await createContact(client, data);
|
||||||
|
toast.success(t("toast.created"));
|
||||||
|
} else {
|
||||||
|
const localContact: ContactCard = {
|
||||||
|
id: `local-${generateUUID()}`,
|
||||||
|
addressBookIds: data.addressBookIds || {},
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
addLocalContact(localContact);
|
||||||
|
toast.success(t("toast.created"));
|
||||||
|
}
|
||||||
|
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||||
|
|
||||||
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
|
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
|
||||||
if (supportsSync && client) {
|
if (supportsSync && client) {
|
||||||
await createContact(client, data);
|
await createContact(client, data);
|
||||||
@@ -286,6 +334,7 @@ export default function ContactsPage() {
|
|||||||
addLocalContact(localContact);
|
addLocalContact(localContact);
|
||||||
toast.success(t("toast.created"));
|
toast.success(t("toast.created"));
|
||||||
}
|
}
|
||||||
|
setDefaultBookIdForCreate(undefined);
|
||||||
setView("list");
|
setView("list");
|
||||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||||
|
|
||||||
@@ -303,6 +352,7 @@ export default function ContactsPage() {
|
|||||||
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
|
setDefaultBookIdForCreate(undefined);
|
||||||
if (view === "group-create" || view === "group-edit") {
|
if (view === "group-create" || view === "group-edit") {
|
||||||
setView(selectedGroup ? "group-detail" : "list");
|
setView(selectedGroup ? "group-detail" : "list");
|
||||||
} else if (view === "bulk-add-to-group") {
|
} else if (view === "bulk-add-to-group") {
|
||||||
@@ -474,7 +524,7 @@ export default function ContactsPage() {
|
|||||||
const renderRightPanel = () => {
|
const renderRightPanel = () => {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "create":
|
case "create":
|
||||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
|
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||||
|
|
||||||
case "edit":
|
case "edit":
|
||||||
if (!selectedContact) return null;
|
if (!selectedContact) return null;
|
||||||
@@ -574,6 +624,16 @@ export default function ContactsPage() {
|
|||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onAddToGroup={
|
||||||
|
selectedContact
|
||||||
|
? () => handleAddContactToGroup(selectedContact.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onDuplicate={
|
||||||
|
selectedContact
|
||||||
|
? () => void handleDuplicateContact(selectedContact)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -637,6 +697,26 @@ export default function ContactsPage() {
|
|||||||
onDropContacts={handleDropContacts}
|
onDropContacts={handleDropContacts}
|
||||||
onDropContactsToCategory={handleDropContactsToCategory}
|
onDropContactsToCategory={handleDropContactsToCategory}
|
||||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||||
|
onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined}
|
||||||
|
onCreateContactInBook={(book) => {
|
||||||
|
setDefaultBookIdForCreate(book.id);
|
||||||
|
handleCreateNew();
|
||||||
|
}}
|
||||||
|
onDeleteAddressBook={client ? async (book) => {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("address_books.delete"),
|
||||||
|
message: t("address_books.confirm_delete", { name: book.name }),
|
||||||
|
variant: "destructive",
|
||||||
|
confirmText: t("address_books.delete"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await removeAddressBook(client, book);
|
||||||
|
toast.success(t("address_books.deleted"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("address_books.delete_failed"));
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -669,7 +749,6 @@ export default function ContactsPage() {
|
|||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
onSelectContact={handleSelectContact}
|
onSelectContact={handleSelectContact}
|
||||||
onCreateNew={handleCreateNew}
|
onCreateNew={handleCreateNew}
|
||||||
categoryLabel={categoryLabel}
|
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
selectedContactIds={selectedContactIds}
|
selectedContactIds={selectedContactIds}
|
||||||
onToggleSelection={toggleContactSelection}
|
onToggleSelection={toggleContactSelection}
|
||||||
@@ -679,6 +758,9 @@ export default function ContactsPage() {
|
|||||||
onBulkDelete={handleBulkDelete}
|
onBulkDelete={handleBulkDelete}
|
||||||
onBulkAddToGroup={handleBulkAddToGroup}
|
onBulkAddToGroup={handleBulkAddToGroup}
|
||||||
onBulkExport={handleBulkExport}
|
onBulkExport={handleBulkExport}
|
||||||
|
onEditContact={handleEditContact}
|
||||||
|
onDeleteContact={handleDeleteContact}
|
||||||
|
onAddContactToGroup={handleAddContactToGroup}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -690,7 +772,7 @@ export default function ContactsPage() {
|
|||||||
setIsListResizing(false);
|
setIsListResizing(false);
|
||||||
localStorage.setItem("contacts-list-width", String(listWidth));
|
localStorage.setItem("contacts-list-width", String(listWidth));
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => { setListWidth(320); localStorage.setItem("contacts-list-width", "320"); }}
|
onDoubleClick={() => { setListWidth(384); localStorage.setItem("contacts-list-width", "384"); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -783,6 +865,23 @@ export default function ContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{sharingAddressBookId && client && (() => {
|
||||||
|
const book = addressBooks.find((b) => b.id === sharingAddressBookId);
|
||||||
|
if (!book) return null;
|
||||||
|
return (
|
||||||
|
<ShareCollectionDialog
|
||||||
|
client={client}
|
||||||
|
kind="addressBook"
|
||||||
|
collectionName={book.name}
|
||||||
|
shareWith={book.shareWith}
|
||||||
|
ownAccountId={client.getAccountId()}
|
||||||
|
onShare={async (principalId, rights) => {
|
||||||
|
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
|
||||||
|
}}
|
||||||
|
onClose={() => setSharingAddressBookId(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
|||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
|
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 { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||||
@@ -104,8 +105,15 @@ export default function FilesPage() {
|
|||||||
|
|
||||||
const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null;
|
const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null;
|
||||||
|
|
||||||
// Check auth on mount
|
// Check auth on mount – skip when already authenticated so that navigating
|
||||||
|
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||||
|
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (state.isAuthenticated && state.client) {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkAuth().finally(() => {
|
checkAuth().finally(() => {
|
||||||
setInitialCheckDone(true);
|
setInitialCheckDone(true);
|
||||||
});
|
});
|
||||||
@@ -127,6 +135,15 @@ export default function FilesPage() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated, client, initClient]);
|
}, [isAuthenticated, client, initClient]);
|
||||||
|
|
||||||
|
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||||
|
// and refresh files via JMAP instead of reloading the page.
|
||||||
|
useRefreshGesture({
|
||||||
|
enabled: isAuthenticated && !!client && supportsFiles === true,
|
||||||
|
onRefresh: async () => {
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Check support and load root after client is initialized
|
// Check support and load root after client is initialized
|
||||||
const storeClient = useFileStore(s => s.client);
|
const storeClient = useFileStore(s => s.client);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useAuthStore } from "@/stores/auth-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 { getPathPrefix } from "@/lib/browser-navigation";
|
import { apiFetch, getPathPrefix } 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 { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
@@ -234,7 +234,7 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
const prefix = getPathPrefix(params.locale as string);
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||||
const res = await fetch('/api/auth/sso/start', {
|
const res = await apiFetch('/api/auth/sso/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -255,7 +255,7 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
window.top!.location.href = authorize_url;
|
window.top!.location.href = authorize_url;
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-origin restriction — fall back to current frame
|
// Cross-origin restriction - fall back to current frame
|
||||||
window.location.href = authorize_url;
|
window.location.href = authorize_url;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -759,7 +759,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
Dev mode — logging in as dev@localhost
|
Dev mode - logging in as dev@localhost
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : oauthOnly ? (
|
) : oauthOnly ? (
|
||||||
|
|||||||
+547
-78
@@ -8,10 +8,13 @@ import { EmailViewer } from "@/components/email/email-viewer";
|
|||||||
import { EmailComposer } from "@/components/email/email-composer";
|
import { EmailComposer } from "@/components/email/email-composer";
|
||||||
import type { ComposerDraftData } from "@/components/email/email-composer";
|
import type { ComposerDraftData } from "@/components/email/email-composer";
|
||||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||||
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
|
import { MobileHeader } from "@/components/layout/mobile-header";
|
||||||
import { ThreadGroup, Email } from "@/lib/jmap/types";
|
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { toast } from "@/stores/toast-store";
|
||||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
@@ -19,7 +22,9 @@ import { useIdentityStore } from "@/stores/identity-store";
|
|||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
|
import { usePromptDialog } from "@/hooks/use-prompt-dialog";
|
||||||
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { playNotificationSound } from "@/lib/notification-sound";
|
import { playNotificationSound } from "@/lib/notification-sound";
|
||||||
@@ -32,6 +37,7 @@ import {
|
|||||||
ComposerErrorFallback,
|
ComposerErrorFallback,
|
||||||
} from "@/components/error";
|
} from "@/components/error";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { PromptDialog } from "@/components/ui/prompt-dialog";
|
||||||
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
||||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||||
@@ -45,6 +51,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
||||||
|
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||||
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -64,6 +71,7 @@ export default function Home() {
|
|||||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||||
const [composerSessionId, setComposerSessionId] = useState(0);
|
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
|
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||||
@@ -155,8 +163,64 @@ export default function Home() {
|
|||||||
hasMoreEmails,
|
hasMoreEmails,
|
||||||
fetchTagCounts,
|
fetchTagCounts,
|
||||||
fetchEmailContent,
|
fetchEmailContent,
|
||||||
|
isUnifiedView,
|
||||||
|
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
||||||
|
refreshUnifiedCounts,
|
||||||
|
exitUnifiedView,
|
||||||
|
emptyMailbox,
|
||||||
|
markMailboxAsRead,
|
||||||
|
createMailbox,
|
||||||
|
renameMailbox,
|
||||||
|
deleteMailbox,
|
||||||
|
batchDelete,
|
||||||
|
batchArchive,
|
||||||
|
batchMarkAsRead,
|
||||||
|
batchMarkAsSpam,
|
||||||
|
batchUndoSpam,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
|
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
||||||
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
|
const connectedAccountsSignature = useMemo(
|
||||||
|
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
|
||||||
|
[accounts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildUnifiedAccounts = useCallback((): UnifiedAccountClient[] => {
|
||||||
|
const connected = useAccountStore.getState().accounts.filter((a) => a.isConnected);
|
||||||
|
const clients = useAuthStore.getState().getAllConnectedClients();
|
||||||
|
const result: UnifiedAccountClient[] = [];
|
||||||
|
for (const account of connected) {
|
||||||
|
const accountClient = clients.get(account.id);
|
||||||
|
if (!accountClient) continue;
|
||||||
|
result.push({
|
||||||
|
accountId: account.id,
|
||||||
|
accountLabel: account.label || account.email,
|
||||||
|
client: accountClient,
|
||||||
|
mailboxes: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const populateUnifiedAccountMailboxes = useCallback(
|
||||||
|
async (list: UnifiedAccountClient[]): Promise<UnifiedAccountClient[]> => {
|
||||||
|
const populated = await Promise.all(
|
||||||
|
list.map(async (entry) => {
|
||||||
|
try {
|
||||||
|
const mailboxes = await entry.client.getMailboxes();
|
||||||
|
return { ...entry, mailboxes };
|
||||||
|
} catch (err) {
|
||||||
|
debug.error('Failed to load mailboxes for unified account', entry.accountId, err);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return populated;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Browser back / forward integration. The restore handler reads the
|
// Browser back / forward integration. The restore handler reads the
|
||||||
// latest values from a ref so we don't have to recreate the callback on
|
// latest values from a ref so we don't have to recreate the callback on
|
||||||
// every render (and so the popstate listener is never stale).
|
// every render (and so the popstate listener is never stale).
|
||||||
@@ -208,7 +272,7 @@ export default function Home() {
|
|||||||
|
|
||||||
// Restore conversation thread (mobile only). We can clear it directly,
|
// Restore conversation thread (mobile only). We can clear it directly,
|
||||||
// but reopening requires the thread group; if the user pressed forward
|
// but reopening requires the thread group; if the user pressed forward
|
||||||
// to return to a thread, we silently skip — back navigation always works.
|
// to return to a thread, we silently skip - back navigation always works.
|
||||||
if ((state.threadId ?? null) !== ctx.conversationThreadId) {
|
if ((state.threadId ?? null) !== ctx.conversationThreadId) {
|
||||||
if (state.threadId === null) {
|
if (state.threadId === null) {
|
||||||
setConversationThread(null);
|
setConversationThread(null);
|
||||||
@@ -295,27 +359,77 @@ export default function Home() {
|
|||||||
onToggleStar: () => {
|
onToggleStar: () => {
|
||||||
if (selectedEmail) handleToggleStar();
|
if (selectedEmail) handleToggleStar();
|
||||||
},
|
},
|
||||||
onArchive: () => {
|
onArchive: async () => {
|
||||||
if (selectedEmail) handleArchive();
|
if (selectedEmailIds.size > 0 && client) {
|
||||||
|
try {
|
||||||
|
await batchArchive(client);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to batch archive:", error);
|
||||||
|
}
|
||||||
|
} else if (selectedEmail) {
|
||||||
|
handleArchive();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDelete: () => {
|
onDelete: async () => {
|
||||||
if (selectedEmail) handleDelete();
|
if (selectedEmailIds.size > 0 && client) {
|
||||||
|
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||||
|
const isInTrash = currentMailbox?.role === 'trash';
|
||||||
|
const isInJunk = currentMailbox?.role === 'junk';
|
||||||
|
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
|
||||||
|
const permanent = isInTrash || (isInJunk && permanentlyDeleteJunk);
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: permanent
|
||||||
|
? t('email_list.permanent_delete_confirm_title')
|
||||||
|
: t('email_list.batch_actions.delete_confirm_title'),
|
||||||
|
message: permanent
|
||||||
|
? t('email_list.permanent_delete_confirm_batch_message', { count: selectedEmailIds.size })
|
||||||
|
: t('email_list.batch_actions.delete_confirm_message', { count: selectedEmailIds.size }),
|
||||||
|
confirmText: permanent
|
||||||
|
? t('email_list.permanent_delete')
|
||||||
|
: t('email_list.batch_actions.delete'),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
await batchDelete(client, permanent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to batch delete:", error);
|
||||||
|
}
|
||||||
|
} else if (selectedEmail) {
|
||||||
|
handleDelete();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onMarkAsUnread: async () => {
|
onMarkAsUnread: async () => {
|
||||||
if (selectedEmail && client) {
|
if (!client) return;
|
||||||
|
if (selectedEmailIds.size > 0) {
|
||||||
|
await batchMarkAsRead(client, false);
|
||||||
|
} else if (selectedEmail) {
|
||||||
await markAsRead(client, selectedEmail.id, false);
|
await markAsRead(client, selectedEmail.id, false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMarkAsRead: async () => {
|
onMarkAsRead: async () => {
|
||||||
if (selectedEmail && client) {
|
if (!client) return;
|
||||||
|
if (selectedEmailIds.size > 0) {
|
||||||
|
await batchMarkAsRead(client, true);
|
||||||
|
} else if (selectedEmail) {
|
||||||
await markAsRead(client, selectedEmail.id, true);
|
await markAsRead(client, selectedEmail.id, true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onToggleSpam: () => {
|
onToggleSpam: async () => {
|
||||||
if (selectedEmail) {
|
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||||
// Check if we're in junk folder
|
const isInJunk = currentMailbox?.role === 'junk';
|
||||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
if (selectedEmailIds.size > 0 && client) {
|
||||||
const isInJunk = currentMailbox?.role === 'junk';
|
const ids = Array.from(selectedEmailIds);
|
||||||
|
try {
|
||||||
|
if (isInJunk) {
|
||||||
|
await batchUndoSpam(client, ids);
|
||||||
|
} else {
|
||||||
|
await batchMarkAsSpam(client, ids);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to batch toggle spam:", error);
|
||||||
|
}
|
||||||
|
} else if (selectedEmail) {
|
||||||
if (isInJunk) {
|
if (isInJunk) {
|
||||||
handleUndoSpam();
|
handleUndoSpam();
|
||||||
} else {
|
} else {
|
||||||
@@ -350,16 +464,31 @@ export default function Home() {
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet]);
|
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes]);
|
||||||
|
|
||||||
// Initialize keyboard shortcuts
|
// Initialize keyboard shortcuts
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
enabled: isAuthenticated && !showComposer,
|
enabled: isAuthenticated && !showComposer,
|
||||||
emails,
|
emails,
|
||||||
selectedEmailId: selectedEmail?.id,
|
selectedEmailId: selectedEmail?.id,
|
||||||
|
selectionCount: selectedEmailIds.size,
|
||||||
handlers: keyboardHandlers,
|
handlers: keyboardHandlers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||||
|
// and refresh mail data via JMAP instead of reloading the page.
|
||||||
|
useRefreshGesture({
|
||||||
|
enabled: isAuthenticated && !!client,
|
||||||
|
onRefresh: async () => {
|
||||||
|
if (!client) return;
|
||||||
|
const state = useEmailStore.getState();
|
||||||
|
await Promise.all([
|
||||||
|
state.fetchMailboxes(client),
|
||||||
|
state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Update page title based on context
|
// Update page title based on context
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let title = appName;
|
let title = appName;
|
||||||
@@ -392,8 +521,15 @@ export default function Home() {
|
|||||||
document.title = title;
|
document.title = title;
|
||||||
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
|
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
|
||||||
|
|
||||||
// Check auth on mount
|
// Check auth on mount – skip when already authenticated so that navigating
|
||||||
|
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||||
|
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (state.isAuthenticated && state.client) {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkAuth().finally(() => {
|
checkAuth().finally(() => {
|
||||||
setInitialCheckDone(true);
|
setInitialCheckDone(true);
|
||||||
});
|
});
|
||||||
@@ -430,7 +566,10 @@ export default function Home() {
|
|||||||
// Load mailboxes and emails when authenticated (only if not already loaded)
|
// Load mailboxes and emails when authenticated (only if not already loaded)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && client && mailboxes.length === 0) {
|
if (isAuthenticated && client && mailboxes.length === 0) {
|
||||||
const loadData = async () => {
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadData = async (attempt = 1) => {
|
||||||
try {
|
try {
|
||||||
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
|
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -442,6 +581,15 @@ export default function Home() {
|
|||||||
const state = useEmailStore.getState();
|
const state = useEmailStore.getState();
|
||||||
const selectedMailboxId = state.selectedMailbox;
|
const selectedMailboxId = state.selectedMailbox;
|
||||||
|
|
||||||
|
// On first login the server may still be provisioning mailboxes.
|
||||||
|
// Retry a few times with back-off before giving up.
|
||||||
|
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
|
||||||
|
const delay = Math.min(1000 * attempt, 5000);
|
||||||
|
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
|
||||||
|
retryTimer = setTimeout(() => loadData(attempt + 1), delay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch emails for the selected mailbox
|
// Fetch emails for the selected mailbox
|
||||||
if (selectedMailboxId) {
|
if (selectedMailboxId) {
|
||||||
await fetchEmails(client, selectedMailboxId);
|
await fetchEmails(client, selectedMailboxId);
|
||||||
@@ -475,6 +623,12 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadData();
|
loadData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
|
client.closePushNotifications();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup push notifications on unmount
|
// Cleanup push notifications on unmount
|
||||||
@@ -485,13 +639,69 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
|
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
|
||||||
|
|
||||||
|
// Keep unified mailbox counts in sync when the feature is enabled and more
|
||||||
|
// than one account is connected. Runs whenever the set of connected accounts
|
||||||
|
// or the primary account's mailboxes change (a proxy for "something worth
|
||||||
|
// recounting happened").
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableUnifiedMailbox || !isAuthenticated || !client) return;
|
||||||
|
const built = buildUnifiedAccounts();
|
||||||
|
if (built.length < 2) return;
|
||||||
|
populateUnifiedAccountMailboxes(built).then((populated) => {
|
||||||
|
refreshUnifiedCounts(populated);
|
||||||
|
});
|
||||||
|
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
|
||||||
|
|
||||||
|
// System-notification click handler. The push SW navigates the user back
|
||||||
|
// here with `?email=<id>` (specific email it built the toast from) or
|
||||||
|
// `?openLatestUnread=1` (generic "New mail" toast — happens when the
|
||||||
|
// preview API failed). We resolve those params once after the inbox has
|
||||||
|
// finished loading and open the right message, then strip the params so a
|
||||||
|
// refresh doesn't re-open it.
|
||||||
|
const notificationParamHandledRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (notificationParamHandledRef.current) return;
|
||||||
|
if (!isAuthenticated || !client) return;
|
||||||
|
if (mailboxes.length === 0) return;
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const emailIdParam = params.get('email');
|
||||||
|
const openLatestUnread = params.get('openLatestUnread') === '1';
|
||||||
|
if (!emailIdParam && !openLatestUnread) return;
|
||||||
|
|
||||||
|
// For the latest-unread case we need the inbox emails loaded; bail and
|
||||||
|
// let the effect re-run once `emails` is populated.
|
||||||
|
if (openLatestUnread && emails.length === 0) return;
|
||||||
|
|
||||||
|
notificationParamHandledRef.current = true;
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
|
||||||
|
if (emailIdParam) {
|
||||||
|
setLoadingEmail(true);
|
||||||
|
fetchEmailContent(client, emailIdParam).finally(() => setLoadingEmail(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// emails are sorted receivedAt-desc, so the first unread is the newest.
|
||||||
|
const newestUnread = emails.find(e => !e.keywords?.$seen);
|
||||||
|
if (newestUnread) {
|
||||||
|
selectEmail(newestUnread);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, client, mailboxes.length, emails, fetchEmailContent, selectEmail, setLoadingEmail]);
|
||||||
|
|
||||||
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
|
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEmail || !client) return;
|
if (!selectedEmail || !client) return;
|
||||||
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
|
// If the email lacks bodyValues, it was auto-selected from the list and needs full content.
|
||||||
if (!selectedEmail.bodyValues) {
|
// Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
|
||||||
|
// calling selectEmail on the stub), to avoid a duplicate request.
|
||||||
|
if (!selectedEmail.bodyValues && !isLoadingEmail) {
|
||||||
|
const perAccountClient = isUnifiedView && selectedEmail.accountId
|
||||||
|
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
|
||||||
|
: undefined;
|
||||||
|
const fetchClient = perAccountClient ?? client;
|
||||||
setLoadingEmail(true);
|
setLoadingEmail(true);
|
||||||
fetchEmailContent(client, selectedEmail.id).finally(() => {
|
fetchEmailContent(fetchClient, selectedEmail.id).finally(() => {
|
||||||
setLoadingEmail(false);
|
setLoadingEmail(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -579,7 +789,9 @@ export default function Home() {
|
|||||||
fromEmail?: string;
|
fromEmail?: string;
|
||||||
fromName?: string;
|
fromName?: string;
|
||||||
identityId?: string;
|
identityId?: string;
|
||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||||
|
inReplyTo?: string[];
|
||||||
|
references?: string[];
|
||||||
}) => {
|
}) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
@@ -587,7 +799,7 @@ export default function Home() {
|
|||||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||||
const originalEmailId = selectedEmail?.id;
|
const originalEmailId = selectedEmail?.id;
|
||||||
|
|
||||||
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments);
|
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references);
|
||||||
setShowComposer(false);
|
setShowComposer(false);
|
||||||
|
|
||||||
// Mark the original email with $answered or $forwarded keyword
|
// Mark the original email with $answered or $forwarded keyword
|
||||||
@@ -693,8 +905,8 @@ export default function Home() {
|
|||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !emailToDelete) return;
|
||||||
|
|
||||||
// Check if we're currently in the trash or junk folder
|
// Check if we're currently in the trash or junk folder
|
||||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||||
@@ -713,7 +925,7 @@ export default function Home() {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteEmail(client, selectedEmail.id, true);
|
await deleteEmail(client, emailToDelete.id, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to permanently delete email:", error);
|
console.error("Failed to permanently delete email:", error);
|
||||||
}
|
}
|
||||||
@@ -722,7 +934,7 @@ export default function Home() {
|
|||||||
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
|
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
|
||||||
if (trashMailbox) {
|
if (trashMailbox) {
|
||||||
try {
|
try {
|
||||||
await moveToMailbox(client, selectedEmail.id, trashMailbox.id);
|
await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to move email to trash:", error);
|
console.error("Failed to move email to trash:", error);
|
||||||
}
|
}
|
||||||
@@ -733,8 +945,11 @@ export default function Home() {
|
|||||||
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
|
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
|
||||||
if (!client || !emailToArchive) return;
|
if (!client || !emailToArchive) return;
|
||||||
|
|
||||||
// Find archive mailbox
|
// Read fresh mailboxes from the store – batch archive calls this in a loop,
|
||||||
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
// and each iteration needs to see folders created by prior iterations.
|
||||||
|
const currentMailboxes = useEmailStore.getState().mailboxes;
|
||||||
|
|
||||||
|
const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
||||||
if (!archiveMailbox) return;
|
if (!archiveMailbox) return;
|
||||||
|
|
||||||
const { archiveMode } = useSettingsStore.getState();
|
const { archiveMode } = useSettingsStore.getState();
|
||||||
@@ -743,14 +958,12 @@ export default function Home() {
|
|||||||
if (archiveMode === 'single') {
|
if (archiveMode === 'single') {
|
||||||
await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id);
|
||||||
} else {
|
} else {
|
||||||
// Determine year/month from the email's received date
|
|
||||||
const emailDate = new Date(emailToArchive.receivedAt);
|
const emailDate = new Date(emailToArchive.receivedAt);
|
||||||
const year = emailDate.getFullYear().toString();
|
const year = emailDate.getFullYear().toString();
|
||||||
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
|
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
|
||||||
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
||||||
|
|
||||||
// Find or create year subfolder under archive
|
let yearMailbox = currentMailboxes.find(
|
||||||
let yearMailbox = mailboxes.find(
|
|
||||||
m => m.name === year && m.parentId === archiveId
|
m => m.name === year && m.parentId === archiveId
|
||||||
);
|
);
|
||||||
if (!yearMailbox) {
|
if (!yearMailbox) {
|
||||||
@@ -761,9 +974,9 @@ export default function Home() {
|
|||||||
if (archiveMode === 'year') {
|
if (archiveMode === 'year') {
|
||||||
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
||||||
} else {
|
} else {
|
||||||
// archiveMode === 'month' — find or create month subfolder under year
|
|
||||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||||
let monthMailbox = mailboxes.find(
|
const afterYear = useEmailStore.getState().mailboxes;
|
||||||
|
let monthMailbox = afterYear.find(
|
||||||
m => m.name === month && m.parentId === yearId
|
m => m.name === month && m.parentId === yearId
|
||||||
);
|
);
|
||||||
if (!monthMailbox) {
|
if (!monthMailbox) {
|
||||||
@@ -795,10 +1008,10 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkAsSpam = async () => {
|
const handleMarkAsSpam = async (emailToMark: Email | null = selectedEmail) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !emailToMark) return;
|
||||||
|
|
||||||
const emailId = selectedEmail.id;
|
const emailId = emailToMark.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await markAsSpam(client, emailId);
|
await markAsSpam(client, emailId);
|
||||||
@@ -826,11 +1039,11 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUndoSpam = async () => {
|
const handleUndoSpam = async (emailToRestore: Email | null = selectedEmail) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !emailToRestore) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await undoSpam(client, selectedEmail.id);
|
await undoSpam(client, emailToRestore.id);
|
||||||
|
|
||||||
const toastInstance = (await import('sonner')).toast;
|
const toastInstance = (await import('sonner')).toast;
|
||||||
toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
|
toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
|
||||||
@@ -886,6 +1099,32 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMailboxSelect = async (mailboxId: string) => {
|
const handleMailboxSelect = async (mailboxId: string) => {
|
||||||
|
if (isUnifiedMailboxId(mailboxId)) {
|
||||||
|
const role = UNIFIED_ROLE_BY_ID[mailboxId];
|
||||||
|
if (!role) return;
|
||||||
|
|
||||||
|
selectMailbox(mailboxId);
|
||||||
|
selectEmail(null);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
setActiveView("list");
|
||||||
|
}
|
||||||
|
if (isTablet) {
|
||||||
|
setTabletListVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const built = buildUnifiedAccounts();
|
||||||
|
const populated = await populateUnifiedAccountMailboxes(built);
|
||||||
|
await fetchUnifiedEmailsAction(populated, role);
|
||||||
|
refreshUnifiedCounts(populated);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnifiedView) {
|
||||||
|
exitUnifiedView();
|
||||||
|
}
|
||||||
|
|
||||||
selectMailbox(mailboxId);
|
selectMailbox(mailboxId);
|
||||||
selectEmail(null); // Clear selected email when switching mailboxes
|
selectEmail(null); // Clear selected email when switching mailboxes
|
||||||
|
|
||||||
@@ -965,10 +1204,202 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tCtxMenu = t;
|
||||||
|
|
||||||
|
const handleMarkFolderRead = async (mailboxId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
const count = await markMailboxAsRead(client, mailboxId);
|
||||||
|
await fetchMailboxes(client);
|
||||||
|
if (selectedMailbox === mailboxId) await fetchEmails(client, mailboxId);
|
||||||
|
if (count > 0) {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count }));
|
||||||
|
} else {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkFolderTreeRead = async (mailboxId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const collectIds = (rootId: string): string[] => {
|
||||||
|
const ids: string[] = [rootId];
|
||||||
|
const stack = [rootId];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop()!;
|
||||||
|
for (const mb of mailboxes) {
|
||||||
|
if (mb.parentId === current) {
|
||||||
|
ids.push(mb.id);
|
||||||
|
stack.push(mb.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ids = collectIds(mailboxId);
|
||||||
|
let total = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
total += await markMailboxAsRead(client, id);
|
||||||
|
}
|
||||||
|
await fetchMailboxes(client);
|
||||||
|
if (selectedMailbox && ids.includes(selectedMailbox)) await fetchEmails(client, selectedMailbox);
|
||||||
|
if (total > 0) {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total }));
|
||||||
|
} else {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAllFoldersRead = async () => {
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: tCtxMenu('mailbox_context_menu.mark_all_confirm_title'),
|
||||||
|
message: tCtxMenu('mailbox_context_menu.mark_all_confirm_message'),
|
||||||
|
confirmText: tCtxMenu('mailbox_context_menu.mark_all_folders_read'),
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const total = await client.markAllAsRead();
|
||||||
|
await fetchMailboxes(client);
|
||||||
|
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
|
||||||
|
if (total > 0) {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total }));
|
||||||
|
} else {
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmptyFolderFromContextMenu = async (mailboxId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
|
||||||
|
if (!mailbox) return;
|
||||||
|
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: tCtxMenu('email_list.empty_folder.confirm_title'),
|
||||||
|
message: tCtxMenu('email_list.empty_folder.confirm_message'),
|
||||||
|
confirmText: tCtxMenu('email_list.empty_folder.confirm_button'),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await emptyMailbox(client, mailboxId);
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_emptied'));
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_empty'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSubfolderFromContextMenu = async (parentId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const name = await promptDialog({
|
||||||
|
title: tCtxMenu('mailbox_context_menu.new_subfolder'),
|
||||||
|
message: tCtxMenu('mailbox_context_menu.prompt_new_subfolder'),
|
||||||
|
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
|
||||||
|
confirmText: tCtxMenu('mailbox_context_menu.create'),
|
||||||
|
});
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
await createMailbox(client, name, parentId);
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateFolderFromContextMenu = async () => {
|
||||||
|
if (!client) return;
|
||||||
|
const name = await promptDialog({
|
||||||
|
title: tCtxMenu('mailbox_context_menu.new_folder'),
|
||||||
|
message: tCtxMenu('mailbox_context_menu.prompt_new_folder'),
|
||||||
|
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
|
||||||
|
confirmText: tCtxMenu('mailbox_context_menu.create'),
|
||||||
|
});
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
await createMailbox(client, name);
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenameFolderFromContextMenu = async (mailboxId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
|
||||||
|
if (!mailbox) return;
|
||||||
|
const name = await promptDialog({
|
||||||
|
title: tCtxMenu('mailbox_context_menu.rename'),
|
||||||
|
message: tCtxMenu('mailbox_context_menu.prompt_rename'),
|
||||||
|
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
|
||||||
|
defaultValue: mailbox.name,
|
||||||
|
confirmText: tCtxMenu('mailbox_context_menu.rename_confirm'),
|
||||||
|
});
|
||||||
|
if (!name || name === mailbox.name) return;
|
||||||
|
try {
|
||||||
|
await renameMailbox(client, mailboxId, name);
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_renamed'));
|
||||||
|
} catch {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_rename'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFolderFromContextMenu = async (mailboxId: string) => {
|
||||||
|
if (!client) return;
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
|
||||||
|
if (!mailbox) return;
|
||||||
|
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: tCtxMenu('mailbox_context_menu.delete_confirm_title'),
|
||||||
|
message: tCtxMenu('mailbox_context_menu.delete_confirm_message', { name: mailbox.name }),
|
||||||
|
confirmText: tCtxMenu('mailbox_context_menu.delete_folder'),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteMailbox(client, mailboxId);
|
||||||
|
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_deleted'));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const jmapType = (err as Error & { jmapType?: string })?.jmapType;
|
||||||
|
if (jmapType === 'mailboxHasChild') {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_children'));
|
||||||
|
} else if (jmapType === 'mailboxHasEmail') {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_email'));
|
||||||
|
} else {
|
||||||
|
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshMailboxes = async () => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
await fetchMailboxes(client);
|
||||||
|
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = logout;
|
const handleLogout = logout;
|
||||||
|
|
||||||
const handleSearch = async (query: string) => {
|
const handleSearch = async (query: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
if (isUnifiedView) return;
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
if (!isFilterEmpty(searchFilters)) {
|
if (!isFilterEmpty(searchFilters)) {
|
||||||
await advancedSearch(client);
|
await advancedSearch(client);
|
||||||
@@ -987,6 +1418,7 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleAdvancedSearch = async () => {
|
const handleAdvancedSearch = async () => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
if (isUnifiedView) return;
|
||||||
await advancedSearch(client);
|
await advancedSearch(client);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -996,9 +1428,9 @@ export default function Home() {
|
|||||||
clearTimeout(advancedSearchDebounceRef.current);
|
clearTimeout(advancedSearchDebounceRef.current);
|
||||||
}
|
}
|
||||||
advancedSearchDebounceRef.current = setTimeout(() => {
|
advancedSearchDebounceRef.current = setTimeout(() => {
|
||||||
if (client) advancedSearch(client);
|
if (client && !isUnifiedView) advancedSearch(client);
|
||||||
}, 300);
|
}, 300);
|
||||||
}, [client, advancedSearch]);
|
}, [client, advancedSearch, isUnifiedView]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1059,6 +1491,12 @@ export default function Home() {
|
|||||||
|
|
||||||
const originalEmailId = selectedEmail.id;
|
const originalEmailId = selectedEmail.id;
|
||||||
|
|
||||||
|
// RFC 5322 §3.6.4 threading — keep the conversation stitched together (#234).
|
||||||
|
const threading = computeReplyThreadingHeaders({
|
||||||
|
messageId: selectedEmail.messageId,
|
||||||
|
references: selectedEmail.references,
|
||||||
|
});
|
||||||
|
|
||||||
// Send reply with just the body text
|
// Send reply with just the body text
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
client,
|
client,
|
||||||
@@ -1070,7 +1508,11 @@ export default function Home() {
|
|||||||
primaryIdentity?.id,
|
primaryIdentity?.id,
|
||||||
primaryIdentity?.email,
|
primaryIdentity?.email,
|
||||||
undefined,
|
undefined,
|
||||||
primaryIdentity?.name || undefined
|
primaryIdentity?.name || undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
threading?.inReplyTo,
|
||||||
|
threading?.references,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mark the original email as answered
|
// Mark the original email as answered
|
||||||
@@ -1112,7 +1554,13 @@ export default function Home() {
|
|||||||
setShowComposer(false);
|
setShowComposer(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set loading state immediately (keep current email visible)
|
// Show the list stub immediately so subject/sender render without
|
||||||
|
// waiting for the body fetch — avoids the loading flicker.
|
||||||
|
const listEmail = emails.find(e => e.id === email.id);
|
||||||
|
if (listEmail) {
|
||||||
|
selectEmail(listEmail);
|
||||||
|
}
|
||||||
|
|
||||||
setLoadingEmail(true);
|
setLoadingEmail(true);
|
||||||
|
|
||||||
// On mobile, switch to viewer
|
// On mobile, switch to viewer
|
||||||
@@ -1127,13 +1575,28 @@ export default function Home() {
|
|||||||
|
|
||||||
// Fetch the full content
|
// Fetch the full content
|
||||||
try {
|
try {
|
||||||
// Find selected mailbox to determine accountId (for shared folders)
|
// In unified view each email carries its own accountId. Use that
|
||||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
// account's client so we fetch from the server that actually owns it.
|
||||||
// Only pass accountId for shared mailboxes
|
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
|
||||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
const perAccountClient = emailAccountId
|
||||||
|
? useAuthStore.getState().getClientForAccount(emailAccountId)
|
||||||
|
: undefined;
|
||||||
|
const fetchClient = perAccountClient ?? client;
|
||||||
|
|
||||||
const fullEmail = await client.getEmail(email.id, accountId);
|
// For shared folders on the primary client, we still need to pass the
|
||||||
|
// shared account's id. In unified view we use the per-account client
|
||||||
|
// directly, so no explicit accountId is needed.
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
|
const accountId = perAccountClient
|
||||||
|
? undefined
|
||||||
|
: mailbox?.isShared ? mailbox.accountId : undefined;
|
||||||
|
|
||||||
|
const fullEmail = await fetchClient.getEmail(email.id, accountId);
|
||||||
if (fullEmail) {
|
if (fullEmail) {
|
||||||
|
if (emailAccountId) {
|
||||||
|
fullEmail.accountId = emailAccountId;
|
||||||
|
fullEmail.accountLabel = listEmail?.accountLabel;
|
||||||
|
}
|
||||||
selectEmail(fullEmail);
|
selectEmail(fullEmail);
|
||||||
// Mark-as-read logic is now handled by useEffect
|
// Mark-as-read logic is now handled by useEffect
|
||||||
}
|
}
|
||||||
@@ -1145,15 +1608,11 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle back navigation from viewer on mobile.
|
// Handle back navigation from viewer on mobile.
|
||||||
// Delegate to the browser history stack so this button is equivalent to
|
// Reset to list state directly. We can't just call window.history.back()
|
||||||
// the OS back button / mouse back button — popstate then restores the
|
// because the nav hook pushes a new entry for every email the user opens,
|
||||||
// previous snapshot via handleNavRestore. The viewer is only reachable
|
// so history.back() would pop to the previous email rather than the list.
|
||||||
// from a state that pushed history, so back() always lands on an app entry.
|
// The OS / hardware back button is still wired through popstate → handleNavRestore.
|
||||||
const handleMobileBack = () => {
|
const handleMobileBack = () => {
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.history.back();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (conversationThread) {
|
if (conversationThread) {
|
||||||
setConversationThread(null);
|
setConversationThread(null);
|
||||||
setConversationEmails([]);
|
setConversationEmails([]);
|
||||||
@@ -1308,6 +1767,15 @@ export default function Home() {
|
|||||||
onMailboxSelect={handleMailboxSelect}
|
onMailboxSelect={handleMailboxSelect}
|
||||||
onTagSelect={handleTagSelect}
|
onTagSelect={handleTagSelect}
|
||||||
onUnreadFilterClick={handleUnreadFilterClick}
|
onUnreadFilterClick={handleUnreadFilterClick}
|
||||||
|
onMarkFolderRead={handleMarkFolderRead}
|
||||||
|
onMarkFolderTreeRead={handleMarkFolderTreeRead}
|
||||||
|
onMarkAllFoldersRead={handleMarkAllFoldersRead}
|
||||||
|
onEmptyFolder={handleEmptyFolderFromContextMenu}
|
||||||
|
onCreateSubfolder={handleCreateSubfolderFromContextMenu}
|
||||||
|
onCreateFolder={handleCreateFolderFromContextMenu}
|
||||||
|
onRenameFolder={handleRenameFolderFromContextMenu}
|
||||||
|
onDeleteFolder={handleDeleteFolderFromContextMenu}
|
||||||
|
onRefreshMailboxes={handleRefreshMailboxes}
|
||||||
onCompose={() => {
|
onCompose={() => {
|
||||||
setComposerMode('compose');
|
setComposerMode('compose');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
@@ -1368,9 +1836,12 @@ export default function Home() {
|
|||||||
} else {
|
} else {
|
||||||
selectAllEmails();
|
selectAllEmails();
|
||||||
}
|
}
|
||||||
} else {
|
} else if (emails.length > 0) {
|
||||||
// Enter selection mode by selecting the first email
|
const currentId = selectedEmail?.id;
|
||||||
if (emails.length > 0) toggleEmailSelection(emails[0].id);
|
const target = currentId && emails.some((e) => e.id === currentId)
|
||||||
|
? currentId
|
||||||
|
: emails[0].id;
|
||||||
|
toggleEmailSelection(target);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -1397,6 +1868,8 @@ export default function Home() {
|
|||||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||||
data-search-input
|
data-search-input
|
||||||
data-tour="search-input"
|
data-tour="search-input"
|
||||||
|
disabled={isUnifiedView}
|
||||||
|
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -1412,13 +1885,15 @@ export default function Home() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleAdvancedSearch}
|
onClick={toggleAdvancedSearch}
|
||||||
|
disabled={isUnifiedView}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||||
|
isUnifiedView && "opacity-50 cursor-not-allowed",
|
||||||
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
title={t("advanced_search.toggle_filters")}
|
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : t("advanced_search.toggle_filters")}
|
||||||
>
|
>
|
||||||
<Filter className="w-4 h-4" />
|
<Filter className="w-4 h-4" />
|
||||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||||
@@ -1602,8 +2077,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDelete={async (email) => {
|
onDelete={async (email) => {
|
||||||
selectEmail(email);
|
await handleDelete(email);
|
||||||
await handleDelete();
|
|
||||||
}}
|
}}
|
||||||
onArchive={async (email) => {
|
onArchive={async (email) => {
|
||||||
await handleArchive(email);
|
await handleArchive(email);
|
||||||
@@ -1617,12 +2091,10 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMarkAsSpam={async (email) => {
|
onMarkAsSpam={async (email) => {
|
||||||
selectEmail(email);
|
await handleMarkAsSpam(email);
|
||||||
await handleMarkAsSpam();
|
|
||||||
}}
|
}}
|
||||||
onUndoSpam={async (email) => {
|
onUndoSpam={async (email) => {
|
||||||
selectEmail(email);
|
await handleUndoSpam(email);
|
||||||
await handleUndoSpam();
|
|
||||||
}}
|
}}
|
||||||
onEditDraft={(email) => {
|
onEditDraft={(email) => {
|
||||||
handleEditDraft(email);
|
handleEditDraft(email);
|
||||||
@@ -1694,7 +2166,11 @@ export default function Home() {
|
|||||||
subject: selectedEmail.subject,
|
subject: selectedEmail.subject,
|
||||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||||
receivedAt: selectedEmail.receivedAt
|
receivedAt: selectedEmail.receivedAt,
|
||||||
|
attachments: selectedEmail.attachments,
|
||||||
|
messageId: selectedEmail.messageId,
|
||||||
|
inReplyTo: selectedEmail.inReplyTo,
|
||||||
|
references: selectedEmail.references,
|
||||||
} : undefined)}
|
} : undefined)}
|
||||||
initialDraftText={composerDraftText}
|
initialDraftText={composerDraftText}
|
||||||
initialData={pendingDraft}
|
initialData={pendingDraft}
|
||||||
@@ -1772,14 +2248,6 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Mobile Header for Viewer */}
|
|
||||||
{isMobile && activeView === "viewer" && (
|
|
||||||
<MobileViewerHeader
|
|
||||||
subject={selectedEmail?.subject}
|
|
||||||
onBack={handleMobileBack}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
||||||
<EmailViewer
|
<EmailViewer
|
||||||
email={selectedEmail}
|
email={selectedEmail}
|
||||||
@@ -1787,12 +2255,12 @@ export default function Home() {
|
|||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onReplyAll={handleReplyAll}
|
onReplyAll={handleReplyAll}
|
||||||
onForward={handleForward}
|
onForward={handleForward}
|
||||||
onDelete={handleDelete}
|
onDelete={() => handleDelete()}
|
||||||
onArchive={() => handleArchive()}
|
onArchive={() => handleArchive()}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
onSetColorTag={handleSetColorTag}
|
onSetColorTag={handleSetColorTag}
|
||||||
onMarkAsSpam={handleMarkAsSpam}
|
onMarkAsSpam={() => handleMarkAsSpam()}
|
||||||
onUndoSpam={handleUndoSpam}
|
onUndoSpam={() => handleUndoSpam()}
|
||||||
onMarkAsRead={async (emailId, read) => {
|
onMarkAsRead={async (emailId, read) => {
|
||||||
if (client) {
|
if (client) {
|
||||||
await markAsRead(client, emailId, read);
|
await markAsRead(client, emailId, read);
|
||||||
@@ -1862,6 +2330,7 @@ export default function Home() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
<PromptDialog {...promptDialogProps} />
|
||||||
<TotpReauthDialog />
|
<TotpReauthDialog />
|
||||||
</div>
|
</div>
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
|
|||||||
+152
-82
@@ -9,7 +9,6 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
Palette,
|
Palette,
|
||||||
Mail,
|
|
||||||
User,
|
User,
|
||||||
Shield,
|
Shield,
|
||||||
UserPen,
|
UserPen,
|
||||||
@@ -20,17 +19,27 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Tags,
|
Tags,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Wrench,
|
|
||||||
BookUser,
|
BookUser,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
Bell,
|
Bell,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
|
LayoutGrid,
|
||||||
|
BookOpen,
|
||||||
|
PenLine,
|
||||||
|
EyeOff,
|
||||||
|
Languages,
|
||||||
|
Info,
|
||||||
|
Bug,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
||||||
import { EmailSettings } from '@/components/settings/email-settings';
|
import { LayoutSettings } from '@/components/settings/layout-settings';
|
||||||
|
import { LanguageSettings } from '@/components/settings/language-settings';
|
||||||
|
import { ReadingSettings } from '@/components/settings/reading-settings';
|
||||||
|
import { ComposingSettings } from '@/components/settings/composing-settings';
|
||||||
|
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
|
||||||
import { AccountSettings } from '@/components/settings/account-settings';
|
import { AccountSettings } from '@/components/settings/account-settings';
|
||||||
import { IdentitySettings } from '@/components/settings/identity-settings';
|
import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||||
import { VacationSettings } from '@/components/settings/vacation-settings';
|
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||||
@@ -39,7 +48,8 @@ import { CalendarManagementSettings } from '@/components/settings/calendar-manag
|
|||||||
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
|
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
|
||||||
import { FilterSettings } from '@/components/settings/filter-settings';
|
import { FilterSettings } from '@/components/settings/filter-settings';
|
||||||
import { TemplateSettings } from '@/components/settings/template-settings';
|
import { TemplateSettings } from '@/components/settings/template-settings';
|
||||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
import { AboutDataSettings } from '@/components/settings/about-data-settings';
|
||||||
|
import { DebugSettings } from '@/components/settings/debug-settings';
|
||||||
import { FolderSettings } from '@/components/settings/folder-settings';
|
import { FolderSettings } from '@/components/settings/folder-settings';
|
||||||
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||||
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||||
@@ -62,8 +72,33 @@ import { useConfig } from '@/hooks/use-config';
|
|||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'themes' | 'plugins' | 'advanced';
|
type Tab =
|
||||||
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
|
| 'account'
|
||||||
|
| 'language'
|
||||||
|
| 'notifications'
|
||||||
|
| 'appearance'
|
||||||
|
| 'layout'
|
||||||
|
| 'reading'
|
||||||
|
| 'composing'
|
||||||
|
| 'identities'
|
||||||
|
| 'vacation'
|
||||||
|
| 'filters'
|
||||||
|
| 'templates'
|
||||||
|
| 'folders'
|
||||||
|
| 'keywords'
|
||||||
|
| 'security'
|
||||||
|
| 'encryption'
|
||||||
|
| 'content_senders'
|
||||||
|
| 'calendar'
|
||||||
|
| 'contacts'
|
||||||
|
| 'files'
|
||||||
|
| 'sidebar_apps'
|
||||||
|
| 'about_data'
|
||||||
|
| 'themes'
|
||||||
|
| 'plugins'
|
||||||
|
| 'debug';
|
||||||
|
|
||||||
|
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||||
|
|
||||||
interface TabDef {
|
interface TabDef {
|
||||||
id: Tab;
|
id: Tab;
|
||||||
@@ -74,28 +109,54 @@ interface TabDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabIcons: Record<Tab, LucideIcon> = {
|
const tabIcons: Record<Tab, LucideIcon> = {
|
||||||
appearance: Palette,
|
|
||||||
email: Mail,
|
|
||||||
notifications: Bell,
|
|
||||||
account: User,
|
account: User,
|
||||||
security: Shield,
|
language: Languages,
|
||||||
|
notifications: Bell,
|
||||||
|
appearance: Palette,
|
||||||
|
layout: LayoutGrid,
|
||||||
|
reading: BookOpen,
|
||||||
|
composing: PenLine,
|
||||||
identities: UserPen,
|
identities: UserPen,
|
||||||
encryption: KeyRound,
|
|
||||||
vacation: PalmtreeIcon,
|
vacation: PalmtreeIcon,
|
||||||
calendar: Calendar,
|
|
||||||
contacts: BookUser,
|
|
||||||
filters: Filter,
|
filters: Filter,
|
||||||
templates: FileText,
|
templates: FileText,
|
||||||
folders: FolderOpen,
|
folders: FolderOpen,
|
||||||
keywords: Tags,
|
keywords: Tags,
|
||||||
|
security: Shield,
|
||||||
|
encryption: KeyRound,
|
||||||
|
content_senders: EyeOff,
|
||||||
|
calendar: Calendar,
|
||||||
|
contacts: BookUser,
|
||||||
files: HardDrive,
|
files: HardDrive,
|
||||||
sidebar_apps: PanelLeftClose,
|
sidebar_apps: PanelLeftClose,
|
||||||
|
about_data: Info,
|
||||||
themes: Palette,
|
themes: Palette,
|
||||||
plugins: Puzzle,
|
plugins: Puzzle,
|
||||||
advanced: Wrench,
|
debug: Bug,
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabGroupOrder: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
|
const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
|
||||||
|
|
||||||
|
// Map legacy tab IDs to current ones; runs once on read of localStorage.
|
||||||
|
const LEGACY_TAB_MAP: Record<string, Tab> = {
|
||||||
|
email: 'reading',
|
||||||
|
advanced: 'about_data',
|
||||||
|
};
|
||||||
|
|
||||||
|
function readPersistedTab(): Tab {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('settings-active-tab');
|
||||||
|
if (!saved) return 'appearance';
|
||||||
|
if (saved in LEGACY_TAB_MAP) {
|
||||||
|
const migrated = LEGACY_TAB_MAP[saved];
|
||||||
|
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
return saved as Tab;
|
||||||
|
} catch {
|
||||||
|
return 'appearance';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -107,13 +168,7 @@ 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>(() => {
|
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem('settings-active-tab');
|
|
||||||
if (saved) return saved as Tab;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
return 'appearance';
|
|
||||||
});
|
|
||||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||||
const isDesktop = useIsDesktop();
|
const isDesktop = useIsDesktop();
|
||||||
|
|
||||||
@@ -124,21 +179,28 @@ export default function SettingsPage() {
|
|||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const dragStartWidth = useRef(256);
|
const dragStartWidth = useRef(256);
|
||||||
|
|
||||||
// Check auth on mount
|
// Check auth on mount – skip when already authenticated so that navigating
|
||||||
|
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||||
|
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (state.isAuthenticated && state.client) {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkAuth().finally(() => {
|
checkAuth().finally(() => {
|
||||||
setInitialCheckDone(true);
|
setInitialCheckDone(true);
|
||||||
});
|
});
|
||||||
}, [checkAuth]);
|
}, [checkAuth]);
|
||||||
|
|
||||||
// Listen for tab change events from child components
|
// Listen for tab change events from child components (with legacy migration)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: Event) => {
|
const handler = (e: Event) => {
|
||||||
const tab = (e as CustomEvent).detail as Tab;
|
const raw = (e as CustomEvent).detail as string;
|
||||||
if (tab) {
|
if (!raw) return;
|
||||||
setActiveTab(tab);
|
const tab = (LEGACY_TAB_MAP[raw] ?? raw) as Tab;
|
||||||
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
|
setActiveTab(tab);
|
||||||
}
|
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
|
||||||
};
|
};
|
||||||
window.addEventListener('settings-tab-change', handler);
|
window.addEventListener('settings-tab-change', handler);
|
||||||
return () => window.removeEventListener('settings-tab-change', handler);
|
return () => window.removeEventListener('settings-tab-change', handler);
|
||||||
@@ -161,25 +223,41 @@ export default function SettingsPage() {
|
|||||||
const supportsFiles = client?.supportsFiles() ?? false;
|
const supportsFiles = client?.supportsFiles() ?? false;
|
||||||
|
|
||||||
const tabs: TabDef[] = [
|
const tabs: TabDef[] = [
|
||||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
|
// General
|
||||||
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
|
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
|
||||||
|
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
|
||||||
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
||||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
|
|
||||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
|
// Appearance
|
||||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
|
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
||||||
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' as TabGroup }] : []),
|
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
|
||||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
|
|
||||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []),
|
// Mail
|
||||||
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' as TabGroup }] : []),
|
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
|
||||||
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
|
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
||||||
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' as TabGroup }] : []),
|
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
|
||||||
|
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
|
||||||
|
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
|
||||||
|
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
|
||||||
|
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
|
||||||
|
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
|
||||||
|
|
||||||
|
// Privacy & Security
|
||||||
|
...(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' },
|
||||||
|
|
||||||
|
// Apps
|
||||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||||
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
|
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
|
||||||
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||||
...(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 }] : []),
|
||||||
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'system' as TabGroup, experimental: true }] : []),
|
|
||||||
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'system' as TabGroup, experimental: true }] : []),
|
// Advanced
|
||||||
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
|
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
||||||
|
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup, experimental: true }] : []),
|
||||||
|
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup, experimental: true }] : []),
|
||||||
|
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Group tabs by category
|
// Group tabs by category
|
||||||
@@ -191,6 +269,10 @@ export default function SettingsPage() {
|
|||||||
}))
|
}))
|
||||||
.filter((g) => g.items.length > 0);
|
.filter((g) => g.items.length > 0);
|
||||||
|
|
||||||
|
// If active tab is not in the visible list (e.g., feature disabled), fall back.
|
||||||
|
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
|
||||||
|
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
|
||||||
|
|
||||||
const handleTabSelect = (tabId: Tab) => {
|
const handleTabSelect = (tabId: Tab) => {
|
||||||
setActiveTab(tabId);
|
setActiveTab(tabId);
|
||||||
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
||||||
@@ -199,39 +281,42 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeTabLabel = tabs.find((tab) => tab.id === activeTab)?.label ?? '';
|
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
||||||
|
|
||||||
const renderTabContent = () => (
|
const renderTabContent = () => (
|
||||||
<>
|
<>
|
||||||
{activeTab === 'appearance' && <AppearanceSettings />}
|
{effectiveActiveTab === 'account' && <AccountSettings />}
|
||||||
{activeTab === 'email' && <EmailSettings />}
|
{effectiveActiveTab === 'language' && <LanguageSettings />}
|
||||||
{activeTab === 'notifications' && <NotificationSettings />}
|
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
|
||||||
{activeTab === 'account' && <AccountSettings />}
|
{effectiveActiveTab === 'appearance' && <AppearanceSettings />}
|
||||||
{activeTab === 'security' && <AccountSecuritySettings />}
|
{effectiveActiveTab === 'layout' && <LayoutSettings />}
|
||||||
{activeTab === 'identities' && <IdentitySettings />}
|
{effectiveActiveTab === 'reading' && <ReadingSettings />}
|
||||||
{activeTab === 'encryption' && <SmimeSettings />}
|
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||||
{activeTab === 'vacation' && <VacationSettings />}
|
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||||
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||||
{activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||||
{activeTab === 'filters' && <FilterSettings />}
|
{effectiveActiveTab === 'templates' && <TemplateSettings />}
|
||||||
{activeTab === 'templates' && <TemplateSettings />}
|
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||||
{activeTab === 'folders' && <FolderSettings />}
|
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||||
{activeTab === 'keywords' && <KeywordSettings />}
|
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||||
{activeTab === 'files' && <FilesSettingsComponent />}
|
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
|
||||||
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||||
{activeTab === 'themes' && <ThemesSettings />}
|
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||||
{activeTab === 'plugins' && <PluginsSettings />}
|
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
||||||
|
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||||
|
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
|
||||||
|
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||||
|
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||||
|
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mobile layout
|
// Mobile layout
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
// Mobile: show content view
|
|
||||||
if (mobileShowContent) {
|
if (mobileShowContent) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-dvh bg-background">
|
<div className="flex flex-col h-dvh bg-background">
|
||||||
{/* Mobile content header */}
|
|
||||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -244,14 +329,12 @@ export default function SettingsPage() {
|
|||||||
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
|
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<div className="bg-card border border-border rounded-lg p-4">
|
<div className="bg-card border border-border rounded-lg p-4">
|
||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Navigation */}
|
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
@@ -264,10 +347,8 @@ export default function SettingsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile: show tab list
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-dvh bg-background">
|
<div className="flex flex-col h-dvh bg-background">
|
||||||
{/* Mobile header */}
|
|
||||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -283,7 +364,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab list */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
{groupedTabs.map((group, groupIndex) => (
|
{groupedTabs.map((group, groupIndex) => (
|
||||||
@@ -319,7 +399,6 @@ export default function SettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logout */}
|
|
||||||
<div className="border-t border-border px-5 py-3">
|
<div className="border-t border-border px-5 py-3">
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
@@ -331,7 +410,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Navigation */}
|
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
@@ -347,7 +425,6 @@ export default function SettingsPage() {
|
|||||||
// Desktop layout
|
// Desktop layout
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh bg-background">
|
<div className="flex h-dvh bg-background">
|
||||||
{/* Navigation Rail */}
|
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
collapsed
|
collapsed
|
||||||
@@ -366,7 +443,6 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
{!inlineApp && (
|
{!inlineApp && (
|
||||||
<>
|
<>
|
||||||
{/* Settings Sidebar */}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-secondary flex flex-col",
|
"border-r border-border bg-secondary flex flex-col",
|
||||||
@@ -374,7 +450,6 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
style={{ width: `${settingsSidebarWidth}px` }}
|
style={{ width: `${settingsSidebarWidth}px` }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<div className="p-4 border-b border-border">
|
<div className="p-4 border-b border-border">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -387,7 +462,6 @@ export default function SettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
||||||
<div className="px-2 space-y-0.5">
|
<div className="px-2 space-y-0.5">
|
||||||
{groupedTabs.map((group, groupIndex) => (
|
{groupedTabs.map((group, groupIndex) => (
|
||||||
@@ -406,14 +480,14 @@ export default function SettingsPage() {
|
|||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(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-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
activeTab === 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'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className={cn(
|
<Icon className={cn(
|
||||||
'w-4 h-4 shrink-0',
|
'w-4 h-4 shrink-0',
|
||||||
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
effectiveActiveTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||||
)} />
|
)} />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
{tab.experimental && (
|
{tab.experimental && (
|
||||||
@@ -430,7 +504,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar resize handle */}
|
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
|
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
|
||||||
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||||
@@ -441,10 +514,8 @@ export default function SettingsPage() {
|
|||||||
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
|
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Settings Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="max-w-3xl mx-auto p-8">
|
<div className="max-w-3xl mx-auto p-8">
|
||||||
{/* Page Header */}
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-2.5 mb-2">
|
<div className="flex items-center gap-2.5 mb-2">
|
||||||
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
|
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
|
||||||
@@ -452,7 +523,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Tab Content */}
|
|
||||||
<div className="bg-card border border-border rounded-lg p-6">
|
<div className="bg-card border border-border rounded-lg p-6">
|
||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+170
-4
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Save, Loader2, RotateCcw } from 'lucide-react';
|
import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@@ -19,7 +20,7 @@ export default function AdminAuthPage() {
|
|||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/admin/config');
|
const res = await apiFetch('/api/admin/config');
|
||||||
if (res.ok) setConfig(await res.json());
|
if (res.ok) setConfig(await res.json());
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -39,7 +40,7 @@ export default function AdminAuthPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(edits),
|
body: JSON.stringify(edits),
|
||||||
@@ -57,7 +58,7 @@ export default function AdminAuthPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevert(key: string) {
|
async function handleRevert(key: string) {
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key }),
|
body: JSON.stringify({ key }),
|
||||||
@@ -68,6 +69,58 @@ export default function AdminAuthPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [setupRunning, setSetupRunning] = useState(false);
|
||||||
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
|
const [setupOrigin, setSetupOrigin] = useState('');
|
||||||
|
const [setupIssuer, setSetupIssuer] = useState('');
|
||||||
|
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
|
||||||
|
|
||||||
|
function openSetupDialog() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const origin = window.location.origin;
|
||||||
|
const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
|
||||||
|
setSetupOrigin(origin);
|
||||||
|
setSetupIssuer(jmapUrl || origin);
|
||||||
|
setSetupOauthOnly(currentValue('oauthOnly') === true);
|
||||||
|
setSetupOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAutoSetup() {
|
||||||
|
setSetupRunning(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/oauth/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
origin: setupOrigin.trim().replace(/\/+$/, ''),
|
||||||
|
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
|
||||||
|
oauthOnly: setupOauthOnly,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
|
||||||
|
});
|
||||||
|
setEdits({});
|
||||||
|
setSetupOpen(false);
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
|
||||||
|
setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
|
||||||
|
} finally {
|
||||||
|
setSetupRunning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
|
||||||
|
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
|
||||||
|
|
||||||
const hasEdits = Object.keys(edits).length > 0;
|
const hasEdits = Object.keys(edits).length > 0;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -99,6 +152,119 @@ export default function AdminAuthPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Auto-setup */}
|
||||||
|
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
|
||||||
|
Requires your Stalwart account to have admin permissions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openSetupDialog}
|
||||||
|
disabled={setupRunning}
|
||||||
|
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
{setupRunning ? 'Configuring…' : 'Set up automagically'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-setup dialog */}
|
||||||
|
{setupOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="oauth-setup-title"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
|
||||||
|
<div className="px-5 py-4 border-b border-border">
|
||||||
|
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
|
||||||
|
Webmail origin
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setup-origin"
|
||||||
|
type="url"
|
||||||
|
value={setupOrigin}
|
||||||
|
onChange={(e) => setSetupOrigin(e.target.value)}
|
||||||
|
disabled={setupRunning}
|
||||||
|
placeholder="https://webmail.example.com"
|
||||||
|
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback</code>) on Stalwart.
|
||||||
|
</p>
|
||||||
|
{!setupOriginValid && setupOrigin.length > 0 && (
|
||||||
|
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
|
||||||
|
Stalwart issuer URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setup-issuer"
|
||||||
|
type="url"
|
||||||
|
value={setupIssuer}
|
||||||
|
onChange={(e) => setSetupIssuer(e.target.value)}
|
||||||
|
disabled={setupRunning}
|
||||||
|
placeholder="https://mail.example.com"
|
||||||
|
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
|
||||||
|
</p>
|
||||||
|
{!setupIssuerValid && setupIssuer.length > 0 && (
|
||||||
|
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={setupOauthOnly}
|
||||||
|
onChange={(e) => setSetupOauthOnly(e.target.checked)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-input"
|
||||||
|
disabled={setupRunning}
|
||||||
|
/>
|
||||||
|
Also enable “OAuth only” (hide password login)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setSetupOpen(false)}
|
||||||
|
disabled={setupRunning}
|
||||||
|
className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAutoSetup}
|
||||||
|
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
{setupRunning ? 'Configuring…' : 'Configure'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* OAuth */}
|
{/* OAuth */}
|
||||||
<Section title="OAuth / OpenID Connect">
|
<Section title="OAuth / OpenID Connect">
|
||||||
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@@ -38,7 +39,7 @@ export default function AdminBrandingPage() {
|
|||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/admin/config');
|
const res = await apiFetch('/api/admin/config');
|
||||||
if (res.ok) setConfig(await res.json());
|
if (res.ok) setConfig(await res.json());
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -58,7 +59,7 @@ export default function AdminBrandingPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(edits),
|
body: JSON.stringify(edits),
|
||||||
@@ -83,7 +84,7 @@ export default function AdminBrandingPage() {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('slot', slot);
|
formData.append('slot', slot);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/branding', {
|
const res = await apiFetch('/api/admin/branding', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -112,7 +113,7 @@ export default function AdminBrandingPage() {
|
|||||||
async function handleDeleteUpload(slot: string) {
|
async function handleDeleteUpload(slot: string) {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/branding', {
|
const res = await apiFetch('/api/admin/branding', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ slot }),
|
body: JSON.stringify({ slot }),
|
||||||
@@ -133,7 +134,7 @@ export default function AdminBrandingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevert(key: string) {
|
async function handleRevert(key: string) {
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key }),
|
body: JSON.stringify({ key }),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Lock } from 'lucide-react';
|
import { Lock } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
export default function ChangePasswordPage() {
|
export default function ChangePasswordPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -28,7 +29,7 @@ export default function ChangePasswordPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/admin/change-password', {
|
const res = await apiFetch('/api/admin/change-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ currentPassword, newPassword }),
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
|||||||
+61
-49
@@ -14,6 +14,7 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
|
Activity,
|
||||||
Mail,
|
Mail,
|
||||||
Calendar,
|
Calendar,
|
||||||
BookUser,
|
BookUser,
|
||||||
@@ -27,6 +28,7 @@ import { useThemeStore } from '@/stores/theme-store';
|
|||||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
const NAV_GROUPS = [
|
const NAV_GROUPS = [
|
||||||
{
|
{
|
||||||
@@ -55,6 +57,7 @@ const NAV_GROUPS = [
|
|||||||
{
|
{
|
||||||
label: 'System',
|
label: 'System',
|
||||||
items: [
|
items: [
|
||||||
|
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
|
||||||
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -64,6 +67,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||||
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
@@ -72,57 +76,62 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pathname !== '/admin/login') {
|
if (pathname === '/admin/login') return;
|
||||||
checkAuth();
|
let cancelled = false;
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
function getJmapHeaders(): Record<string, string> {
|
async function checkAuth() {
|
||||||
return getActiveAccountSlotHeaders();
|
try {
|
||||||
}
|
const jmapHeaders = getActiveAccountSlotHeaders();
|
||||||
|
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
|
||||||
|
const data = await res.json();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
async function checkAuth() {
|
const stalwartAdmin = data.stalwartAdmin === true;
|
||||||
try {
|
setIsStalwartAdmin(stalwartAdmin);
|
||||||
const jmapHeaders = getJmapHeaders();
|
|
||||||
const res = await fetch('/api/admin/auth', { headers: jmapHeaders });
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
const stalwartAdmin = data.stalwartAdmin === true;
|
// If neither password-based admin nor Stalwart admin, redirect away
|
||||||
setIsStalwartAdmin(stalwartAdmin);
|
if (!data.enabled && !stalwartAdmin) {
|
||||||
|
router.replace('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If neither password-based admin nor Stalwart admin, redirect away
|
if (data.authenticated) {
|
||||||
if (!data.enabled && !stalwartAdmin) {
|
|
||||||
router.replace('/');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.authenticated) {
|
|
||||||
setAuthenticated(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If Stalwart admin but not yet authenticated, auto-login
|
|
||||||
if (stalwartAdmin) {
|
|
||||||
const loginRes = await fetch('/api/admin/auth', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
|
||||||
body: JSON.stringify({ stalwartAuth: true }),
|
|
||||||
});
|
|
||||||
if (loginRes.ok) {
|
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
router.replace('/admin/login');
|
// If Stalwart admin but not yet authenticated, auto-login
|
||||||
} catch {
|
if (stalwartAdmin) {
|
||||||
router.replace('/admin/login');
|
const loginRes = await apiFetch('/api/admin/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
||||||
|
body: JSON.stringify({ stalwartAuth: true }),
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (loginRes.ok) {
|
||||||
|
setAuthenticated(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await loginRes.json().catch(() => ({}));
|
||||||
|
setAuthError(body?.error || `Admin auto-login failed (HTTP ${loginRes.status})`);
|
||||||
|
setAuthenticated(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace('/admin/login');
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
|
||||||
|
setAuthenticated(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
checkAuth();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [pathname, router]);
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await fetch('/api/admin/auth', { method: 'DELETE' });
|
await apiFetch('/api/admin/auth', { method: 'DELETE' });
|
||||||
router.replace('/admin/login');
|
router.replace('/admin/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,14 +140,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authenticated === null) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
||||||
<div className="animate-pulse text-muted-foreground text-sm">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-background">
|
<div className="min-h-screen flex bg-background">
|
||||||
{/* Slim webmail nav rail */}
|
{/* Slim webmail nav rail */}
|
||||||
@@ -268,7 +269,18 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 overflow-auto">
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
{children}
|
{authError ? (
|
||||||
|
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
<p className="font-medium">Admin authentication failed</p>
|
||||||
|
<p className="mt-1 text-destructive/80">{authError}</p>
|
||||||
|
</div>
|
||||||
|
) : authenticated === null ? (
|
||||||
|
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
|
||||||
|
Loading admin panel…
|
||||||
|
</div>
|
||||||
|
) : authenticated ? (
|
||||||
|
children
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { Shield } from 'lucide-react';
|
import { Shield } from 'lucide-react';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -21,7 +22,7 @@ export default function AdminLoginPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/auth', {
|
const res = await apiFetch('/api/admin/auth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { RefreshCw } from 'lucide-react';
|
||||||
import type { AuditEntry } from '@/lib/admin/types';
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
export default function AdminLogsPage() {
|
export default function AdminLogsPage() {
|
||||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||||
@@ -17,7 +18,7 @@ export default function AdminLogsPage() {
|
|||||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
if (actionFilter) params.set('action', actionFilter);
|
if (actionFilter) params.set('action', actionFilter);
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/audit?${params}`);
|
const res = await apiFetch(`/api/admin/audit?${params}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setEntries(data.entries || []);
|
setEntries(data.entries || []);
|
||||||
@@ -138,7 +139,7 @@ export default function AdminLogsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDetail(detail: Record<string, unknown>): string {
|
function formatDetail(detail: Record<string, unknown>): string {
|
||||||
if (!detail || Object.keys(detail).length === 0) return '—';
|
if (!detail || Object.keys(detail).length === 0) return '-';
|
||||||
if (detail.reason) return String(detail.reason);
|
if (detail.reason) return String(detail.reason);
|
||||||
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
||||||
if (detail.changes && Array.isArray(detail.changes)) {
|
if (detail.changes && Array.isArray(detail.changes)) {
|
||||||
|
|||||||
@@ -0,0 +1,542 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Download,
|
||||||
|
Loader2,
|
||||||
|
Puzzle,
|
||||||
|
SwatchBook,
|
||||||
|
Star,
|
||||||
|
Trash2,
|
||||||
|
Check,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
ExternalLink,
|
||||||
|
Shield,
|
||||||
|
AlertTriangle,
|
||||||
|
FileCode,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface PreviewData {
|
||||||
|
extension: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
type: 'plugin' | 'theme';
|
||||||
|
pluginType: string | null;
|
||||||
|
description: string;
|
||||||
|
longDescription: string | null;
|
||||||
|
tags: string[];
|
||||||
|
permissions: string[];
|
||||||
|
totalDownloads: number;
|
||||||
|
featured: boolean;
|
||||||
|
githubRepo: string | null;
|
||||||
|
license: string | null;
|
||||||
|
minAppVersion: string | null;
|
||||||
|
author: {
|
||||||
|
displayName: string;
|
||||||
|
githubLogin: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
verified?: boolean;
|
||||||
|
} | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
versions: Array<{
|
||||||
|
version: string;
|
||||||
|
changelog: string | null;
|
||||||
|
bundleSize: number;
|
||||||
|
minAppVersion: string | null;
|
||||||
|
publishedAt: string | null;
|
||||||
|
permissions: string[];
|
||||||
|
}>;
|
||||||
|
screenshots: Array<{ url: string; altText: string | null }>;
|
||||||
|
themePreviews: Array<{
|
||||||
|
variant: 'light' | 'dark';
|
||||||
|
previewPath: string;
|
||||||
|
colors: Record<string, string> | null;
|
||||||
|
}>;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
bundle: {
|
||||||
|
manifest: Record<string, unknown> | null;
|
||||||
|
source: { name: string; content: string; truncated: boolean } | null;
|
||||||
|
size: number;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
installed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RISKY_PERMISSIONS = new Set([
|
||||||
|
'mail:write',
|
||||||
|
'mail:delete',
|
||||||
|
'storage:write',
|
||||||
|
'network',
|
||||||
|
'admin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default function MarketplacePreviewPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const slug = params.slug as string;
|
||||||
|
|
||||||
|
const [data, setData] = useState<PreviewData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [installing, setInstalling] = useState(false);
|
||||||
|
const [uninstalling, setUninstalling] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [showSource, setShowSource] = useState(false);
|
||||||
|
const [showManifest, setShowManifest] = useState(false);
|
||||||
|
|
||||||
|
const fetchPreview = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/admin/marketplace/${encodeURIComponent(slug)}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
setError(body.error || 'Failed to load preview');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setData(await res.json());
|
||||||
|
} catch {
|
||||||
|
setError('Failed to connect to extension directory');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPreview(); }, [fetchPreview]);
|
||||||
|
|
||||||
|
async function handleInstall() {
|
||||||
|
if (!data) return;
|
||||||
|
setInstalling(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/marketplace', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: data.extension.slug,
|
||||||
|
version: data.extension.latestVersion || '1.0.0',
|
||||||
|
type: data.extension.type,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
|
||||||
|
setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` });
|
||||||
|
setData(prev => prev ? { ...prev, installed: true } : prev);
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: body.error || 'Installation failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||||
|
} finally {
|
||||||
|
setInstalling(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUninstall() {
|
||||||
|
if (!data) return;
|
||||||
|
if (!confirm(`Remove "${data.extension.name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
setUninstalling(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const endpoint = data.extension.type === 'theme'
|
||||||
|
? '/api/admin/themes'
|
||||||
|
: '/api/admin/plugins';
|
||||||
|
const res = await apiFetch(endpoint, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: data.extension.slug }),
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: `"${data.extension.name}" removed` });
|
||||||
|
setData(prev => prev ? { ...prev, installed: false } : prev);
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: body.error || 'Uninstall failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Uninstall failed - network error' });
|
||||||
|
} finally {
|
||||||
|
setUninstalling(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Link
|
||||||
|
href="/admin/marketplace"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-destructive">{error || 'Extension not found'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = data.extension;
|
||||||
|
const bundle = data.bundle;
|
||||||
|
const isPlugin = ext.type === 'plugin';
|
||||||
|
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
|
||||||
|
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
|
||||||
|
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
{/* Back link */}
|
||||||
|
<Link
|
||||||
|
href="/admin/marketplace"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||||
|
{isPlugin ? (
|
||||||
|
<Puzzle className="w-7 h-7 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<SwatchBook className="w-7 h-7 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground truncate">{ext.name}</h1>
|
||||||
|
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
|
||||||
|
{data.installed && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium">
|
||||||
|
<Check className="w-3 h-3" /> Installed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||||
|
isPlugin
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
|
||||||
|
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
|
||||||
|
}`}>
|
||||||
|
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}
|
||||||
|
</span>
|
||||||
|
{ext.author && (
|
||||||
|
<span>by {ext.author.displayName}</span>
|
||||||
|
)}
|
||||||
|
{ext.latestVersion && <span>v{ext.latestVersion}</span>}
|
||||||
|
{ext.license && <span>{ext.license}</span>}
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
{ext.totalDownloads.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{data.installed ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<SettingsIcon className="w-4 h-4" />
|
||||||
|
Manage
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleUninstall}
|
||||||
|
disabled={uninstalling}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{uninstalling ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||||
|
Uninstall
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
disabled={installing || !!bundle.error}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bundle.error && (
|
||||||
|
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Could not preview bundle</p>
|
||||||
|
<p className="text-xs mt-0.5 opacity-90">{bundle.error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">About</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">{ext.description}</p>
|
||||||
|
{ext.longDescription && ext.longDescription !== ext.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-3 whitespace-pre-wrap">{ext.longDescription}</p>
|
||||||
|
)}
|
||||||
|
{ext.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-3">
|
||||||
|
{ext.tags.map(tag => (
|
||||||
|
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-4 pt-3 border-t border-border flex-wrap">
|
||||||
|
{ext.minAppVersion && <span>Requires app v{ext.minAppVersion}+</span>}
|
||||||
|
{bundle.size > 0 && <span>Bundle: {(bundle.size / 1024).toFixed(1)} KB</span>}
|
||||||
|
{ext.githubRepo && (
|
||||||
|
<a
|
||||||
|
href={`https://github.com/${ext.githubRepo}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
{ext.githubRepo}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Screenshots */}
|
||||||
|
{ext.screenshots.length > 0 && (
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Screenshots</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||||
|
{ext.screenshots.map((s, i) => (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={s.url}
|
||||||
|
alt={s.altText || `Screenshot ${i + 1}`}
|
||||||
|
className="w-full rounded-md border border-border bg-muted"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Theme color preview */}
|
||||||
|
{!isPlugin && ext.themePreviews.length > 0 && (
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Theme preview</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||||
|
{ext.themePreviews.map(preview => (
|
||||||
|
<ThemeColorSwatch key={preview.variant} preview={preview} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Permissions */}
|
||||||
|
{isPlugin && (
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Permissions</h2>
|
||||||
|
</div>
|
||||||
|
{manifestPerms.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">This plugin requests no permissions.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 space-y-1.5">
|
||||||
|
{manifestPerms.map(perm => {
|
||||||
|
const risky = RISKY_PERMISSIONS.has(perm);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={perm}
|
||||||
|
className={`flex items-center gap-2 text-sm rounded-md px-2 py-1 ${
|
||||||
|
risky
|
||||||
|
? 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300'
|
||||||
|
: 'bg-muted/50 text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{risky && <AlertTriangle className="w-3.5 h-3.5 shrink-0" />}
|
||||||
|
<code className="font-mono text-xs">{perm}</code>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{frameOrigins.length > 0 && (
|
||||||
|
<div className="mt-4 pt-3 border-t border-border">
|
||||||
|
<h3 className="text-xs font-medium text-foreground">Iframe origins</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
The plugin will be allowed to embed content from these origins.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{frameOrigins.map(origin => (
|
||||||
|
<li key={origin} className="text-xs font-mono text-foreground bg-muted/50 px-2 py-1 rounded">
|
||||||
|
{origin}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings schema preview */}
|
||||||
|
{isPlugin && settingsSchema && Object.keys(settingsSchema).length > 0 && (
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">User settings</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Settings users will be able to configure after install.</p>
|
||||||
|
<ul className="mt-3 divide-y divide-border">
|
||||||
|
{Object.entries(settingsSchema).map(([key, field]) => (
|
||||||
|
<li key={key} className="py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-foreground">{key}</code>
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{field.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-foreground mt-0.5">{field.label}</div>
|
||||||
|
{field.description && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">{field.description}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Source / manifest disclosure */}
|
||||||
|
{bundle.manifest && (
|
||||||
|
<section className="border border-border rounded-lg">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">manifest.json</h2>
|
||||||
|
</div>
|
||||||
|
{showManifest ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||||
|
</button>
|
||||||
|
{showManifest && (
|
||||||
|
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre">
|
||||||
|
{JSON.stringify(bundle.manifest, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bundle.source && (
|
||||||
|
<section className="border border-border rounded-lg">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">{bundle.source.name}</h2>
|
||||||
|
{bundle.source.truncated && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">truncated</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showSource ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||||
|
</button>
|
||||||
|
{showSource && (
|
||||||
|
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre max-h-[600px] overflow-y-auto">
|
||||||
|
{bundle.source.content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Version history */}
|
||||||
|
{ext.versions.length > 0 && (
|
||||||
|
<section className="border border-border rounded-lg p-4">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Version history</h2>
|
||||||
|
<ul className="mt-3 divide-y divide-border">
|
||||||
|
{ext.versions.slice(0, 5).map(v => (
|
||||||
|
<li key={v.version} className="py-2 flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-foreground">v{v.version}</code>
|
||||||
|
{v.publishedAt && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(v.publishedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{v.changelog && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 whitespace-pre-wrap">{v.changelog}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{(v.bundleSize / 1024).toFixed(1)} KB
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record<string, string> | null } }) {
|
||||||
|
const colors = preview.colors || {};
|
||||||
|
const bg = colors.background || (preview.variant === 'dark' ? '#0f0f10' : '#ffffff');
|
||||||
|
const fg = colors.foreground || (preview.variant === 'dark' ? '#fafafa' : '#0a0a0a');
|
||||||
|
const accent = colors.primary || colors.accent || '#7c5cff';
|
||||||
|
const muted = colors.muted || (preview.variant === 'dark' ? '#1a1a1c' : '#f5f5f5');
|
||||||
|
const border = colors.border || (preview.variant === 'dark' ? '#27272a' : '#e5e5e5');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border overflow-hidden">
|
||||||
|
<div className="px-3 py-2 text-xs font-medium text-muted-foreground bg-muted/30 border-b border-border capitalize">
|
||||||
|
{preview.variant}
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-2" style={{ background: bg, color: fg }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-block w-6 h-6 rounded" style={{ background: accent }} />
|
||||||
|
<span className="text-sm font-medium" style={{ color: fg }}>Sample text</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded p-2 text-xs" style={{ background: muted, border: `1px solid ${border}` }}>
|
||||||
|
<span style={{ color: fg }}>Card surface</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{Object.entries(colors).slice(0, 6).map(([key, value]) => (
|
||||||
|
<span
|
||||||
|
key={key}
|
||||||
|
title={`${key}: ${value}`}
|
||||||
|
className="inline-block w-4 h-4 rounded border"
|
||||||
|
style={{ background: value, borderColor: border }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
|
import Link from 'next/link';
|
||||||
|
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface Extension {
|
interface Extension {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -57,7 +59,7 @@ export default function AdminMarketplacePage() {
|
|||||||
params.set('perPage', String(perPage));
|
params.set('perPage', String(perPage));
|
||||||
params.set('sort', 'newest');
|
params.set('sort', 'newest');
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/marketplace?${params}`);
|
const res = await apiFetch(`/api/admin/marketplace?${params}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
setError(data.error || 'Failed to connect to extension directory');
|
setError(data.error || 'Failed to connect to extension directory');
|
||||||
@@ -95,7 +97,7 @@ export default function AdminMarketplacePage() {
|
|||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/marketplace', {
|
const res = await apiFetch('/api/admin/marketplace', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -116,7 +118,7 @@ export default function AdminMarketplacePage() {
|
|||||||
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: 'error', text: 'Installation failed — network error' });
|
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||||
} finally {
|
} finally {
|
||||||
setInstalling(null);
|
setInstalling(null);
|
||||||
}
|
}
|
||||||
@@ -261,10 +263,11 @@ function ExtensionCard({
|
|||||||
onInstall: () => void;
|
onInstall: () => void;
|
||||||
}) {
|
}) {
|
||||||
const isPlugin = extension.type === 'plugin';
|
const isPlugin = extension.type === 'plugin';
|
||||||
|
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||||
<div className="p-4">
|
<Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0">
|
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0">
|
||||||
@@ -276,7 +279,9 @@ function ExtensionCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm font-medium text-foreground truncate">{extension.name}</span>
|
<span className="text-sm font-medium text-foreground truncate group-hover:underline">
|
||||||
|
{extension.name}
|
||||||
|
</span>
|
||||||
{extension.featured && (
|
{extension.featured && (
|
||||||
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
|
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
|
||||||
)}
|
)}
|
||||||
@@ -314,7 +319,7 @@ function ExtensionCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer (download count + permissions) */}
|
||||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
@@ -327,27 +332,34 @@ function ExtensionCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground">
|
||||||
{extension.installed ? (
|
<Eye className="w-3 h-3" />
|
||||||
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
|
Preview
|
||||||
<Check className="w-3 h-3" />
|
</span>
|
||||||
Installed
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={onInstall}
|
|
||||||
disabled={installing}
|
|
||||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{installing ? (
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
Install
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Quick install button (sits over the link, stops navigation) */}
|
||||||
|
<div className="px-4 pb-4 -mt-1">
|
||||||
|
{extension.installed ? (
|
||||||
|
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
Installed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||||
|
disabled={installing}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{installing ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Quick install
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+32
-13
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||||
import type { AuditEntry } from '@/lib/admin/types';
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface AdminStatus {
|
interface AdminStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -30,6 +31,7 @@ export default function AdminDashboardPage() {
|
|||||||
const [pluginCount, setPluginCount] = useState(0);
|
const [pluginCount, setPluginCount] = useState(0);
|
||||||
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 [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,14 +39,15 @@ export default function AdminDashboardPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function fetchDashboardData() {
|
async function fetchDashboardData() {
|
||||||
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
|
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes, telemetryRes] = await Promise.all([
|
||||||
fetch('/api/admin/auth'),
|
apiFetch('/api/admin/auth'),
|
||||||
fetch('/api/admin/audit?limit=10'),
|
apiFetch('/api/admin/audit?limit=10'),
|
||||||
fetch('/api/config'),
|
apiFetch('/api/config'),
|
||||||
fetch('/api/admin/config'),
|
apiFetch('/api/admin/config'),
|
||||||
fetch('/api/admin/plugins').catch(() => null),
|
apiFetch('/api/admin/plugins').catch(() => null),
|
||||||
fetch('/api/admin/themes').catch(() => null),
|
apiFetch('/api/admin/themes').catch(() => null),
|
||||||
fetch('/api/admin/policy').catch(() => null),
|
apiFetch('/api/admin/policy').catch(() => null),
|
||||||
|
apiFetch('/api/admin/telemetry').catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (statusRes.ok) setStatus(await statusRes.json());
|
if (statusRes.ok) setStatus(await statusRes.json());
|
||||||
@@ -72,10 +75,16 @@ export default function AdminDashboardPage() {
|
|||||||
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
|
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
|
||||||
setPolicyRuleCount(restrictionCount + disabledGates);
|
setPolicyRuleCount(restrictionCount + disabledGates);
|
||||||
}
|
}
|
||||||
|
if (telemetryRes?.ok) {
|
||||||
|
const telemetry = await telemetryRes.json();
|
||||||
|
if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') {
|
||||||
|
setAccountCounts(telemetry.accountCounts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (configData?.jmapServerUrl) {
|
if (configData?.jmapServerUrl) {
|
||||||
try {
|
try {
|
||||||
const jmapRes = await fetch('/api/config');
|
const jmapRes = await apiFetch('/api/config');
|
||||||
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
||||||
} catch {
|
} catch {
|
||||||
setJmapHealth('error');
|
setJmapHealth('error');
|
||||||
@@ -98,8 +107,8 @@ export default function AdminDashboardPage() {
|
|||||||
setWarnings(w);
|
setWarnings(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
const jmapUrl = config?.jmapServerUrl || '—';
|
const jmapUrl = config?.jmapServerUrl || '-';
|
||||||
const jmapHostname = jmapUrl !== '—' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '—';
|
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl space-y-8">
|
<div className="max-w-3xl space-y-8">
|
||||||
@@ -126,9 +135,9 @@ export default function AdminDashboardPage() {
|
|||||||
{/* Server Info */}
|
{/* Server Info */}
|
||||||
<SettingsSection title="Server" description="Application and connection details">
|
<SettingsSection title="Server" description="Application and connection details">
|
||||||
<SettingItem label="Application">
|
<SettingItem label="Application">
|
||||||
<span className="text-sm text-foreground">{config?.appName || '—'}</span>
|
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
<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">
|
<SettingItem label="JMAP Connection">
|
||||||
@@ -164,6 +173,16 @@ export default function AdminDashboardPage() {
|
|||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Accounts */}
|
||||||
|
<SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days">
|
||||||
|
<SettingItem label="Total accounts" description="Distinct identities seen in the retention window">
|
||||||
|
<span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Active in last 7 days" description="Identities with a login in the past week">
|
||||||
|
<span className="text-sm text-foreground">{accountCounts?.active7d ?? '-'}</span>
|
||||||
|
</SettingItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Extensions */}
|
{/* Extensions */}
|
||||||
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
|
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
|
||||||
<SettingItem label="Plugins">
|
<SettingItem label="Plugins">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigField {
|
interface ConfigField {
|
||||||
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||||
@@ -68,8 +69,8 @@ export default function PluginConfigPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [pluginsRes, configRes] = await Promise.all([
|
const [pluginsRes, configRes] = await Promise.all([
|
||||||
fetch('/api/admin/plugins'),
|
apiFetch('/api/admin/plugins'),
|
||||||
fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (pluginsRes.ok) {
|
if (pluginsRes.ok) {
|
||||||
@@ -117,7 +118,7 @@ export default function PluginConfigPage() {
|
|||||||
|
|
||||||
// Delete if clearing a non-required field
|
// Delete if clearing a non-required field
|
||||||
if (!newVal && !field.required) {
|
if (!newVal && !field.required) {
|
||||||
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key }),
|
body: JSON.stringify({ key }),
|
||||||
@@ -130,7 +131,7 @@ export default function PluginConfigPage() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key, value }),
|
body: JSON.stringify({ key, value }),
|
||||||
@@ -230,7 +231,7 @@ export default function PluginConfigPage() {
|
|||||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="">— Select —</option>
|
<option value="">- Select -</option>
|
||||||
{field.options.map(opt => (
|
{field.options.map(opt => (
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
|||||||
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface PluginEntry {
|
interface PluginEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,7 +35,7 @@ export default function AdminPluginsPage() {
|
|||||||
|
|
||||||
async function fetchPolicy() {
|
async function fetchPolicy() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy');
|
const res = await apiFetch('/api/admin/policy');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setPolicy(data);
|
setPolicy(data);
|
||||||
@@ -73,7 +74,7 @@ export default function AdminPluginsPage() {
|
|||||||
setSavingPolicy(true);
|
setSavingPolicy(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy', {
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(policy),
|
body: JSON.stringify(policy),
|
||||||
@@ -95,7 +96,7 @@ export default function AdminPluginsPage() {
|
|||||||
async function fetchPlugins() {
|
async function fetchPlugins() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/plugins');
|
const res = await apiFetch('/api/admin/plugins');
|
||||||
if (res.ok) setPlugins(await res.json());
|
if (res.ok) setPlugins(await res.json());
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -113,7 +114,7 @@ export default function AdminPluginsPage() {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -136,7 +137,7 @@ export default function AdminPluginsPage() {
|
|||||||
|
|
||||||
async function togglePlugin(id: string, enabled: boolean) {
|
async function togglePlugin(id: string, enabled: boolean) {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id, enabled }),
|
body: JSON.stringify({ id, enabled }),
|
||||||
@@ -156,7 +157,7 @@ export default function AdminPluginsPage() {
|
|||||||
const body: Record<string, unknown> = { id, forceEnabled };
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
if (forceEnabled) body.enabled = true;
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -190,7 +191,7 @@ export default function AdminPluginsPage() {
|
|||||||
}
|
}
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const p of disabled) {
|
for (const p of disabled) {
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: p.id, enabled: true }),
|
body: JSON.stringify({ id: p.id, enabled: true }),
|
||||||
@@ -216,7 +217,7 @@ export default function AdminPluginsPage() {
|
|||||||
}
|
}
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const p of enabled) {
|
for (const p of enabled) {
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: p.id, enabled: false }),
|
body: JSON.stringify({ id: p.id, enabled: false }),
|
||||||
@@ -236,7 +237,7 @@ export default function AdminPluginsPage() {
|
|||||||
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await fetch('/api/admin/plugins', {
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id }),
|
body: JSON.stringify({ id }),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Save, Loader2, Lock } from 'lucide-react';
|
import { Save, Loader2, Lock } from 'lucide-react';
|
||||||
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
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';
|
||||||
|
|
||||||
// Feature gates managed on their own admin pages (excluded from this list)
|
// Feature gates managed on their own admin pages (excluded from this list)
|
||||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
||||||
@@ -14,6 +15,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
|||||||
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' },
|
||||||
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' },
|
||||||
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
||||||
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
|
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
|
||||||
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||||
@@ -54,7 +56,7 @@ export default function AdminPolicyPage() {
|
|||||||
async function fetchPolicy() {
|
async function fetchPolicy() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy');
|
const res = await apiFetch('/api/admin/policy');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setPolicy(data);
|
setPolicy(data);
|
||||||
@@ -106,7 +108,7 @@ export default function AdminPolicyPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/policy', {
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(policy),
|
body: JSON.stringify(policy),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@@ -21,7 +22,7 @@ export default function AdminSettingsPage() {
|
|||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch('/api/admin/config');
|
const res = await apiFetch('/api/admin/config');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setConfig(await res.json());
|
setConfig(await res.json());
|
||||||
}
|
}
|
||||||
@@ -43,7 +44,7 @@ export default function AdminSettingsPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(edits),
|
body: JSON.stringify(edits),
|
||||||
@@ -61,7 +62,7 @@ export default function AdminSettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevert(key: string) {
|
async function handleRevert(key: string) {
|
||||||
const res = await fetch('/api/admin/config', {
|
const res = await apiFetch('/api/admin/config', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key }),
|
body: JSON.stringify({ key }),
|
||||||
@@ -121,7 +122,6 @@ export default function AdminSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
|
|
||||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface TelemetryStatus {
|
||||||
|
consent: 'pending' | 'on' | 'off';
|
||||||
|
consentSource: 'env' | 'file';
|
||||||
|
endpoint: string;
|
||||||
|
defaultEndpoint: string;
|
||||||
|
consentedAt: string | null;
|
||||||
|
lastSentAt: string | null;
|
||||||
|
nextScheduledAt: string | null;
|
||||||
|
payloadPreview: Record<string, unknown>;
|
||||||
|
accountCounts: { total: number; active7d: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso: string | null): string {
|
||||||
|
if (!iso) return 'never';
|
||||||
|
const d = Date.now() - new Date(iso).getTime();
|
||||||
|
if (d < 0) return new Date(iso).toLocaleString();
|
||||||
|
const m = Math.floor(d / 60000);
|
||||||
|
if (m < 1) return 'just now';
|
||||||
|
if (m < 60) return `${m} min ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 48) return `${h} hours ago`;
|
||||||
|
const days = Math.floor(h / 24);
|
||||||
|
return `${days} days ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminTelemetryPage() {
|
||||||
|
const [status, setStatus] = useState<TelemetryStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [endpointDraft, setEndpointDraft] = useState('');
|
||||||
|
const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/telemetry');
|
||||||
|
if (!r.ok) throw new Error('failed to load');
|
||||||
|
const data = (await r.json()) as TelemetryStatus;
|
||||||
|
setStatus(data);
|
||||||
|
setEndpointDraft(data.endpoint);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { void refresh(); }, []);
|
||||||
|
|
||||||
|
async function setConsent(consent: 'on' | 'off'): Promise<void> {
|
||||||
|
setBusy('consent');
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/telemetry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'set-consent', consent }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||||
|
alert(j.error ?? 'failed');
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEndpoint(): Promise<void> {
|
||||||
|
setBusy('endpoint');
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/telemetry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||||
|
alert(j.error ?? 'failed');
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNow(): Promise<void> {
|
||||||
|
setBusy('send');
|
||||||
|
setSendResult(null);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/telemetry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'send-now' }),
|
||||||
|
});
|
||||||
|
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string };
|
||||||
|
setSendResult({
|
||||||
|
ok: !!j.ok,
|
||||||
|
msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`,
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !status) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envOverridden = status.consentSource === 'env';
|
||||||
|
const isOn = status.consent === 'on';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-6 space-y-6">
|
||||||
|
<header className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Bulwark sends 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>;
|
||||||
|
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
|
||||||
|
<a
|
||||||
|
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Full schema and policy <ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Status</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
|
||||||
|
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
|
||||||
|
{status.consent === 'off' && 'Heartbeats are off.'}
|
||||||
|
{envOverridden && (
|
||||||
|
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'consent' || envOverridden || isOn}
|
||||||
|
onClick={() => void setConsent('on')}
|
||||||
|
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'consent' || envOverridden || status.consent === 'off'}
|
||||||
|
onClick={() => void setConsent('off')}
|
||||||
|
className="px-3 py-1.5 rounded-md border hover:bg-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-2 gap-2 text-sm pt-2 border-t">
|
||||||
|
<dt className="text-muted-foreground">Last sent</dt>
|
||||||
|
<dd>{timeAgo(status.lastSentAt)}</dd>
|
||||||
|
<dt className="text-muted-foreground">Next scheduled</dt>
|
||||||
|
<dd>{timeAgo(status.nextScheduledAt)}</dd>
|
||||||
|
<dt className="text-muted-foreground">Consented at</dt>
|
||||||
|
<dd>{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-2">
|
||||||
|
<div className="font-medium">Account activity</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Unique accounts that have logged in over the last 90 days. Identities are stored as a
|
||||||
|
per-instance HMAC, never as plaintext usernames. These are the numbers reported in the
|
||||||
|
heartbeat as bucketed ranges.
|
||||||
|
</p>
|
||||||
|
<dl className="grid grid-cols-2 gap-2 text-sm pt-1">
|
||||||
|
<dt className="text-muted-foreground">Total (90d)</dt>
|
||||||
|
<dd className="font-mono">{status.accountCounts?.total ?? 0}</dd>
|
||||||
|
<dt className="text-muted-foreground">Active (7d)</dt>
|
||||||
|
<dd className="font-mono">{status.accountCounts?.active7d ?? 0}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="font-medium">Endpoint</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Where heartbeats are sent. Defaults to the project's collector. Point at your own collector
|
||||||
|
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={endpointDraft}
|
||||||
|
onChange={(e) => setEndpointDraft(e.target.value)}
|
||||||
|
placeholder={status.defaultEndpoint}
|
||||||
|
className="flex-1 px-3 py-1.5 rounded-md border bg-background"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
|
||||||
|
onClick={() => void saveEndpoint()}
|
||||||
|
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" /> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Payload preview</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Exactly what the next heartbeat would send from this install, right now.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'send' || !isOn}
|
||||||
|
onClick={() => void sendNow()}
|
||||||
|
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" /> Send now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{sendResult && (
|
||||||
|
<div
|
||||||
|
className={`text-sm flex items-center gap-2 ${
|
||||||
|
sendResult.ok ? 'text-emerald-600' : 'text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sendResult.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||||
|
{sendResult.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
|
||||||
|
{JSON.stringify(status.payloadPreview, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react';
|
|||||||
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
||||||
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';
|
||||||
|
|
||||||
const BUILTIN_THEME_OPTIONS = [
|
const BUILTIN_THEME_OPTIONS = [
|
||||||
{ id: 'builtin-nord', name: 'Nord' },
|
{ id: 'builtin-nord', name: 'Nord' },
|
||||||
@@ -38,7 +39,7 @@ export default function AdminThemesPage() {
|
|||||||
|
|
||||||
async function fetchPolicy() {
|
async function fetchPolicy() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy');
|
const res = await apiFetch('/api/admin/policy');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setPolicy({
|
setPolicy({
|
||||||
@@ -122,7 +123,7 @@ export default function AdminThemesPage() {
|
|||||||
setSavingPolicy(true);
|
setSavingPolicy(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/policy', {
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(policy),
|
body: JSON.stringify(policy),
|
||||||
@@ -144,7 +145,7 @@ export default function AdminThemesPage() {
|
|||||||
async function fetchThemes() {
|
async function fetchThemes() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/themes');
|
const res = await apiFetch('/api/admin/themes');
|
||||||
if (res.ok) setThemes(await res.json());
|
if (res.ok) setThemes(await res.json());
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -162,7 +163,7 @@ export default function AdminThemesPage() {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -185,7 +186,7 @@ export default function AdminThemesPage() {
|
|||||||
|
|
||||||
async function toggleTheme(id: string, enabled: boolean) {
|
async function toggleTheme(id: string, enabled: boolean) {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id, enabled }),
|
body: JSON.stringify({ id, enabled }),
|
||||||
@@ -204,7 +205,7 @@ export default function AdminThemesPage() {
|
|||||||
const body: Record<string, unknown> = { id, forceEnabled };
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
if (forceEnabled) body.enabled = true;
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -237,7 +238,7 @@ export default function AdminThemesPage() {
|
|||||||
}
|
}
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const t of disabled) {
|
for (const t of disabled) {
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: t.id, enabled: true }),
|
body: JSON.stringify({ id: t.id, enabled: true }),
|
||||||
@@ -262,7 +263,7 @@ export default function AdminThemesPage() {
|
|||||||
}
|
}
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const t of enabled) {
|
for (const t of enabled) {
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: t.id, enabled: false }),
|
body: JSON.stringify({ id: t.id, enabled: false }),
|
||||||
@@ -282,7 +283,7 @@ export default function AdminThemesPage() {
|
|||||||
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const res = await fetch('/api/admin/themes', {
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id }),
|
body: JSON.stringify({ id }),
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse Stalwart error response to extract meaningful error message
|
|
||||||
*/
|
|
||||||
function parseStalwartError(responseText: string): string {
|
|
||||||
try {
|
|
||||||
const error = JSON.parse(responseText);
|
|
||||||
if (error.detail) return error.detail;
|
|
||||||
if (error.error) return error.error;
|
|
||||||
return `HTTP ${error.status || 'Error'}`;
|
|
||||||
} catch {
|
|
||||||
return responseText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/account/stalwart/auth
|
|
||||||
* Proxy to Stalwart GET /api/account/auth
|
|
||||||
*/
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const creds = await getStalwartCredentials(request);
|
|
||||||
if (!creds) {
|
|
||||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'Authorization': creds.authHeader },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
const detail = parseStalwartError(text);
|
|
||||||
logger.warn('Stalwart auth info failed', { status: response.status, detail });
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: detail || 'Failed to fetch auth info' },
|
|
||||||
{ status: response.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Stalwart auth proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/account/stalwart/auth
|
|
||||||
* Proxy to Stalwart POST /api/account/auth
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const creds = await getStalwartCredentials(request);
|
|
||||||
if (!creds) {
|
|
||||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': creds.authHeader,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
logger.warn('Stalwart auth update failed', { status: response.status });
|
|
||||||
return NextResponse.json(data, { status: response.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Stalwart auth update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse Stalwart error response to extract meaningful error message
|
|
||||||
*/
|
|
||||||
function parseStalwartError(responseText: string): string {
|
|
||||||
try {
|
|
||||||
const error = JSON.parse(responseText);
|
|
||||||
if (error.detail) return error.detail;
|
|
||||||
if (error.error) return error.error;
|
|
||||||
return `HTTP ${error.status || 'Error'}`;
|
|
||||||
} catch {
|
|
||||||
return responseText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/account/stalwart/crypto
|
|
||||||
* Proxy to Stalwart GET /api/account/crypto
|
|
||||||
*/
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const creds = await getStalwartCredentials(request);
|
|
||||||
if (!creds) {
|
|
||||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'Authorization': creds.authHeader },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
const detail = parseStalwartError(text);
|
|
||||||
logger.warn('Stalwart crypto info failed', { status: response.status, detail });
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: detail || 'Failed to fetch crypto info' },
|
|
||||||
{ status: response.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Stalwart crypto proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/account/stalwart/crypto
|
|
||||||
* Proxy to Stalwart POST /api/account/crypto
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const creds = await getStalwartCredentials(request);
|
|
||||||
if (!creds) {
|
|
||||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': creds.authHeader,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
logger.warn('Stalwart crypto update failed', { status: response.status });
|
|
||||||
return NextResponse.json(data, { status: response.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Stalwart crypto update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/account/stalwart/jmap
|
||||||
|
*
|
||||||
|
* Passthrough to Stalwart's JMAP endpoint using the stored basic-auth
|
||||||
|
* context so the browser does not need access to the user's credentials.
|
||||||
|
*
|
||||||
|
* Body: standard JMAP request `{ using: string[], methodCalls: [...] }`
|
||||||
|
*
|
||||||
|
* In Stalwart 0.16 all management operations (password change, app
|
||||||
|
* passwords, API keys, account settings, etc.) are exposed as JMAP
|
||||||
|
* methods under the `x:` namespace on the same endpoint.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.text();
|
||||||
|
|
||||||
|
const response = await fetch(`${creds.serverUrl}/jmap/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': creds.authHeader,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
return new NextResponse(responseText, {
|
||||||
|
status: response.status,
|
||||||
|
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Stalwart JMAP passthrough error', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { encryptSession } from '@/lib/auth/crypto';
|
|
||||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
||||||
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
|
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax' as const,
|
|
||||||
path: '/',
|
|
||||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/account/stalwart/password
|
|
||||||
* Change user password via Stalwart PATCH /api/principal/{name}
|
|
||||||
*
|
|
||||||
* Body: { currentPassword: string, newPassword: string }
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const creds = await getStalwartCredentials(request);
|
|
||||||
if (!creds) {
|
|
||||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { currentPassword, newPassword } = await request.json();
|
|
||||||
|
|
||||||
if (!currentPassword || !newPassword) {
|
|
||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newPassword.length < 8) {
|
|
||||||
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify current password by attempting to authenticate
|
|
||||||
const verifyAuth = `Basic ${Buffer.from(`${creds.username}:${currentPassword}`).toString('base64')}`;
|
|
||||||
const verifyResponse = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'Authorization': verifyAuth },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!verifyResponse.ok) {
|
|
||||||
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Change password via Stalwart principal API
|
|
||||||
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Authorization': creds.authHeader,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ action: 'set', field: 'secrets', value: newPassword },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
logger.warn('Stalwart password change failed', { status: response.status });
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to change password', details: text },
|
|
||||||
{ status: response.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If session cookie exists, update it with the new password
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
|
|
||||||
if (creds.hasSessionCookie) {
|
|
||||||
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
|
|
||||||
cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (creds.authHeader.startsWith('Basic ')) {
|
|
||||||
setStalwartAuthContextInStore(cookieStore, creds.slot, {
|
|
||||||
serverUrl: creds.serverUrl,
|
|
||||||
username: creds.username,
|
|
||||||
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Stalwart password change proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse Stalwart error response to extract meaningful error message
|
|
||||||
*/
|
|
||||||
function parseStalwartError(responseText: string): string {
|
|
||||||
try {
|
|
||||||
const error = JSON.parse(responseText);
|
|
||||||
if (error.detail) return error.detail;
|
|
||||||
if (error.error) return error.error;
|
|
||||||
return `HTTP ${error.status || 'Error'}`;
|
|
||||||
} catch {
|
|
||||||
return responseText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/account/stalwart/principal
|
|
||||||
* Proxy to Stalwart GET /api/principal/{username}
|
|
||||||
*/
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const creds = await getStalwartCredentials(request);
|
|
||||||
if (!creds) {
|
|
||||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'Authorization': creds.authHeader },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
const detail = parseStalwartError(text);
|
|
||||||
logger.warn('Stalwart principal fetch failed', { status: response.status, detail });
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: detail || 'Failed to fetch principal' },
|
|
||||||
{ status: response.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Stalwart principal proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PATCH /api/account/stalwart/principal
|
|
||||||
* Proxy to Stalwart PATCH /api/principal/{username}
|
|
||||||
* Body: PrincipalUpdateAction[] (array of {action, field, value})
|
|
||||||
*/
|
|
||||||
export async function PATCH(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const creds = await getStalwartCredentials(request);
|
|
||||||
if (!creds) {
|
|
||||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// Prevent secrets field from being changed through this endpoint (use /password instead)
|
|
||||||
if (Array.isArray(body)) {
|
|
||||||
const hasSecrets = body.some((action: { field?: string }) => action.field === 'secrets');
|
|
||||||
if (hasSecrets) {
|
|
||||||
return NextResponse.json({ error: 'Use /api/account/stalwart/password to change passwords' }, { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Authorization': creds.authHeader,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
logger.warn('Stalwart principal update failed', { status: response.status });
|
|
||||||
return NextResponse.json(data, { status: response.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Stalwart principal update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/account/stalwart/probe
|
|
||||||
* Detect whether the JMAP server is Stalwart by probing /api/account/auth
|
|
||||||
*/
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const creds = await getStalwartCredentials(request);
|
|
||||||
if (!creds) {
|
|
||||||
return NextResponse.json({ isStalwart: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'Authorization': creds.authHeader },
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTimeout(timeout);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return NextResponse.json({ isStalwart: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const isStalwart = data.data !== undefined && typeof data.data.otpEnabled === 'boolean';
|
|
||||||
|
|
||||||
return NextResponse.json({ isStalwart });
|
|
||||||
} catch {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
return NextResponse.json({ isStalwart: false });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Stalwart probe error', { error: error instanceof Error ? error.message : 'Unknown' });
|
|
||||||
return NextResponse.json({ isStalwart: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { readAuditLog } from '@/lib/admin/audit';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/audit — Get paginated audit log entries (admin-protected)
|
* GET /api/admin/audit - Get paginated audit log entries (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Binary file not shown.
@@ -14,7 +14,7 @@ const MIME_TYPES: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/branding/[filename] — Serve uploaded branding images
|
* GET /api/admin/branding/[filename] - Serve uploaded branding images
|
||||||
*
|
*
|
||||||
* This endpoint is public (no admin auth) so browsers can load images.
|
* This endpoint is public (no admin auth) so browsers can load images.
|
||||||
* Only files in the branding directory are served; directory traversal is prevented.
|
* Only files in the branding directory are served; directory traversal is prevented.
|
||||||
@@ -53,11 +53,19 @@ export async function GET(
|
|||||||
|
|
||||||
const buffer = await readFile(resolved);
|
const buffer = await readFile(resolved);
|
||||||
|
|
||||||
|
// SVG can carry inline <script> and event handlers that execute when the
|
||||||
|
// file is fetched as a top-level document. Defense in depth on top of
|
||||||
|
// admin-only upload: nosniff blocks MIME confusion, the CSP forces a
|
||||||
|
// sandboxed unique origin so any script in an SVG is inert and cannot
|
||||||
|
// touch app cookies or storage.
|
||||||
return new NextResponse(buffer, {
|
return new NextResponse(buffer, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': contentType,
|
'Content-Type': contentType,
|
||||||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||||
'Content-Length': String(buffer.length),
|
'Content-Length': String(buffer.length),
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'Content-Security-Policy':
|
||||||
|
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function sanitizeFilename(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/branding — Upload a branding image file
|
* POST /api/admin/branding - Upload a branding image file
|
||||||
*
|
*
|
||||||
* Expects multipart/form-data with:
|
* Expects multipart/form-data with:
|
||||||
* - file: the image file
|
* - file: the image file
|
||||||
@@ -105,7 +105,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/branding — Remove an uploaded branding file
|
* DELETE /api/admin/branding - Remove an uploaded branding file
|
||||||
*
|
*
|
||||||
* Expects JSON body: { slot: string }
|
* Expects JSON body: { slot: string }
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { auditLog } from '@/lib/admin/audit';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/change-password — Change admin password
|
* POST /api/admin/change-password - Change admin password
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/config — Get full config with sources (admin-protected)
|
* GET /api/admin/config - Get full config with sources (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -26,7 +26,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/config — Update config overrides (admin-protected)
|
* PATCH /api/admin/config - Update config overrides (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -64,7 +64,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/config — Remove admin override for a key (revert to env/default)
|
* DELETE /api/admin/config - Remove admin override for a key (revert to env/default)
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import {
|
||||||
|
getPluginRegistry,
|
||||||
|
getThemeRegistry,
|
||||||
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||||
|
|
||||||
|
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
||||||
|
|
||||||
|
const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/marketplace/[slug]
|
||||||
|
* Returns full preview info for an extension: directory metadata,
|
||||||
|
* the bundle's manifest, a (truncated) source preview, and install status.
|
||||||
|
* Lets admins audit what they're about to install before pressing the button.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
// 1. Extension metadata + screenshots + theme previews from the directory
|
||||||
|
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL);
|
||||||
|
const detailRes = await fetch(detailUrl.toString(), {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!detailRes.ok) {
|
||||||
|
const status = detailRes.status === 404 ? 404 : 502;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: status === 404 ? 'Extension not found' : 'Directory request failed' },
|
||||||
|
{ status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailJson = await detailRes.json();
|
||||||
|
const extension = detailJson.data as Record<string, unknown> | undefined;
|
||||||
|
if (!extension) {
|
||||||
|
return NextResponse.json({ error: 'Extension not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = extension.type as 'plugin' | 'theme';
|
||||||
|
const latestVersion = (extension.latestVersion as { version?: string } | null)?.version
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
// 2. Pull the bundle so we can show what's actually inside.
|
||||||
|
let manifest: Record<string, unknown> | null = null;
|
||||||
|
let sourcePreview: { name: string; content: string; truncated: boolean } | null = null;
|
||||||
|
let bundleError: string | null = null;
|
||||||
|
let bundleSize = 0;
|
||||||
|
|
||||||
|
if (latestVersion) {
|
||||||
|
try {
|
||||||
|
const bundleUrl = new URL(
|
||||||
|
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
|
||||||
|
DIRECTORY_URL,
|
||||||
|
);
|
||||||
|
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bundleRes.ok) {
|
||||||
|
bundleError = `Bundle download failed (${bundleRes.status})`;
|
||||||
|
} else {
|
||||||
|
const buffer = await bundleRes.arrayBuffer();
|
||||||
|
bundleSize = buffer.byteLength;
|
||||||
|
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
|
||||||
|
if (buffer.byteLength > maxSize) {
|
||||||
|
bundleError = `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit`;
|
||||||
|
} else {
|
||||||
|
const zip = await JSZip.loadAsync(buffer);
|
||||||
|
|
||||||
|
// Detect optional root directory inside the ZIP.
|
||||||
|
const entries = Object.keys(zip.files);
|
||||||
|
const topDirs = new Set(entries.map((e) => e.split('/')[0]));
|
||||||
|
let root = '';
|
||||||
|
if (topDirs.size === 1) {
|
||||||
|
const dir = [...topDirs][0];
|
||||||
|
if (zip.files[dir + '/'] || entries.some((e) => e.startsWith(dir + '/'))) {
|
||||||
|
root = dir + '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestFile = zip.file(root + 'manifest.json');
|
||||||
|
if (!manifestFile) {
|
||||||
|
bundleError = 'Bundle missing manifest.json';
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(await manifestFile.async('string'));
|
||||||
|
} catch {
|
||||||
|
bundleError = 'Invalid manifest.json in bundle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest) {
|
||||||
|
if (type === 'theme') {
|
||||||
|
const cssFile = zip.file(root + 'theme.css');
|
||||||
|
if (cssFile) {
|
||||||
|
const css = await cssFile.async('string');
|
||||||
|
sourcePreview = {
|
||||||
|
name: 'theme.css',
|
||||||
|
content: css.length > MAX_PREVIEW_SOURCE_LEN
|
||||||
|
? css.slice(0, MAX_PREVIEW_SOURCE_LEN)
|
||||||
|
: css,
|
||||||
|
truncated: css.length > MAX_PREVIEW_SOURCE_LEN,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const entrypoint = (manifest.entrypoint as string) || 'index.js';
|
||||||
|
const jsFile = zip.file(root + entrypoint);
|
||||||
|
if (jsFile) {
|
||||||
|
const code = await jsFile.async('string');
|
||||||
|
sourcePreview = {
|
||||||
|
name: entrypoint,
|
||||||
|
content: code.length > MAX_PREVIEW_SOURCE_LEN
|
||||||
|
? code.slice(0, MAX_PREVIEW_SOURCE_LEN)
|
||||||
|
: code,
|
||||||
|
truncated: code.length > MAX_PREVIEW_SOURCE_LEN,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
bundleError = err instanceof Error ? err.message : 'Failed to read bundle';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bundleError = 'Extension has no published version';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Install status (slug is used as the registry id at install time)
|
||||||
|
const [pluginRegistry, themeRegistry] = await Promise.all([
|
||||||
|
getPluginRegistry(),
|
||||||
|
getThemeRegistry(),
|
||||||
|
]);
|
||||||
|
const installed = type === 'theme'
|
||||||
|
? themeRegistry.themes.some((t) => t.id === slug)
|
||||||
|
: pluginRegistry.plugins.some((p) => p.id === slug);
|
||||||
|
|
||||||
|
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
|
||||||
|
const screenshots = Array.isArray(extension.screenshots)
|
||||||
|
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
|
||||||
|
url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(),
|
||||||
|
altText: s.altText ?? null,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Strip the heavy `manifest` blob from versions when echoing the directory data.
|
||||||
|
const versions = Array.isArray(extension.versions)
|
||||||
|
? (extension.versions as Array<Record<string, unknown>>).map((v) => ({
|
||||||
|
version: v.version,
|
||||||
|
changelog: v.changelog,
|
||||||
|
bundleSize: v.bundleSize,
|
||||||
|
minAppVersion: v.minAppVersion,
|
||||||
|
publishedAt: v.publishedAt,
|
||||||
|
permissions: v.permissions,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
extension: {
|
||||||
|
slug: extension.slug,
|
||||||
|
name: extension.name,
|
||||||
|
type: extension.type,
|
||||||
|
pluginType: extension.pluginType ?? null,
|
||||||
|
description: extension.description,
|
||||||
|
longDescription: extension.longDescription ?? null,
|
||||||
|
tags: extension.tags ?? [],
|
||||||
|
permissions: extension.permissions ?? [],
|
||||||
|
totalDownloads: extension.totalDownloads ?? 0,
|
||||||
|
featured: extension.featured ?? false,
|
||||||
|
githubRepo: extension.githubRepo ?? null,
|
||||||
|
license: extension.license ?? null,
|
||||||
|
minAppVersion: extension.minAppVersion ?? null,
|
||||||
|
author: extension.author ?? null,
|
||||||
|
latestVersion,
|
||||||
|
versions,
|
||||||
|
screenshots,
|
||||||
|
themePreviews: extension.themePreviews ?? [],
|
||||||
|
createdAt: extension.createdAt ?? null,
|
||||||
|
updatedAt: extension.updatedAt ?? null,
|
||||||
|
},
|
||||||
|
bundle: {
|
||||||
|
manifest,
|
||||||
|
source: sourcePreview,
|
||||||
|
size: bundleSize,
|
||||||
|
error: bundleError,
|
||||||
|
},
|
||||||
|
installed,
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Marketplace preview error', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to load preview' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,14 +10,18 @@ import {
|
|||||||
type ServerPlugin,
|
type ServerPlugin,
|
||||||
type ServerTheme,
|
type ServerTheme,
|
||||||
} from '@/lib/admin/plugin-registry';
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
import {
|
||||||
|
sanitizeFrameOrigins,
|
||||||
|
invalidateFrameOriginsCache,
|
||||||
|
} 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, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
|
|
||||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
|
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/marketplace — Search/browse the extension directory
|
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||||
* Proxies to the extension directory API
|
* Proxies to the extension directory API
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -75,7 +79,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/marketplace — Install an extension from the directory
|
* POST /api/admin/marketplace - Install an extension from the directory
|
||||||
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -226,6 +230,22 @@ export async function POST(request: NextRequest) {
|
|||||||
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
|
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plugins may declare iframe origins they need for embedded content.
|
||||||
|
// Anything that doesn't pass strict origin validation is silently
|
||||||
|
// dropped - the plugin still installs, but those origins are not
|
||||||
|
// added to the host CSP.
|
||||||
|
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
|
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
|
||||||
|
? (manifest.frameOrigins as unknown[]).filter(
|
||||||
|
(v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
if (droppedFrameOrigins.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
id: (manifest.id as string) || slug,
|
id: (manifest.id as string) || slug,
|
||||||
name: (manifest.name as string) || slug,
|
name: (manifest.name as string) || slug,
|
||||||
@@ -238,10 +258,14 @@ export async function POST(request: NextRequest) {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
installedAt: now,
|
installedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
...(declaredFrameOrigins.length > 0
|
||||||
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip);
|
invalidateFrameOriginsCache();
|
||||||
|
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins }, ip);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, plugin, warnings });
|
return NextResponse.json({ success: true, plugin, warnings });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { locales as ALL_LOCALES } from '@/i18n/routing';
|
||||||
|
|
||||||
|
const CLIENT_ID = 'bulwark-webmail';
|
||||||
|
const CLIENT_DESCRIPTION = 'Bulwark Webmail (auto-configured)';
|
||||||
|
const JMAP_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
interface JmapMethodCall {
|
||||||
|
using: string[];
|
||||||
|
methodCalls: Array<[string, Record<string, unknown>, string]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JmapMethodResponse {
|
||||||
|
methodResponses?: Array<[string, Record<string, unknown>, string]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(url: string, init: Parameters<typeof fetch>[1]): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), JMAP_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...init, signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jmapCall(
|
||||||
|
serverUrl: string,
|
||||||
|
authHeader: string,
|
||||||
|
body: JmapMethodCall,
|
||||||
|
): Promise<JmapMethodResponse> {
|
||||||
|
const res = await fetchWithTimeout(`${serverUrl}/jmap/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': authHeader, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`JMAP HTTP ${res.status} ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<JmapMethodResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStalwartAccountId(
|
||||||
|
serverUrl: string,
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const res = await fetchWithTimeout(`${serverUrl}/.well-known/jmap`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Authorization': authHeader },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const session = await res.json() as { primaryAccounts?: Record<string, string> };
|
||||||
|
return session.primaryAccounts?.['urn:stalwart:jmap']
|
||||||
|
?? session.primaryAccounts?.['urn:ietf:params:jmap:mail']
|
||||||
|
?? Object.values(session.primaryAccounts ?? {})[0]
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRedirectUris(origin: string, localeList: readonly string[]): Record<string, true> {
|
||||||
|
const out: Record<string, true> = {};
|
||||||
|
for (const loc of localeList) {
|
||||||
|
out[`${origin}/${loc}/auth/callback`] = true;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetupRequestBody {
|
||||||
|
origin?: string;
|
||||||
|
issuerUrl?: string;
|
||||||
|
locales?: string[];
|
||||||
|
oauthOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidOriginUrl(value: string): boolean {
|
||||||
|
return /^https?:\/\/[^/]+$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await requireAdminAuth();
|
||||||
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No Stalwart session available. Sign in to your mail account in another tab and retry.' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json() as SetupRequestBody;
|
||||||
|
const origin = (body.origin ?? '').trim().replace(/\/+$/, '');
|
||||||
|
if (!isValidOriginUrl(origin)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Webmail origin must be a URL like "https://webmail.example.com" with no path.' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const issuerUrl = (body.issuerUrl ?? origin).trim().replace(/\/+$/, '');
|
||||||
|
if (!isValidOriginUrl(issuerUrl)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Stalwart issuer URL must be a URL like "https://mail.example.com" with no path.' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const localeList = Array.isArray(body.locales) && body.locales.length > 0
|
||||||
|
? body.locales.filter(l => typeof l === 'string' && /^[a-z]{2,5}(-[A-Za-z0-9]+)*$/.test(l))
|
||||||
|
: Array.from(ALL_LOCALES);
|
||||||
|
if (localeList.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No valid locales supplied.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const oauthOnly = body.oauthOnly === true;
|
||||||
|
|
||||||
|
const accountId = await getStalwartAccountId(creds.serverUrl, creds.authHeader);
|
||||||
|
if (!accountId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Could not resolve Stalwart account from JMAP session.' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryRes = await jmapCall(creds.serverUrl, creds.authHeader, {
|
||||||
|
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||||
|
methodCalls: [[
|
||||||
|
'x:OAuthClient/query',
|
||||||
|
{ accountId, filter: { clientId: CLIENT_ID } },
|
||||||
|
'0',
|
||||||
|
]],
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryEntry = queryRes.methodResponses?.[0];
|
||||||
|
if (!queryEntry || queryEntry[0] === 'error') {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Stalwart denied OAuthClient/query - your Stalwart account likely lacks admin permissions.',
|
||||||
|
detail: queryEntry?.[1],
|
||||||
|
}, { status: 403 });
|
||||||
|
}
|
||||||
|
const existingIds = (queryEntry[1].ids as string[] | undefined) ?? [];
|
||||||
|
|
||||||
|
const secret = randomBytes(32).toString('base64url');
|
||||||
|
const redirectUris = buildRedirectUris(origin, localeList);
|
||||||
|
|
||||||
|
let setArgs: Record<string, unknown>;
|
||||||
|
let action: 'created' | 'updated';
|
||||||
|
if (existingIds.length > 0) {
|
||||||
|
const targetId = existingIds[0];
|
||||||
|
action = 'updated';
|
||||||
|
setArgs = {
|
||||||
|
accountId,
|
||||||
|
update: {
|
||||||
|
[targetId]: {
|
||||||
|
secret,
|
||||||
|
redirectUris,
|
||||||
|
description: CLIENT_DESCRIPTION,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
action = 'created';
|
||||||
|
setArgs = {
|
||||||
|
accountId,
|
||||||
|
create: {
|
||||||
|
new: {
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
description: CLIENT_DESCRIPTION,
|
||||||
|
secret,
|
||||||
|
redirectUris,
|
||||||
|
contacts: { [creds.username]: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const setRes = await jmapCall(creds.serverUrl, creds.authHeader, {
|
||||||
|
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||||
|
methodCalls: [['x:OAuthClient/set', setArgs, '0']],
|
||||||
|
});
|
||||||
|
|
||||||
|
const setEntry = setRes.methodResponses?.[0];
|
||||||
|
if (!setEntry || setEntry[0] === 'error') {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Stalwart denied OAuthClient/set - admin permissions required.',
|
||||||
|
detail: setEntry?.[1],
|
||||||
|
}, { status: 403 });
|
||||||
|
}
|
||||||
|
const setBody = setEntry[1] as {
|
||||||
|
notCreated?: Record<string, unknown>;
|
||||||
|
notUpdated?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
if (setBody.notCreated && Object.keys(setBody.notCreated).length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Stalwart refused to create the OAuth client.', detail: setBody.notCreated },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (setBody.notUpdated && Object.keys(setBody.notUpdated).length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Stalwart refused to update the OAuth client.', detail: setBody.notUpdated },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
oauthEnabled: true,
|
||||||
|
oauthClientId: CLIENT_ID,
|
||||||
|
oauthClientSecret: secret,
|
||||||
|
oauthIssuerUrl: issuerUrl,
|
||||||
|
};
|
||||||
|
if (oauthOnly) updates.oauthOnly = true;
|
||||||
|
await configManager.setAdminConfig(updates);
|
||||||
|
|
||||||
|
await auditLog('admin.oauth_setup', {
|
||||||
|
action,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
origin,
|
||||||
|
issuer: issuerUrl,
|
||||||
|
redirectUriCount: localeList.length,
|
||||||
|
oauthOnly,
|
||||||
|
}, ip);
|
||||||
|
|
||||||
|
logger.info('Admin OAuth setup', {
|
||||||
|
action,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
origin,
|
||||||
|
issuer: issuerUrl,
|
||||||
|
locales: localeList.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
action,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
origin,
|
||||||
|
issuerUrl,
|
||||||
|
redirectUriCount: localeList.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin OAuth setup error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins/[id]/bundle — Serve plugin JS bundle
|
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||||
*
|
*
|
||||||
* Public endpoint so the client-side plugin loader can fetch bundles.
|
* Public endpoint so the client-side plugin loader can fetch bundles.
|
||||||
* Only serves plugins that exist in the registry and are enabled.
|
* Only serves plugins that exist in the registry and are enabled.
|
||||||
|
|||||||
@@ -2,15 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getPlugin } from '@/lib/admin/plugin-registry';
|
import { getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
||||||
import { requireAdminAuth } from '@/lib/admin/session';
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins/[id]/config — Read all config for a plugin
|
* GET /api/admin/plugins/[id]/config - Read plugin config
|
||||||
*
|
*
|
||||||
* Returns the full config object for admin-configured plugin settings.
|
* - Admin sessions receive every field, including those declared
|
||||||
* This endpoint is accessible from the client-side plugin API.
|
* `type: 'secret'` in the plugin's configSchema.
|
||||||
|
* - Authenticated mailbox users (the plugin running in their browser)
|
||||||
|
* receive only non-secret fields.
|
||||||
|
* - Anonymous callers are rejected so unauthenticated visitors cannot
|
||||||
|
* enumerate plugin secrets.
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -20,13 +25,34 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adminAuth = await requireAdminAuth();
|
||||||
|
const isAdmin = !('error' in adminAuth);
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const plugin = await getPlugin(id);
|
const plugin = await getPlugin(id);
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getPluginConfig(id);
|
const config = await getPluginConfig(id);
|
||||||
return NextResponse.json(config, {
|
|
||||||
|
let response: Record<string, unknown> = config;
|
||||||
|
if (!isAdmin && plugin.configSchema) {
|
||||||
|
response = {};
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
const field = plugin.configSchema[key];
|
||||||
|
if (field?.type === 'secret') continue;
|
||||||
|
response[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(response, {
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -35,7 +61,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/admin/plugins/[id]/config — Set a config key
|
* PUT /api/admin/plugins/[id]/config - Set a config key
|
||||||
*
|
*
|
||||||
* Body: { key: string, value: unknown }
|
* Body: { key: string, value: unknown }
|
||||||
* Requires admin authentication (checked via admin session).
|
* Requires admin authentication (checked via admin session).
|
||||||
@@ -83,7 +109,7 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/plugins/[id]/config — Delete a config key
|
* DELETE /api/admin/plugins/[id]/config - Delete a config key
|
||||||
*
|
*
|
||||||
* Body: { key: string }
|
* Body: { key: string }
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
deletePlugin as removePlugin,
|
deletePlugin as removePlugin,
|
||||||
type ServerPlugin,
|
type ServerPlugin,
|
||||||
} from '@/lib/admin/plugin-registry';
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
import {
|
||||||
|
sanitizeFrameOrigins,
|
||||||
|
invalidateFrameOriginsCache,
|
||||||
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
|
|
||||||
// Server-side extraction using the same validation logic
|
// Server-side extraction using the same validation logic
|
||||||
// ZIP parsing needs to happen on the server for admin-uploaded plugins
|
// ZIP parsing needs to happen on the server for admin-uploaded plugins
|
||||||
@@ -23,7 +27,7 @@ const SUSPICIOUS_JS_PATTERNS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins — List all admin-managed plugins
|
* GET /api/admin/plugins - List all admin-managed plugins
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -41,7 +45,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/plugins — Upload and install a plugin ZIP
|
* POST /api/admin/plugins - Upload and install a plugin ZIP
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -152,6 +156,8 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
id: manifest.id as string,
|
id: manifest.id as string,
|
||||||
@@ -166,12 +172,16 @@ export async function POST(request: NextRequest) {
|
|||||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(declaredFrameOrigins.length > 0
|
||||||
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
|
: {}),
|
||||||
installedAt: now,
|
installedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
|
invalidateFrameOriginsCache();
|
||||||
|
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins }, ip);
|
||||||
|
|
||||||
return NextResponse.json({ plugin });
|
return NextResponse.json({ plugin });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -181,7 +191,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/plugins — Update plugin metadata (enable/disable)
|
* PATCH /api/admin/plugins - Update plugin metadata (enable/disable)
|
||||||
* Body: { id: string, enabled: boolean }
|
* Body: { id: string, enabled: boolean }
|
||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
@@ -209,6 +219,11 @@ export async function PATCH(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable/disable changes the set of plugins contributing frame origins.
|
||||||
|
if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
|
||||||
|
invalidateFrameOriginsCache();
|
||||||
|
}
|
||||||
|
|
||||||
await auditLog('plugin.update', { id, ...updates }, ip);
|
await auditLog('plugin.update', { id, ...updates }, ip);
|
||||||
return NextResponse.json({ plugin: updated });
|
return NextResponse.json({ plugin: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -218,7 +233,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/plugins — Remove a plugin
|
* DELETE /api/admin/plugins - Remove a plugin
|
||||||
* Body: { id: string }
|
* Body: { id: string }
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
@@ -238,6 +253,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateFrameOriginsCache();
|
||||||
await auditLog('plugin.delete', { id }, ip);
|
await auditLog('plugin.delete', { id }, ip);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { logger } from '@/lib/logger';
|
|||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/policy — Get settings policy (NOT admin-protected — users read this)
|
* GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this)
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -22,7 +22,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/admin/policy — Update settings policy (admin-protected)
|
* PUT /api/admin/policy - Update settings policy (admin-protected)
|
||||||
*/
|
*/
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/admin/stalwart-check
|
|
||||||
* Check if the currently logged-in user is a Stalwart admin.
|
|
||||||
* Probes the admin-only principal-list endpoint — if the user can access it, they're an admin.
|
|
||||||
*/
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const creds = await getStalwartCredentials(request);
|
|
||||||
if (!creds) {
|
|
||||||
return NextResponse.json({ isStalwartAdmin: false }, {
|
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Probe an admin-only endpoint: listing principals requires admin privileges.
|
|
||||||
// Use limit=1 to minimize payload.
|
|
||||||
const url = `${creds.apiUrl}/api/principal?limit=1`;
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'Authorization': creds.authHeader },
|
|
||||||
});
|
|
||||||
|
|
||||||
const isStalwartAdmin = response.ok;
|
|
||||||
logger.info('Stalwart admin check', { username: creds.username, status: response.status, isStalwartAdmin });
|
|
||||||
|
|
||||||
return NextResponse.json({ isStalwartAdmin }, {
|
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Stalwart admin check error', {
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown',
|
|
||||||
});
|
|
||||||
return NextResponse.json({ isStalwartAdmin: false }, {
|
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import {
|
||||||
|
effectiveConsent,
|
||||||
|
loadState,
|
||||||
|
saveState,
|
||||||
|
buildPayload,
|
||||||
|
sendOnce,
|
||||||
|
reschedule,
|
||||||
|
DEFAULT_ENDPOINT,
|
||||||
|
getLoginCounts,
|
||||||
|
resolveEndpointAllowed,
|
||||||
|
} from '@/lib/telemetry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/telemetry
|
||||||
|
* Returns current consent + endpoint + next/last send + a live preview
|
||||||
|
* of exactly what the next heartbeat would contain.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const auth = await requireAdminAuth();
|
||||||
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
|
const { consent, source, state } = await effectiveConsent();
|
||||||
|
const [payload, accountCounts] = await Promise.all([
|
||||||
|
buildPayload(),
|
||||||
|
getLoginCounts(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
consent,
|
||||||
|
consentSource: source,
|
||||||
|
endpoint: state.endpoint || DEFAULT_ENDPOINT,
|
||||||
|
consentedAt: state.consentedAt,
|
||||||
|
lastSentAt: state.lastSentAt,
|
||||||
|
nextScheduledAt: state.nextScheduledAt,
|
||||||
|
defaultEndpoint: DEFAULT_ENDPOINT,
|
||||||
|
payloadPreview: payload,
|
||||||
|
accountCounts,
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('telemetry GET error', {
|
||||||
|
error: err instanceof Error ? err.message : 'unknown',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/telemetry
|
||||||
|
* Body: { action: 'set-consent' | 'set-endpoint' | 'send-now', ... }
|
||||||
|
* set-consent : { action, consent: 'on' | 'off' }
|
||||||
|
* set-endpoint : { action, endpoint: string }
|
||||||
|
* send-now : { action }
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await requireAdminAuth();
|
||||||
|
if ('error' in auth) return auth.error;
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => null)) as
|
||||||
|
| { action?: string; consent?: string; endpoint?: string }
|
||||||
|
| null;
|
||||||
|
if (!body || typeof body.action !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'action required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { source } = await effectiveConsent();
|
||||||
|
|
||||||
|
if (body.action === 'set-consent') {
|
||||||
|
if (source === 'env') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'consent is overridden by BULWARK_TELEMETRY env var' },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body.consent !== 'on' && body.consent !== 'off') {
|
||||||
|
return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const state = await loadState();
|
||||||
|
const before = state.consent;
|
||||||
|
state.consent = body.consent;
|
||||||
|
if (body.consent === 'on' && !state.consentedAt) {
|
||||||
|
state.consentedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
await saveState(state);
|
||||||
|
await reschedule();
|
||||||
|
await auditLog('telemetry.set-consent', { from: before, to: body.consent }, ip);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.action === 'set-endpoint') {
|
||||||
|
if (typeof body.endpoint !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'endpoint required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const trimmed = body.endpoint.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
const check = await resolveEndpointAllowed(trimmed);
|
||||||
|
if (!check.ok) {
|
||||||
|
return NextResponse.json({ error: check.reason }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const state = await loadState();
|
||||||
|
const before = state.endpoint;
|
||||||
|
state.endpoint = trimmed || DEFAULT_ENDPOINT;
|
||||||
|
await saveState(state);
|
||||||
|
await auditLog('telemetry.set-endpoint', { from: before, to: state.endpoint }, ip);
|
||||||
|
return NextResponse.json({ ok: true, endpoint: state.endpoint });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.action === 'send-now') {
|
||||||
|
const result = await sendOnce({ reason: 'admin-manual' });
|
||||||
|
await auditLog(
|
||||||
|
'telemetry.send-now',
|
||||||
|
{ ok: result.ok, status: result.status ?? null, error: result.error ?? null },
|
||||||
|
ip,
|
||||||
|
);
|
||||||
|
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('telemetry POST error', {
|
||||||
|
error: err instanceof Error ? err.message : 'unknown',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/themes/[id]/css — Serve theme CSS to clients
|
* GET /api/admin/themes/[id]/css - Serve theme CSS to clients
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { MAX_THEME_SIZE } from '@/lib/plugin-types';
|
|||||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/themes — List all admin-managed themes
|
* GET /api/admin/themes - List all admin-managed themes
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -32,7 +32,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/themes — Upload and install a theme ZIP
|
* POST /api/admin/themes - Upload and install a theme ZIP
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -151,7 +151,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/themes — Update theme metadata (enable/disable)
|
* PATCH /api/admin/themes - Update theme metadata (enable/disable)
|
||||||
* Body: { id: string, enabled: boolean }
|
* Body: { id: string, enabled: boolean }
|
||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
@@ -188,7 +188,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/themes — Remove a theme
|
* DELETE /api/admin/themes - Remove a theme
|
||||||
* Body: { id: string }
|
* Body: { id: string }
|
||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
clearStalwartAuthContextInStore,
|
clearStalwartAuthContextInStore,
|
||||||
setStalwartAuthContextInStore,
|
setStalwartAuthContextInStore,
|
||||||
} from '@/lib/stalwart/auth-context';
|
} from '@/lib/stalwart/auth-context';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
...getCookieOptions(),
|
...getCookieOptions(),
|
||||||
@@ -25,7 +28,9 @@ function getSlot(request: NextRequest): number {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
|
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||||
|
const oauthOnly = configManager.get<boolean>('oauthOnly', false);
|
||||||
|
if (oauthEnabled && oauthOnly) {
|
||||||
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,10 +39,37 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
||||||
|
// caller cannot point this route at internal hosts. Only when no server URL
|
||||||
|
// is configured AND the deployment explicitly allows custom JMAP endpoints
|
||||||
|
// do we honor the body URL — and even then it must be a public URL.
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const configuredServerUrl =
|
||||||
|
configManager.get<string>('jmapServerUrl', '') ||
|
||||||
|
process.env.JMAP_SERVER_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||||
|
'';
|
||||||
|
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||||
|
|
||||||
|
let upstreamUrl: string;
|
||||||
|
let upstreamTrusted: boolean;
|
||||||
|
if (configuredServerUrl) {
|
||||||
|
upstreamUrl = configuredServerUrl;
|
||||||
|
upstreamTrusted = true;
|
||||||
|
} else if (allowCustomEndpoint) {
|
||||||
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
|
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
|
||||||
|
}
|
||||||
|
upstreamUrl = serverUrl;
|
||||||
|
upstreamTrusted = false;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||||
const cookieName = sessionCookieName(slot);
|
const cookieName = sessionCookieName(slot);
|
||||||
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
|
||||||
const token = encryptSession(normalizedServerUrl, username, password);
|
const token = encryptSession(normalizedServerUrl, username, password);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||||
@@ -47,6 +79,8 @@ export async function POST(request: NextRequest) {
|
|||||||
authHeader,
|
authHeader,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void recordLogin(username, normalizedServerUrl);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof JmapAuthVerificationError) {
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
@@ -95,7 +129,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT — retrieve full credentials (including password) for session restoration.
|
* PUT - retrieve full credentials (including password) for session restoration.
|
||||||
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
|
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
|
||||||
* browser fetch() requests succeed. Non-browser clients cannot forge these.
|
* browser fetch() requests succeed. Non-browser clients cannot forge these.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||||
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
|
||||||
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||||
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
||||||
@@ -23,8 +26,35 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
||||||
|
// caller cannot point this route at internal hosts. Only when no server URL
|
||||||
|
// is configured AND the deployment explicitly allows custom JMAP endpoints
|
||||||
|
// do we honor the body URL — and even then it must be a public URL.
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const configuredServerUrl =
|
||||||
|
configManager.get<string>('jmapServerUrl', '') ||
|
||||||
|
process.env.JMAP_SERVER_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||||
|
'';
|
||||||
|
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||||
|
|
||||||
|
let upstreamUrl: string;
|
||||||
|
let upstreamTrusted: boolean;
|
||||||
|
if (configuredServerUrl) {
|
||||||
|
upstreamUrl = configuredServerUrl;
|
||||||
|
upstreamTrusted = true;
|
||||||
|
} else if (allowCustomEndpoint) {
|
||||||
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
|
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
|
||||||
|
}
|
||||||
|
upstreamUrl = serverUrl;
|
||||||
|
upstreamTrusted = false;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
const slot = getSlot(request, bodySlot);
|
const slot = getSlot(request, bodySlot);
|
||||||
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
|
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
|
||||||
|
|
||||||
await setStalwartAuthContext(slot, {
|
await setStalwartAuthContext(slot, {
|
||||||
serverUrl: normalizedServerUrl,
|
serverUrl: normalizedServerUrl,
|
||||||
@@ -32,6 +62,8 @@ export async function POST(request: NextRequest) {
|
|||||||
authHeader,
|
authHeader,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void recordLogin(username, normalizedServerUrl);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof JmapAuthVerificationError) {
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
|
|||||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName } 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';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||||
@@ -66,7 +69,7 @@ async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Network error — endpoint not reachable
|
// Network error - endpoint not reachable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,22 +86,39 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||||
|
|
||||||
// Use the server-side JMAP_SERVER_URL if set (may differ from the
|
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
||||||
// public URL the browser uses, e.g. inside Docker).
|
// caller cannot point this route at internal hosts. Only when no server
|
||||||
const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl;
|
// URL is configured (and the deployment explicitly allows custom JMAP
|
||||||
|
// endpoints) do we fall back to the user-supplied URL - and even then
|
||||||
|
// it must resolve to a public address.
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const configuredServerUrl =
|
||||||
|
configManager.get<string>('jmapServerUrl', '') ||
|
||||||
|
process.env.JMAP_SERVER_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||||
|
'';
|
||||||
|
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||||
|
|
||||||
const tokenEndpoint = await findTokenEndpoint(internalServerUrl);
|
let upstreamUrl: string;
|
||||||
if (!tokenEndpoint) {
|
if (configuredServerUrl) {
|
||||||
// Also try with the client-provided URL in case the internal one differs
|
upstreamUrl = configuredServerUrl;
|
||||||
const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null;
|
} else if (allowCustomEndpoint) {
|
||||||
if (!clientEndpoint) {
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl });
|
logger.warn('TOTP token exchange: rejected non-public server URL');
|
||||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
|
||||||
}
|
}
|
||||||
return await attemptAllStrategies(clientEndpoint, username, password, slot);
|
upstreamUrl = serverUrl;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return await attemptAllStrategies(tokenEndpoint, username, password, slot);
|
const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
|
||||||
|
if (!tokenEndpoint) {
|
||||||
|
logger.warn('TOTP token exchange: no token endpoint found');
|
||||||
|
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('TOTP token exchange 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 });
|
||||||
@@ -107,14 +127,15 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
async function attemptAllStrategies(
|
async function attemptAllStrategies(
|
||||||
tokenEndpoint: string,
|
tokenEndpoint: string,
|
||||||
|
serverUrl: string,
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
slot: number,
|
slot: number,
|
||||||
): Promise<NextResponse> {
|
): Promise<NextResponse> {
|
||||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||||
|
|
||||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
||||||
const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
const 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 basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||||
|
|
||||||
@@ -125,6 +146,7 @@ async function attemptAllStrategies(
|
|||||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||||
@@ -136,6 +158,7 @@ async function attemptAllStrategies(
|
|||||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||||
@@ -147,6 +170,7 @@ async function attemptAllStrategies(
|
|||||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||||
@@ -158,6 +182,7 @@ async function attemptAllStrategies(
|
|||||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,89 +1,9 @@
|
|||||||
import { lookup } from 'node:dns/promises';
|
|
||||||
import { BlockList, isIP } from 'node:net';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
|
|
||||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const FETCH_TIMEOUT_MS = 15000;
|
const FETCH_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
const blockedAddressRanges = new BlockList();
|
|
||||||
blockedAddressRanges.addAddress('0.0.0.0');
|
|
||||||
blockedAddressRanges.addAddress('127.0.0.1');
|
|
||||||
blockedAddressRanges.addSubnet('10.0.0.0', 8);
|
|
||||||
blockedAddressRanges.addSubnet('172.16.0.0', 12);
|
|
||||||
blockedAddressRanges.addSubnet('192.168.0.0', 16);
|
|
||||||
blockedAddressRanges.addSubnet('169.254.0.0', 16);
|
|
||||||
blockedAddressRanges.addAddress('::', 'ipv6');
|
|
||||||
blockedAddressRanges.addAddress('::1', 'ipv6');
|
|
||||||
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
|
|
||||||
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
|
|
||||||
|
|
||||||
function normalizeHostname(hostname: string): string {
|
|
||||||
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBlockedIpAddress(hostname: string): boolean {
|
|
||||||
const normalized = normalizeHostname(hostname);
|
|
||||||
const family = isIP(normalized);
|
|
||||||
if (family === 4) {
|
|
||||||
return blockedAddressRanges.check(normalized, 'ipv4');
|
|
||||||
}
|
|
||||||
if (family === 6) {
|
|
||||||
return blockedAddressRanges.check(normalized, 'ipv6');
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isValidExternalUrl(urlString: string): Promise<boolean> {
|
|
||||||
let url: URL;
|
|
||||||
try {
|
|
||||||
url = new URL(urlString);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostname = normalizeHostname(url.hostname);
|
|
||||||
|
|
||||||
// Block private/internal hostnames
|
|
||||||
if (
|
|
||||||
hostname === 'localhost' ||
|
|
||||||
hostname.endsWith('.localhost') ||
|
|
||||||
hostname.endsWith('.local') ||
|
|
||||||
hostname.endsWith('.internal') ||
|
|
||||||
hostname.endsWith('.arpa') ||
|
|
||||||
hostname.endsWith('.localdomain')
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block URLs with credentials
|
|
||||||
if (url.username || url.password) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isBlockedIpAddress(hostname)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isIP(hostname)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const records = await lookup(hostname, { all: true, verbatim: true });
|
|
||||||
if (records.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return records.every((record) => !isBlockedIpAddress(record.address));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { url?: string };
|
let body: { url?: string };
|
||||||
try {
|
try {
|
||||||
@@ -98,7 +18,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await isValidExternalUrl(url))) {
|
if (!(await isPublicHttpUrl(url))) {
|
||||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +31,7 @@ export async function POST(request: NextRequest) {
|
|||||||
let response: Response | undefined;
|
let response: Response | undefined;
|
||||||
|
|
||||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||||
if (!(await isValidExternalUrl(currentUrl))) {
|
if (!(await isPublicHttpUrl(currentUrl))) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -131,7 +51,6 @@ export async function POST(request: NextRequest) {
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
|
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
|
||||||
}
|
}
|
||||||
// Resolve relative redirects
|
|
||||||
currentUrl = new URL(location, currentUrl).toString();
|
currentUrl = new URL(location, currentUrl).toString();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/plugins — Public endpoint for clients to discover server-managed plugins & themes
|
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
|
||||||
*
|
*
|
||||||
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
|
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
|
||||||
* No admin auth required — this is how regular users receive plugins/themes.
|
* No admin auth required - this is how regular users receive plugins/themes.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/push/preview
|
||||||
|
*
|
||||||
|
* Called from the service worker when a Web Push wake-up arrives. Fetches the
|
||||||
|
* latest unread email so the SW can build an enriched system notification
|
||||||
|
* (sender, subject, avatar) without ever exposing JMAP credentials to the
|
||||||
|
* SW context.
|
||||||
|
*
|
||||||
|
* The relay's push payload is intentionally minimal (just a state-change
|
||||||
|
* ping), so this is what makes "From: Alice / Subject: …" appear instead of
|
||||||
|
* a generic "New mail" string.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) {
|
||||||
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
|
||||||
|
headers: { Authorization: creds.authHeader },
|
||||||
|
});
|
||||||
|
if (!sessionRes.ok) {
|
||||||
|
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
const session = (await sessionRes.json()) as {
|
||||||
|
apiUrl?: string;
|
||||||
|
primaryAccounts?: Record<string, string>;
|
||||||
|
};
|
||||||
|
const apiUrl = session.apiUrl;
|
||||||
|
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
|
||||||
|
if (!apiUrl || !accountId) {
|
||||||
|
return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the inbox, then pull the most recent unread message in it. We use
|
||||||
|
// a single batched JMAP request with back-references so this round-trip
|
||||||
|
// is one POST regardless of how many messages exist.
|
||||||
|
const requestBody = {
|
||||||
|
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
||||||
|
methodCalls: [
|
||||||
|
[
|
||||||
|
'Mailbox/query',
|
||||||
|
{ accountId, filter: { role: 'inbox' }, limit: 1 },
|
||||||
|
'mb',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Email/query',
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
filter: {
|
||||||
|
operator: 'AND',
|
||||||
|
conditions: [
|
||||||
|
{ inMailbox: { resultOf: 'mb', name: 'Mailbox/query', path: '/ids/0' } },
|
||||||
|
{ notKeyword: '$seen' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||||
|
limit: 1,
|
||||||
|
calculateTotal: true,
|
||||||
|
},
|
||||||
|
'eq',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Email/get',
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
'#ids': { resultOf: 'eq', name: 'Email/query', path: '/ids' },
|
||||||
|
properties: ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt'],
|
||||||
|
},
|
||||||
|
'eg',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const jmapRes = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: creds.authHeader,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
if (!jmapRes.ok) {
|
||||||
|
return NextResponse.json({ error: 'JMAP request failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
const data = (await jmapRes.json()) as {
|
||||||
|
methodResponses: [string, Record<string, unknown>, string][];
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmailLite = {
|
||||||
|
id: string;
|
||||||
|
threadId: string;
|
||||||
|
from?: { name?: string | null; email?: string }[] | null;
|
||||||
|
subject?: string | null;
|
||||||
|
preview?: string | null;
|
||||||
|
receivedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let email: EmailLite | null = null;
|
||||||
|
let unreadTotal = 0;
|
||||||
|
for (const [method, body] of data.methodResponses) {
|
||||||
|
if (method === 'Email/query') {
|
||||||
|
unreadTotal = ((body as { total?: number }).total) ?? 0;
|
||||||
|
}
|
||||||
|
if (method === 'Email/get') {
|
||||||
|
const list = (body as { list?: EmailLite[] }).list ?? [];
|
||||||
|
email = list[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
email,
|
||||||
|
unreadTotal,
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
// SW already gates on its own logic - don't let push events get
|
||||||
|
// cached and served stale.
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// `fetch failed` from undici is too generic to debug — the real reason
|
||||||
|
// (ENOTFOUND, ECONNREFUSED, TLS error, …) is on `error.cause`.
|
||||||
|
const err = error as Error & { cause?: { code?: string; message?: string } };
|
||||||
|
logger.error('push preview failed', {
|
||||||
|
error: err?.message ?? 'Unknown error',
|
||||||
|
causeCode: err?.cause?.code,
|
||||||
|
causeMessage: err?.cause?.message,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const VALID_SIZES = new Set([192, 512]);
|
||||||
|
|
||||||
|
// Cache resized images in memory to avoid reprocessing on every request
|
||||||
|
const cache = new Map<number, Blob>();
|
||||||
|
|
||||||
|
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||||
|
// Absolute URL (http/https)
|
||||||
|
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
|
||||||
|
const res = await fetch(iconUrl);
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch PWA icon: ${res.status}`);
|
||||||
|
return Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path relative to public/ directory
|
||||||
|
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||||
|
return readFile(publicPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ size: string }> }
|
||||||
|
) {
|
||||||
|
const { size: sizeParam } = await params;
|
||||||
|
const size = parseInt(sizeParam, 10);
|
||||||
|
|
||||||
|
if (!VALID_SIZES.has(size)) {
|
||||||
|
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL;
|
||||||
|
if (!iconUrl) {
|
||||||
|
return new NextResponse('No PWA icon configured', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pngHeaders = {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (cache.has(size)) {
|
||||||
|
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceBuffer = await fetchSourceImage(iconUrl);
|
||||||
|
const resized = await sharp(sourceBuffer)
|
||||||
|
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const ab = new ArrayBuffer(resized.byteLength);
|
||||||
|
new Uint8Array(ab).set(resized);
|
||||||
|
const blob = new Blob([ab], { type: 'image/png' });
|
||||||
|
cache.set(size, blob);
|
||||||
|
|
||||||
|
return new NextResponse(blob, { headers: pngHeaders });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to generate PWA icon:', err);
|
||||||
|
return new NextResponse('Failed to generate icon', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,7 +135,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce admin policy — strip locked settings so users can't override them
|
// Enforce admin policy - strip locked settings so users can't override them
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const policy = configManager.getPolicy();
|
const policy = configManager.getPolicy();
|
||||||
const filteredSettings = { ...settings };
|
const filteredSettings = { ...settings };
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const davPath = request.headers.get('X-WebDAV-Path') || '/';
|
const davPath = request.headers.get('X-WebDAV-Path') || '/';
|
||||||
const baseUrl = creds.apiUrl.replace(/\/$/, '');
|
const baseUrl = creds.serverUrl.replace(/\/$/, '');
|
||||||
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
|
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
|
||||||
|
|
||||||
// Build headers for the upstream request
|
// Build headers for the upstream request
|
||||||
|
|||||||
+81
-1
@@ -204,15 +204,44 @@ body {
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Forces light-theme CSS variables inside the email content area, so when
|
||||||
|
"Always Show Emails in Light Mode" is enabled in dark theme the surrounding
|
||||||
|
sender info / attachments / plain-text body don't end up with light text
|
||||||
|
on a white background. */
|
||||||
|
.email-content-light {
|
||||||
|
--color-background: #ffffff;
|
||||||
|
--color-foreground: #0f172a;
|
||||||
|
--color-muted: #f1f5f9;
|
||||||
|
--color-muted-foreground: #64748b;
|
||||||
|
--color-border: #e2e8f0;
|
||||||
|
--color-card: #ffffff;
|
||||||
|
--color-card-foreground: #0f172a;
|
||||||
|
--color-popover: #ffffff;
|
||||||
|
--color-popover-foreground: #0f172a;
|
||||||
|
--color-secondary: #f8fafc;
|
||||||
|
--color-secondary-foreground: #0f172a;
|
||||||
|
--color-accent: #dbeafe;
|
||||||
|
--color-accent-foreground: #1e40af;
|
||||||
|
--color-input: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-content-light .email-content-text a {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
.email-content-text {
|
.email-content-text {
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-content-text a {
|
.email-content-text a {
|
||||||
color: var(--color-primary);
|
color: #2563eb;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .email-content-text a {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
.email-content {
|
.email-content {
|
||||||
font-family:
|
font-family:
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||||
@@ -676,3 +705,54 @@ body {
|
|||||||
.tiptap .ProseMirror-selectednode img {
|
.tiptap .ProseMirror-selectednode img {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tiptap table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap table td,
|
||||||
|
.tiptap table th {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
vertical-align: top;
|
||||||
|
position: relative;
|
||||||
|
min-width: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap table th {
|
||||||
|
background-color: var(--color-muted) !important;
|
||||||
|
color: var(--color-foreground) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap table p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap table .selectedCell::after {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
content: "";
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap table .column-resize-handle {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
bottom: -2px;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
top: 0;
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap.resize-cursor {
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
return {
|
return {
|
||||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||||
description: "Minimalist webmail client using JMAP protocol",
|
description: "Minimalist webmail client using JMAP protocol",
|
||||||
manifest: "/manifest.json",
|
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
statusBarStyle: "black-translucent",
|
statusBarStyle: "black-translucent",
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
const appName =
|
||||||
|
process.env.APP_NAME ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||||
|
"Bulwark Webmail";
|
||||||
|
|
||||||
|
const shortName = process.env.APP_SHORT_NAME || appName;
|
||||||
|
const description =
|
||||||
|
process.env.APP_DESCRIPTION ||
|
||||||
|
"A modern webmail client built for Stalwart Mail Server";
|
||||||
|
const themeColor = process.env.PWA_THEME_COLOR || "#ffffff";
|
||||||
|
const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff";
|
||||||
|
|
||||||
|
// If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs
|
||||||
|
// via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs.
|
||||||
|
const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL);
|
||||||
|
|
||||||
|
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||||
|
? [
|
||||||
|
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ src: "/icon-192x192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icon-maskable-light-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icon-maskable-dark-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icon-maskable-dark-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: appName,
|
||||||
|
short_name: shortName,
|
||||||
|
description,
|
||||||
|
start_url: "/",
|
||||||
|
scope: "/",
|
||||||
|
display: "standalone",
|
||||||
|
orientation: "portrait-primary",
|
||||||
|
theme_color: themeColor,
|
||||||
|
background_color: backgroundColor,
|
||||||
|
icons,
|
||||||
|
categories: ["productivity"],
|
||||||
|
screenshots: [
|
||||||
|
{ src: "/screenshot-540x720.png", sizes: "540x720", type: "image/png" },
|
||||||
|
{ src: "/screenshot-1280x720.png", sizes: "1280x720", type: "image/png" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ interface CalendarAgendaViewProps {
|
|||||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export function CalendarAgendaView({
|
|||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
|
onContextMenuEvent,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
}: CalendarAgendaViewProps) {
|
}: CalendarAgendaViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
@@ -157,6 +159,7 @@ export function CalendarAgendaView({
|
|||||||
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
|
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
|
||||||
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}
|
||||||
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
|
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
|
||||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ interface CalendarDayViewProps {
|
|||||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
|
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
@@ -37,6 +39,8 @@ export function CalendarDayView({
|
|||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
|
onContextMenuEvent,
|
||||||
|
onContextMenuEmpty,
|
||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -117,6 +121,7 @@ export function CalendarDayView({
|
|||||||
created: t("notifications.event_created"),
|
created: t("notifications.event_created"),
|
||||||
error: t("notifications.event_error"),
|
error: t("notifications.event_error"),
|
||||||
},
|
},
|
||||||
|
isMobile,
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatHour = (h: number): string => {
|
const formatHour = (h: number): string => {
|
||||||
@@ -141,7 +146,13 @@ export function CalendarDayView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
|
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
|
||||||
<div className="px-4 py-2 border-b border-border">
|
<div
|
||||||
|
className="px-4 py-2 border-b border-border"
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("[data-calendar-event],button")) return;
|
||||||
|
onContextMenuEmpty(e, selectedDate, undefined, true);
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
{allDayEvents.length > 0 && (
|
{allDayEvents.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
||||||
@@ -157,6 +168,7 @@ export function CalendarDayView({
|
|||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
|
onContextMenu={onContextMenuEvent}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -236,6 +248,7 @@ export function CalendarDayView({
|
|||||||
aria-label={formatHour(h)}
|
aria-label={formatHour(h)}
|
||||||
onClick={() => handleSlotClick(selectedDate, h)}
|
onClick={() => handleSlotClick(selectedDate, h)}
|
||||||
onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)}
|
onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)}
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, selectedDate, h, false) : undefined}
|
||||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
/>
|
/>
|
||||||
@@ -243,9 +256,11 @@ export function CalendarDayView({
|
|||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
|
||||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const isResizing = resizeVisual?.eventId === ev.id;
|
||||||
|
const top = isResizing ? resizeVisual!.topPx : baseTop;
|
||||||
|
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
|
||||||
const calId = getPrimaryCalendarId(ev);
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const leftPct = (column / totalColumns) * 100;
|
const leftPct = (column / totalColumns) * 100;
|
||||||
const widthPct = (1 / totalColumns) * 100;
|
const widthPct = (1 / totalColumns) * 100;
|
||||||
@@ -264,13 +279,24 @@ export function CalendarDayView({
|
|||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
|
onContextMenu={onContextMenuEvent}
|
||||||
draggable
|
draggable
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
data-resize-handle
|
||||||
|
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||||
|
aria-label={t("events.resize")}
|
||||||
|
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
|
||||||
|
onPointerMove={handleResizePointerMove}
|
||||||
|
onPointerUp={handleResizePointerUp}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
data-resize-handle
|
data-resize-handle
|
||||||
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||||
aria-label={t("events.resize")}
|
aria-label={t("events.resize")}
|
||||||
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
|
||||||
onPointerMove={handleResizePointerMove}
|
onPointerMove={handleResizePointerMove}
|
||||||
onPointerUp={handleResizePointerUp}
|
onPointerUp={handleResizePointerUp}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ interface CalendarMonthViewProps {
|
|||||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
|
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||||
onCreateAtTime?: (date: Date) => void;
|
onCreateAtTime?: (date: Date) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
@@ -37,6 +39,8 @@ export function CalendarMonthView({
|
|||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
|
onContextMenuEvent,
|
||||||
|
onContextMenuEmpty,
|
||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -172,6 +176,7 @@ export function CalendarMonthView({
|
|||||||
aria-label={fullDateLabel}
|
aria-label={fullDateLabel}
|
||||||
onClick={() => onSelectDate(day)}
|
onClick={() => onSelectDate(day)}
|
||||||
onDoubleClick={() => onCreateAtTime?.(day)}
|
onDoubleClick={() => onCreateAtTime?.(day)}
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
|
||||||
onDragOver={(e) => handleCellDragOver(e, key)}
|
onDragOver={(e) => handleCellDragOver(e, key)}
|
||||||
onDragLeave={handleCellDragLeave}
|
onDragLeave={handleCellDragLeave}
|
||||||
onDrop={(e) => handleCellDrop(e, day)}
|
onDrop={(e) => handleCellDrop(e, day)}
|
||||||
@@ -279,6 +284,7 @@ export function CalendarMonthView({
|
|||||||
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
|
onContextMenu={onContextMenuEvent}
|
||||||
draggable
|
draggable
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
|
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } 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";
|
||||||
@@ -11,6 +11,8 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||||
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
interface CalendarSidebarPanelProps {
|
interface CalendarSidebarPanelProps {
|
||||||
@@ -18,6 +20,11 @@ interface CalendarSidebarPanelProps {
|
|||||||
selectedCalendarIds: string[];
|
selectedCalendarIds: string[];
|
||||||
onToggleVisibility: (id: string) => void;
|
onToggleVisibility: (id: string) => void;
|
||||||
onColorChange?: (calendarId: string, color: string) => void;
|
onColorChange?: (calendarId: string, color: string) => void;
|
||||||
|
onShareCalendar?: (calendar: Calendar) => void;
|
||||||
|
onCreateEvent?: (calendar: Calendar) => void;
|
||||||
|
onClearCalendar?: (calendar: Calendar) => void;
|
||||||
|
onDeleteCalendar?: (calendar: Calendar) => void;
|
||||||
|
onCreateCalendar?: () => void;
|
||||||
onSubscribe?: () => void;
|
onSubscribe?: () => void;
|
||||||
onEditSubscription?: (subscriptionId: string) => void;
|
onEditSubscription?: (subscriptionId: string) => void;
|
||||||
client?: IJMAPClient | null;
|
client?: IJMAPClient | null;
|
||||||
@@ -28,12 +35,18 @@ export function CalendarSidebarPanel({
|
|||||||
selectedCalendarIds,
|
selectedCalendarIds,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
|
onShareCalendar,
|
||||||
|
onCreateEvent,
|
||||||
|
onClearCalendar,
|
||||||
|
onDeleteCalendar,
|
||||||
|
onCreateCalendar,
|
||||||
onSubscribe,
|
onSubscribe,
|
||||||
onEditSubscription,
|
onEditSubscription,
|
||||||
client,
|
client,
|
||||||
}: CalendarSidebarPanelProps) {
|
}: CalendarSidebarPanelProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const tSub = useTranslations("calendar.subscription");
|
const tSub = useTranslations("calendar.subscription");
|
||||||
|
const tMgmt = useTranslations("calendar.management");
|
||||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
||||||
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
||||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||||
@@ -49,11 +62,8 @@ export function CalendarSidebarPanel({
|
|||||||
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
|
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
|
||||||
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
|
|
||||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
||||||
const colorPickerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
||||||
const sharedAccountGroups = useMemo(() => {
|
const sharedAccountGroups = useMemo(() => {
|
||||||
@@ -69,30 +79,6 @@ export function CalendarSidebarPanel({
|
|||||||
return Array.from(groups.values());
|
return Array.from(groups.values());
|
||||||
}, [calendars]);
|
}, [calendars]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!colorPickerId && !contextMenuCalId) return;
|
|
||||||
const handleClick = (e: MouseEvent) => {
|
|
||||||
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
|
|
||||||
setColorPickerId(null);
|
|
||||||
}
|
|
||||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
|
||||||
setContextMenuCalId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
setColorPickerId(null);
|
|
||||||
setContextMenuCalId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClick);
|
|
||||||
document.addEventListener('keydown', handleKey);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClick);
|
|
||||||
document.removeEventListener('keydown', handleKey);
|
|
||||||
};
|
|
||||||
}, [colorPickerId, contextMenuCalId]);
|
|
||||||
|
|
||||||
const getSubscriptionForCalendar = (calendarId: string) => {
|
const getSubscriptionForCalendar = (calendarId: string) => {
|
||||||
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
||||||
};
|
};
|
||||||
@@ -100,7 +86,6 @@ export function CalendarSidebarPanel({
|
|||||||
const handleRefreshSubscription = async (subId: string) => {
|
const handleRefreshSubscription = async (subId: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
setRefreshingSubId(subId);
|
setRefreshingSubId(subId);
|
||||||
setContextMenuCalId(null);
|
|
||||||
try {
|
try {
|
||||||
await refreshICalSubscription(client, subId);
|
await refreshICalSubscription(client, subId);
|
||||||
toast.success(tSub('refresh_success'));
|
toast.success(tSub('refresh_success'));
|
||||||
@@ -113,7 +98,6 @@ export function CalendarSidebarPanel({
|
|||||||
|
|
||||||
const handleUnsubscribe = async (subId: string) => {
|
const handleUnsubscribe = async (subId: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
setContextMenuCalId(null);
|
|
||||||
try {
|
try {
|
||||||
await removeICalSubscription(client, subId);
|
await removeICalSubscription(client, subId);
|
||||||
toast.success(tSub('deleted'));
|
toast.success(tSub('deleted'));
|
||||||
@@ -127,21 +111,13 @@ export function CalendarSidebarPanel({
|
|||||||
const renderCalendarItem = (cal: Calendar) => {
|
const renderCalendarItem = (cal: Calendar) => {
|
||||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||||
const color = cal.color || "#3b82f6";
|
const color = cal.color || "#3b82f6";
|
||||||
|
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={cal.id} className="relative">
|
<div key={cal.id} className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggleVisibility(cal.id)}
|
onClick={() => onToggleVisibility(cal.id)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
|
||||||
e.preventDefault();
|
|
||||||
if (isSubscriptionCalendar(cal.id) && client) {
|
|
||||||
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
|
|
||||||
setColorPickerId(null);
|
|
||||||
} else if (onColorChange) {
|
|
||||||
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
|
|
||||||
setContextMenuCalId(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
||||||
"hover:bg-muted"
|
"hover:bg-muted"
|
||||||
@@ -169,70 +145,101 @@ export function CalendarSidebarPanel({
|
|||||||
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Subscription context menu on right-click */}
|
|
||||||
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
|
|
||||||
const sub = getSubscriptionForCalendar(cal.id);
|
|
||||||
if (!sub) return null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={contextMenuRef}
|
|
||||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setContextMenuCalId(null);
|
|
||||||
onEditSubscription?.(sub.id);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
|
||||||
{tSub('edit')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRefreshSubscription(sub.id)}
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
|
||||||
{tSub('refresh')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleUnsubscribe(sub.id)}
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
{tSub('unsubscribe')}
|
|
||||||
</button>
|
|
||||||
{sub.lastRefreshed && (
|
|
||||||
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
|
|
||||||
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Color picker popover on right-click */}
|
|
||||||
{colorPickerId === cal.id && onColorChange && (
|
|
||||||
<div
|
|
||||||
ref={colorPickerRef}
|
|
||||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
|
|
||||||
>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
|
|
||||||
<CalendarColorPicker
|
|
||||||
value={color}
|
|
||||||
onChange={(c) => {
|
|
||||||
onColorChange(cal.id, c);
|
|
||||||
setColorPickerId(null);
|
|
||||||
}}
|
|
||||||
allowCustom
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderCalendarMenu = () => {
|
||||||
|
const cal = contextMenu.data;
|
||||||
|
if (!cal) return null;
|
||||||
|
|
||||||
|
if (isSubscriptionCalendar(cal.id)) {
|
||||||
|
const sub = getSubscriptionForCalendar(cal.id);
|
||||||
|
if (!sub || !client) return null;
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={tSub('edit')}
|
||||||
|
onClick={() => { closeContextMenu(); onEditSubscription?.(sub.id); }}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={RefreshCw}
|
||||||
|
label={tSub('refresh')}
|
||||||
|
onClick={() => { closeContextMenu(); handleRefreshSubscription(sub.id); }}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={tSub('unsubscribe')}
|
||||||
|
onClick={() => { closeContextMenu(); handleUnsubscribe(sub.id); }}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
{sub.lastRefreshed && (
|
||||||
|
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
|
||||||
|
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
|
||||||
|
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
||||||
|
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
||||||
|
const canChangeColor = !!onColorChange;
|
||||||
|
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
||||||
|
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
||||||
|
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
|
||||||
|
const color = cal.color || "#3b82f6";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||||
|
{canCreate && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Plus}
|
||||||
|
label={tMgmt('new_event_in_calendar')}
|
||||||
|
onClick={() => { closeContextMenu(); onCreateEvent(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canShare && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Users}
|
||||||
|
label={tMgmt('share')}
|
||||||
|
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canChangeColor && (
|
||||||
|
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
|
||||||
|
<div className="px-2 py-1.5 w-[200px]">
|
||||||
|
<CalendarColorPicker
|
||||||
|
value={color}
|
||||||
|
onChange={(c) => { onColorChange(cal.id, c); closeContextMenu(); }}
|
||||||
|
allowCustom
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ContextMenuSubMenu>
|
||||||
|
)}
|
||||||
|
{showSeparator && <ContextMenuSeparator />}
|
||||||
|
{canClear && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Eraser}
|
||||||
|
label={tMgmt('clear_events')}
|
||||||
|
onClick={() => { closeContextMenu(); onClearCalendar(cal); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={tMgmt('delete')}
|
||||||
|
onClick={() => { closeContextMenu(); onDeleteCalendar(cal); }}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{enableCalendarTasks && (
|
{enableCalendarTasks && (
|
||||||
@@ -250,9 +257,22 @@ export function CalendarSidebarPanel({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
<div className="flex items-center justify-between mb-2 px-1 group">
|
||||||
{t("my_calendars")}
|
{onCreateCalendar ? (
|
||||||
</h3>
|
<button
|
||||||
|
onClick={onCreateCalendar}
|
||||||
|
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||||
|
title={tMgmt('add_calendar')}
|
||||||
|
>
|
||||||
|
{t('my_calendars')}
|
||||||
|
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{t('my_calendars')}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{personalCalendars.map(renderCalendarItem)}
|
{personalCalendars.map(renderCalendarItem)}
|
||||||
</div>
|
</div>
|
||||||
@@ -268,6 +288,8 @@ export function CalendarSidebarPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{renderCalendarMenu()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,6 +260,9 @@ 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">
|
||||||
|
{t("views.today")}
|
||||||
|
</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")}>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -279,14 +282,14 @@ export function CalendarToolbar({
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="flex border border-border rounded-md overflow-hidden">
|
<div className="flex h-8 border border-border rounded-md overflow-hidden">
|
||||||
{views.map((v) => (
|
{views.map((v) => (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
onClick={() => onViewModeChange(v)}
|
onClick={() => onViewModeChange(v)}
|
||||||
title={t(`views.${v}_hint`)}
|
title={t(`views.${v}_hint`)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1.5 text-xs font-medium transition-colors",
|
"inline-flex items-center px-3 text-xs font-medium transition-colors",
|
||||||
v === viewMode
|
v === viewMode
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "hover:bg-muted text-muted-foreground"
|
: "hover:bg-muted text-muted-foreground"
|
||||||
@@ -300,7 +303,7 @@ export function CalendarToolbar({
|
|||||||
|
|
||||||
{(onImport || onSubscribe) && !isMobile && (
|
{(onImport || onSubscribe) && !isMobile && (
|
||||||
<div className="relative" ref={importDropdownRef}>
|
<div className="relative" ref={importDropdownRef}>
|
||||||
<Button variant="outline" size="sm" 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 mr-1" />
|
||||||
{t("import.title")}
|
{t("import.title")}
|
||||||
<ChevronDown className="w-3 h-3 ml-1" />
|
<ChevronDown className="w-3 h-3 ml-1" />
|
||||||
@@ -331,7 +334,7 @@ export function CalendarToolbar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Button size="sm" 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 mr-1" />
|
||||||
{t("events.create")}
|
{t("events.create")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface CalendarWeekViewProps {
|
|||||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onHoverLeave?: () => void;
|
onHoverLeave?: () => void;
|
||||||
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
|
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
@@ -42,6 +44,8 @@ export function CalendarWeekView({
|
|||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
onHoverEvent,
|
onHoverEvent,
|
||||||
onHoverLeave,
|
onHoverLeave,
|
||||||
|
onContextMenuEvent,
|
||||||
|
onContextMenuEmpty,
|
||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
@@ -181,6 +185,7 @@ export function CalendarWeekView({
|
|||||||
created: t("notifications.event_created"),
|
created: t("notifications.event_created"),
|
||||||
error: t("notifications.event_error"),
|
error: t("notifications.event_error"),
|
||||||
},
|
},
|
||||||
|
isMobile,
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatHour = (h: number): string => {
|
const formatHour = (h: number): string => {
|
||||||
@@ -216,7 +221,11 @@ export function CalendarWeekView({
|
|||||||
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||||
>
|
>
|
||||||
{weekDays.map((day) => (
|
{weekDays.map((day) => (
|
||||||
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" />
|
<div
|
||||||
|
key={format(day, "yyyy-MM-dd")}
|
||||||
|
className="bg-background min-h-[28px]"
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
@@ -242,6 +251,7 @@ export function CalendarWeekView({
|
|||||||
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
|
onContextMenu={onContextMenuEvent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -374,6 +384,7 @@ export function CalendarWeekView({
|
|||||||
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
|
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
|
||||||
onClick={() => handleSlotClick(day, h)}
|
onClick={() => handleSlotClick(day, h)}
|
||||||
onDoubleClick={() => handleSlotDoubleClick(day, h)}
|
onDoubleClick={() => handleSlotDoubleClick(day, h)}
|
||||||
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined}
|
||||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
/>
|
/>
|
||||||
@@ -381,9 +392,11 @@ export function CalendarWeekView({
|
|||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
|
||||||
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const isResizing = resizeVisual?.eventId === ev.id;
|
||||||
|
const top = isResizing ? resizeVisual!.topPx : baseTop;
|
||||||
|
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
|
||||||
const calId = getPrimaryCalendarId(ev);
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const leftPct = (column / totalColumns) * 100;
|
const leftPct = (column / totalColumns) * 100;
|
||||||
const widthPct = (1 / totalColumns) * 100;
|
const widthPct = (1 / totalColumns) * 100;
|
||||||
@@ -402,13 +415,24 @@ export function CalendarWeekView({
|
|||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||||
onMouseLeave={onHoverLeave}
|
onMouseLeave={onHoverLeave}
|
||||||
|
onContextMenu={onContextMenuEvent}
|
||||||
draggable
|
draggable
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
data-resize-handle
|
||||||
|
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||||
|
aria-label={t("events.resize")}
|
||||||
|
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
|
||||||
|
onPointerMove={handleResizePointerMove}
|
||||||
|
onPointerUp={handleResizePointerUp}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
data-resize-handle
|
data-resize-handle
|
||||||
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||||
aria-label={t("events.resize")}
|
aria-label={t("events.resize")}
|
||||||
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
|
||||||
onPointerMove={handleResizePointerMove}
|
onPointerMove={handleResizePointerMove}
|
||||||
onPointerUp={handleResizePointerUp}
|
onPointerUp={handleResizePointerUp}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { X, Loader2, Calendar as CalendarIcon } from "lucide-react";
|
||||||
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
|
interface CreateCalendarModalProps {
|
||||||
|
client: IJMAPClient;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProps) {
|
||||||
|
const t = useTranslations("calendar.management");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const createCalendar = useCalendarStore((s) => s.createCalendar);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [color, setColor] = useState("#3b82f6");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const isValid = name.trim().length > 0;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const created = await createCalendar(client, { name: trimmed, color });
|
||||||
|
if (created) {
|
||||||
|
toast.success(t("calendar_created"));
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
toast.error(t("error_create"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("error_create"));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [name, color, client, createCalendar, onClose, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && !isSubmitting) onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
}, [onClose, isSubmitting]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const modal = modalRef.current;
|
||||||
|
if (!modal) return;
|
||||||
|
const focusableEls = modal.querySelectorAll<HTMLElement>(
|
||||||
|
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
const firstEl = focusableEls[0];
|
||||||
|
const lastEl = focusableEls[focusableEls.length - 1];
|
||||||
|
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
if (e.shiftKey && document.activeElement === firstEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastEl?.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === lastEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstEl?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
modal.addEventListener("keydown", handler);
|
||||||
|
firstEl?.focus();
|
||||||
|
return () => modal.removeEventListener("keydown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-[1px]"
|
||||||
|
onClick={() => !isSubmitting && onClose()}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("add_calendar")}
|
||||||
|
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarIcon className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">{t("add_calendar")}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
aria-label={tCommon("close")}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
|
{t("name")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("name_placeholder")}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
|
{t("color")}
|
||||||
|
</label>
|
||||||
|
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||||
|
{tCommon("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
{tCommon("loading")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("create")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import { Plus, CalendarDays, CheckSquare, Clock } from "lucide-react";
|
||||||
|
|
||||||
|
interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmptySpaceContextMenuProps {
|
||||||
|
position: Position;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
onNewEvent: () => void;
|
||||||
|
onNewAllDayEvent: () => void;
|
||||||
|
onNewTask?: () => void;
|
||||||
|
onGoToToday: () => void;
|
||||||
|
showAllDayOption?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptySpaceContextMenu({
|
||||||
|
position,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
menuRef,
|
||||||
|
onNewEvent,
|
||||||
|
onNewAllDayEvent,
|
||||||
|
onNewTask,
|
||||||
|
onGoToToday,
|
||||||
|
showAllDayOption = true,
|
||||||
|
}: EmptySpaceContextMenuProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
|
||||||
|
const handle = (fn: () => void) => () => {
|
||||||
|
fn();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuItem icon={Plus} label={t("events.new_event")} onClick={handle(onNewEvent)} />
|
||||||
|
{showAllDayOption && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={CalendarDays}
|
||||||
|
label={t("events.new_all_day_event")}
|
||||||
|
onClick={handle(onNewAllDayEvent)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onNewTask && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={CheckSquare}
|
||||||
|
label={t("events.new_task")}
|
||||||
|
onClick={handle(onNewTask)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem icon={Clock} label={t("events.go_to_today")} onClick={handle(onGoToToday)} />
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ interface EventCardProps {
|
|||||||
onClick?: (anchorRect: DOMRect) => void;
|
onClick?: (anchorRect: DOMRect) => void;
|
||||||
onMouseEnter?: (anchorRect: DOMRect) => void;
|
onMouseEnter?: (anchorRect: DOMRect) => void;
|
||||||
onMouseLeave?: () => void;
|
onMouseLeave?: () => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
draggable?: boolean;
|
draggable?: boolean;
|
||||||
continuesBefore?: boolean;
|
continuesBefore?: boolean;
|
||||||
@@ -65,7 +66,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
|
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, 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);
|
||||||
@@ -109,12 +110,15 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
"aria-roledescription": "draggable event",
|
"aria-roledescription": "draggable event",
|
||||||
} : {};
|
} : {};
|
||||||
|
|
||||||
|
const handleContextMenu = onContextMenu ? (e: React.MouseEvent) => onContextMenu(e, event) : undefined;
|
||||||
|
|
||||||
if (variant === "chip") {
|
if (variant === "chip") {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||||
onMouseLeave={() => onMouseLeave?.()}
|
onMouseLeave={() => onMouseLeave?.()}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -142,6 +146,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||||
onMouseLeave={() => onMouseLeave?.()}
|
onMouseLeave={() => onMouseLeave?.()}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -172,6 +177,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||||
onMouseLeave={() => onMouseLeave?.()}
|
onMouseLeave={() => onMouseLeave?.()}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
data-calendar-event
|
data-calendar-event
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import {
|
||||||
|
Pencil,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
ClipboardCopy,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
|
||||||
|
interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventContextMenuProps {
|
||||||
|
event: CalendarEvent;
|
||||||
|
position: Position;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDuplicate: () => void;
|
||||||
|
onExportICS: () => void;
|
||||||
|
onCopyTitle: () => void;
|
||||||
|
onCopyMeetingLink?: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventContextMenu({
|
||||||
|
event,
|
||||||
|
position,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
menuRef,
|
||||||
|
onEdit,
|
||||||
|
onDuplicate,
|
||||||
|
onExportICS,
|
||||||
|
onCopyTitle,
|
||||||
|
onCopyMeetingLink,
|
||||||
|
onDelete,
|
||||||
|
}: EventContextMenuProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
|
||||||
|
const handle = (fn: () => void) => () => {
|
||||||
|
fn();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMeetingLink = !!(
|
||||||
|
event.virtualLocations && Object.values(event.virtualLocations).some((v) => v.uri)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuItem icon={Pencil} label={t("events.edit")} onClick={handle(onEdit)} />
|
||||||
|
<ContextMenuItem icon={Copy} label={t("events.duplicate")} onClick={handle(onDuplicate)} />
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Download}
|
||||||
|
label={t("events.export_ics")}
|
||||||
|
onClick={handle(onExportICS)}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={ClipboardCopy}
|
||||||
|
label={t("events.copy_title")}
|
||||||
|
onClick={handle(onCopyTitle)}
|
||||||
|
/>
|
||||||
|
{hasMeetingLink && onCopyMeetingLink && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={LinkIcon}
|
||||||
|
label={t("events.copy_link")}
|
||||||
|
onClick={handle(onCopyMeetingLink)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("events.delete")}
|
||||||
|
onClick={handle(onDelete)}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
getUserStatus,
|
getUserStatus,
|
||||||
getParticipantList,
|
getParticipantList,
|
||||||
} from "@/lib/calendar-participants";
|
} from "@/lib/calendar-participants";
|
||||||
|
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||||
|
|
||||||
interface EventDetailPopoverProps {
|
interface EventDetailPopoverProps {
|
||||||
event: CalendarEvent;
|
event: CalendarEvent;
|
||||||
@@ -253,6 +254,8 @@ export function EventDetailPopover({
|
|||||||
|
|
||||||
const hasParticipants = participants.length > 0;
|
const hasParticipants = participants.length > 0;
|
||||||
|
|
||||||
|
const formatEventDate = useFormatEventDate();
|
||||||
|
|
||||||
const popover = (
|
const popover = (
|
||||||
<div
|
<div
|
||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
@@ -329,7 +332,7 @@ export function EventDetailPopover({
|
|||||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{format(startDate, "EEE, MMM d, yyyy")}
|
{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 ml-1.5">{t("events.all_day")}</span>
|
||||||
|
|||||||
+130
-112
@@ -9,7 +9,7 @@ import { format, parseISO, addHours, addDays } from "date-fns";
|
|||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { ParticipantInput } from "./participant-input";
|
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
getUserParticipantId,
|
getUserParticipantId,
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { generateUUID } from "@/lib/utils";
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||||
|
|
||||||
export interface PendingEventPreview {
|
export interface PendingEventPreview {
|
||||||
start: Date;
|
start: Date;
|
||||||
@@ -35,6 +36,8 @@ interface EventModalProps {
|
|||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
defaultDate?: Date;
|
defaultDate?: Date;
|
||||||
defaultEndDate?: Date;
|
defaultEndDate?: Date;
|
||||||
|
defaultAllDay?: boolean;
|
||||||
|
defaultCalendarId?: string;
|
||||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
||||||
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||||
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
||||||
@@ -113,6 +116,8 @@ export function EventModal({
|
|||||||
calendars,
|
calendars,
|
||||||
defaultDate,
|
defaultDate,
|
||||||
defaultEndDate,
|
defaultEndDate,
|
||||||
|
defaultAllDay,
|
||||||
|
defaultCalendarId,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
@@ -126,6 +131,7 @@ export function EventModal({
|
|||||||
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;
|
||||||
|
const formatEventDate = useFormatEventDate();
|
||||||
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
|
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
|
||||||
|
|
||||||
const userIsOrganizer = useMemo(() => {
|
const userIsOrganizer = useMemo(() => {
|
||||||
@@ -197,9 +203,10 @@ export function EventModal({
|
|||||||
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
||||||
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
||||||
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
|
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
|
||||||
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
const [allDay, setAllDay] = useState(event?.showWithoutTime || defaultAllDay || false);
|
||||||
const [calendarId, setCalendarId] = useState<string>(() => {
|
const [calendarId, setCalendarId] = useState<string>(() => {
|
||||||
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
||||||
|
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
|
||||||
const defaultCal = calendars.find(c => c.isDefault);
|
const defaultCal = calendars.find(c => c.isDefault);
|
||||||
return defaultCal?.id || calendars[0]?.id || "";
|
return defaultCal?.id || calendars[0]?.id || "";
|
||||||
});
|
});
|
||||||
@@ -233,6 +240,7 @@ export function EventModal({
|
|||||||
.map(p => ({ name: p.name, email: p.email }));
|
.map(p => ({ name: p.name, email: p.email }));
|
||||||
});
|
});
|
||||||
const [sendInvitations, setSendInvitations] = useState(true);
|
const [sendInvitations, setSendInvitations] = useState(true);
|
||||||
|
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
||||||
|
|
||||||
// Report live preview to parent for grid outline
|
// Report live preview to parent for grid outline
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -264,6 +272,9 @@ export function EventModal({
|
|||||||
if (!trimmedTitle || isSaving) return;
|
if (!trimmedTitle || isSaving) return;
|
||||||
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
|
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
|
||||||
|
|
||||||
|
const pendingAttendee = participantInputRef.current?.flush() ?? null;
|
||||||
|
const effectiveAttendees = pendingAttendee ? [...attendees, pendingAttendee] : attendees;
|
||||||
|
|
||||||
const startStr = allDay
|
const startStr = allDay
|
||||||
? `${startDate}T00:00:00`
|
? `${startDate}T00:00:00`
|
||||||
: `${startDate}T${startTime}:00`;
|
: `${startDate}T${startTime}:00`;
|
||||||
@@ -301,6 +312,10 @@ export function EventModal({
|
|||||||
privacy: "public",
|
privacy: "public",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
data.uid = generateUUID();
|
||||||
|
}
|
||||||
|
|
||||||
if (location.trim()) {
|
if (location.trim()) {
|
||||||
data.locations = {
|
data.locations = {
|
||||||
loc1: {
|
loc1: {
|
||||||
@@ -373,18 +388,20 @@ export function EventModal({
|
|||||||
data.alerts = null;
|
data.alerts = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attendees.length > 0 && currentUserEmails.length > 0) {
|
if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) {
|
||||||
const organizerEmail = currentUserEmails[0];
|
const organizerEmail = currentUserEmails[0];
|
||||||
const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || "";
|
const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || "";
|
||||||
data.participants = buildParticipantMap(
|
data.participants = buildParticipantMap(
|
||||||
{ name: organizerName, email: organizerEmail },
|
{ name: organizerName, email: organizerEmail },
|
||||||
attendees
|
effectiveAttendees
|
||||||
) as Record<string, CalendarParticipant>;
|
) as Record<string, CalendarParticipant>;
|
||||||
} else if (attendees.length === 0 && event?.participants) {
|
data.replyTo = { imip: `mailto:${organizerEmail}` };
|
||||||
|
} else if (effectiveAttendees.length === 0 && event?.participants) {
|
||||||
data.participants = null;
|
data.participants = null;
|
||||||
|
data.replyTo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
|
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await onSave(data, shouldSendScheduling);
|
await onSave(data, shouldSendScheduling);
|
||||||
@@ -480,14 +497,14 @@ export function EventModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||||
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
||||||
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 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 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="px-6 py-4 space-y-3">
|
<div className="px-6 py-4 space-y-3">
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3">
|
<div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3">
|
||||||
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||||
@@ -502,7 +519,7 @@ export function EventModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
|
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||||
{!event.showWithoutTime && (
|
{!event.showWithoutTime && (
|
||||||
<span className="text-muted-foreground ml-2">
|
<span className="text-muted-foreground ml-2">
|
||||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||||
@@ -535,48 +552,48 @@ export function EventModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 border-t border-border flex-shrink-0">
|
<div className="px-6 py-4 border-t border-border flex-shrink-0">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
|
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
||||||
onClick={() => handleRsvp("accepted")}
|
onClick={() => handleRsvp("accepted")}
|
||||||
className={userCurrentStatus === "accepted"
|
className={userCurrentStatus === "accepted"
|
||||||
? "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 mr-1" />}
|
||||||
{t("participants.accepted")}
|
{t("participants.accepted")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
||||||
onClick={() => handleRsvp("tentative")}
|
onClick={() => handleRsvp("tentative")}
|
||||||
className={userCurrentStatus === "tentative"
|
className={userCurrentStatus === "tentative"
|
||||||
? "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 mr-1" />}
|
||||||
{t("participants.tentative")}
|
{t("participants.tentative")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
||||||
onClick={() => handleRsvp("declined")}
|
onClick={() => handleRsvp("declined")}
|
||||||
className={userCurrentStatus === "declined"
|
className={userCurrentStatus === "declined"
|
||||||
? "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 mr-1" />}
|
||||||
{t("participants.declined")}
|
{t("participants.declined")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -623,7 +640,7 @@ export function EventModal({
|
|||||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{format(startD, "EEE, MMM d, yyyy")}
|
{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 ml-1.5">{t("events.all_day")}</span>
|
||||||
@@ -752,16 +769,16 @@ export function EventModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">
|
||||||
{isEdit ? t("events.edit") : t("events.create")}
|
{isEdit ? t("events.edit") : t("events.create")}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 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 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="px-6 py-4 space-y-4">
|
<div className="px-6 py-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
|
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
|
||||||
@@ -837,6 +854,7 @@ export function EventModal({
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<ParticipantInput
|
<ParticipantInput
|
||||||
|
ref={participantInputRef}
|
||||||
participants={attendees}
|
participants={attendees}
|
||||||
onAdd={handleAddAttendee}
|
onAdd={handleAddAttendee}
|
||||||
onRemove={handleRemoveAttendee}
|
onRemove={handleRemoveAttendee}
|
||||||
@@ -970,69 +988,69 @@ export function EventModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0">
|
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{isEdit && onDelete && (
|
{isEdit && onDelete && (
|
||||||
showDeleteConfirm ? (
|
showDeleteConfirm ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">
|
<span className="text-sm text-red-600 dark:text-red-400">
|
||||||
{t("form.delete_confirm")}
|
{t("form.delete_confirm")}
|
||||||
</span>
|
</span>
|
||||||
{hasParticipants && (
|
{hasParticipants && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{t("participants.cancel_notification")}
|
{t("participants.cancel_notification")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
|
||||||
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
|
||||||
>
|
|
||||||
{t("events.delete")}
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
|
||||||
{t("form.cancel")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
||||||
className="text-red-600 dark:text-red-400"
|
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
|
||||||
{t("events.delete")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||||
)}
|
{t("form.cancel")}
|
||||||
{isEdit && onDuplicate && !showDeleteConfirm && (
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleDuplicate}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
aria-label={t("events.duplicate")}
|
className="text-red-600 dark:text-red-400"
|
||||||
>
|
>
|
||||||
<Copy className="w-4 h-4 mr-1" />
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
{t("events.duplicate")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)
|
||||||
</div>
|
)}
|
||||||
|
{isEdit && onDuplicate && !showDeleteConfirm && (
|
||||||
<div className="flex gap-2">
|
<Button
|
||||||
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
|
variant="ghost"
|
||||||
{t("form.cancel")}
|
size="sm"
|
||||||
|
onClick={handleDuplicate}
|
||||||
|
aria-label={t("events.duplicate")}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4 mr-1" />
|
||||||
|
{t("events.duplicate")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={!title.trim() || isSaving}>
|
)}
|
||||||
{t("form.save")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={!title.trim() || isSaving}>
|
||||||
|
{t("form.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getEventStartDate } from "@/lib/calendar-utils";
|
|||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import { apiFetch } from "@/lib/browser-navigation";
|
||||||
|
|
||||||
interface ICalImportModalProps {
|
interface ICalImportModalProps {
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
@@ -126,7 +127,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
setIsParsing(true);
|
setIsParsing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/fetch-ical", {
|
const response = await apiFetch("/api/fetch-ical", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ url: trimmed }),
|
body: JSON.stringify({ url: trimmed }),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from "react";
|
||||||
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";
|
||||||
@@ -18,9 +18,13 @@ interface ParticipantInputProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParticipantInputHandle {
|
||||||
|
flush: () => Participant | null;
|
||||||
|
}
|
||||||
|
|
||||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
export function ParticipantInput({ participants, onAdd, onRemove, disabled }: ParticipantInputProps) {
|
export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantInputProps>(function ParticipantInput({ participants, onAdd, onRemove, disabled }, ref) {
|
||||||
const t = useTranslations("calendar.participants");
|
const t = useTranslations("calendar.participants");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState<Participant[]>([]);
|
const [suggestions, setSuggestions] = useState<Participant[]>([]);
|
||||||
@@ -86,8 +90,26 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
|
|||||||
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
|
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
|
||||||
|
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (trimmed && EMAIL_REGEX.test(trimmed)) {
|
||||||
|
addParticipant({ name: "", email: trimmed });
|
||||||
|
}
|
||||||
setTimeout(() => setShowSuggestions(false), 200);
|
setTimeout(() => setShowSuggestions(false), 200);
|
||||||
}, []);
|
}, [query, addParticipant]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
flush: () => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed || !EMAIL_REGEX.test(trimmed)) return null;
|
||||||
|
if (participants.some(e => e.email.toLowerCase() === trimmed.toLowerCase())) return null;
|
||||||
|
const p = { name: "", email: trimmed };
|
||||||
|
onAdd(p);
|
||||||
|
setQuery("");
|
||||||
|
setSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
}), [query, participants, onAdd]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -143,7 +165,22 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
|
|||||||
key={`${p.email}-${i}`}
|
key={`${p.email}-${i}`}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]"
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]"
|
||||||
>
|
>
|
||||||
<span className="truncate">{p.name || p.email}</span>
|
{!disabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onRemove(p.email);
|
||||||
|
setQuery(p.email);
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}}
|
||||||
|
className="truncate hover:underline focus:outline-none focus:underline cursor-text"
|
||||||
|
aria-label={`${t("edit")} ${p.name || p.email}`}
|
||||||
|
>
|
||||||
|
{p.name || p.email}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="truncate">{p.name || p.email}</span>
|
||||||
|
)}
|
||||||
{!disabled && (
|
{!disabled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -160,4 +197,4 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -60,10 +60,8 @@ describe('ContactDetail', () => {
|
|||||||
it('calls onDelete when delete button is clicked', () => {
|
it('calls onDelete when delete button is clicked', () => {
|
||||||
const onDelete = vi.fn();
|
const onDelete = vi.fn();
|
||||||
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
|
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
|
||||||
const deleteButton = screen.getAllByRole('button').find(
|
fireEvent.click(screen.getByLabelText('detail.more_actions'));
|
||||||
btn => btn.className.includes('text-red')
|
fireEvent.click(screen.getByRole('menuitem', { name: /context_menu\.delete/ }));
|
||||||
);
|
|
||||||
fireEvent.click(deleteButton!);
|
|
||||||
expect(onDelete).toHaveBeenCalledOnce();
|
expect(onDelete).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ const defaultProps = {
|
|||||||
onSearchChange: vi.fn(),
|
onSearchChange: vi.fn(),
|
||||||
onSelectContact: vi.fn(),
|
onSelectContact: vi.fn(),
|
||||||
onCreateNew: vi.fn(),
|
onCreateNew: vi.fn(),
|
||||||
categoryLabel: 'All Contacts',
|
|
||||||
selectedContactIds: new Set<string>(),
|
selectedContactIds: new Set<string>(),
|
||||||
onToggleSelection: vi.fn(),
|
onToggleSelection: vi.fn(),
|
||||||
onSelectRangeContacts: vi.fn(),
|
onSelectRangeContacts: vi.fn(),
|
||||||
@@ -45,6 +44,9 @@ const defaultProps = {
|
|||||||
onBulkDelete: vi.fn(),
|
onBulkDelete: vi.fn(),
|
||||||
onBulkAddToGroup: vi.fn(),
|
onBulkAddToGroup: vi.fn(),
|
||||||
onBulkExport: vi.fn(),
|
onBulkExport: vi.fn(),
|
||||||
|
onEditContact: vi.fn(),
|
||||||
|
onDeleteContact: vi.fn(),
|
||||||
|
onAddContactToGroup: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('ContactList', () => {
|
describe('ContactList', () => {
|
||||||
@@ -76,8 +78,4 @@ describe('ContactList', () => {
|
|||||||
expect(screen.getByText('bulk.export')).toBeInTheDocument();
|
expect(screen.getByText('bulk.export')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows category label with count', () => {
|
|
||||||
render(<ContactList {...defaultProps} />);
|
|
||||||
expect(screen.getByText('All Contacts (2)')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
|
import { Section } from "./contact-detail";
|
||||||
|
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
|
||||||
|
const EMAIL_LIMIT = 5;
|
||||||
|
const EVENT_LIMIT = 5;
|
||||||
|
const EVENT_LOOKAHEAD_DAYS = 365;
|
||||||
|
|
||||||
|
interface ContactActivityProps {
|
||||||
|
contact: ContactCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContactEmails(contact: ContactCard): string[] {
|
||||||
|
if (!contact.emails) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const e of Object.values(contact.emails)) {
|
||||||
|
const addr = e.address?.trim().toLowerCase();
|
||||||
|
if (addr && !seen.has(addr)) {
|
||||||
|
seen.add(addr);
|
||||||
|
result.push(addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmailFilter(addresses: string[]): Record<string, unknown> {
|
||||||
|
const conditions: Record<string, unknown>[] = [];
|
||||||
|
for (const addr of addresses) {
|
||||||
|
conditions.push({ from: addr });
|
||||||
|
conditions.push({ to: addr });
|
||||||
|
}
|
||||||
|
if (conditions.length === 1) return conditions[0];
|
||||||
|
return { operator: "OR", conditions };
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean {
|
||||||
|
if (event.participants) {
|
||||||
|
for (const p of Object.values(event.participants)) {
|
||||||
|
const email = p.email?.trim().toLowerCase();
|
||||||
|
if (email && addresses.has(email)) return true;
|
||||||
|
if (p.sendTo) {
|
||||||
|
for (const target of Object.values(p.sendTo)) {
|
||||||
|
const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null;
|
||||||
|
if (m && addresses.has(m[1].trim().toLowerCase())) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.organizerCalendarAddress) {
|
||||||
|
const m = event.organizerCalendarAddress.match(/mailto:(.+)/i);
|
||||||
|
const org = m ? m[1].trim().toLowerCase() : event.organizerCalendarAddress.trim().toLowerCase();
|
||||||
|
if (addresses.has(org)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmailSender(email: Email): { name: string; address: string } {
|
||||||
|
const from = email.from?.[0];
|
||||||
|
return {
|
||||||
|
name: from?.name?.trim() || from?.email || "",
|
||||||
|
address: from?.email || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
|
||||||
|
const groups = new Map<string, CalendarEvent[]>();
|
||||||
|
for (const e of events) {
|
||||||
|
const d = new Date(e.start);
|
||||||
|
const key = isNaN(d.getTime()) ? e.start : d.toISOString().slice(0, 10);
|
||||||
|
const arr = groups.get(key) || [];
|
||||||
|
arr.push(e);
|
||||||
|
groups.set(key, arr);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactActivity({ contact }: ContactActivityProps) {
|
||||||
|
const t = useTranslations("contacts.activity");
|
||||||
|
const format = useFormatter();
|
||||||
|
const router = useRouter();
|
||||||
|
const client = useAuthStore((s) => s.client);
|
||||||
|
const selectEmail = useEmailStore((s) => s.selectEmail);
|
||||||
|
const setSelectedEventId = useCalendarStore((s) => s.setSelectedEventId);
|
||||||
|
const setSelectedDate = useCalendarStore((s) => s.setSelectedDate);
|
||||||
|
|
||||||
|
const [emails, setEmails] = useState<Email[] | null>(null);
|
||||||
|
const [events, setEvents] = useState<CalendarEvent[] | null>(null);
|
||||||
|
const [emailsLoading, setEmailsLoading] = useState(false);
|
||||||
|
const [eventsLoading, setEventsLoading] = useState(false);
|
||||||
|
const [emailsError, setEmailsError] = useState(false);
|
||||||
|
const [eventsError, setEventsError] = useState(false);
|
||||||
|
|
||||||
|
const addresses = getContactEmails(contact);
|
||||||
|
const addressKey = addresses.join(",");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client || addresses.length === 0) {
|
||||||
|
setEmails([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setEmailsLoading(true);
|
||||||
|
setEmailsError(false);
|
||||||
|
client
|
||||||
|
.advancedSearchEmails(buildEmailFilter(addresses), undefined, EMAIL_LIMIT, 0)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setEmails(res.emails);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setEmailsError(true);
|
||||||
|
setEmails([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setEmailsLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client || addresses.length === 0) {
|
||||||
|
setEvents([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setEventsLoading(true);
|
||||||
|
setEventsError(false);
|
||||||
|
const now = new Date();
|
||||||
|
const before = new Date(now.getTime() + EVENT_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000);
|
||||||
|
const addrSet = new Set(addresses);
|
||||||
|
client
|
||||||
|
.queryAllCalendarEvents(
|
||||||
|
{ after: now.toISOString(), before: before.toISOString() },
|
||||||
|
[{ property: "start", isAscending: true }],
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
.then((all) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const matching = all
|
||||||
|
.filter((e) => eventInvolvesContact(e, addrSet))
|
||||||
|
.sort((a, b) => a.start.localeCompare(b.start))
|
||||||
|
.slice(0, EVENT_LIMIT);
|
||||||
|
setEvents(matching);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setEventsError(true);
|
||||||
|
setEvents([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setEventsLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
if (addresses.length === 0) return null;
|
||||||
|
|
||||||
|
const handleOpenEmail = (email: Email) => {
|
||||||
|
selectEmail(email);
|
||||||
|
router.push("/mail");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEvent = (event: CalendarEvent) => {
|
||||||
|
const start = new Date(event.start);
|
||||||
|
if (!isNaN(start.getTime())) setSelectedDate(start);
|
||||||
|
setSelectedEventId(event.id);
|
||||||
|
router.push("/calendar");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatEmailDate = (dateStr: string) => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (isNaN(d.getTime())) return dateStr;
|
||||||
|
const now = new Date();
|
||||||
|
const sameYear = d.getFullYear() === now.getFullYear();
|
||||||
|
return format.dateTime(d, sameYear
|
||||||
|
? { month: "short", day: "numeric" }
|
||||||
|
: { year: "numeric", month: "short", day: "numeric" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatEventTime = (event: CalendarEvent) => {
|
||||||
|
if (event.showWithoutTime) return t("all_day");
|
||||||
|
const d = new Date(event.start);
|
||||||
|
if (isNaN(d.getTime())) return "";
|
||||||
|
return format.dateTime(d, { hour: "numeric", minute: "2-digit" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatEventDateHeader = (isoDate: string) => {
|
||||||
|
const d = new Date(isoDate);
|
||||||
|
if (isNaN(d.getTime())) return isoDate;
|
||||||
|
const now = new Date();
|
||||||
|
const sameYear = d.getFullYear() === now.getFullYear();
|
||||||
|
return format.dateTime(d, sameYear
|
||||||
|
? { weekday: "long", month: "long", day: "numeric" }
|
||||||
|
: { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventGroups = events ? Array.from(groupEventsByDate(events).entries()) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-x-8">
|
||||||
|
<Section title={t("recent_emails")}>
|
||||||
|
{emailsLoading ? (
|
||||||
|
<LoadingRow />
|
||||||
|
) : emailsError ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("load_failed")}</p>
|
||||||
|
) : !emails || emails.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("no_emails")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="-mx-2">
|
||||||
|
{emails.map((email) => {
|
||||||
|
const sender = getEmailSender(email);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={email.id}
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Avatar name={sender.name} email={sender.address} size="sm" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{sender.name || t("unknown_sender")}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||||
|
{formatEmailDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm truncate">
|
||||||
|
{email.subject || t("no_subject")}
|
||||||
|
</div>
|
||||||
|
{email.preview && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||||
|
{email.preview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t("upcoming_events")}>
|
||||||
|
{eventsLoading ? (
|
||||||
|
<LoadingRow />
|
||||||
|
) : eventsError ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("load_failed")}</p>
|
||||||
|
) : !events || events.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("no_events")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{eventGroups.map(([dateKey, group]) => (
|
||||||
|
<div key={dateKey}>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||||
|
{formatEventDateHeader(dateKey)}
|
||||||
|
</div>
|
||||||
|
<div className="-mx-2">
|
||||||
|
{group.map((event) => (
|
||||||
|
<button
|
||||||
|
key={event.id}
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0">
|
||||||
|
{formatEventTime(event)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm truncate flex-1 min-w-0">
|
||||||
|
{event.title || t("no_title")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingRow() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuHeader,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import {
|
||||||
|
Eye,
|
||||||
|
Pencil,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
ClipboardCopy,
|
||||||
|
Download,
|
||||||
|
Users,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
Printer,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ContactCard } from "@/lib/jmap/types";
|
||||||
|
import { getContactPrimaryEmail } from "@/stores/contact-store";
|
||||||
|
import { exportContact } from "./contact-export";
|
||||||
|
import { printContact } from "./contact-print";
|
||||||
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
|
function getContactPrimaryPhone(contact: ContactCard): string {
|
||||||
|
if (!contact.phones) return "";
|
||||||
|
return Object.values(contact.phones)[0]?.number || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactContextMenuProps {
|
||||||
|
contact: ContactCard;
|
||||||
|
position: Position;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
isMultiSelect?: boolean;
|
||||||
|
selectedCount?: number;
|
||||||
|
onOpen: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onAddToGroup: () => void;
|
||||||
|
onDuplicate?: () => void;
|
||||||
|
onBatchExport?: () => void;
|
||||||
|
onBatchAddToGroup?: () => void;
|
||||||
|
onBatchDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactContextMenu({
|
||||||
|
contact,
|
||||||
|
position,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
menuRef,
|
||||||
|
isMultiSelect = false,
|
||||||
|
selectedCount = 1,
|
||||||
|
onOpen,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onAddToGroup,
|
||||||
|
onDuplicate,
|
||||||
|
onBatchExport,
|
||||||
|
onBatchAddToGroup,
|
||||||
|
onBatchDelete,
|
||||||
|
}: ContactContextMenuProps) {
|
||||||
|
const t = useTranslations("contacts");
|
||||||
|
const email = getContactPrimaryEmail(contact);
|
||||||
|
const phone = getContactPrimaryPhone(contact);
|
||||||
|
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||||
|
|
||||||
|
const handle = (fn: () => void) => () => {
|
||||||
|
fn();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendEmail = () => {
|
||||||
|
if (!email) return;
|
||||||
|
window.location.href = `mailto:${email}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCall = () => {
|
||||||
|
if (!phone) return;
|
||||||
|
window.location.href = `tel:${phone}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async (value: string) => {
|
||||||
|
if (!value) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
toast.success(t("detail.copied"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("detail.copy_failed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
exportContact(contact);
|
||||||
|
toast.success(t("export.success", { count: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
printContact(contact);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showBatchActions) {
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuHeader>
|
||||||
|
{t("bulk.selected", { count: selectedCount })}
|
||||||
|
</ContextMenuHeader>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Users}
|
||||||
|
label={t("bulk.add_to_group")}
|
||||||
|
onClick={handle(() => onBatchAddToGroup?.())}
|
||||||
|
disabled={!onBatchAddToGroup}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Download}
|
||||||
|
label={t("bulk.export")}
|
||||||
|
onClick={handle(() => onBatchExport?.())}
|
||||||
|
disabled={!onBatchExport}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("bulk.delete")}
|
||||||
|
onClick={handle(() => onBatchDelete?.())}
|
||||||
|
disabled={!onBatchDelete}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
|
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
|
||||||
|
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
|
||||||
|
{(email || phone) && <ContextMenuSeparator />}
|
||||||
|
{email && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Mail}
|
||||||
|
label={t("context_menu.send_email")}
|
||||||
|
onClick={handle(handleSendEmail)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{phone && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Phone}
|
||||||
|
label={t("context_menu.call")}
|
||||||
|
onClick={handle(handleCall)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{email && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={ClipboardCopy}
|
||||||
|
label={t("detail.copy_email")}
|
||||||
|
onClick={handle(() => handleCopy(email))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{phone && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={ClipboardCopy}
|
||||||
|
label={t("detail.copy_phone")}
|
||||||
|
onClick={handle(() => handleCopy(phone))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Users}
|
||||||
|
label={t("context_menu.add_to_group")}
|
||||||
|
onClick={handle(onAddToGroup)}
|
||||||
|
/>
|
||||||
|
{onDuplicate && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Copy}
|
||||||
|
label={t("context_menu.duplicate")}
|
||||||
|
onClick={handle(onDuplicate)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Download}
|
||||||
|
label={t("context_menu.export_vcard")}
|
||||||
|
onClick={handle(handleExport)}
|
||||||
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Printer}
|
||||||
|
label={t("context_menu.print")}
|
||||||
|
onClick={handle(handlePrint)}
|
||||||
|
/>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("context_menu.delete")}
|
||||||
|
onClick={handle(onDelete)}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } 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, Tag, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react";
|
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 { 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 } from "@/stores/contact-store";
|
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
|
||||||
|
import { ContactActivity } from "./contact-activity";
|
||||||
import { useSmimeStore } from "@/stores/smime-store";
|
import { useSmimeStore } from "@/stores/smime-store";
|
||||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
||||||
import type { CertificateInfo } from "@/lib/smime/types";
|
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 { printContact } from "./contact-print";
|
||||||
|
|
||||||
|
type MoreItem =
|
||||||
|
| {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
destructive?: boolean;
|
||||||
|
separator?: false;
|
||||||
|
}
|
||||||
|
| { separator: true };
|
||||||
|
|
||||||
interface ContactDetailProps {
|
interface ContactDetailProps {
|
||||||
contact: ContactCard | null;
|
contact: ContactCard | null;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onAddToGroup?: () => void;
|
||||||
|
onDuplicate?: () => void;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -26,9 +41,45 @@ function formatPhoneFeatures(features?: Record<string, boolean>): string {
|
|||||||
return Object.keys(features).filter(k => features[k]).join(", ");
|
return Object.keys(features).filter(k => features[k]).join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } {
|
||||||
|
if (typeof dateInput === "object" && dateInput !== null) {
|
||||||
|
if (dateInput["@type"] === "Timestamp" && typeof dateInput.utc === "string") {
|
||||||
|
const d = new Date(dateInput.utc);
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const pd = dateInput as PartialDate;
|
||||||
|
return { year: pd.year, month: pd.month, day: pd.day };
|
||||||
|
}
|
||||||
|
const s = String(dateInput);
|
||||||
|
if (s.startsWith("--")) {
|
||||||
|
const parts = s.substring(2).split("-");
|
||||||
|
return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined };
|
||||||
|
}
|
||||||
|
const d = new Date(s);
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompletedYears(dateInput: AnniversaryDate): number | null {
|
||||||
|
const { year, month, day } = getDateParts(dateInput);
|
||||||
|
if (!year) return null;
|
||||||
|
const now = new Date();
|
||||||
|
let years = now.getFullYear() - year;
|
||||||
|
const m = month ?? 1;
|
||||||
|
const d = day ?? 1;
|
||||||
|
const nowM = now.getMonth() + 1;
|
||||||
|
const nowD = now.getDate();
|
||||||
|
if (nowM < m || (nowM === m && nowD < d)) years -= 1;
|
||||||
|
if (years < 0) return null;
|
||||||
|
return years;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateInput: AnniversaryDate): string {
|
function formatDate(dateInput: AnniversaryDate): string {
|
||||||
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
|
|
||||||
// Handle RFC 9553 Timestamp objects: { "@type": "Timestamp", utc: "..." }
|
|
||||||
if (typeof dateInput === 'object' && dateInput !== null) {
|
if (typeof dateInput === 'object' && dateInput !== null) {
|
||||||
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
|
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
|
||||||
try {
|
try {
|
||||||
@@ -40,20 +91,15 @@ function formatDate(dateInput: AnniversaryDate): string {
|
|||||||
return String(dateInput.utc);
|
return String(dateInput.utc);
|
||||||
}
|
}
|
||||||
const pd = dateInput as PartialDate;
|
const pd = dateInput as PartialDate;
|
||||||
const year = pd.year;
|
|
||||||
const month = pd.month;
|
|
||||||
const day = pd.day;
|
|
||||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
|
if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]);
|
||||||
if (day) parts.push(String(day));
|
if (pd.day) parts.push(String(pd.day));
|
||||||
if (year) parts.push(String(year));
|
if (pd.year) parts.push(String(pd.year));
|
||||||
return parts.join(' ') || String(dateInput);
|
return parts.join(' ') || String(dateInput);
|
||||||
}
|
}
|
||||||
const dateStr = String(dateInput);
|
const dateStr = String(dateInput);
|
||||||
// Handle both ISO dates and partial dates like 1990-01-15 or --01-15
|
|
||||||
if (dateStr.startsWith("--")) {
|
if (dateStr.startsWith("--")) {
|
||||||
// Partial date without year
|
|
||||||
const parts = dateStr.substring(2).split("-");
|
const parts = dateStr.substring(2).split("-");
|
||||||
const month = parseInt(parts[0], 10);
|
const month = parseInt(parts[0], 10);
|
||||||
const day = parts[1] ? parseInt(parts[1], 10) : undefined;
|
const day = parts[1] ? parseInt(parts[1], 10) : undefined;
|
||||||
@@ -69,7 +115,7 @@ function formatDate(dateInput: AnniversaryDate): string {
|
|||||||
return dateStr;
|
return dateStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) {
|
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
const smimeStore = useSmimeStore();
|
const smimeStore = useSmimeStore();
|
||||||
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
|
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
|
||||||
@@ -87,7 +133,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
try {
|
try {
|
||||||
let derBytes: ArrayBuffer | string | null = null;
|
let derBytes: ArrayBuffer | string | null = null;
|
||||||
if (key.uri.startsWith('data:')) {
|
if (key.uri.startsWith('data:')) {
|
||||||
// data URI — extract base64 content
|
|
||||||
const commaIdx = key.uri.indexOf(',');
|
const commaIdx = key.uri.indexOf(',');
|
||||||
if (commaIdx === -1) continue;
|
if (commaIdx === -1) continue;
|
||||||
const b64 = key.uri.substring(commaIdx + 1);
|
const b64 = key.uri.substring(commaIdx + 1);
|
||||||
@@ -96,7 +141,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
||||||
derBytes = bytes.buffer;
|
derBytes = bytes.buffer;
|
||||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
} else if (key.uri.startsWith('-----BEGIN')) {
|
||||||
// PEM-encoded certificate inline
|
|
||||||
derBytes = key.uri;
|
derBytes = key.uri;
|
||||||
}
|
}
|
||||||
if (!derBytes) continue;
|
if (!derBytes) continue;
|
||||||
@@ -127,6 +171,29 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
|
|
||||||
const name = getContactDisplayName(contact);
|
const name = getContactDisplayName(contact);
|
||||||
const email = getContactPrimaryEmail(contact);
|
const email = getContactPrimaryEmail(contact);
|
||||||
|
const photoUri = getContactPhotoUri(contact);
|
||||||
|
const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined;
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
exportContact(contact);
|
||||||
|
toast.success(t("export.success", { count: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
printContact(contact, name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moreItems: MoreItem[] = [];
|
||||||
|
if (onAddToGroup) {
|
||||||
|
moreItems.push({ icon: Users, label: t("context_menu.add_to_group"), onClick: onAddToGroup });
|
||||||
|
}
|
||||||
|
if (onDuplicate) {
|
||||||
|
moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate });
|
||||||
|
}
|
||||||
|
moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport });
|
||||||
|
moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint });
|
||||||
|
moreItems.push({ separator: true });
|
||||||
|
moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true });
|
||||||
const emails = contact.emails ? Object.values(contact.emails) : [];
|
const emails = contact.emails ? Object.values(contact.emails) : [];
|
||||||
const phones = contact.phones ? Object.values(contact.phones) : [];
|
const phones = contact.phones ? Object.values(contact.phones) : [];
|
||||||
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
|
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
|
||||||
@@ -170,255 +237,259 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
|
|
||||||
const hasNickname = nicknames.length > 0;
|
const hasNickname = nicknames.length > 0;
|
||||||
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
|
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
|
||||||
|
const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[];
|
||||||
|
const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
|
||||||
|
const hasWork = titles.length > 0 || orgs.length > 0;
|
||||||
|
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
|
||||||
|
const hasPersonal = anniversaries.length > 0 || personalInfo.length > 0 || hasGender || preferredLanguages.length > 0;
|
||||||
|
|
||||||
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)}>
|
||||||
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
|
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
|
||||||
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
|
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||||
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
|
<Avatar name={name} email={email} contactPhotoUri={photoUri} size={isMobile ? "md" : "lg"} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "—"}</h2>
|
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
|
||||||
{hasNickname && (
|
{hasNickname && (
|
||||||
<p className="text-sm text-muted-foreground truncate">“{nicknames.map(n => n.name).join(", ")}”</p>
|
<p className="text-sm text-muted-foreground truncate">“{nicknames.map(n => n.name).join(", ")}”</p>
|
||||||
)}
|
)}
|
||||||
{titleLine && (
|
{subtitleParts.length > 0 && (
|
||||||
<p className="text-sm text-muted-foreground truncate">{titleLine}</p>
|
<p className="text-sm text-muted-foreground truncate">{subtitleParts.join(" · ")}</p>
|
||||||
)}
|
|
||||||
{orgs.length > 0 && orgs[0].name && (
|
|
||||||
<p className="text-sm text-muted-foreground truncate">{orgs[0].name}</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-shrink-0 flex-wrap">
|
||||||
|
{email && (
|
||||||
|
<a
|
||||||
|
href={`mailto:${email}`}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4 mr-1" />
|
||||||
|
{t("detail.compose_email")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{phone && (
|
||||||
|
<a
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Phone className="w-4 h-4 mr-1" />
|
||||||
|
{t("context_menu.call")}
|
||||||
|
</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 mr-1" />
|
||||||
{t("form.edit_title")}
|
{t("form.edit_title")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={onDelete} className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 touch-manipulation">
|
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-6">
|
<div className={cn("divide-y divide-border/60", isMobile ? "px-4" : "px-6")}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
{hasContactDetails && (
|
||||||
{/* Contact info */}
|
<Section title={t("detail.section_contact")}>
|
||||||
{emails.length > 0 && (
|
<div className="space-y-3">
|
||||||
<Section icon={Mail} title={t("detail.emails")} category="contact">
|
|
||||||
{emails.map((e, i) => (
|
{emails.map((e, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 group">
|
<FieldRow key={`em${i}`} icon={Mail} label={e.label || formatContexts(e.contexts) || t("detail.email_default_label")}>
|
||||||
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline">
|
<div className="flex items-center gap-2 group">
|
||||||
{e.address}
|
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline break-all">
|
||||||
</a>
|
{e.address}
|
||||||
{e.contexts && <ContextBadge contexts={e.contexts} />}
|
|
||||||
{e.label && <span className="text-xs text-muted-foreground">({e.label})</span>}
|
|
||||||
<div className={cn(
|
|
||||||
"flex items-center gap-0.5 transition-opacity",
|
|
||||||
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
|
||||||
)}>
|
|
||||||
<a
|
|
||||||
href={`mailto:${e.address}`}
|
|
||||||
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
|
|
||||||
title={t("detail.compose_email")}
|
|
||||||
aria-label={t("detail.compose_email")}
|
|
||||||
>
|
|
||||||
<Send className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
</a>
|
</a>
|
||||||
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
|
<RowActions>
|
||||||
|
<a
|
||||||
|
href={`mailto:${e.address}`}
|
||||||
|
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
|
||||||
|
title={t("detail.compose_email")}
|
||||||
|
aria-label={t("detail.compose_email")}
|
||||||
|
>
|
||||||
|
<Send className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</a>
|
||||||
|
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
|
||||||
|
</RowActions>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FieldRow>
|
||||||
))}
|
))}
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phones.length > 0 && (
|
|
||||||
<Section icon={Phone} title={t("detail.phones")} category="contact">
|
|
||||||
{phones.map((p, i) => {
|
{phones.map((p, i) => {
|
||||||
const featureStr = formatPhoneFeatures(p.features);
|
const features = formatPhoneFeatures(p.features);
|
||||||
|
const labelParts = [p.label, formatContexts(p.contexts), features].filter(Boolean) as string[];
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-center gap-2 group">
|
<FieldRow key={`ph${i}`} icon={Phone} label={labelParts.length ? labelParts.join(" · ") : t("detail.phone_default_label")}>
|
||||||
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
|
<div className="flex items-center gap-2 group">
|
||||||
{p.number}
|
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
|
||||||
</a>
|
{p.number}
|
||||||
{p.contexts && <ContextBadge contexts={p.contexts} />}
|
</a>
|
||||||
{featureStr && (
|
<RowActions>
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{featureStr}</span>
|
<CopyButton value={p.number} label={t("detail.copy_phone")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
|
||||||
)}
|
</RowActions>
|
||||||
<CopyButton
|
</div>
|
||||||
value={p.number}
|
</FieldRow>
|
||||||
label={t("detail.copy_phone")}
|
|
||||||
successMsg={t("detail.copied")}
|
|
||||||
failMsg={t("detail.copy_failed")}
|
|
||||||
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(roles.length > 0 || jobTitles.length > 1) && (
|
{addresses.map((a, i) => {
|
||||||
<Section icon={Briefcase} title={t("detail.titles")} category="work">
|
const lines: string[] = [];
|
||||||
{titles.map((tl, i) => (
|
if (a.full || a.fullAddress) {
|
||||||
<div key={i} className="text-sm flex items-center gap-2">
|
lines.push((a.full || a.fullAddress) as string);
|
||||||
<span>{tl.name}</span>
|
} else if (a.components && a.components.length > 0) {
|
||||||
{tl.kind && (
|
const joined = a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ");
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{tl.kind}</span>
|
if (joined) lines.push(joined);
|
||||||
)}
|
} else {
|
||||||
</div>
|
const parts = [a.street, [a.postcode, a.locality].filter(Boolean).join(" "), a.region, a.country]
|
||||||
))}
|
.map(s => (typeof s === "string" ? s.trim() : ""))
|
||||||
</Section>
|
.filter(Boolean) as string[];
|
||||||
)}
|
lines.push(...parts);
|
||||||
|
}
|
||||||
{orgs.length > 0 && (
|
return (
|
||||||
<Section icon={Building} title={t("detail.organizations")} category="work">
|
<FieldRow key={`ad${i}`} icon={MapPin} label={formatContexts(a.contexts) || t("detail.address_default_label")}>
|
||||||
{orgs.map((o, i) => (
|
<div className="text-sm space-y-0.5">
|
||||||
<div key={i} className="text-sm">
|
{lines.map((line, idx) => (
|
||||||
{o.name}
|
<div key={idx}>{line}</div>
|
||||||
{o.units && o.units.length > 0 && (
|
))}
|
||||||
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Addresses span full width */}
|
|
||||||
{addresses.length > 0 && (
|
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
|
||||||
<Section icon={MapPin} title={t("detail.addresses")} category="location">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
|
||||||
{addresses.map((a, i) => (
|
|
||||||
<div key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
|
|
||||||
<div>
|
|
||||||
{a.full || a.fullAddress
|
|
||||||
? (a.full || a.fullAddress)
|
|
||||||
: a.components && a.components.length > 0
|
|
||||||
? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ")
|
|
||||||
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
|
||||||
{a.contexts && <ContextBadge contexts={a.contexts} />}
|
|
||||||
</div>
|
|
||||||
{a.timeZone && (
|
{a.timeZone && (
|
||||||
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
|
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</FieldRow>
|
||||||
</div>
|
);
|
||||||
</Section>
|
})}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onlineServices.length > 0 && (
|
|
||||||
<Section icon={Globe} title={t("detail.online_services")} category="digital">
|
|
||||||
{onlineServices.map((svc, i) => (
|
{onlineServices.map((svc, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 group">
|
<FieldRow
|
||||||
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
|
key={`os${i}`}
|
||||||
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
icon={Globe}
|
||||||
{svc.user || svc.uri}
|
label={[svc.service, formatContexts(svc.contexts)].filter(Boolean).join(" · ") || t("detail.online_service_default_label")}
|
||||||
</a>
|
>
|
||||||
) : (
|
<div className="flex items-center gap-2 group">
|
||||||
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
|
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
|
||||||
)}
|
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||||
{svc.service && (
|
{svc.user || svc.uri}
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{svc.service}</span>
|
</a>
|
||||||
)}
|
) : (
|
||||||
{svc.contexts && <ContextBadge contexts={svc.contexts} />}
|
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
|
||||||
<CopyButton
|
)}
|
||||||
value={svc.user || svc.uri}
|
<RowActions>
|
||||||
label={t("detail.copy_url")}
|
<CopyButton value={svc.user || svc.uri} label={t("detail.copy_url")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
|
||||||
successMsg={t("detail.copied")}
|
</RowActions>
|
||||||
failMsg={t("detail.copy_failed")}
|
</div>
|
||||||
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
|
</FieldRow>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</Section>
|
</div>
|
||||||
)}
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{anniversaries.length > 0 && (
|
{hasWork && (
|
||||||
<Section icon={Cake} title={t("detail.anniversaries")} category="personal">
|
<Section title={t("detail.section_work")}>
|
||||||
{anniversaries.map((ann, i) => (
|
<div className="space-y-3">
|
||||||
<div key={i} className="flex items-center gap-2 text-sm">
|
{orgs.map((o, i) => (
|
||||||
<span>{formatDate(ann.date)}</span>
|
<FieldRow key={`org${i}`} icon={Building} label={t("detail.organization_label")}>
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
<div className="text-sm">
|
||||||
{t(`detail.anniversary_${ann.kind}`)}
|
{o.name}
|
||||||
</span>
|
{o.units && o.units.length > 0 && (
|
||||||
</div>
|
<span className="text-muted-foreground"> · {o.units.map(u => u.name).join(", ")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FieldRow>
|
||||||
))}
|
))}
|
||||||
</Section>
|
{titles.map((tl, i) => (
|
||||||
)}
|
<FieldRow
|
||||||
|
key={`tl${i}`}
|
||||||
{personalInfo.length > 0 && (
|
icon={Briefcase}
|
||||||
<Section icon={Heart} title={t("detail.personal_info")} category="personal">
|
label={tl.kind === "role" ? t("detail.role_label") : t("detail.title_label")}
|
||||||
{personalInfo.map((pi, i) => (
|
>
|
||||||
<div key={i} className="flex items-center gap-2 text-sm">
|
<div className="text-sm">{tl.name}</div>
|
||||||
<span>{pi.value}</span>
|
</FieldRow>
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{t(`detail.personal_${pi.kind}`)}</span>
|
|
||||||
{pi.level && (
|
|
||||||
<span className="text-xs text-muted-foreground">({pi.level})</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</Section>
|
</div>
|
||||||
)}
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && (
|
{hasPersonal && (
|
||||||
<Section icon={UserCircle} title={t("detail.gender")} category="personal">
|
<Section title={t("detail.section_personal")}>
|
||||||
<div className="text-sm">
|
<div className="space-y-3">
|
||||||
{contact.speakToAs.grammaticalGender && <span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>}
|
{anniversaries.map((ann, i) => {
|
||||||
{contact.speakToAs.pronouns && (() => {
|
const years = getCompletedYears(ann.date);
|
||||||
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
const suffixKey = ann.kind === "birth" ? "detail.age_years" : "detail.years_since";
|
||||||
return firstPronoun ? (
|
return (
|
||||||
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " — " : ""}{firstPronoun}</span>
|
<FieldRow key={`an${i}`} icon={Cake} label={t(`detail.anniversary_${ann.kind}`)}>
|
||||||
) : null;
|
<div className="text-sm">
|
||||||
})()}
|
{formatDate(ann.date)}
|
||||||
</div>
|
{years !== null && (
|
||||||
</Section>
|
<span className="text-muted-foreground"> · {t(suffixKey, { count: years })}</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{preferredLanguages.length > 0 && (
|
</FieldRow>
|
||||||
<Section icon={Languages} title={t("detail.languages")} category="personal">
|
);
|
||||||
|
})}
|
||||||
|
{hasGender && (
|
||||||
|
<FieldRow icon={UserCircle} label={t("detail.gender")}>
|
||||||
|
<div className="text-sm">
|
||||||
|
{contact.speakToAs?.grammaticalGender && (
|
||||||
|
<span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>
|
||||||
|
)}
|
||||||
|
{contact.speakToAs?.pronouns && (() => {
|
||||||
|
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
||||||
|
return firstPronoun ? (
|
||||||
|
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " · " : ""}{firstPronoun}</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</FieldRow>
|
||||||
|
)}
|
||||||
{preferredLanguages.map((lang, i) => (
|
{preferredLanguages.map((lang, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 text-sm">
|
<FieldRow
|
||||||
<span>{lang.language}</span>
|
key={`lg${i}`}
|
||||||
{lang.contexts && <ContextBadge contexts={lang.contexts} />}
|
icon={Languages}
|
||||||
</div>
|
label={formatContexts(lang.contexts) || t("detail.language_label")}
|
||||||
|
>
|
||||||
|
<div className="text-sm">{lang.language}</div>
|
||||||
|
</FieldRow>
|
||||||
))}
|
))}
|
||||||
</Section>
|
{personalInfo.map((pi, i) => (
|
||||||
)}
|
<FieldRow
|
||||||
|
key={`pi${i}`}
|
||||||
|
icon={Heart}
|
||||||
|
label={`${t(`detail.personal_${pi.kind}`)}${pi.level ? ` · ${pi.level}` : ""}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm">{pi.value}</div>
|
||||||
|
</FieldRow>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{keywords.length > 0 && (
|
{keywords.length > 0 && (
|
||||||
<Section icon={Tag} title={t("detail.categories")} category="digital">
|
<Section title={t("detail.categories")}>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{keywords.map((kw, i) => (
|
{keywords.map((kw, i) => (
|
||||||
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
|
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
|
||||||
{kw}
|
{kw}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{relatedTo.length > 0 && (
|
{relatedTo.length > 0 && (
|
||||||
<Section icon={Users} title={t("detail.related_contacts")} category="personal">
|
<Section title={t("detail.related_contacts")}>
|
||||||
|
<div className="space-y-2">
|
||||||
{relatedTo.map(([uri, rel], i) => {
|
{relatedTo.map(([uri, rel], i) => {
|
||||||
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
|
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-center gap-2 text-sm">
|
<FieldRow key={`rel${i}`} icon={Users} label={relType || t("detail.related_default_label")}>
|
||||||
<span>{uri}</span>
|
<div className="text-sm break-all">{uri}</div>
|
||||||
{relType && (
|
</FieldRow>
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{relType}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Section>
|
</div>
|
||||||
)}
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{cryptoKeys.length > 0 && (
|
{cryptoKeys.length > 0 && (
|
||||||
<Section icon={KeyRound} title={t("detail.crypto_keys")} category="digital">
|
<Section title={t("detail.crypto_keys")}>
|
||||||
|
<div className="space-y-3">
|
||||||
{cryptoKeys.map((key, i) => {
|
{cryptoKeys.map((key, i) => {
|
||||||
const certInfo = parsedCerts.get(i);
|
const certInfo = parsedCerts.get(i);
|
||||||
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
|
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
|
||||||
@@ -427,7 +498,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
: false;
|
: false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} className="p-3 rounded-lg border border-border space-y-1">
|
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
|
||||||
{certInfo ? (
|
{certInfo ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -448,12 +519,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
|
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
|
||||||
</div>
|
</div>
|
||||||
{!alreadyImported && (
|
{!alreadyImported && (
|
||||||
<Button
|
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="ml-4 mt-1"
|
|
||||||
onClick={() => handleImportContactCert(i)}
|
|
||||||
>
|
|
||||||
<Download className="w-3 h-3 mr-1" />
|
<Download className="w-3 h-3 mr-1" />
|
||||||
{t("detail.import_to_smime")}
|
{t("detail.import_to_smime")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -463,7 +529,8 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm break-all">
|
<div className="flex items-start gap-2 text-sm break-all">
|
||||||
|
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
||||||
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||||
{key.uri}
|
{key.uri}
|
||||||
@@ -476,91 +543,168 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Section>
|
</div>
|
||||||
)}
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
|
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
|
||||||
<Section icon={Calendar} title={t("detail.calendar")} category="calendar">
|
<Section title={t("detail.calendar")}>
|
||||||
|
<div className="space-y-3">
|
||||||
{contact.calendarUri && (
|
{contact.calendarUri && (
|
||||||
<div className="text-sm">
|
<FieldRow icon={Calendar} label={t("detail.calendar_uri")}>
|
||||||
<span className="text-muted-foreground">{t("detail.calendar_uri")}: </span>
|
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||||
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.calendarUri}</a>
|
{contact.calendarUri}
|
||||||
</div>
|
</a>
|
||||||
|
</FieldRow>
|
||||||
)}
|
)}
|
||||||
{contact.schedulingUri && (
|
{contact.schedulingUri && (
|
||||||
<div className="text-sm">
|
<FieldRow icon={Calendar} label={t("detail.scheduling_uri")}>
|
||||||
<span className="text-muted-foreground">{t("detail.scheduling_uri")}: </span>
|
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||||
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.schedulingUri}</a>
|
{contact.schedulingUri}
|
||||||
</div>
|
</a>
|
||||||
|
</FieldRow>
|
||||||
)}
|
)}
|
||||||
{contact.freeBusyUri && (
|
{contact.freeBusyUri && (
|
||||||
<div className="text-sm">
|
<FieldRow icon={Calendar} label={t("detail.freebusy_uri")}>
|
||||||
<span className="text-muted-foreground">{t("detail.freebusy_uri")}: </span>
|
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||||
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.freeBusyUri}</a>
|
{contact.freeBusyUri}
|
||||||
</div>
|
</a>
|
||||||
|
</FieldRow>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</div>
|
||||||
)}
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Notes span full width */}
|
{notes.length > 0 && (
|
||||||
{notes.length > 0 && (
|
<Section title={t("detail.notes")}>
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<div className="flex items-start gap-3">
|
||||||
<Section icon={StickyNote} title={t("detail.notes")} category="notes">
|
<StickyNote className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
|
||||||
|
<div className="text-sm space-y-2 flex-1 min-w-0">
|
||||||
{notes.map((n, i) => (
|
{notes.map((n, i) => (
|
||||||
<p key={i} className="text-sm whitespace-pre-wrap">{n.note}</p>
|
<p key={i} className="whitespace-pre-wrap">{n.note}</p>
|
||||||
))}
|
))}
|
||||||
</Section>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Timestamps span full width */}
|
<ContactActivity contact={contact} />
|
||||||
{(contact.created || contact.updated) && (
|
|
||||||
<div className="md:col-span-2 xl:col-span-3 pt-2 border-t border-border text-xs text-muted-foreground space-y-1">
|
{(contact.created || contact.updated) && (
|
||||||
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
|
<div className="py-4 text-xs text-muted-foreground space-y-1">
|
||||||
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
|
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
|
||||||
</div>
|
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatContexts(contexts?: Record<string, boolean>): string {
|
||||||
|
if (!contexts) return "";
|
||||||
|
return Object.keys(contexts).filter(k => contexts[k]).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Section({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<section className={cn("py-6", className)}>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">{title}</h3>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldRow({ icon: Icon, label, children }: { icon: React.ComponentType<{ className?: string }>; label?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Icon className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{label && <div className="text-xs text-muted-foreground mb-0.5">{label}</div>}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowActions({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDocMouseDown = (e: MouseEvent) => {
|
||||||
|
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDocMouseDown);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDocMouseDown);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="touch-manipulation"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{items.map((item, i) => {
|
||||||
|
if (item.separator) {
|
||||||
|
return <div key={i} role="separator" className="my-1 h-px bg-border" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
item.onClick();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
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",
|
||||||
|
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.icon className={cn("w-4 h-4 flex-shrink-0", item.destructive ? "text-red-600 dark:text-red-400" : "text-muted-foreground")} />
|
||||||
|
<span className="flex-1">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SectionCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
|
|
||||||
|
|
||||||
const categoryStyles: Record<SectionCategory, string> = {
|
|
||||||
contact: "border-l-blue-400 dark:border-l-blue-500",
|
|
||||||
work: "border-l-amber-400 dark:border-l-amber-500",
|
|
||||||
location: "border-l-emerald-400 dark:border-l-emerald-500",
|
|
||||||
personal: "border-l-violet-400 dark:border-l-violet-500",
|
|
||||||
digital: "border-l-cyan-400 dark:border-l-cyan-500",
|
|
||||||
calendar: "border-l-rose-400 dark:border-l-rose-500",
|
|
||||||
notes: "border-l-stone-400 dark:border-l-stone-500",
|
|
||||||
};
|
|
||||||
|
|
||||||
function Section({ icon: Icon, title, children, category = "contact" }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode; category?: SectionCategory }) {
|
|
||||||
return (
|
|
||||||
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", categoryStyles[category])}>
|
|
||||||
<div className="flex items-center gap-2 mb-2.5">
|
|
||||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5 pl-6">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextBadge({ contexts }: { contexts: Record<string, boolean> }) {
|
|
||||||
const labels = Object.keys(contexts).filter(k => contexts[k]);
|
|
||||||
if (labels.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground ml-1">
|
|
||||||
{labels.join(", ")}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) {
|
function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -572,7 +716,7 @@ function CopyButton({ value, label, successMsg, failMsg, className }: { value: s
|
|||||||
toast.error(failMsg);
|
toast.error(failMsg);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation transition-opacity", className)}
|
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation", className)}
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react";
|
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress } from "@/lib/jmap/types";
|
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
|
||||||
|
|
||||||
interface EmailEntry {
|
interface EmailEntry {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -49,59 +50,78 @@ interface ContactFormProps {
|
|||||||
contact?: ContactCard | null;
|
contact?: ContactCard | null;
|
||||||
addressBooks?: AddressBook[];
|
addressBooks?: AddressBook[];
|
||||||
allKeywords?: string[];
|
allKeywords?: string[];
|
||||||
|
defaultAddressBookId?: string;
|
||||||
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FormCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
|
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = true }: {
|
||||||
|
|
||||||
const formCategoryStyles: Record<FormCategory, string> = {
|
|
||||||
contact: "border-l-blue-400 dark:border-l-blue-500",
|
|
||||||
work: "border-l-amber-400 dark:border-l-amber-500",
|
|
||||||
location: "border-l-emerald-400 dark:border-l-emerald-500",
|
|
||||||
personal: "border-l-violet-400 dark:border-l-violet-500",
|
|
||||||
digital: "border-l-cyan-400 dark:border-l-cyan-500",
|
|
||||||
calendar: "border-l-rose-400 dark:border-l-rose-500",
|
|
||||||
notes: "border-l-stone-400 dark:border-l-stone-500",
|
|
||||||
};
|
|
||||||
|
|
||||||
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = false, category = "contact" }: {
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
title: string;
|
title: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
collapsible?: boolean;
|
collapsible?: boolean;
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
category?: FormCategory;
|
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(defaultOpen || !collapsible);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("rounded-lg border border-border bg-card border-l-[3px] px-4 py-3", formCategoryStyles[category])}>
|
<section className="py-5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full py-0.5 text-sm font-medium text-foreground transition-colors",
|
"flex items-center gap-2 w-full text-left",
|
||||||
collapsible && "hover:text-muted-foreground cursor-pointer",
|
collapsible ? "cursor-pointer" : "cursor-default"
|
||||||
!collapsible && "cursor-default"
|
|
||||||
)}
|
)}
|
||||||
onClick={() => collapsible && setOpen(!open)}
|
onClick={() => collapsible && setOpen(!open)}
|
||||||
tabIndex={collapsible ? 0 : -1}
|
tabIndex={collapsible ? 0 : -1}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
|
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
<span className="flex-1 text-left">{title}</span>
|
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||||
{collapsible && (
|
{collapsible && (
|
||||||
open ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
open ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{(open || !collapsible) && (
|
||||||
<div className="space-y-3 pt-3 pb-1">
|
<div className="space-y-3 mt-3">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_PHOTO_DIM = 512;
|
||||||
|
const PHOTO_QUALITY = 0.85;
|
||||||
|
|
||||||
|
async function processImageFile(file: File): Promise<{ uri: string; mediaType: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const ratio = Math.min(1, MAX_PHOTO_DIM / Math.max(img.width, img.height));
|
||||||
|
const w = Math.max(1, Math.round(img.width * ratio));
|
||||||
|
const h = Math.max(1, Math.round(img.height * ratio));
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
reject(new Error("canvas-unsupported"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
const uri = canvas.toDataURL("image/jpeg", PHOTO_QUALITY);
|
||||||
|
resolve({ uri, mediaType: "image/jpeg" });
|
||||||
|
};
|
||||||
|
img.onerror = () => reject(new Error("invalid-image"));
|
||||||
|
img.src = reader.result as string;
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error("read-failed"));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function Select({ value, onChange, children, className }: {
|
function Select({ value, onChange, children, className }: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||||
@@ -124,11 +144,13 @@ function Select({ value, onChange, children, className }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) {
|
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) {
|
||||||
const t = useTranslations("contacts.form");
|
const t = useTranslations("contacts.form");
|
||||||
const isEditing = !!contact;
|
const isEditing = !!contact;
|
||||||
|
|
||||||
const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || "";
|
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
|
||||||
|
const findComponent = (...kinds: string[]) =>
|
||||||
|
contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || "";
|
||||||
|
|
||||||
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
|
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
|
||||||
function anniversaryDateToString(date: AnniversaryDate): string {
|
function anniversaryDateToString(date: AnniversaryDate): string {
|
||||||
@@ -191,11 +213,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const [prefix, setPrefix] = useState(findComponent("prefix"));
|
const [prefix, setPrefix] = useState(findComponent("title", "prefix"));
|
||||||
const [givenName, setGivenName] = useState(findComponent("given"));
|
const [givenName, setGivenName] = useState(findComponent("given"));
|
||||||
const [additionalName, setAdditionalName] = useState(findComponent("additional"));
|
const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle"));
|
||||||
const [surname, setSurname] = useState(findComponent("surname"));
|
const [surname, setSurname] = useState(findComponent("surname"));
|
||||||
const [suffix, setSuffix] = useState(findComponent("suffix"));
|
const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
|
||||||
|
|
||||||
const [nickname, setNickname] = useState(
|
const [nickname, setNickname] = useState(
|
||||||
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
|
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
|
||||||
@@ -309,14 +331,61 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
return ids[0];
|
return ids[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (defaultAddressBookId && addressBooks?.some(b => b.id === defaultAddressBookId)) {
|
||||||
|
return defaultAddressBookId;
|
||||||
|
}
|
||||||
return "";
|
return "";
|
||||||
}, [contact]);
|
}, [contact, defaultAddressBookId, addressBooks]);
|
||||||
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
|
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
|
||||||
|
|
||||||
|
const initialPhotoEntry = useMemo(() => {
|
||||||
|
if (!contact?.media) return null;
|
||||||
|
for (const [key, m] of Object.entries(contact.media)) {
|
||||||
|
if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [contact]);
|
||||||
|
const [photoUri, setPhotoUri] = useState<string | undefined>(initialPhotoEntry?.uri);
|
||||||
|
const [photoMediaType, setPhotoMediaType] = useState<string | undefined>(initialPhotoEntry?.mediaType);
|
||||||
|
const [photoError, setPhotoError] = useState<string | null>(null);
|
||||||
|
const [photoUploading, setPhotoUploading] = useState(false);
|
||||||
|
const photoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
|
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
|
const handlePhotoSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
setPhotoError(t("photo_invalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
setPhotoError(t("photo_too_large"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPhotoError(null);
|
||||||
|
setPhotoUploading(true);
|
||||||
|
try {
|
||||||
|
const { uri, mediaType } = await processImageFile(file);
|
||||||
|
setPhotoUri(uri);
|
||||||
|
setPhotoMediaType(mediaType);
|
||||||
|
} catch {
|
||||||
|
setPhotoError(t("photo_invalid"));
|
||||||
|
} finally {
|
||||||
|
setPhotoUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhotoRemove = () => {
|
||||||
|
setPhotoUri(undefined);
|
||||||
|
setPhotoMediaType(undefined);
|
||||||
|
setPhotoError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const validateEmail = (address: string): boolean => {
|
const validateEmail = (address: string): boolean => {
|
||||||
if (!address.trim()) return true;
|
if (!address.trim()) return true;
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim());
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim());
|
||||||
@@ -373,12 +442,13 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
phonesMap[`p${i}`] = obj;
|
phonesMap[`p${i}`] = obj;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
|
||||||
const nameComponents = [];
|
const nameComponents = [];
|
||||||
if (prefix.trim()) nameComponents.push({ kind: "prefix" as const, value: prefix.trim() });
|
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
|
||||||
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
|
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
|
||||||
if (additionalName.trim()) nameComponents.push({ kind: "additional" as const, value: additionalName.trim() });
|
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
|
||||||
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
|
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
|
||||||
if (suffix.trim()) nameComponents.push({ kind: "suffix" as const, value: suffix.trim() });
|
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
|
||||||
|
|
||||||
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
|
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
|
||||||
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
|
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
|
||||||
@@ -427,6 +497,17 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mediaMap: Record<string, ContactMedia> = {};
|
||||||
|
if (contact?.media) {
|
||||||
|
for (const [key, m] of Object.entries(contact.media)) {
|
||||||
|
if (m.kind !== "photo") mediaMap[key] = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (photoUri) {
|
||||||
|
const photoKey = initialPhotoEntry?.key || "photo";
|
||||||
|
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -453,6 +534,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
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,
|
||||||
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
|
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -466,6 +548,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const previewName = [givenName, surname].filter(Boolean).join(" ").trim();
|
||||||
|
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-background">
|
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-background">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||||
@@ -478,38 +563,82 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4 max-w-3xl">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 mb-4">
|
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 mb-4">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="flex items-center gap-4 pb-4">
|
||||||
|
<button
|
||||||
{/* Address Book Selector */}
|
type="button"
|
||||||
{addressBooks && addressBooks.length > 1 && (
|
onClick={() => photoInputRef.current?.click()}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
disabled={photoUploading}
|
||||||
<FormSection icon={Book} title={t("section_address_book") || "Directory"} category="contact">
|
className="relative group rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-60"
|
||||||
<select
|
title={t("upload_photo")}
|
||||||
value={selectedBookId}
|
aria-label={t("upload_photo")}
|
||||||
onChange={(e) => setSelectedBookId(e.target.value)}
|
>
|
||||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
<Avatar
|
||||||
|
name={previewName || undefined}
|
||||||
|
email={previewEmail || undefined}
|
||||||
|
contactPhotoUri={photoUri}
|
||||||
|
size="lg"
|
||||||
|
className="!w-20 !h-20 !text-xl"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 rounded-full bg-black/55 text-white flex flex-col items-center justify-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<Camera className="w-5 h-5" />
|
||||||
|
<span className="text-[10px] font-medium leading-none">{t("change_photo")}</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={photoInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handlePhotoSelect}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col gap-1 min-w-0 flex-1">
|
||||||
|
<p className="text-xs text-muted-foreground">{t("photo_hint")}</p>
|
||||||
|
{photoError && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400">{photoError}</p>
|
||||||
|
)}
|
||||||
|
{photoUri && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePhotoRemove}
|
||||||
|
className="inline-flex items-center gap-1 self-start text-xs text-muted-foreground hover:text-destructive transition-colors"
|
||||||
>
|
>
|
||||||
<option value="">{t("select_address_book") || "Select a directory..."}</option>
|
<Trash2 className="w-3 h-3" />
|
||||||
{addressBooks.map((book) => (
|
{t("remove_photo")}
|
||||||
<option key={book.id} value={book.id}>
|
</button>
|
||||||
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
|
)}
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormSection>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
|
||||||
|
{addressBooks && addressBooks.length > 1 && (
|
||||||
|
<FormSection icon={Book} title={t("section_address_book") || "Directory"}>
|
||||||
|
<select
|
||||||
|
value={selectedBookId}
|
||||||
|
onChange={(e) => setSelectedBookId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||||
|
>
|
||||||
|
<option value="">{t("select_address_book") || "Select a directory..."}</option>
|
||||||
|
{addressBooks.map((book) => (
|
||||||
|
<option key={book.id} value={book.id}>
|
||||||
|
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Name & Identity — full width */}
|
<FormSection icon={User} title={t("section_identity")}>
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
|
||||||
<FormSection icon={User} title={t("section_identity")} category="contact">
|
|
||||||
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
|
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
|
||||||
@@ -543,10 +672,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<FormSection icon={Mail} title={t("email")} collapsible defaultOpen category="contact">
|
<FormSection icon={Mail} title={t("email")}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{emails.map((entry, i) => (
|
{emails.map((entry, i) => (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
@@ -575,7 +703,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
setEmails(next);
|
setEmails(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="work">{t("context_work")}</option>
|
<option value="work">{t("context_work")}</option>
|
||||||
<option value="private">{t("context_private")}</option>
|
<option value="private">{t("context_private")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -598,7 +726,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Phone */}
|
{/* Phone */}
|
||||||
<FormSection icon={Phone} title={t("phone")} collapsible defaultOpen category="contact">
|
<FormSection icon={Phone} title={t("phone")}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{phones.map((entry, i) => (
|
{phones.map((entry, i) => (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
@@ -639,7 +767,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
setPhones(next);
|
setPhones(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="work">{t("context_work")}</option>
|
<option value="work">{t("context_work")}</option>
|
||||||
<option value="private">{t("context_private")}</option>
|
<option value="private">{t("context_private")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -656,7 +784,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Work & Organization */}
|
{/* Work & Organization */}
|
||||||
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen category="work">
|
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!(organization || department || jobTitle || role)}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
|
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
|
||||||
@@ -677,9 +805,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Addresses — full width */}
|
{/* Addresses */}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen={addresses.length > 0}>
|
||||||
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{addresses.map((addr, i) => (
|
{addresses.map((addr, i) => (
|
||||||
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
|
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
|
||||||
@@ -698,7 +825,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
value={addr.context}
|
value={addr.context}
|
||||||
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
|
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="work">{t("context_work")}</option>
|
<option value="work">{t("context_work")}</option>
|
||||||
<option value="private">{t("context_private")}</option>
|
<option value="private">{t("context_private")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -711,10 +838,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Online Services */}
|
{/* Online Services */}
|
||||||
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen category="digital">
|
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen={onlineServices.length > 0}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{onlineServices.map((svc, i) => (
|
{onlineServices.map((svc, i) => (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
@@ -743,7 +869,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Anniversaries */}
|
{/* Anniversaries */}
|
||||||
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen category="personal">
|
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen={anniversaries.length > 0}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{anniversaries.map((ann, i) => (
|
{anniversaries.map((ann, i) => (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
@@ -775,7 +901,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Personal Info */}
|
{/* Personal Info */}
|
||||||
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen category="personal">
|
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen={personalInfoEntries.length > 0}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{personalInfoEntries.map((pi, i) => (
|
{personalInfoEntries.map((pi, i) => (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
@@ -816,7 +942,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Categories */}
|
{/* Categories */}
|
||||||
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
|
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen={!!keywordsStr}>
|
||||||
<CategoryComboBox
|
<CategoryComboBox
|
||||||
keywordsStr={keywordsStr}
|
keywordsStr={keywordsStr}
|
||||||
onChange={setKeywordsStr}
|
onChange={setKeywordsStr}
|
||||||
@@ -828,12 +954,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Gender */}
|
{/* Gender */}
|
||||||
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen category="personal">
|
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen={!!(genderSex || genderIdentity)}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
||||||
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
|
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
|
||||||
<option value="">—</option>
|
<option value="">-</option>
|
||||||
<option value="masculine">{t("gender_male")}</option>
|
<option value="masculine">{t("gender_male")}</option>
|
||||||
<option value="feminine">{t("gender_female")}</option>
|
<option value="feminine">{t("gender_female")}</option>
|
||||||
<option value="other">{t("gender_other")}</option>
|
<option value="other">{t("gender_other")}</option>
|
||||||
@@ -849,7 +975,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Calendar */}
|
{/* Calendar */}
|
||||||
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen category="calendar">
|
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen={!!(calendarUri || schedulingUri || freeBusyUri)}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
|
<label className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
|
||||||
@@ -866,9 +992,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* Notes — full width */}
|
{/* Notes */}
|
||||||
<div className="md:col-span-2 xl:col-span-3">
|
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen={!!note}>
|
||||||
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
|
|
||||||
<textarea
|
<textarea
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
@@ -876,7 +1001,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
|||||||
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring"
|
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export function ContactImportDialog({
|
|||||||
</div>
|
</div>
|
||||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate">{cName || cEmail || "—"}</div>
|
<div className="text-sm font-medium truncate">{cName || cEmail || "-"}</div>
|
||||||
{cEmail && cName && (
|
{cEmail && cName && (
|
||||||
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
|
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ interface ContactListItemProps {
|
|||||||
selectedContactIds: Set<string>;
|
selectedContactIds: Set<string>;
|
||||||
onClick: (e: React.MouseEvent) => void;
|
onClick: (e: React.MouseEvent) => void;
|
||||||
onCheckboxClick: (e: React.MouseEvent) => void;
|
onCheckboxClick: (e: React.MouseEvent) => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent, contact: ContactCard) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick }: ContactListItemProps) {
|
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu }: ContactListItemProps) {
|
||||||
const name = getContactDisplayName(contact);
|
const name = getContactDisplayName(contact);
|
||||||
const email = getContactPrimaryEmail(contact);
|
const email = getContactPrimaryEmail(contact);
|
||||||
const org = contact.organizations
|
const org = contact.organizations
|
||||||
@@ -56,6 +57,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
|||||||
draggable
|
draggable
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onContextMenu={onContextMenu ? (e) => onContextMenu(e, contact) : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
|
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
|
||||||
isSelected
|
isSelected
|
||||||
@@ -90,7 +92,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
|||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate">
|
<div className="text-sm font-medium truncate">
|
||||||
{name || email || "—"}
|
{name || email || "-"}
|
||||||
</div>
|
</div>
|
||||||
{density !== 'extra-compact' && email && name && (
|
{density !== 'extra-compact' && email && name && (
|
||||||
<div className="text-xs text-muted-foreground truncate">{email}</div>
|
<div className="text-xs text-muted-foreground truncate">{email}</div>
|
||||||
|
|||||||
@@ -1,16 +1,98 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square } from "lucide-react";
|
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ContactListItem } from "./contact-list-item";
|
import { ContactListItem } from "./contact-list-item";
|
||||||
|
import { ContactContextMenu } from "./contact-context-menu";
|
||||||
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard } from "@/lib/jmap/types";
|
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName } from "@/stores/contact-store";
|
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
|
type TriState = boolean | null;
|
||||||
|
|
||||||
|
interface ListFilters {
|
||||||
|
organization: string;
|
||||||
|
jobTitle: string;
|
||||||
|
location: string;
|
||||||
|
emailDomain: string;
|
||||||
|
birthdayMonth: number | null;
|
||||||
|
hasEmail: TriState;
|
||||||
|
hasPhone: TriState;
|
||||||
|
hasPhoto: TriState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FILTERS: ListFilters = {
|
||||||
|
organization: "",
|
||||||
|
jobTitle: "",
|
||||||
|
location: "",
|
||||||
|
emailDomain: "",
|
||||||
|
birthdayMonth: null,
|
||||||
|
hasEmail: null,
|
||||||
|
hasPhone: null,
|
||||||
|
hasPhoto: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function cycleTri(v: TriState): TriState {
|
||||||
|
return v === null ? true : v === true ? false : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countActiveFilters(f: ListFilters): number {
|
||||||
|
let n = 0;
|
||||||
|
if (f.organization.trim()) n++;
|
||||||
|
if (f.jobTitle.trim()) n++;
|
||||||
|
if (f.location.trim()) n++;
|
||||||
|
if (f.emailDomain.trim()) n++;
|
||||||
|
if (f.birthdayMonth !== null) n++;
|
||||||
|
if (f.hasEmail !== null) n++;
|
||||||
|
if (f.hasPhone !== null) n++;
|
||||||
|
if (f.hasPhoto !== null) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchTri(actual: boolean, filter: TriState): boolean {
|
||||||
|
if (filter === null) return true;
|
||||||
|
return actual === filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnniversaryMonth(date: AnniversaryDate): number | null {
|
||||||
|
if (typeof date === "string") {
|
||||||
|
const iso = date.match(/^(\d{4})-(\d{2})/);
|
||||||
|
if (iso) return parseInt(iso[2], 10);
|
||||||
|
const partial = date.match(/^--(\d{2})/);
|
||||||
|
if (partial) return parseInt(partial[1], 10);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ("month" in date && date.month) return date.month;
|
||||||
|
if ("utc" in date && date.utc) {
|
||||||
|
const d = new Date(date.utc);
|
||||||
|
return isNaN(d.getTime()) ? null : d.getMonth() + 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleChip({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: TriState; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs transition-colors border",
|
||||||
|
value === true && "bg-primary/10 border-primary/30 text-primary",
|
||||||
|
value === false && "bg-muted border-border text-muted-foreground line-through",
|
||||||
|
value === null && "bg-background border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ContactListProps {
|
interface ContactListProps {
|
||||||
contacts: ContactCard[];
|
contacts: ContactCard[];
|
||||||
selectedContactId: string | null;
|
selectedContactId: string | null;
|
||||||
@@ -18,7 +100,6 @@ interface ContactListProps {
|
|||||||
onSearchChange: (query: string) => void;
|
onSearchChange: (query: string) => void;
|
||||||
onSelectContact: (id: string) => void;
|
onSelectContact: (id: string) => void;
|
||||||
onCreateNew: () => void;
|
onCreateNew: () => void;
|
||||||
categoryLabel: string;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
selectedContactIds: Set<string>;
|
selectedContactIds: Set<string>;
|
||||||
onToggleSelection: (id: string) => void;
|
onToggleSelection: (id: string) => void;
|
||||||
@@ -28,6 +109,9 @@ interface ContactListProps {
|
|||||||
onBulkDelete: () => void;
|
onBulkDelete: () => void;
|
||||||
onBulkAddToGroup: () => void;
|
onBulkAddToGroup: () => void;
|
||||||
onBulkExport: () => void;
|
onBulkExport: () => void;
|
||||||
|
onEditContact: (id: string) => void;
|
||||||
|
onDeleteContact: (contact: ContactCard) => void;
|
||||||
|
onAddContactToGroup: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactList({
|
export function ContactList({
|
||||||
@@ -37,7 +121,6 @@ export function ContactList({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSelectContact,
|
onSelectContact,
|
||||||
onCreateNew,
|
onCreateNew,
|
||||||
categoryLabel,
|
|
||||||
className,
|
className,
|
||||||
selectedContactIds,
|
selectedContactIds,
|
||||||
onToggleSelection,
|
onToggleSelection,
|
||||||
@@ -47,32 +130,99 @@ export function ContactList({
|
|||||||
onBulkDelete,
|
onBulkDelete,
|
||||||
onBulkAddToGroup,
|
onBulkAddToGroup,
|
||||||
onBulkExport,
|
onBulkExport,
|
||||||
|
onEditContact,
|
||||||
|
onDeleteContact,
|
||||||
|
onAddContactToGroup,
|
||||||
}: ContactListProps) {
|
}: ContactListProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
|
const locale = useLocale();
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
|
||||||
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
|
||||||
|
const activeFilters = countActiveFilters(filters);
|
||||||
|
|
||||||
|
const monthNames = useMemo(() => {
|
||||||
|
const fmt = new Intl.DateTimeFormat(locale, { month: "long" });
|
||||||
|
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2000, i, 1)));
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!searchQuery) return contacts;
|
const lower = searchQuery.trim().toLowerCase();
|
||||||
const lower = searchQuery.toLowerCase();
|
const orgLower = filters.organization.trim().toLowerCase();
|
||||||
|
const jobLower = filters.jobTitle.trim().toLowerCase();
|
||||||
|
const locLower = filters.location.trim().toLowerCase();
|
||||||
|
const domainLower = filters.emailDomain.trim().toLowerCase().replace(/^@/, "");
|
||||||
|
|
||||||
return contacts.filter((c) => {
|
return contacts.filter((c) => {
|
||||||
|
const emails = c.emails ? Object.values(c.emails) : [];
|
||||||
|
const phones = c.phones ? Object.values(c.phones) : [];
|
||||||
|
const orgs = c.organizations ? Object.values(c.organizations) : [];
|
||||||
|
const titles = c.titles ? Object.values(c.titles) : [];
|
||||||
|
const addresses = c.addresses ? Object.values(c.addresses) : [];
|
||||||
|
const anniversaries = c.anniversaries ? Object.values(c.anniversaries) : [];
|
||||||
|
|
||||||
|
if (!matchTri(emails.length > 0, filters.hasEmail)) return false;
|
||||||
|
if (!matchTri(phones.length > 0, filters.hasPhone)) return false;
|
||||||
|
if (!matchTri(!!getContactPhotoUri(c), filters.hasPhoto)) return false;
|
||||||
|
|
||||||
|
if (orgLower) {
|
||||||
|
const match = orgs.some((o) => {
|
||||||
|
if (o.name?.toLowerCase().includes(orgLower)) return true;
|
||||||
|
if (o.units?.some((u) => u.name?.toLowerCase().includes(orgLower))) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (!match) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jobLower) {
|
||||||
|
if (!titles.some((ti) => ti.name?.toLowerCase().includes(jobLower))) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locLower) {
|
||||||
|
const match = addresses.some((a) => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (a.full) parts.push(a.full);
|
||||||
|
if (a.fullAddress) parts.push(a.fullAddress);
|
||||||
|
if (a.locality) parts.push(a.locality);
|
||||||
|
if (a.region) parts.push(a.region);
|
||||||
|
if (a.country) parts.push(a.country);
|
||||||
|
if (a.postcode) parts.push(a.postcode);
|
||||||
|
if (a.street) parts.push(a.street);
|
||||||
|
if (a.components) {
|
||||||
|
for (const comp of a.components) {
|
||||||
|
if (comp.value) parts.push(comp.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.some((p) => p.toLowerCase().includes(locLower));
|
||||||
|
});
|
||||||
|
if (!match) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domainLower) {
|
||||||
|
const match = emails.some((e) => {
|
||||||
|
const at = e.address?.toLowerCase().split("@");
|
||||||
|
return at && at.length > 1 && at[1].includes(domainLower);
|
||||||
|
});
|
||||||
|
if (!match) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.birthdayMonth !== null) {
|
||||||
|
const target = filters.birthdayMonth;
|
||||||
|
const match = anniversaries.some((a) => a.kind === "birth" && getAnniversaryMonth(a.date) === target);
|
||||||
|
if (!match) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lower) return true;
|
||||||
const name = getContactDisplayName(c).toLowerCase();
|
const name = getContactDisplayName(c).toLowerCase();
|
||||||
const emails = c.emails
|
if (name.includes(lower)) return true;
|
||||||
? Object.values(c.emails).map((e) => e.address.toLowerCase())
|
if (emails.some((e) => e.address?.toLowerCase().includes(lower))) return true;
|
||||||
: [];
|
if (phones.some((p) => p.number?.toLowerCase().includes(lower))) return true;
|
||||||
const phones = c.phones
|
if (orgs.some((o) => o.name?.toLowerCase().includes(lower))) return true;
|
||||||
? Object.values(c.phones).map((p) => p.number?.toLowerCase() || "")
|
return false;
|
||||||
: [];
|
|
||||||
const org = c.organizations
|
|
||||||
? Object.values(c.organizations).map((o) => o.name?.toLowerCase() || "")
|
|
||||||
: [];
|
|
||||||
return (
|
|
||||||
name.includes(lower) ||
|
|
||||||
emails.some((e) => e.includes(lower)) ||
|
|
||||||
phones.some((p) => p.includes(lower)) ||
|
|
||||||
org.some((o) => o.includes(lower))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}, [contacts, searchQuery]);
|
}, [contacts, searchQuery, filters]);
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
return [...filtered].sort((a, b) => {
|
return [...filtered].sort((a, b) => {
|
||||||
@@ -84,29 +234,214 @@ export function ContactList({
|
|||||||
|
|
||||||
const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]);
|
const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]);
|
||||||
|
|
||||||
|
// Group sorted contacts by first letter of display name. Non-letter
|
||||||
|
// starters (digits, symbols, empty) collect under "#" which sorts last.
|
||||||
|
const groupedSections = useMemo(() => {
|
||||||
|
const collator = new Intl.Collator(locale, { sensitivity: "base" });
|
||||||
|
const groups = new Map<string, ContactCard[]>();
|
||||||
|
for (const contact of sorted) {
|
||||||
|
const name = getContactDisplayName(contact).trim();
|
||||||
|
const first = name.charAt(0);
|
||||||
|
const letter = first && first.toLocaleUpperCase(locale).match(/\p{L}/u)
|
||||||
|
? first.toLocaleUpperCase(locale)
|
||||||
|
: "#";
|
||||||
|
const bucket = groups.get(letter);
|
||||||
|
if (bucket) bucket.push(contact);
|
||||||
|
else groups.set(letter, [contact]);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries())
|
||||||
|
.sort(([a], [b]) => {
|
||||||
|
if (a === "#") return 1;
|
||||||
|
if (b === "#") return -1;
|
||||||
|
return collator.compare(a, b);
|
||||||
|
})
|
||||||
|
.map(([letter, items]) => ({ letter, items }));
|
||||||
|
}, [sorted, locale]);
|
||||||
|
|
||||||
const hasSelection = selectedContactIds.size > 0;
|
const hasSelection = selectedContactIds.size > 0;
|
||||||
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
|
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col h-full", className)}>
|
<div className={cn("flex flex-col h-full", className)}>
|
||||||
{/* Search header */}
|
{/* Toolbar: select / search / filter */}
|
||||||
<div className="px-3 border-b border-border space-y-1.5" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
<div className="border-b border-border bg-background">
|
||||||
<div className="flex items-center justify-between">
|
<div className="px-3 py-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground truncate">
|
<div className="flex items-center gap-1.5">
|
||||||
{categoryLabel} ({contacts.length})
|
<button
|
||||||
</span>
|
type="button"
|
||||||
</div>
|
onClick={() => {
|
||||||
<div className="relative">
|
if (hasSelection) {
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
if (allSelected) onClearSelection();
|
||||||
<Input
|
else onSelectAll(sortedIds);
|
||||||
placeholder={t("search_placeholder")}
|
} else if (sortedIds.length > 0) {
|
||||||
value={searchQuery}
|
const target = selectedContactId && sortedIds.includes(selectedContactId)
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
? selectedContactId
|
||||||
className="pl-8 h-8 text-sm"
|
: sortedIds[0];
|
||||||
/>
|
onToggleSelection(target);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 p-2 rounded-md transition-colors",
|
||||||
|
hasSelection
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
title={hasSelection ? (allSelected ? t("bulk.clear") : t("bulk.select_all")) : t("filters.select")}
|
||||||
|
>
|
||||||
|
{hasSelection ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("search_placeholder")}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSearchChange("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
aria-label={t("clear_search")}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFiltersOpen((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||||
|
filtersOpen || activeFilters > 0
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
title={t("filters.toggle")}
|
||||||
|
aria-label={t("filters.toggle")}
|
||||||
|
>
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
{!filtersOpen && activeFilters > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
|
||||||
|
{activeFilters}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filtersOpen && (
|
||||||
|
<div className="border-b border-border bg-muted/30 animate-in slide-in-from-top-2 fade-in duration-200">
|
||||||
|
<div className="px-4 py-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-foreground">{t("filters.title")}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilters(EMPTY_FILTERS)}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3 mr-1" />
|
||||||
|
{t("filters.clear")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setFiltersOpen(false)}
|
||||||
|
className="h-7 w-7"
|
||||||
|
aria-label={t("filters.close")}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.organization")}</label>
|
||||||
|
<Input
|
||||||
|
value={filters.organization}
|
||||||
|
onChange={(e) => setFilters((f) => ({ ...f, organization: e.target.value }))}
|
||||||
|
placeholder={t("filters.organization_placeholder")}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.job_title")}</label>
|
||||||
|
<Input
|
||||||
|
value={filters.jobTitle}
|
||||||
|
onChange={(e) => setFilters((f) => ({ ...f, jobTitle: e.target.value }))}
|
||||||
|
placeholder={t("filters.job_title_placeholder")}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.location")}</label>
|
||||||
|
<Input
|
||||||
|
value={filters.location}
|
||||||
|
onChange={(e) => setFilters((f) => ({ ...f, location: e.target.value }))}
|
||||||
|
placeholder={t("filters.location_placeholder")}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.email_domain")}</label>
|
||||||
|
<Input
|
||||||
|
value={filters.emailDomain}
|
||||||
|
onChange={(e) => setFilters((f) => ({ ...f, emailDomain: e.target.value }))}
|
||||||
|
placeholder={t("filters.email_domain_placeholder")}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.birthday_month")}</label>
|
||||||
|
<select
|
||||||
|
value={filters.birthdayMonth ?? ""}
|
||||||
|
onChange={(e) => setFilters((f) => ({ ...f, birthdayMonth: e.target.value === "" ? null : Number(e.target.value) }))}
|
||||||
|
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
||||||
|
aria-label={t("filters.birthday_month")}
|
||||||
|
>
|
||||||
|
<option value="">{t("filters.any_month")}</option>
|
||||||
|
{monthNames.map((name, i) => (
|
||||||
|
<option key={i} value={i + 1}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<ToggleChip
|
||||||
|
icon={<Mail className="w-3.5 h-3.5" />}
|
||||||
|
label={t("filters.has_email")}
|
||||||
|
value={filters.hasEmail}
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, hasEmail: cycleTri(f.hasEmail) }))}
|
||||||
|
/>
|
||||||
|
<ToggleChip
|
||||||
|
icon={<Phone className="w-3.5 h-3.5" />}
|
||||||
|
label={t("filters.has_phone")}
|
||||||
|
value={filters.hasPhone}
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, hasPhone: cycleTri(f.hasPhone) }))}
|
||||||
|
/>
|
||||||
|
<ToggleChip
|
||||||
|
icon={<ImageIcon className="w-3.5 h-3.5" />}
|
||||||
|
label={t("filters.has_photo")}
|
||||||
|
value={filters.hasPhoto}
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, hasPhoto: cycleTri(f.hasPhoto) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bulk action bar */}
|
{/* Bulk action bar */}
|
||||||
{hasSelection && (
|
{hasSelection && (
|
||||||
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap">
|
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap">
|
||||||
@@ -157,19 +492,32 @@ export function ContactList({
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{sorted.length === 0 ? (
|
{sorted.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
|
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
|
||||||
{searchQuery ? (
|
{searchQuery || activeFilters > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
{activeFilters > 0 && !searchQuery ? (
|
||||||
<p className="text-sm font-medium text-foreground">{t("empty_search")}</p>
|
<Filter className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
||||||
<p className="text-xs text-muted-foreground mt-1">{t("empty_search_hint")}</p>
|
) : (
|
||||||
<Button
|
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
||||||
variant="outline"
|
)}
|
||||||
size="sm"
|
<p className="text-sm font-medium text-foreground">
|
||||||
className="mt-3"
|
{searchQuery ? t("empty_search") : t("empty_filtered")}
|
||||||
onClick={() => onSearchChange("")}
|
</p>
|
||||||
>
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{t("clear_search")}
|
{searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")}
|
||||||
</Button>
|
</p>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
{searchQuery && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onSearchChange("")}>
|
||||||
|
{t("clear_search")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{activeFilters > 0 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5 mr-1" />
|
||||||
|
{t("filters.clear")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -184,8 +532,8 @@ export function ContactList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
(() => {
|
||||||
{sorted.map((contact) => (
|
const renderItem = (contact: ContactCard) => (
|
||||||
<ContactListItem
|
<ContactListItem
|
||||||
key={contact.id}
|
key={contact.id}
|
||||||
contact={contact}
|
contact={contact}
|
||||||
@@ -210,11 +558,45 @@ export function ContactList({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggleSelection(contact.id);
|
onToggleSelection(contact.id);
|
||||||
}}
|
}}
|
||||||
|
onContextMenu={(e, c) => openContextMenu(e, c)}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
</div>
|
return groupByLetter ? (
|
||||||
|
<div>
|
||||||
|
{groupedSections.map(({ letter, items }) => (
|
||||||
|
<section key={letter}>
|
||||||
|
<div className="sticky top-0 z-10 px-4 py-0.5 bg-background/90 backdrop-blur-sm text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wide">
|
||||||
|
{letter}
|
||||||
|
</div>
|
||||||
|
{items.map(renderItem)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>{sorted.map(renderItem)}</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{contextMenu.data && (
|
||||||
|
<ContactContextMenu
|
||||||
|
contact={contextMenu.data}
|
||||||
|
position={contextMenu.position}
|
||||||
|
isOpen={contextMenu.isOpen}
|
||||||
|
onClose={closeContextMenu}
|
||||||
|
menuRef={menuRef}
|
||||||
|
isMultiSelect={selectedContactIds.has(contextMenu.data.id)}
|
||||||
|
selectedCount={selectedContactIds.size}
|
||||||
|
onOpen={() => onSelectContact(contextMenu.data!.id)}
|
||||||
|
onEdit={() => onEditContact(contextMenu.data!.id)}
|
||||||
|
onDelete={() => onDeleteContact(contextMenu.data!)}
|
||||||
|
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
|
||||||
|
onBatchExport={onBulkExport}
|
||||||
|
onBatchAddToGroup={onBulkAddToGroup}
|
||||||
|
onBatchDelete={onBulkDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { ContactCard, PartialDate } from "@/lib/jmap/types";
|
||||||
|
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printContact(contact: ContactCard, displayName?: string): void {
|
||||||
|
const printWindow = window.open("", "_blank");
|
||||||
|
if (!printWindow) return;
|
||||||
|
|
||||||
|
const name = displayName || getContactDisplayName(contact);
|
||||||
|
const photoUri = getContactPhotoUri(contact);
|
||||||
|
const emails = contact.emails ? Object.values(contact.emails) : [];
|
||||||
|
const phones = contact.phones ? Object.values(contact.phones) : [];
|
||||||
|
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
|
||||||
|
const titles = contact.titles ? Object.values(contact.titles) : [];
|
||||||
|
const addresses = contact.addresses ? Object.values(contact.addresses) : [];
|
||||||
|
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
|
||||||
|
const notes = contact.notes ? Object.values(contact.notes) : [];
|
||||||
|
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
|
||||||
|
|
||||||
|
const rows: string[] = [];
|
||||||
|
const section = (title: string, items: string[]) => {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
rows.push(`<section><h2>${escapeHtml(title)}</h2><dl>${items.join("")}</dl></section>`);
|
||||||
|
};
|
||||||
|
const row = (label: string, value: string) =>
|
||||||
|
`<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`;
|
||||||
|
|
||||||
|
section("Email", emails.map(e => row(e.label || "Email", e.address || "")));
|
||||||
|
section("Phone", phones.map(p => row(p.label || "Phone", p.number || "")));
|
||||||
|
section(
|
||||||
|
"Organization",
|
||||||
|
orgs.map(o => {
|
||||||
|
const units = o.units ? Object.values(o.units).map(u => u.name).filter(Boolean).join(", ") : "";
|
||||||
|
const value = [o.name, units].filter(Boolean).join(" \u2014 ");
|
||||||
|
return row("Organization", value);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
section("Title", titles.map(t => row(t.kind === "role" ? "Role" : "Title", t.name || "")));
|
||||||
|
section(
|
||||||
|
"Address",
|
||||||
|
addresses.map(a => {
|
||||||
|
const parts = [a.street, a.locality, a.region, a.postcode, a.country]
|
||||||
|
.map(p => (typeof p === "string" ? p : ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
return row(a.label || "Address", parts.join(", "));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
section(
|
||||||
|
"Online",
|
||||||
|
onlineServices.map(s => row(s.label || s.service || "Online", s.uri || s.user || "")),
|
||||||
|
);
|
||||||
|
section(
|
||||||
|
"Anniversary",
|
||||||
|
anniversaries.map(a => {
|
||||||
|
const date = a.date;
|
||||||
|
let formatted = "";
|
||||||
|
if (typeof date === "object" && date !== null) {
|
||||||
|
if ("utc" in date && typeof date.utc === "string") formatted = date.utc;
|
||||||
|
else {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const pd = date as PartialDate;
|
||||||
|
if (pd.year) parts.push(String(pd.year));
|
||||||
|
if (pd.month) parts.push(String(pd.month).padStart(2, "0"));
|
||||||
|
if (pd.day) parts.push(String(pd.day).padStart(2, "0"));
|
||||||
|
formatted = parts.join("-");
|
||||||
|
}
|
||||||
|
} else if (date) {
|
||||||
|
formatted = String(date);
|
||||||
|
}
|
||||||
|
return row(a.kind || "Date", formatted);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
section("Notes", notes.map(n => row("", n.note || "")));
|
||||||
|
|
||||||
|
const photoTag = photoUri
|
||||||
|
? `<img class="photo" src="${escapeHtml(photoUri)}" alt="" />`
|
||||||
|
: `<div class="photo placeholder">${escapeHtml((name || "?").charAt(0).toUpperCase())}</div>`;
|
||||||
|
|
||||||
|
const html = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>${escapeHtml(name || "Contact")}</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #111; margin: 32px; }
|
||||||
|
header { display: flex; align-items: center; gap: 20px; padding-bottom: 16px; border-bottom: 1px solid #ccc; }
|
||||||
|
.photo { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||||||
|
.photo.placeholder { background: #e5e7eb; color: #4b5563; display: flex; align-items: center; justify-content: center; font-size: 36px; font-weight: 600; }
|
||||||
|
h1 { margin: 0; font-size: 22px; }
|
||||||
|
section { margin-top: 20px; }
|
||||||
|
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #555; margin: 0 0 8px; }
|
||||||
|
dl { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 6px; column-gap: 16px; font-size: 14px; }
|
||||||
|
dt { color: #555; }
|
||||||
|
dd { margin: 0; word-break: break-word; }
|
||||||
|
@media print { body { margin: 16mm; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
${photoTag}
|
||||||
|
<div><h1>${escapeHtml(name || "Contact")}</h1></div>
|
||||||
|
</header>
|
||||||
|
${rows.join("")}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
printWindow.document.write(html);
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.focus();
|
||||||
|
printWindow.print();
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ interface ContactsSidebarProps {
|
|||||||
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;
|
||||||
|
onShareAddressBook?: (addressBook: AddressBook) => void;
|
||||||
|
onCreateContactInBook?: (addressBook: AddressBook) => void;
|
||||||
|
onDeleteAddressBook?: (addressBook: AddressBook) => void;
|
||||||
onRenameKeyword?: (keyword: string) => void;
|
onRenameKeyword?: (keyword: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -62,6 +65,9 @@ export function ContactsSidebar({
|
|||||||
onDropContacts,
|
onDropContacts,
|
||||||
onDropContactsToCategory,
|
onDropContactsToCategory,
|
||||||
onRenameAddressBook,
|
onRenameAddressBook,
|
||||||
|
onShareAddressBook,
|
||||||
|
onCreateContactInBook,
|
||||||
|
onDeleteAddressBook,
|
||||||
onRenameKeyword,
|
onRenameKeyword,
|
||||||
className,
|
className,
|
||||||
}: ContactsSidebarProps) {
|
}: ContactsSidebarProps) {
|
||||||
@@ -288,7 +294,7 @@ export function ContactsSidebar({
|
|||||||
contactCount={contactCountByBook[book.id] || 0}
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
onDropContacts={onDropContacts}
|
onDropContacts={onDropContacts}
|
||||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -430,7 +436,7 @@ export function ContactsSidebar({
|
|||||||
contactCount={contactCountByBook[book.id] || 0}
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
onDropContacts={onDropContacts}
|
onDropContacts={onDropContacts}
|
||||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -438,24 +444,65 @@ export function ContactsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Address book context menu */}
|
{/* Address book context menu */}
|
||||||
{bookContextMenu.data && onRenameAddressBook && (
|
{bookContextMenu.data && (onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) && (() => {
|
||||||
<ContextMenu
|
const book = bookContextMenu.data;
|
||||||
ref={bookMenuRef}
|
const canCreate = onCreateContactInBook && book.myRights?.mayWrite !== false;
|
||||||
isOpen={bookContextMenu.isOpen}
|
const canRename = onRenameAddressBook && book.myRights?.mayWrite !== false;
|
||||||
position={bookContextMenu.position}
|
const canShare = onShareAddressBook && book.myRights?.mayShare && !book.isShared;
|
||||||
onClose={closeBookContextMenu}
|
const canDelete = onDeleteAddressBook && !book.isDefault && !book.isShared && book.myRights?.mayDelete !== false;
|
||||||
>
|
const showSeparator = (canCreate || canRename || canShare) && canDelete;
|
||||||
<ContextMenuItem
|
return (
|
||||||
icon={Pencil}
|
<ContextMenu
|
||||||
label={t("address_books.rename")}
|
ref={bookMenuRef}
|
||||||
onClick={() => {
|
isOpen={bookContextMenu.isOpen}
|
||||||
const book = bookContextMenu.data!;
|
position={bookContextMenu.position}
|
||||||
closeBookContextMenu();
|
onClose={closeBookContextMenu}
|
||||||
onRenameAddressBook(book);
|
>
|
||||||
}}
|
{canCreate && (
|
||||||
/>
|
<ContextMenuItem
|
||||||
</ContextMenu>
|
icon={UserPlus}
|
||||||
)}
|
label={t("address_books.new_contact_in_book")}
|
||||||
|
onClick={() => {
|
||||||
|
closeBookContextMenu();
|
||||||
|
onCreateContactInBook(book);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canRename && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Pencil}
|
||||||
|
label={t("address_books.rename")}
|
||||||
|
onClick={() => {
|
||||||
|
closeBookContextMenu();
|
||||||
|
onRenameAddressBook(book);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canShare && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Users}
|
||||||
|
label={t("address_books.share")}
|
||||||
|
onClick={() => {
|
||||||
|
closeBookContextMenu();
|
||||||
|
onShareAddressBook(book);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showSeparator && <ContextMenuSeparator />}
|
||||||
|
{canDelete && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("address_books.delete")}
|
||||||
|
onClick={() => {
|
||||||
|
closeBookContextMenu();
|
||||||
|
onDeleteAddressBook(book);
|
||||||
|
}}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Keyword (category) context menu */}
|
{/* Keyword (category) context menu */}
|
||||||
{keywordContextMenu.data && onRenameKeyword && (
|
{keywordContextMenu.data && onRenameKeyword && (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
} 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';
|
||||||
@@ -372,6 +373,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
|
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
|
||||||
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
|
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
|
||||||
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
|
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||||
|
|
||||||
const attachment = findCalendarAttachment(email);
|
const attachment = findCalendarAttachment(email);
|
||||||
|
|
||||||
@@ -442,6 +444,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
|
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
|
||||||
const isCancellation = method === 'cancel';
|
const isCancellation = method === 'cancel';
|
||||||
const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter';
|
const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter';
|
||||||
|
const canCollapse = method === 'reply';
|
||||||
|
const showDetails = !canCollapse || !isCollapsed;
|
||||||
const allowsRsvp = method === 'request';
|
const allowsRsvp = method === 'request';
|
||||||
const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown';
|
const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown';
|
||||||
|
|
||||||
@@ -527,7 +531,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
|| null;
|
|| null;
|
||||||
|
|
||||||
// Send the iMIP REPLY email to the organizer (client-side scheduling).
|
// Send the iMIP REPLY email to the organizer (client-side scheduling).
|
||||||
// Called after updating the local calendar event. Best-effort — if it
|
// Called after updating the local calendar event. Best-effort - if it
|
||||||
// fails we still report the RSVP as sent since the calendar was updated.
|
// fails we still report the RSVP as sent since the calendar was updated.
|
||||||
const sendImipReply = async () => {
|
const sendImipReply = async () => {
|
||||||
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
|
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
|
||||||
@@ -750,9 +754,22 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium text-foreground truncate">{bannerTitle}</span>
|
<span className="text-sm font-medium text-foreground truncate">{bannerTitle}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{canCollapse && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCollapsed((prev) => !prev)}
|
||||||
|
aria-expanded={!isCollapsed}
|
||||||
|
aria-label={isCollapsed ? t('expand') : t('collapse')}
|
||||||
|
title={isCollapsed ? t('expand') : t('collapse')}
|
||||||
|
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
{showDetails && (
|
||||||
<div className="px-4 py-3 space-y-2.5">
|
<div className="px-4 py-3 space-y-2.5">
|
||||||
<div className="lg:flex lg:gap-6">
|
<div className="lg:flex lg:gap-6">
|
||||||
{/* Left: Event info */}
|
{/* Left: Event info */}
|
||||||
@@ -894,8 +911,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
{showDetails && (
|
||||||
<div className="px-4 py-2.5 border-t border-border bg-muted/20 flex items-center gap-2 flex-wrap">
|
<div className="px-4 py-2.5 border-t border-border bg-muted/20 flex items-center gap-2 flex-wrap">
|
||||||
{canRespond && (
|
{canRespond && (
|
||||||
<>
|
<>
|
||||||
@@ -1020,6 +1039,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
|
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import { useTranslations } from "next-intl";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
|
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
|
||||||
import { cn, formatFileSize, formatDateTime } from "@/lib/utils";
|
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useSmimeStore } from "@/stores/smime-store";
|
import { useSmimeStore } from "@/stores/smime-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
@@ -30,6 +31,7 @@ import { TemplateForm } from "@/components/templates/template-form";
|
|||||||
import type { EmailTemplate } from "@/lib/template-types";
|
import type { EmailTemplate } from "@/lib/template-types";
|
||||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||||
import { findReplyIdentityId } from "@/lib/reply-identity";
|
import { findReplyIdentityId } from "@/lib/reply-identity";
|
||||||
|
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||||
|
|
||||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||||
@@ -65,7 +67,9 @@ interface EmailComposerProps {
|
|||||||
fromEmail?: string;
|
fromEmail?: string;
|
||||||
fromName?: string;
|
fromName?: string;
|
||||||
identityId?: string;
|
identityId?: string;
|
||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||||
|
inReplyTo?: string[];
|
||||||
|
references?: string[];
|
||||||
}) => void | Promise<void>;
|
}) => void | Promise<void>;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onDiscardDraft?: (draftId: string) => void;
|
onDiscardDraft?: (draftId: string) => void;
|
||||||
@@ -84,9 +88,27 @@ interface EmailComposerProps {
|
|||||||
body?: string;
|
body?: string;
|
||||||
htmlBody?: string;
|
htmlBody?: string;
|
||||||
receivedAt?: string;
|
receivedAt?: string;
|
||||||
|
accountId?: string;
|
||||||
|
attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>;
|
||||||
|
// Threading: parent's Message-ID and References, used to set RFC 5322
|
||||||
|
// In-Reply-To and References on outgoing replies. See #234.
|
||||||
|
messageId?: string;
|
||||||
|
inReplyTo?: string[];
|
||||||
|
references?: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ComposerAttachment = {
|
||||||
|
file?: File;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
blobId?: string;
|
||||||
|
uploading?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
abortController?: AbortController;
|
||||||
|
};
|
||||||
|
|
||||||
export function EmailComposer({
|
export function EmailComposer({
|
||||||
onSend,
|
onSend,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -102,6 +124,7 @@ export function EmailComposer({
|
|||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||||
|
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
|
||||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||||
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||||
@@ -199,7 +222,22 @@ export function EmailComposer({
|
|||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const lastSavedDataRef = useRef<string>("");
|
const lastSavedDataRef = useRef<string>("");
|
||||||
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
|
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
|
||||||
|
if (mode === 'forward' && replyTo?.attachments?.length) {
|
||||||
|
return replyTo.attachments
|
||||||
|
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
|
||||||
|
// (matches the viewer's hideInlineImageAttachments logic).
|
||||||
|
.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
|
||||||
|
.map(att => ({
|
||||||
|
name: att.name || 'attachment',
|
||||||
|
type: att.type || 'application/octet-stream',
|
||||||
|
size: att.size,
|
||||||
|
blobId: att.blobId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const inlineImagesRef = useRef<Array<{ cid: string; blobId: string; type: string; name: string; size: number; dataUrl: string }>>([]);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
|
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
|
||||||
const [shakeField, setShakeField] = useState<string | null>(null);
|
const [shakeField, setShakeField] = useState<string | null>(null);
|
||||||
@@ -254,12 +292,28 @@ export function EmailComposer({
|
|||||||
|
|
||||||
if (matchedIdentityId) {
|
if (matchedIdentityId) {
|
||||||
setSelectedIdentityId(matchedIdentityId);
|
setSelectedIdentityId(matchedIdentityId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: match identity by the account's email when replying from unified view
|
||||||
|
if (replyTo?.accountId) {
|
||||||
|
const account = useAccountStore.getState().getAccountById(replyTo.accountId);
|
||||||
|
if (account?.email) {
|
||||||
|
const accountEmail = account.email.trim().toLowerCase();
|
||||||
|
const accountIdentity = identities.find(
|
||||||
|
(identity) => identity.email.trim().toLowerCase() === accountEmail
|
||||||
|
);
|
||||||
|
if (accountIdentity) {
|
||||||
|
setSelectedIdentityId(accountIdentity.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
autoSelectReplyIdentity,
|
autoSelectReplyIdentity,
|
||||||
identities,
|
identities,
|
||||||
initialData?.selectedIdentityId,
|
initialData?.selectedIdentityId,
|
||||||
mode,
|
mode,
|
||||||
|
replyTo?.accountId,
|
||||||
replyTo?.bcc,
|
replyTo?.bcc,
|
||||||
replyTo?.cc,
|
replyTo?.cc,
|
||||||
replyTo?.to,
|
replyTo?.to,
|
||||||
@@ -306,7 +360,7 @@ export function EmailComposer({
|
|||||||
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId };
|
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId };
|
||||||
|
|
||||||
// Track initial values for dirty detection (captured once on first render)
|
// Track initial values for dirty detection (captured once on first render)
|
||||||
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: 0 });
|
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length });
|
||||||
const isDirtyRef = useRef(false);
|
const isDirtyRef = useRef(false);
|
||||||
isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc ||
|
isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc ||
|
||||||
bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
|
bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
|
||||||
@@ -505,9 +559,16 @@ export function EmailComposer({
|
|||||||
const addFiles = useCallback(async (files: File[]) => {
|
const addFiles = useCallback(async (files: File[]) => {
|
||||||
if (!client || files.length === 0) return;
|
if (!client || files.length === 0) return;
|
||||||
|
|
||||||
const newAttachments = files.map(file => {
|
const newAttachments: ComposerAttachment[] = files.map(file => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
return { file, uploading: true, abortController: controller };
|
return {
|
||||||
|
file,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
size: file.size,
|
||||||
|
uploading: true,
|
||||||
|
abortController: controller,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
setAttachments(prev => [...prev, ...newAttachments]);
|
setAttachments(prev => [...prev, ...newAttachments]);
|
||||||
|
|
||||||
@@ -542,18 +603,38 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const handleImageUpload = useCallback((file: File): Promise<string | null> => {
|
const handleImageUpload = useCallback(async (
|
||||||
return new Promise((resolve) => {
|
file: File,
|
||||||
const reader = new FileReader();
|
): Promise<{ src: string; cid: string } | null> => {
|
||||||
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
if (!client) return null;
|
||||||
reader.onerror = () => {
|
try {
|
||||||
debug.error(`Failed to read inline image ${file.name}`);
|
const readAsDataUrl = new Promise<string | null>((resolve) => {
|
||||||
toast.error(t('upload_failed', { filename: file.name }));
|
const reader = new FileReader();
|
||||||
resolve(null);
|
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
||||||
};
|
reader.onerror = () => resolve(null);
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
}, [t]);
|
const [{ blobId }, dataUrl] = await Promise.all([
|
||||||
|
client.uploadBlob(file),
|
||||||
|
readAsDataUrl,
|
||||||
|
]);
|
||||||
|
if (!dataUrl) throw new Error('Failed to read image as data URL');
|
||||||
|
const cid = `${generateUUID()}@webmail`;
|
||||||
|
inlineImagesRef.current.push({
|
||||||
|
cid,
|
||||||
|
blobId,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
dataUrl,
|
||||||
|
});
|
||||||
|
return { src: dataUrl, cid };
|
||||||
|
} catch (error) {
|
||||||
|
debug.error(`Failed to upload inline image ${file.name}:`, error);
|
||||||
|
toast.error(t('upload_failed', { filename: file.name }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [client, t]);
|
||||||
|
|
||||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!event.target.files) return;
|
if (!event.target.files) return;
|
||||||
@@ -630,9 +711,9 @@ export function EmailComposer({
|
|||||||
.filter(att => att.blobId && !att.uploading)
|
.filter(att => att.blobId && !att.uploading)
|
||||||
.map(att => ({
|
.map(att => ({
|
||||||
blobId: att.blobId!,
|
blobId: att.blobId!,
|
||||||
name: att.file.name,
|
name: att.name,
|
||||||
type: att.file.type,
|
type: att.type,
|
||||||
size: att.file.size,
|
size: att.size,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Create a hash of current data to compare with last saved
|
// Create a hash of current data to compare with last saved
|
||||||
@@ -649,7 +730,7 @@ export function EmailComposer({
|
|||||||
// Generate sub-addressed email if tag is set
|
// Generate sub-addressed email if tag is set
|
||||||
const fromEmail = currentIdentity?.email
|
const fromEmail = currentIdentity?.email
|
||||||
? subAddressTag
|
? subAddressTag
|
||||||
? generateSubAddress(currentIdentity.email, subAddressTag)
|
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||||
: currentIdentity.email
|
: currentIdentity.email
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -664,7 +745,8 @@ export function EmailComposer({
|
|||||||
fromEmail,
|
fromEmail,
|
||||||
draftId || undefined,
|
draftId || undefined,
|
||||||
uploadedAttachments,
|
uploadedAttachments,
|
||||||
currentIdentity?.name || undefined
|
currentIdentity?.name || undefined,
|
||||||
|
plainTextMode ? undefined : body
|
||||||
);
|
);
|
||||||
|
|
||||||
setDraftId(savedDraftId);
|
setDraftId(savedDraftId);
|
||||||
@@ -733,6 +815,49 @@ export function EmailComposer({
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Rewrite data: URLs of dropped images (tagged with data-cid) into cid:
|
||||||
|
// references so recipient clients that strip data URIs can still render them.
|
||||||
|
const rewriteInlineImages = (html: string): {
|
||||||
|
html: string;
|
||||||
|
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
|
||||||
|
} => {
|
||||||
|
const known = inlineImagesRef.current;
|
||||||
|
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
||||||
|
const used = new Map<string, typeof known[number]>();
|
||||||
|
|
||||||
|
if (known.length > 0) {
|
||||||
|
doc.querySelectorAll('img[data-cid]').forEach((img) => {
|
||||||
|
const cid = img.getAttribute('data-cid');
|
||||||
|
if (!cid) return;
|
||||||
|
const entry = known.find((e) => e.cid === cid);
|
||||||
|
if (!entry) return;
|
||||||
|
img.setAttribute('src', `cid:${cid}`);
|
||||||
|
img.removeAttribute('data-cid');
|
||||||
|
used.set(cid, entry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient mail clients apply default <p> margins inside table cells,
|
||||||
|
// inflating row height. Tiptap wraps cell text in <p>, so force margin:0
|
||||||
|
// to match the composer's tight rows.
|
||||||
|
doc.querySelectorAll('td > p, th > p').forEach((p) => {
|
||||||
|
const existing = p.getAttribute('style') || '';
|
||||||
|
p.setAttribute('style', `margin:0;${existing}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
html: doc.body.innerHTML,
|
||||||
|
attachments: Array.from(used.values()).map((e) => ({
|
||||||
|
blobId: e.blobId,
|
||||||
|
name: e.name,
|
||||||
|
type: e.type,
|
||||||
|
size: e.size,
|
||||||
|
disposition: 'inline' as const,
|
||||||
|
cid: e.cid,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const handleSend = async (skipAttachmentCheck = false) => {
|
const handleSend = async (skipAttachmentCheck = false) => {
|
||||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
@@ -782,7 +907,7 @@ export function EmailComposer({
|
|||||||
|
|
||||||
const fromEmail = currentIdentity?.email
|
const fromEmail = currentIdentity?.email
|
||||||
? subAddressTag
|
? subAddressTag
|
||||||
? generateSubAddress(currentIdentity.email, subAddressTag)
|
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||||
: currentIdentity.email
|
: currentIdentity.email
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -798,14 +923,21 @@ export function EmailComposer({
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// RFC 5322 §3.6.4 threading — only continues the chain on a reply, not a forward.
|
||||||
|
const threadingHeaders = (mode === 'reply' || mode === 'replyAll')
|
||||||
|
? computeReplyThreadingHeaders(replyTo)
|
||||||
|
: null;
|
||||||
|
|
||||||
// In plain text mode, send text/plain only (no HTML body)
|
// In plain text mode, send text/plain only (no HTML body)
|
||||||
const finalBody = plainTextMode
|
const finalBody = plainTextMode
|
||||||
? appendPlainTextSignature(body, currentIdentity)
|
? appendPlainTextSignature(body, currentIdentity)
|
||||||
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
||||||
|
|
||||||
|
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
|
||||||
const finalHtmlBody = plainTextMode
|
const finalHtmlBody = plainTextMode
|
||||||
? undefined
|
? undefined
|
||||||
: `<div>${body}</div>${buildSignatureHtml()}`;
|
: `<div>${rewritten!.html}</div>${buildSignatureHtml()}`;
|
||||||
|
const inlineAttachments = rewritten?.attachments ?? [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||||
@@ -834,27 +966,47 @@ export function EmailComposer({
|
|||||||
for (const att of attachments) {
|
for (const att of attachments) {
|
||||||
if (att.error || att.uploading) continue;
|
if (att.error || att.uploading) continue;
|
||||||
let content: ArrayBuffer;
|
let content: ArrayBuffer;
|
||||||
if (att.file.size > 0) {
|
if (att.file && att.file.size > 0) {
|
||||||
content = await att.file.arrayBuffer();
|
content = await att.file.arrayBuffer();
|
||||||
} else if (att.blobId && client) {
|
} else if (att.blobId && client) {
|
||||||
content = await client.fetchBlobArrayBuffer(att.blobId, att.file.name, att.file.type);
|
content = await client.fetchBlobArrayBuffer(att.blobId, att.name, att.type);
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
mimeAttachments.push({
|
mimeAttachments.push({
|
||||||
filename: att.file.name,
|
filename: att.name,
|
||||||
contentType: att.file.type || 'application/octet-stream',
|
contentType: att.type || 'application/octet-stream',
|
||||||
content,
|
content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (const inline of inlineAttachments) {
|
||||||
|
if (!client) break;
|
||||||
|
const content = await client.fetchBlobArrayBuffer(inline.blobId, inline.name, inline.type);
|
||||||
|
mimeAttachments.push({
|
||||||
|
filename: inline.name,
|
||||||
|
contentType: inline.type,
|
||||||
|
content,
|
||||||
|
cid: inline.cid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Build canonical MIME
|
// 4. Build canonical MIME
|
||||||
|
// mime-builder takes inReplyTo as a single ref-form msg-id (with brackets);
|
||||||
|
// references stays an array. threadingHeaders contains bare msg-ids.
|
||||||
|
const mimeInReplyTo = threadingHeaders?.inReplyTo[0]
|
||||||
|
? `<${threadingHeaders.inReplyTo[0]}>`
|
||||||
|
: undefined;
|
||||||
|
const mimeReferences = threadingHeaders?.references.length
|
||||||
|
? threadingHeaders.references.map(id => `<${id}>`)
|
||||||
|
: undefined;
|
||||||
const mimeBytes = buildMimeMessage({
|
const mimeBytes = buildMimeMessage({
|
||||||
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
||||||
to: toAddresses.map(e => ({ email: e })),
|
to: toAddresses.map(e => ({ email: e })),
|
||||||
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
|
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
|
||||||
bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined,
|
bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined,
|
||||||
subject,
|
subject,
|
||||||
|
inReplyTo: mimeInReplyTo,
|
||||||
|
references: mimeReferences,
|
||||||
textBody: finalBody,
|
textBody: finalBody,
|
||||||
htmlBody: finalHtmlBody,
|
htmlBody: finalHtmlBody,
|
||||||
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined,
|
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined,
|
||||||
@@ -867,6 +1019,8 @@ export function EmailComposer({
|
|||||||
to: toAddresses.map(e => ({ email: e })),
|
to: toAddresses.map(e => ({ email: e })),
|
||||||
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
|
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
|
||||||
subject,
|
subject,
|
||||||
|
inReplyTo: mimeInReplyTo,
|
||||||
|
references: mimeReferences,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 5. Sign if enabled
|
// 5. Sign if enabled
|
||||||
@@ -906,9 +1060,10 @@ export function EmailComposer({
|
|||||||
} else {
|
} else {
|
||||||
// Standard JMAP send path
|
// Standard JMAP send path
|
||||||
// Collect uploaded attachment blobIds for the send request
|
// Collect uploaded attachment blobIds for the send request
|
||||||
const uploadedAttachments = attachments
|
const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments
|
||||||
.filter(att => att.blobId && !att.uploading && !att.error)
|
.filter(att => att.blobId && !att.uploading && !att.error)
|
||||||
.map(att => ({ blobId: att.blobId!, name: att.file.name, type: att.file.type || 'application/octet-stream', size: att.file.size }));
|
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
|
||||||
|
uploadedAttachments.push(...inlineAttachments);
|
||||||
|
|
||||||
await onSend?.({
|
await onSend?.({
|
||||||
to: toAddresses,
|
to: toAddresses,
|
||||||
@@ -922,6 +1077,8 @@ export function EmailComposer({
|
|||||||
fromName: currentIdentity?.name || undefined,
|
fromName: currentIdentity?.name || undefined,
|
||||||
identityId: currentIdentity?.id,
|
identityId: currentIdentity?.id,
|
||||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||||
|
inReplyTo: threadingHeaders?.inReplyTo,
|
||||||
|
references: threadingHeaders?.references,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,8 +1137,14 @@ export function EmailComposer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className={cn("flex h-full bg-background", className)}>
|
||||||
|
<PluginSlot
|
||||||
|
name="composer-sidebar"
|
||||||
|
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
|
||||||
|
/>
|
||||||
|
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
|
||||||
<div
|
<div
|
||||||
className={cn("flex flex-col h-full bg-background relative", className)}
|
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
|
||||||
data-tour="composer"
|
data-tour="composer"
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
@@ -1053,7 +1216,7 @@ export function EmailComposer({
|
|||||||
>
|
>
|
||||||
{identities.map((identity) => {
|
{identities.map((identity) => {
|
||||||
const displayEmail = subAddressTag
|
const displayEmail = subAddressTag
|
||||||
? generateSubAddress(identity.email, subAddressTag)
|
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
|
||||||
: identity.email;
|
: identity.email;
|
||||||
return (
|
return (
|
||||||
<option key={identity.id} value={identity.id}>
|
<option key={identity.id} value={identity.id}>
|
||||||
@@ -1066,7 +1229,7 @@ export function EmailComposer({
|
|||||||
<span className="text-sm text-foreground flex-1 truncate">
|
<span className="text-sm text-foreground flex-1 truncate">
|
||||||
{subAddressTag ? (
|
{subAddressTag ? (
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{generateSubAddress(primaryIdentity?.email || '', subAddressTag)}
|
{generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1288,9 +1451,9 @@ export function EmailComposer({
|
|||||||
) : (
|
) : (
|
||||||
<Paperclip className="w-3 h-3 flex-shrink-0" />
|
<Paperclip className="w-3 h-3 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.file.name}</span>
|
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
({formatFileSize(att.file.size)})
|
({formatFileSize(att.size)})
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeAttachment(index)}
|
onClick={() => removeAttachment(index)}
|
||||||
@@ -1554,6 +1717,11 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<PluginSlot
|
||||||
|
name="composer-sidebar-right"
|
||||||
|
className="hidden md:flex shrink-0 h-full overflow-hidden border-l border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ interface EmailContextMenuProps {
|
|||||||
// Batch actions
|
// Batch actions
|
||||||
onBatchMarkAsRead?: (read: boolean) => void;
|
onBatchMarkAsRead?: (read: boolean) => void;
|
||||||
onBatchDelete?: () => void;
|
onBatchDelete?: () => void;
|
||||||
|
onBatchArchive?: () => void;
|
||||||
onBatchMoveToMailbox?: (mailboxId: string) => void;
|
onBatchMoveToMailbox?: (mailboxId: string) => void;
|
||||||
onBatchMarkAsSpam?: () => void;
|
onBatchMarkAsSpam?: () => void;
|
||||||
onBatchUndoSpam?: () => void;
|
onBatchUndoSpam?: () => void;
|
||||||
@@ -127,6 +128,7 @@ export function EmailContextMenu({
|
|||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
onBatchMarkAsRead,
|
onBatchMarkAsRead,
|
||||||
onBatchDelete,
|
onBatchDelete,
|
||||||
|
onBatchArchive,
|
||||||
onBatchMoveToMailbox,
|
onBatchMoveToMailbox,
|
||||||
onBatchMarkAsSpam,
|
onBatchMarkAsSpam,
|
||||||
onBatchUndoSpam,
|
onBatchUndoSpam,
|
||||||
@@ -235,8 +237,10 @@ export function EmailContextMenu({
|
|||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Archive}
|
icon={Archive}
|
||||||
label={t("archive")}
|
label={t("archive")}
|
||||||
onClick={() => handleAction(onArchive!)}
|
onClick={() =>
|
||||||
disabled={!onArchive}
|
handleAction(showBatchActions ? onBatchArchive! : onArchive!)
|
||||||
|
}
|
||||||
|
disabled={showBatchActions ? !onBatchArchive : !onArchive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete */}
|
{/* Delete */}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Mail, Tag } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Email, Identity } from '@/lib/jmap/types';
|
import type { Email, Identity } from '@/lib/jmap/types';
|
||||||
import { parseSubAddress } from '@/lib/sub-addressing';
|
import { parseSubAddress } from '@/lib/sub-addressing';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
interface EmailIdentityBadgeProps {
|
interface EmailIdentityBadgeProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -20,12 +21,13 @@ export function EmailIdentityBadge({
|
|||||||
className,
|
className,
|
||||||
}: EmailIdentityBadgeProps) {
|
}: EmailIdentityBadgeProps) {
|
||||||
const t = useTranslations('identities.badge');
|
const t = useTranslations('identities.badge');
|
||||||
|
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
|
||||||
|
|
||||||
const fromAddress = email.from?.[0]?.email;
|
const fromAddress = email.from?.[0]?.email;
|
||||||
if (!fromAddress) return null;
|
if (!fromAddress) return null;
|
||||||
|
|
||||||
// Parse the from address to check for sub-addressing
|
// Parse the from address to check for sub-addressing
|
||||||
const parsedFrom = parseSubAddress(fromAddress);
|
const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter);
|
||||||
|
|
||||||
// Find matching identity (email sent BY the user)
|
// Find matching identity (email sent BY the user)
|
||||||
const matchingIdentity = identities.find(
|
const matchingIdentity = identities.find(
|
||||||
@@ -37,7 +39,7 @@ export function EmailIdentityBadge({
|
|||||||
if (!matchingIdentity) {
|
if (!matchingIdentity) {
|
||||||
// Check all TO addresses for sub-address tags matching user's identities
|
// Check all TO addresses for sub-address tags matching user's identities
|
||||||
for (const recipient of email.to || []) {
|
for (const recipient of email.to || []) {
|
||||||
const parsedTo = parseSubAddress(recipient.email);
|
const parsedTo = parseSubAddress(recipient.email, subAddressDelimiter);
|
||||||
if (parsedTo.tag) {
|
if (parsedTo.tag) {
|
||||||
// Check if this base email matches any of the user's identities
|
// Check if this base email matches any of the user's identities
|
||||||
const matchingToIdentity = identities.find(
|
const matchingToIdentity = identities.find(
|
||||||
@@ -70,7 +72,7 @@ export function EmailIdentityBadge({
|
|||||||
title={t('sub_address_tag', { tag: displayTag })}
|
title={t('sub_address_tag', { tag: displayTag })}
|
||||||
>
|
>
|
||||||
<Tag className="w-3 h-3" />
|
<Tag className="w-3 h-3" />
|
||||||
<span className="font-mono">+{displayTag}</span>
|
<span className="font-mono">{subAddressDelimiter}{displayTag}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,7 +116,7 @@ export function EmailIdentityBadge({
|
|||||||
aria-label={t('sub_address_tag', { tag: displayTag })}
|
aria-label={t('sub_address_tag', { tag: displayTag })}
|
||||||
>
|
>
|
||||||
<Tag className="w-3 h-3" />
|
<Tag className="w-3 h-3" />
|
||||||
<span className="font-mono">{t('subaddress_tag', { tag: displayTag })}</span>
|
<span className="font-mono">{subAddressDelimiter}{displayTag}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
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 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;
|
||||||
@@ -49,11 +50,12 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
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 isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color tags using keyword definitions from settings
|
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
||||||
const colorTagIds = getEmailColorTags(email.keywords);
|
const colorTagIds = getEmailColorTags(email.keywords);
|
||||||
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
|
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 = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||||
@@ -99,7 +101,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
: "bg-background"
|
: "bg-background"
|
||||||
),
|
),
|
||||||
selected && !colorTag && "shadow-sm",
|
selected && !colorTag && "shadow-sm",
|
||||||
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
!colorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||||
|
!colorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||||
isUnread && !selected && !colorTag && "bg-warning/10",
|
isUnread && !selected && !colorTag && "bg-warning/10",
|
||||||
// Add visual feedback for checked state
|
// Add visual feedback for checked state
|
||||||
@@ -163,6 +166,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
size="md"
|
size="md"
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
|
disableImages={hideJunkAvatarImages}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -284,7 +288,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Third Line: Preview (controlled by showPreview setting) */}
|
{/* Third Line: Preview (controlled by showPreview setting) */}
|
||||||
{showPreview && density !== 'extra-compact' && (
|
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
isUnread
|
isUnread
|
||||||
@@ -302,7 +306,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
{/* Hover Quick Actions */}
|
{/* Hover Quick Actions */}
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={email}
|
email={email}
|
||||||
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
|
backgroundClassName={colorTag ? colorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar}
|
onToggleStar={onToggleStar}
|
||||||
onMarkAsRead={onMarkAsRead}
|
onMarkAsRead={onMarkAsRead}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function EmailList({
|
|||||||
batchMarkAsRead,
|
batchMarkAsRead,
|
||||||
batchDelete,
|
batchDelete,
|
||||||
batchMoveToMailbox,
|
batchMoveToMailbox,
|
||||||
|
batchArchive,
|
||||||
batchMarkAsSpam,
|
batchMarkAsSpam,
|
||||||
batchUndoSpam,
|
batchUndoSpam,
|
||||||
loadMoreEmails,
|
loadMoreEmails,
|
||||||
@@ -176,7 +177,7 @@ export function EmailList({
|
|||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
await batchDelete(client);
|
await batchDelete(client, isInTrash);
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => setIsProcessing(false), 500);
|
setTimeout(() => setIsProcessing(false), 500);
|
||||||
}
|
}
|
||||||
@@ -496,6 +497,14 @@ export function EmailList({
|
|||||||
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
||||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||||
onBatchDelete={() => client && batchDelete(client)}
|
onBatchDelete={() => client && batchDelete(client)}
|
||||||
|
onBatchArchive={async () => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
await batchArchive(client);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to batch archive:', error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
|
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
|
||||||
onBatchMarkAsSpam={async () => {
|
onBatchMarkAsSpam={async () => {
|
||||||
if (client) {
|
if (client) {
|
||||||
|
|||||||
+362
-236
@@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr
|
|||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
@@ -85,7 +85,7 @@ import { UnsubscribeBanner } from "./unsubscribe-banner";
|
|||||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||||
import { useTour } from "@/components/tour/tour-provider";
|
import { useTour } from "@/components/tour/tour-provider";
|
||||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||||
import { findCalendarAttachment } from "@/lib/calendar-invitation";
|
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||||
import { RecipientPopover } from "./recipient-popover";
|
import { RecipientPopover } from "./recipient-popover";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
import { SmimeStatusBanner } from "./smime-status-banner";
|
import { SmimeStatusBanner } from "./smime-status-banner";
|
||||||
@@ -740,7 +740,7 @@ function ContactSidebarPanel({
|
|||||||
<div key={i} className="text-sm">
|
<div key={i} className="text-sm">
|
||||||
{o.name}
|
{o.name}
|
||||||
{o.units && o.units.length > 0 && (
|
{o.units && o.units.length > 0 && (
|
||||||
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -896,6 +896,7 @@ export function EmailViewer({
|
|||||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||||
|
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
@@ -1170,7 +1171,7 @@ export function EmailViewer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already read — record that so manual unread toggle won't re-trigger auto-mark
|
// Already read - record that so manual unread toggle won't re-trigger auto-mark
|
||||||
if (email.keywords?.$seen) {
|
if (email.keywords?.$seen) {
|
||||||
autoMarkedEmailRef.current = email.id;
|
autoMarkedEmailRef.current = email.id;
|
||||||
return;
|
return;
|
||||||
@@ -1644,7 +1645,7 @@ export function EmailViewer({
|
|||||||
// Check if inner content is also signed
|
// Check if inner content is also signed
|
||||||
const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes);
|
const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes);
|
||||||
if (nestedSignedData) {
|
if (nestedSignedData) {
|
||||||
// Nested sign-then-encrypt — verify inner signature
|
// Nested sign-then-encrypt - verify inner signature
|
||||||
const innerBytes = normalizeCmsBytes(nestedSignedData.bytes);
|
const innerBytes = normalizeCmsBytes(nestedSignedData.bytes);
|
||||||
smimeDebug('[S/MIME] nested signed-data candidate:', {
|
smimeDebug('[S/MIME] nested signed-data candidate:', {
|
||||||
source: nestedSignedData.source,
|
source: nestedSignedData.source,
|
||||||
@@ -1853,7 +1854,7 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// Check if the email already has a usable HTML body with real content
|
// Check if the email already has a usable HTML body with real content
|
||||||
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
||||||
// boilerplate (CSS + ) — treat these as effectively empty.
|
// boilerplate (CSS + ) - treat these as effectively empty.
|
||||||
const htmlPartId = email.htmlBody?.[0]?.partId;
|
const htmlPartId = email.htmlBody?.[0]?.partId;
|
||||||
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
|
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
|
||||||
let hasRealHtmlBody = !!htmlValue;
|
let hasRealHtmlBody = !!htmlValue;
|
||||||
@@ -1898,7 +1899,7 @@ export function EmailViewer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('email', 'TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
debug.log('email', 'TNEF parse result - htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
||||||
|
|
||||||
if (parsed.htmlBody && !hasRealHtmlBody) {
|
if (parsed.htmlBody && !hasRealHtmlBody) {
|
||||||
setTnefHtml(parsed.htmlBody);
|
setTnefHtml(parsed.htmlBody);
|
||||||
@@ -1912,7 +1913,7 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
||||||
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
|
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted - the winmail.dat may use an unsupported format');
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.groupEnd();
|
debug.groupEnd();
|
||||||
@@ -1975,7 +1976,7 @@ export function EmailViewer({
|
|||||||
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
||||||
if (cancelled) { debug.groupEnd(); return; }
|
if (cancelled) { debug.groupEnd(); return; }
|
||||||
|
|
||||||
debug.log('email', 'Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
debug.log('email', 'Embedded RFC822 parsed - html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
||||||
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
|
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
|
||||||
', attachments:', parsed.attachments?.length ?? 0);
|
', attachments:', parsed.attachments?.length ?? 0);
|
||||||
|
|
||||||
@@ -2074,21 +2075,30 @@ export function EmailViewer({
|
|||||||
|
|
||||||
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
|
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
|
||||||
if (smimeDecryptedAttachments.length > 0) {
|
if (smimeDecryptedAttachments.length > 0) {
|
||||||
return smimeDecryptedAttachments.map((attachment, index) => ({
|
return smimeDecryptedAttachments
|
||||||
id: `smime-${index}-${attachment.filename || attachment.mimeType}`,
|
.filter(att => !(hideInlineImageAttachments && att.contentId && (att.mimeType || '').startsWith('image/')))
|
||||||
name: attachment.filename,
|
.map((attachment, index) => ({
|
||||||
type: attachment.mimeType || 'application/octet-stream',
|
id: `smime-${index}-${attachment.filename || attachment.mimeType}`,
|
||||||
size: getPostalMimeAttachmentSize(attachment),
|
name: attachment.filename,
|
||||||
cid: attachment.contentId,
|
type: attachment.mimeType || 'application/octet-stream',
|
||||||
decryptedAttachment: attachment,
|
size: getPostalMimeAttachmentSize(attachment),
|
||||||
}));
|
cid: attachment.contentId,
|
||||||
|
decryptedAttachment: attachment,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasCalInvitation = calendarInvitationParsingEnabled && !!email && !!findCalendarAttachment(email);
|
||||||
const jmapAttachments = (email?.attachments ?? [])
|
const jmapAttachments = (email?.attachments ?? [])
|
||||||
// Hide winmail.dat when we have successfully extracted TNEF content or attachments
|
// Hide winmail.dat when we have successfully extracted TNEF content or attachments
|
||||||
.filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type))
|
.filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type))
|
||||||
// Hide message/rfc822 when we have unwrapped the embedded email
|
// Hide message/rfc822 when we have unwrapped the embedded email
|
||||||
.filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822')
|
.filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822')
|
||||||
|
// Hide calendar MIME parts (text/calendar, application/ics) when the invitation
|
||||||
|
// banner is shown - prevents raw ICS files appearing as spurious attachments.
|
||||||
|
.filter(att => !hasCalInvitation || !isCalendarMimeType(att.type))
|
||||||
|
// Hide inline cid-referenced images when the user has opted to keep them
|
||||||
|
// out of the attachment list (default on): these are embedded in the body.
|
||||||
|
.filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
|
||||||
.map((attachment, index) => ({
|
.map((attachment, index) => ({
|
||||||
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
||||||
name: attachment.name || null,
|
name: attachment.name || null,
|
||||||
@@ -2119,7 +2129,7 @@ export function EmailViewer({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
|
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
|
||||||
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments]);
|
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]);
|
||||||
|
|
||||||
// Generate email source for viewing
|
// Generate email source for viewing
|
||||||
const generateEmailSource = (email: Email): string => {
|
const generateEmailSource = (email: Email): string => {
|
||||||
@@ -2275,7 +2285,7 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// Sanitize and prepare email HTML content
|
// Sanitize and prepare email HTML content
|
||||||
const emailContent = useMemo(() => {
|
const emailContent = useMemo(() => {
|
||||||
if (!email) return { html: "", isHtml: false };
|
if (!email) return { html: "", isHtml: false, hasStyleTag: false };
|
||||||
|
|
||||||
// Check if we have body values
|
// Check if we have body values
|
||||||
if (email.bodyValues) {
|
if (email.bodyValues) {
|
||||||
@@ -2312,7 +2322,7 @@ export function EmailViewer({
|
|||||||
let blockedExternalContent = false;
|
let blockedExternalContent = false;
|
||||||
|
|
||||||
// Use shared sanitization config as base (more secure)
|
// Use shared sanitization config as base (more secure)
|
||||||
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
|
const sanitizeConfig = { ...EMAIL_IFRAME_SANITIZE_CONFIG };
|
||||||
|
|
||||||
// Check if sender is trusted (localStorage list or address book)
|
// Check if sender is trusted (localStorage list or address book)
|
||||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||||
@@ -2328,8 +2338,7 @@ export function EmailViewer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (shouldBlockExternal) {
|
if (shouldBlockExternal) {
|
||||||
sanitizeConfig.FORBID_TAGS.push('link');
|
sanitizeConfig.FORBID_TAGS = [...sanitizeConfig.FORBID_TAGS, 'link'];
|
||||||
sanitizeConfig.FORBID_ATTR.push('background');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
@@ -2347,11 +2356,19 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bgAttr = node.getAttribute?.('background');
|
||||||
|
if (bgAttr && (bgAttr.startsWith('http://') || bgAttr.startsWith('https://') || bgAttr.startsWith('//'))) {
|
||||||
|
node.setAttribute('data-blocked-background', bgAttr);
|
||||||
|
node.removeAttribute('background');
|
||||||
|
blockedExternalContent = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (htmlNode.style) {
|
if (htmlNode.style) {
|
||||||
const style = htmlNode.style.cssText;
|
const style = htmlNode.style.cssText;
|
||||||
if (style && style.includes('url(')) {
|
if (style && style.includes('url(')) {
|
||||||
const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi);
|
const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi);
|
||||||
if (urlMatch) {
|
if (urlMatch) {
|
||||||
|
node.setAttribute('data-blocked-style', style);
|
||||||
htmlNode.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()');
|
htmlNode.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()');
|
||||||
blockedExternalContent = true;
|
blockedExternalContent = true;
|
||||||
}
|
}
|
||||||
@@ -2385,7 +2402,8 @@ export function EmailViewer({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
html: cleanHtml,
|
html: cleanHtml,
|
||||||
isHtml: true
|
isHtml: true,
|
||||||
|
hasStyleTag: /<style[\s>]/i.test(htmlContent),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2393,17 +2411,10 @@ export function EmailViewer({
|
|||||||
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
||||||
const textContent = email.bodyValues[email.textBody[0].partId].value;
|
const textContent = email.bodyValues[email.textBody[0].partId].value;
|
||||||
|
|
||||||
// Convert plain text to HTML with proper formatting
|
|
||||||
// Uses white-space: pre-wrap on the container to preserve newlines/whitespace
|
|
||||||
const htmlFromText = textContent
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: htmlFromText,
|
html: plainTextToSafeHtml(textContent),
|
||||||
isHtml: false
|
isHtml: false,
|
||||||
|
hasStyleTag: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2417,15 +2428,21 @@ export function EmailViewer({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<div style="color: var(--color-muted-foreground); font-style: italic;">${previewHtml}</div>`,
|
html: `<div style="color: var(--color-muted-foreground); font-style: italic;">${previewHtml}</div>`,
|
||||||
isHtml: false
|
isHtml: false,
|
||||||
|
hasStyleTag: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
||||||
isHtml: false
|
isHtml: false,
|
||||||
|
hasStyleTag: false,
|
||||||
};
|
};
|
||||||
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]);
|
// Intentionally omit allowExternalContent and trust state from deps:
|
||||||
|
// toggling permission imperatively unblocks content via restoreBlockedContent
|
||||||
|
// in an effect below, so the iframe srcDoc stays stable and doesn't reload/flash.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [email, externalContentPolicy, cidBlobUrls]);
|
||||||
|
|
||||||
// Override email content with S/MIME decrypted content when available
|
// Override email content with S/MIME decrypted content when available
|
||||||
const effectiveEmailContent = useMemo(() => {
|
const effectiveEmailContent = useMemo(() => {
|
||||||
@@ -2436,42 +2453,27 @@ export function EmailViewer({
|
|||||||
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_SANITIZE_CONFIG);
|
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||||
return { html: cleanHtml, isHtml: true };
|
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(smimeDecryptedHtml) };
|
||||||
}
|
}
|
||||||
if (smimeDecryptedText) {
|
if (smimeDecryptedText) {
|
||||||
const htmlFromText = smimeDecryptedText
|
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false, hasStyleTag: false };
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
||||||
return { html: htmlFromText, isHtml: false };
|
|
||||||
}
|
}
|
||||||
// TNEF (winmail.dat) extracted content
|
// TNEF (winmail.dat) extracted content
|
||||||
if (tnefHtml) {
|
if (tnefHtml) {
|
||||||
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_SANITIZE_CONFIG);
|
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||||
return { html: cleanHtml, isHtml: true };
|
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml) };
|
||||||
}
|
}
|
||||||
if (tnefText) {
|
if (tnefText) {
|
||||||
const htmlFromText = tnefText
|
return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false };
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
||||||
return { html: htmlFromText, isHtml: false };
|
|
||||||
}
|
}
|
||||||
// Embedded message/rfc822 unwrapped content
|
// Embedded message/rfc822 unwrapped content
|
||||||
if (embeddedEmailHtml) {
|
if (embeddedEmailHtml) {
|
||||||
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_SANITIZE_CONFIG);
|
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||||
return { html: cleanHtml, isHtml: true };
|
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml) };
|
||||||
}
|
}
|
||||||
if (embeddedEmailText) {
|
if (embeddedEmailText) {
|
||||||
const htmlFromText = embeddedEmailText
|
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false };
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
||||||
return { html: htmlFromText, isHtml: false };
|
|
||||||
}
|
}
|
||||||
return emailContent;
|
return emailContent;
|
||||||
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
||||||
@@ -2613,10 +2615,14 @@ export function EmailViewer({
|
|||||||
|
|
||||||
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
|
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
|
||||||
|
|
||||||
|
// Bare HTML emails (no <style>) tend to be plain prose without their own
|
||||||
|
// layout — give them the same padding as plain-text mails (.email-content-text).
|
||||||
|
const bodyPadding = effectiveEmailContent.hasStyleTag ? '0' : '1rem 1.25rem';
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; padding: 16px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||||
img { max-width: 100% !important; height: auto !important; }
|
img { max-width: 100% !important; height: auto !important; }
|
||||||
a { color: #1a73e8; }
|
a { color: #1a73e8; }
|
||||||
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
||||||
@@ -2626,6 +2632,79 @@ export function EmailViewer({
|
|||||||
</style></head><body>${effectiveEmailContent.html}</body></html>`;
|
</style></head><body>${effectiveEmailContent.html}</body></html>`;
|
||||||
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
|
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
|
||||||
|
|
||||||
|
// Imperatively restore blocked external content inside the iframe document.
|
||||||
|
// Avoids re-rendering the iframe srcDoc (which would reload and flash) when
|
||||||
|
// the user clicks "Load images" or "Trust sender".
|
||||||
|
const restoreBlockedContent = useCallback(() => {
|
||||||
|
const doc = iframeRef.current?.contentDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
|
||||||
|
doc.querySelectorAll('img[data-blocked-src]').forEach((node) => {
|
||||||
|
const el = node as HTMLImageElement;
|
||||||
|
const src = el.getAttribute('data-blocked-src');
|
||||||
|
if (src) {
|
||||||
|
el.setAttribute('src', src);
|
||||||
|
el.style.display = '';
|
||||||
|
el.removeAttribute('data-blocked-src');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('[data-blocked-style]').forEach((node) => {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
const style = el.getAttribute('data-blocked-style');
|
||||||
|
if (style !== null) {
|
||||||
|
el.style.cssText = style;
|
||||||
|
el.removeAttribute('data-blocked-style');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('[data-blocked-background]').forEach((node) => {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
const bg = el.getAttribute('data-blocked-background');
|
||||||
|
if (bg) {
|
||||||
|
el.setAttribute('background', bg);
|
||||||
|
el.removeAttribute('data-blocked-background');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('[data-blocked-collapsed-style]').forEach((node) => {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
const style = el.getAttribute('data-blocked-collapsed-style');
|
||||||
|
if (style !== null) {
|
||||||
|
el.style.cssText = style;
|
||||||
|
el.removeAttribute('data-blocked-collapsed-style');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Whenever permission is granted (allow toggled, or sender becomes trusted),
|
||||||
|
// restore blocked content in the existing iframe — no srcDoc rebuild.
|
||||||
|
const senderEmailLower = email?.from?.[0]?.email?.toLowerCase();
|
||||||
|
const senderIsTrustedNow = senderEmailLower
|
||||||
|
? isSenderTrusted(senderEmailLower) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmailLower))
|
||||||
|
: false;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasBlockedContent) return;
|
||||||
|
if (!allowExternalContent && !senderIsTrustedNow) return;
|
||||||
|
restoreBlockedContent();
|
||||||
|
}, [allowExternalContent, senderIsTrustedNow, hasBlockedContent, restoreBlockedContent]);
|
||||||
|
|
||||||
|
// Tracks the last rendered body height so the loading skeleton can hold
|
||||||
|
// the same size — avoids the body shrink/expand flash when switching emails.
|
||||||
|
const lastBodyHeightRef = useRef<number>(300);
|
||||||
|
|
||||||
|
// True while the new email's body is still being fetched. Catches the
|
||||||
|
// window between selectedEmail changing and isLoading flipping true, so the
|
||||||
|
// quick reply / body don't flicker through a partial render.
|
||||||
|
const isBodyLoading = isLoading || !email?.bodyValues || Object.keys(email.bodyValues).length === 0;
|
||||||
|
|
||||||
|
// Gates the quick reply on the iframe having loaded the current srcDoc, so
|
||||||
|
// it doesn't flash in below a still-resizing iframe.
|
||||||
|
const [iframeReady, setIframeReady] = useState(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setIframeReady(false);
|
||||||
|
}, [emailIframeSrcDoc]);
|
||||||
|
|
||||||
const handleIframeLoad = useCallback(() => {
|
const handleIframeLoad = useCallback(() => {
|
||||||
const iframe = iframeRef.current;
|
const iframe = iframeRef.current;
|
||||||
if (!iframe) return;
|
if (!iframe) return;
|
||||||
@@ -2636,9 +2715,13 @@ export function EmailViewer({
|
|||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
const height = doc.documentElement.scrollHeight;
|
const height = doc.documentElement.scrollHeight;
|
||||||
iframe.style.height = height + 'px';
|
iframe.style.height = height + 'px';
|
||||||
|
lastBodyHeightRef.current = height;
|
||||||
});
|
});
|
||||||
resizeObserver.observe(doc.body);
|
resizeObserver.observe(doc.body);
|
||||||
iframe.style.height = doc.documentElement.scrollHeight + 'px';
|
const initialHeight = doc.documentElement.scrollHeight;
|
||||||
|
iframe.style.height = initialHeight + 'px';
|
||||||
|
lastBodyHeightRef.current = initialHeight;
|
||||||
|
setIframeReady(true);
|
||||||
|
|
||||||
// Make links open in new tab
|
// Make links open in new tab
|
||||||
doc.querySelectorAll('a').forEach(a => {
|
doc.querySelectorAll('a').forEach(a => {
|
||||||
@@ -2893,7 +2976,7 @@ export function EmailViewer({
|
|||||||
<>
|
<>
|
||||||
{/* Left: Reply actions */}
|
{/* Left: Reply actions */}
|
||||||
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
|
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
|
||||||
{showBackButton && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
{showBackButton && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -2957,13 +3040,8 @@ export function EmailViewer({
|
|||||||
<PluginSlot name="toolbar-actions" />
|
<PluginSlot name="toolbar-actions" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
{/* Right: Organize actions - order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
||||||
<div className="flex items-center gap-0 sm:gap-0.5">
|
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||||
{isLoading && (
|
|
||||||
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Archive */}
|
{/* Archive */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -3054,7 +3132,7 @@ export function EmailViewer({
|
|||||||
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
|
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Tag Picker — hidden on mobile, overflows to More menu */}
|
{/* Tag Picker - hidden on mobile, overflows to More menu */}
|
||||||
<div data-overflow-item data-overflow-priority="6" className="hidden sm:flex items-center">
|
<div data-overflow-item data-overflow-priority="6" className="hidden sm:flex items-center">
|
||||||
<div className="w-px h-5 bg-border mx-0.5" />
|
<div className="w-px h-5 bg-border mx-0.5" />
|
||||||
<div ref={tagMenuRef} className="relative">
|
<div ref={tagMenuRef} className="relative">
|
||||||
@@ -3070,13 +3148,13 @@ export function EmailViewer({
|
|||||||
<>
|
<>
|
||||||
<span className="flex items-center gap-0.5">
|
<span className="flex items-center gap-0.5">
|
||||||
{currentColors.slice(0, 3).map((tagId) => {
|
{currentColors.slice(0, 3).map((tagId) => {
|
||||||
const kw = emailKeywords.find(k => k.id === tagId);
|
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||||
return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null;
|
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
{showToolbarLabels && currentColors.length === 1 && (
|
{showToolbarLabels && currentColors.length === 1 && (
|
||||||
<span className="text-xs font-medium text-foreground">
|
<span className="text-xs font-medium text-foreground">
|
||||||
{emailKeywords.find(k => k.id === currentColors[0])?.label}
|
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -3123,7 +3201,7 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spam — hidden on mobile, overflows to More menu */}
|
{/* Spam - hidden on mobile, overflows to More menu */}
|
||||||
{(onMarkAsSpam || onUndoSpam) && (
|
{(onMarkAsSpam || onUndoSpam) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -3145,7 +3223,7 @@ export function EmailViewer({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toggle read state — hidden on mobile, overflows to More menu */}
|
{/* Toggle read state - hidden on mobile, overflows to More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3158,7 +3236,7 @@ export function EmailViewer({
|
|||||||
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
|
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Print — hidden on mobile, overflows to More menu */}
|
{/* Print - hidden on mobile, overflows to More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3172,7 +3250,7 @@ export function EmailViewer({
|
|||||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
|
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* View source — hidden on mobile, overflows to More menu */}
|
{/* View source - hidden on mobile, overflows to More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3200,7 +3278,7 @@ export function EmailViewer({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* More menu — click-based */}
|
{/* More menu - click-based */}
|
||||||
<div ref={moreMenuRef} className="relative">
|
<div ref={moreMenuRef} className="relative">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -3246,7 +3324,7 @@ export function EmailViewer({
|
|||||||
<Archive className="w-4 h-4" />
|
<Archive className="w-4 h-4" />
|
||||||
{t('archive')}
|
{t('archive')}
|
||||||
</button>
|
</button>
|
||||||
{/* Overflow: move to folder — submenu */}
|
{/* Overflow: move to folder - submenu */}
|
||||||
{moveTree.length > 0 && onMoveToMailbox && (
|
{moveTree.length > 0 && onMoveToMailbox && (
|
||||||
<div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")}
|
<div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")}
|
||||||
onMouseEnter={() => setMoreMenuSub('move')}
|
onMouseEnter={() => setMoreMenuSub('move')}
|
||||||
@@ -3298,7 +3376,7 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Overflow: tag — submenu */}
|
{/* Overflow: tag - submenu */}
|
||||||
{colorOptions.length > 0 && (
|
{colorOptions.length > 0 && (
|
||||||
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
||||||
onMouseEnter={() => setMoreMenuSub('tag')}
|
onMouseEnter={() => setMoreMenuSub('tag')}
|
||||||
@@ -3449,70 +3527,172 @@ export function EmailViewer({
|
|||||||
moreMenuOpen ? "translate-x-0" : "translate-x-full"
|
moreMenuOpen ? "translate-x-0" : "translate-x-full"
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span>
|
{moreMenuSub ? (
|
||||||
<Button variant="ghost" size="icon" onClick={() => setMoreMenuOpen(false)} className="h-9 w-9">
|
<button
|
||||||
|
onClick={() => setMoreMenuSub(null)}
|
||||||
|
className="flex items-center gap-1 -ml-2 px-2 py-1 rounded hover:bg-muted text-sm font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
{moreMenuSub === 'move' ? t('move_to') : t('tag')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => { setMoreMenuOpen(false); setMoreMenuSub(null); }} className="h-9 w-9">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto py-2">
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
<button
|
{moreMenuSub === null && (
|
||||||
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Archive className="w-5 h-5" />
|
|
||||||
{t('archive')}
|
|
||||||
</button>
|
|
||||||
{/* Move to folder */}
|
|
||||||
{moveTree.length > 0 && onMoveToMailbox && (
|
|
||||||
<>
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Archive className="w-5 h-5" />
|
||||||
|
{t('archive')}
|
||||||
|
</button>
|
||||||
|
{/* Move to folder (opens sub-view) */}
|
||||||
|
{moveTree.length > 0 && onMoveToMailbox && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMoreMenuSub('move')}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<FolderInput className="w-5 h-5" />
|
||||||
|
<span className="flex-1">{t('move_to')}</span>
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Tag (opens sub-view) */}
|
||||||
|
{colorOptions.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMoreMenuSub('tag')}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Tag className="w-5 h-5" />
|
||||||
|
<span className="flex-1">{t('tag')}</span>
|
||||||
|
{currentColors.length > 0 && (
|
||||||
|
<div className="flex -space-x-1 mr-1">
|
||||||
|
{currentColors.slice(0, 3).map((c) => {
|
||||||
|
const opt = colorOptions.find((o) => o.value === c);
|
||||||
|
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Spam */}
|
||||||
|
{(onMarkAsSpam || onUndoSpam) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
{isInJunkFolder ? (
|
||||||
|
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<ShieldAlert className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||||
|
)}
|
||||||
|
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Toggle read state */}
|
||||||
|
<button
|
||||||
|
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
{isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />}
|
||||||
|
{isUnread ? t('mark_read') : t('mark_unread')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Printer className="w-5 h-5" />
|
||||||
|
{t('print')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Code className="w-5 h-5" />
|
||||||
|
{t('view_source')}
|
||||||
|
</button>
|
||||||
|
{effectiveEmailContent.isHtml && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||||
|
{isDark ? 'View in light mode' : 'View in dark mode'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('move_to')}</div>
|
<button
|
||||||
{(() => {
|
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
|
||||||
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
return nodes.map((node) => {
|
>
|
||||||
const Icon = getMoveMailboxIcon(node.role);
|
<Download className="w-5 h-5" />
|
||||||
const isTarget = moveTargetIds.has(node.id);
|
{t('export_email')}
|
||||||
return (
|
</button>
|
||||||
<div key={node.id}>
|
<button
|
||||||
{isTarget ? (
|
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); }}
|
||||||
<button
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); }}
|
>
|
||||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
|
<Upload className="w-5 h-5" />
|
||||||
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
{t('import_email')}
|
||||||
>
|
</button>
|
||||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
{onShowShortcuts && (
|
||||||
<span className="truncate">{node.name}</span>
|
<button
|
||||||
</button>
|
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
|
||||||
) : (
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
<div
|
>
|
||||||
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
|
<Keyboard className="w-5 h-5" />
|
||||||
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
{t('keyboard_shortcuts')}
|
||||||
>
|
</button>
|
||||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
)}
|
||||||
<span>{node.name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return renderMobileNodes(moveTree);
|
|
||||||
})()}
|
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Tags */}
|
{moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => {
|
||||||
{colorOptions.length > 0 && (
|
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
const Icon = getMoveMailboxIcon(node.role);
|
||||||
|
const isTarget = moveTargetIds.has(node.id);
|
||||||
|
return (
|
||||||
|
<div key={node.id}>
|
||||||
|
{isTarget ? (
|
||||||
|
<button
|
||||||
|
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
|
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
|
||||||
|
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
|
||||||
|
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{node.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return renderMobileNodes(moveTree);
|
||||||
|
})()}
|
||||||
|
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
|
|
||||||
{colorOptions.map((option) => {
|
{colorOptions.map((option) => {
|
||||||
const isActive = currentColors.includes(option.value);
|
const isActive = currentColors.includes(option.value);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
||||||
isActive && "bg-accent font-medium"
|
isActive && "bg-accent font-medium"
|
||||||
@@ -3526,99 +3706,20 @@ export function EmailViewer({
|
|||||||
})}
|
})}
|
||||||
{currentColors.length > 0 && (
|
{currentColors.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
|
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4 flex-shrink-0" />
|
<X className="w-4 h-4 flex-shrink-0" />
|
||||||
<span>{t('remove_color')}</span>
|
<span>{t('remove_color')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Spam */}
|
|
||||||
{(onMarkAsSpam || onUndoSpam) && (
|
|
||||||
<button
|
|
||||||
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
{isInJunkFolder ? (
|
|
||||||
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
|
|
||||||
) : (
|
|
||||||
<ShieldAlert className="h-5 w-5 text-red-600 dark:text-red-400" />
|
|
||||||
)}
|
|
||||||
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Toggle read state */}
|
|
||||||
<button
|
|
||||||
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
{isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />}
|
|
||||||
{isUnread ? t('mark_read') : t('mark_unread')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Printer className="w-5 h-5" />
|
|
||||||
{t('print')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Code className="w-5 h-5" />
|
|
||||||
{t('view_source')}
|
|
||||||
</button>
|
|
||||||
{effectiveEmailContent.isHtml && (
|
|
||||||
<button
|
|
||||||
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
|
||||||
{isDark ? 'View in light mode' : 'View in dark mode'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
<button
|
|
||||||
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Download className="w-5 h-5" />
|
|
||||||
{t('export_email')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Upload className="w-5 h-5" />
|
|
||||||
{t('import_email')}
|
|
||||||
</button>
|
|
||||||
{onShowShortcuts && (
|
|
||||||
<button
|
|
||||||
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
|
|
||||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Keyboard className="w-5 h-5" />
|
|
||||||
{t('keyboard_shortcuts')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Main email content */}
|
{/* Main email content */}
|
||||||
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
||||||
{/* Loading overlay when fetching new email */}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="absolute inset-0 bg-background/60 backdrop-blur-[2px] z-50 flex items-center justify-center animate-in fade-in duration-200">
|
|
||||||
<div className="bg-background rounded-lg shadow-lg border border-border p-4 flex items-center gap-3">
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
|
||||||
<span className="text-sm font-medium text-foreground">{t('loading_email')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* === TOOLBAR (top position) === */}
|
{/* === TOOLBAR (top position) === */}
|
||||||
{toolbarPosition === 'top' && (
|
{toolbarPosition === 'top' && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -3641,7 +3742,7 @@ export function EmailViewer({
|
|||||||
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||||
{/* Back button (for below-subject mode on tablet) */}
|
{/* Back button (for below-subject mode on tablet) */}
|
||||||
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
{toolbarPosition === 'below-subject' && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -3674,11 +3775,11 @@ export function EmailViewer({
|
|||||||
{currentColors.length > 0 && (
|
{currentColors.length > 0 && (
|
||||||
<span className="flex items-center gap-0.5">
|
<span className="flex items-center gap-0.5">
|
||||||
{currentColors.map((tagId) => {
|
{currentColors.map((tagId) => {
|
||||||
const kw = emailKeywords.find(k => k.id === tagId);
|
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
|
||||||
return dotClass ? (
|
return (
|
||||||
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
|
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
|
||||||
) : null;
|
);
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -3689,8 +3790,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Date/time on the right of subject row */}
|
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
|
||||||
<div className="flex-shrink-0 text-right">
|
<div className="hidden sm:block flex-shrink-0 text-right">
|
||||||
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
|
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||||
</span>
|
</span>
|
||||||
@@ -4035,7 +4136,7 @@ export function EmailViewer({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
||||||
Spam Score
|
Spam Score
|
||||||
<InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better — scores above 5 are likely spam" />
|
<InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better - scores above 5 are likely spam" />
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"text-xs",
|
"text-xs",
|
||||||
@@ -4497,6 +4598,17 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Date/time + size on the right (mobile) */}
|
||||||
|
<div className="sm:hidden flex-shrink-0 text-right ml-2">
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
{email.size > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground/60">
|
||||||
|
{formatFileSize(email.size)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -4604,14 +4716,28 @@ export function EmailViewer({
|
|||||||
<PluginSlot name="email-banner" extraProps={{ email }} />
|
<PluginSlot name="email-banner" extraProps={{ email }} />
|
||||||
|
|
||||||
{/* Email Body */}
|
{/* Email Body */}
|
||||||
<div className="email-content-wrapper overflow-x-auto">
|
<div className={cn(
|
||||||
{effectiveEmailContent.isHtml ? (
|
"email-content-wrapper overflow-x-auto",
|
||||||
|
emailAlwaysLightMode ? "bg-white email-content-light" : "bg-background"
|
||||||
|
)}>
|
||||||
|
{isBodyLoading ? (
|
||||||
|
<div
|
||||||
|
className="space-y-3 px-6 py-4 animate-pulse"
|
||||||
|
style={{ minHeight: `${lastBodyHeightRef.current}px` }}
|
||||||
|
>
|
||||||
|
<div className="h-2 bg-muted/15 rounded w-full"></div>
|
||||||
|
<div className="h-2 bg-muted/15 rounded w-5/6"></div>
|
||||||
|
<div className="h-2 bg-muted/15 rounded w-4/6"></div>
|
||||||
|
<div className="h-2 bg-muted/15 rounded w-full"></div>
|
||||||
|
<div className="h-2 bg-muted/15 rounded w-3/4"></div>
|
||||||
|
</div>
|
||||||
|
) : effectiveEmailContent.isHtml ? (
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
srcDoc={emailIframeSrcDoc}
|
srcDoc={emailIframeSrcDoc}
|
||||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||||
title="Email content"
|
title="Email content"
|
||||||
className="w-full border-0 rounded"
|
className="w-full border-0"
|
||||||
style={{ minHeight: '100px', colorScheme: isDark && emailHasNativeDarkMode ? 'light dark' : 'light' }}
|
style={{ minHeight: '100px', colorScheme: isDark && emailHasNativeDarkMode ? 'light dark' : 'light' }}
|
||||||
onLoad={handleIframeLoad}
|
onLoad={handleIframeLoad}
|
||||||
/>
|
/>
|
||||||
@@ -4632,8 +4758,8 @@ export function EmailViewer({
|
|||||||
|
|
||||||
<PluginSlot name="email-footer" />
|
<PluginSlot name="email-footer" />
|
||||||
|
|
||||||
{/* Quick Reply Section - hidden for drafts */}
|
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
||||||
{!isDraft && (<div className={cn(
|
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className={cn(
|
||||||
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
|
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
|
||||||
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
|
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
|
||||||
)}>
|
)}>
|
||||||
@@ -4787,61 +4913,61 @@ export function EmailViewer({
|
|||||||
onClick={onNavigatePrev}
|
onClick={onNavigatePrev}
|
||||||
disabled={!onNavigatePrev}
|
disabled={!onNavigatePrev}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
|
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
|
||||||
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
||||||
)}
|
)}
|
||||||
aria-label={t('tooltips.previous')}
|
aria-label={t('tooltips.previous')}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
<span className="text-[10px] font-medium leading-tight">{t('previous')}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('previous')}</span>
|
||||||
</button>
|
</button>
|
||||||
{isDraft && onEditDraft ? (
|
{isDraft && onEditDraft ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => onEditDraft()}
|
onClick={() => onEditDraft()}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
|
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary active:text-primary/80 transition-colors duration-150"
|
||||||
aria-label={t('tooltips.edit_draft')}
|
aria-label={t('tooltips.edit_draft')}
|
||||||
>
|
>
|
||||||
<EditIcon className="w-5 h-5" />
|
<EditIcon className="w-5 h-5" />
|
||||||
<span className="text-[10px] font-medium leading-tight">{t('edit_draft')}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('edit_draft')}</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => onReply?.()}
|
onClick={() => onReply?.()}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||||
aria-label={t('tooltips.reply')}
|
aria-label={t('tooltips.reply')}
|
||||||
>
|
>
|
||||||
<Reply className="w-5 h-5" />
|
<Reply className="w-5 h-5" />
|
||||||
<span className="text-[10px] font-medium leading-tight">{t('reply')}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('reply')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onReplyAll}
|
onClick={onReplyAll}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||||
aria-label={t('tooltips.reply_all')}
|
aria-label={t('tooltips.reply_all')}
|
||||||
>
|
>
|
||||||
<ReplyAll className="w-5 h-5" />
|
<ReplyAll className="w-5 h-5" />
|
||||||
<span className="text-[10px] font-medium leading-tight">{t('reply_all')}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('reply_all')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onForward}
|
onClick={onForward}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||||
aria-label={t('tooltips.forward')}
|
aria-label={t('tooltips.forward')}
|
||||||
>
|
>
|
||||||
<Forward className="w-5 h-5" />
|
<Forward className="w-5 h-5" />
|
||||||
<span className="text-[10px] font-medium leading-tight">{t('forward')}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('forward')}</span>
|
||||||
</button>
|
</button>
|
||||||
</>)}
|
</>)}
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateNext}
|
onClick={onNavigateNext}
|
||||||
disabled={!onNavigateNext}
|
disabled={!onNavigateNext}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
|
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
|
||||||
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
||||||
)}
|
)}
|
||||||
aria-label={t('tooltips.next')}
|
aria-label={t('tooltips.next')}
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-5 h-5" />
|
<ChevronRight className="w-5 h-5" />
|
||||||
<span className="text-[10px] font-medium leading-tight">{t('next')}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('next')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface RecipientPopoverProps {
|
|||||||
email: string;
|
email: string;
|
||||||
/** Display label override (e.g. "me") */
|
/** Display label override (e.g. "me") */
|
||||||
displayLabel?: string;
|
displayLabel?: string;
|
||||||
/** Called when user clicks "View contact" — receives the contact and email */
|
/** Called when user clicks "View contact" - receives the contact and email */
|
||||||
onViewContact?: (contact: ContactCard | null, email: string) => void;
|
onViewContact?: (contact: ContactCard | null, email: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ export const ResizableImage = Node.create({
|
|||||||
alt: { default: null },
|
alt: { default: null },
|
||||||
title: { default: null },
|
title: { default: null },
|
||||||
width: { default: null },
|
width: { default: null },
|
||||||
|
cid: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (el) => el.getAttribute("data-cid"),
|
||||||
|
renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useCallback } from "react";
|
import React, { useEffect, useCallback, useState, useRef } from "react";
|
||||||
import { useEditor, EditorContent } from "@tiptap/react";
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Underline from "@tiptap/extension-underline";
|
import Underline from "@tiptap/extension-underline";
|
||||||
@@ -10,6 +10,10 @@ 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";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import { Table } from "@tiptap/extension-table";
|
||||||
|
import { TableRow } from "@tiptap/extension-table-row";
|
||||||
|
import { TableHeader } from "@tiptap/extension-table-header";
|
||||||
|
import { TableCell } from "@tiptap/extension-table-cell";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Bold,
|
Bold,
|
||||||
@@ -29,12 +33,21 @@ import {
|
|||||||
RemoveFormatting,
|
RemoveFormatting,
|
||||||
Heading1,
|
Heading1,
|
||||||
Heading2,
|
Heading2,
|
||||||
|
Table as TableIcon,
|
||||||
|
Trash2,
|
||||||
|
Rows3,
|
||||||
|
Columns3,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export interface InlineImageUpload {
|
||||||
|
src: string;
|
||||||
|
cid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface RichTextEditorProps {
|
interface RichTextEditorProps {
|
||||||
content: string;
|
content: string;
|
||||||
onChange: (html: string) => void;
|
onChange: (html: string) => void;
|
||||||
onImageUpload?: (file: File) => Promise<string | null>;
|
onImageUpload?: (file: File) => Promise<InlineImageUpload | null>;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
hasError?: boolean;
|
hasError?: boolean;
|
||||||
@@ -74,6 +87,43 @@ function ToolbarSeparator() {
|
|||||||
return <div className="w-px h-5 bg-border mx-0.5" />;
|
return <div className="w-px h-5 bg-border mx-0.5" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TABLE_PICKER_ROWS = 6;
|
||||||
|
const TABLE_PICKER_COLS = 8;
|
||||||
|
|
||||||
|
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
|
||||||
|
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="grid gap-0.5"
|
||||||
|
style={{ gridTemplateColumns: `repeat(${TABLE_PICKER_COLS}, 1fr)` }}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
>
|
||||||
|
{Array.from({ length: TABLE_PICKER_ROWS * TABLE_PICKER_COLS }).map((_, i) => {
|
||||||
|
const r = Math.floor(i / TABLE_PICKER_COLS);
|
||||||
|
const c = i % TABLE_PICKER_COLS;
|
||||||
|
const active = hover && r <= hover.r && c <= hover.c;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onMouseEnter={() => setHover({ r, c })}
|
||||||
|
onClick={() => onPick(r + 1, c + 1)}
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4 border border-border/60 rounded-[2px] transition-colors",
|
||||||
|
active ? "bg-primary border-primary" : "bg-background hover:bg-accent"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1.5 text-center">
|
||||||
|
{hover ? `${hover.r + 1} × ${hover.c + 1}` : "Pick size"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function RichTextEditor({
|
export function RichTextEditor({
|
||||||
content,
|
content,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -104,6 +154,27 @@ export function RichTextEditor({
|
|||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder,
|
placeholder,
|
||||||
}),
|
}),
|
||||||
|
Table.configure({
|
||||||
|
resizable: true,
|
||||||
|
HTMLAttributes: {
|
||||||
|
border: "1",
|
||||||
|
cellpadding: "6",
|
||||||
|
cellspacing: "0",
|
||||||
|
width: "100%",
|
||||||
|
style: "width:100%;border-collapse:collapse;",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TableRow,
|
||||||
|
TableHeader.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
style: "padding:6px 8px;border:1px solid #ccc;background-color:#f5f5f5;color:#1f2937;text-align:left;",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TableCell.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
style: "padding:6px 8px;border:1px solid #ccc;vertical-align:top;",
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
@@ -120,11 +191,11 @@ export function RichTextEditor({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
for (const file of imageFiles) {
|
for (const file of imageFiles) {
|
||||||
upload(file).then((url) => {
|
upload(file).then((result) => {
|
||||||
if (url) {
|
if (result) {
|
||||||
const { state } = view;
|
const { state } = view;
|
||||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||||
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
|
const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid });
|
||||||
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
|
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
|
||||||
view.dispatch(tr);
|
view.dispatch(tr);
|
||||||
}
|
}
|
||||||
@@ -141,10 +212,10 @@ export function RichTextEditor({
|
|||||||
if (imageFiles.length === 0) return false;
|
if (imageFiles.length === 0) return false;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
for (const file of imageFiles) {
|
for (const file of imageFiles) {
|
||||||
upload(file).then((url) => {
|
upload(file).then((result) => {
|
||||||
if (url) {
|
if (result) {
|
||||||
const { state } = view;
|
const { state } = view;
|
||||||
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
|
const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid });
|
||||||
const tr = state.tr.replaceSelectionWith(node);
|
const tr = state.tr.replaceSelectionWith(node);
|
||||||
view.dispatch(tr);
|
view.dispatch(tr);
|
||||||
}
|
}
|
||||||
@@ -183,6 +254,20 @@ export function RichTextEditor({
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
||||||
|
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tableMenuOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (tableWrapperRef.current && !tableWrapperRef.current.contains(e.target as Node)) {
|
||||||
|
setTableMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [tableMenuOpen]);
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("min-h-[100px]", className)} />
|
<div className={cn("min-h-[100px]", className)} />
|
||||||
@@ -304,6 +389,89 @@ export function RichTextEditor({
|
|||||||
<LinkIcon className="w-4 h-4" />
|
<LinkIcon className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<div ref={tableWrapperRef} className="relative">
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("table")}
|
||||||
|
onClick={() => setTableMenuOpen((v) => !v)}
|
||||||
|
title="Table"
|
||||||
|
>
|
||||||
|
<TableIcon className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
{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]">
|
||||||
|
{editor.isActive("table") ? (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Rows3 className="w-4 h-4" /> Add row above
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Rows3 className="w-4 h-4" /> Add row below
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Columns3 className="w-4 h-4" /> Add column before
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Columns3 className="w-4 h-4" /> Add column after
|
||||||
|
</button>
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" /> Delete row
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" /> Delete column
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Rows3 className="w-4 h-4" /> Toggle header row
|
||||||
|
</button>
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
<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"
|
||||||
|
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" /> Delete table
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TableSizePicker
|
||||||
|
onPick={(rows, cols) => {
|
||||||
|
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run();
|
||||||
|
setTableMenuOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ToolbarSeparator />
|
<ToolbarSeparator />
|
||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user