Compare commits

..
362 Commits
Author SHA1 Message Date
Linus Rath 0913dbd3e4 chore: update version to 1.5.1 2026-04-25 16:34:45 +02:00
Linus Rath 29197ea355 feat: replace folder prompt() calls with proper modal dialog 2026-04-25 16:30:18 +02:00
Linus Rath 4788e8a91a feat: add right-click context menu to mail folders sidebar 2026-04-25 16:26:05 +02:00
Linus Rath b80678b00f feat: add 'Today' button to desktop calendar toolbar 2026-04-25 03:11:16 +02:00
Linus Rath 4f7c9c332b feat: enhance OAuth auto-setup with dialog and validation for origin and issuer URLs 2026-04-25 02:53:12 +02:00
Linus Rath da103ff06f feat: implement OAuth auto-setup functionality for Stalwart integration 2026-04-25 01:37:18 +02:00
Linus Rath 2111c77870 fix: restore admin panel after Stalwart v0.16 REST API removal 2026-04-25 01:13:17 +02:00
Linus Rath e5083ec1df fix: restore admin panel after Stalwart v0.16 REST API removal 2026-04-25 01:12:33 +02:00
Linus Rath df8d04e233 fix: restore broken viewer toolbar actions and improve mobile menu #220 2026-04-24 19:31:38 +02:00
Linus RathandGitHub 9a11a18a44 Update README.md 2026-04-24 17:02:00 +02:00
Christian Lepthien ce9f7af330 fix: improve corean flag 2026-04-24 14:56:58 +02:00
Linus Rath 081e8a0310 feat: add setting to show avatars in junk folder, disabled by default 2026-04-23 18:23:48 +02:00
Linus Rath 6c3529b368 fix: stop flicker on background folder refresh 2026-04-23 18:06:40 +02:00
Linus Rath 077a4f03a7 fix: preserve search/filter on batch move and archive 2026-04-23 18:01:21 +02:00
Linus Rath b04dfaf252 fix: preserve search/filter when moving emails via drag-drop 2026-04-23 16:37:17 +02:00
Linus Rath ec5593f567 Merge branch 'dev' into main 2026-04-23 00:10:22 +02:00
Linus Rath c30c38a7af chore: update version to 1.5.0 2026-04-23 00:07:34 +02:00
Linus Rath 7494fc1776 chore: update version to 1.5.0 2026-04-22 23:49:44 +02:00
Linus Rath 16fe92d2e9 chore: update version to 1.5.0 2026-04-22 23:31:32 +02:00
Linus Rath 3544e6a9e7 chore: bump dependencies to latest compatible versions 2026-04-22 23:03:05 +02:00
Linus Rath dd1f3e11e6 fix: retry mailbox fetch on first login to handle lazy provisioning #217 2026-04-22 22:45:49 +02:00
Linus Rath 6503482b55 feat: support resizing events from the top edge 2026-04-22 21:14:28 +02:00
Linus Rath cab57f6cd7 feat: add timezone-aware formatting for event start times and update utcEnd on duration change 2026-04-22 21:05:41 +02:00
Linus Rath 27f4fbdce4 feat: optimize layout of overlapping events in calendar 2026-04-22 20:53:04 +02:00
Linus Rath 39a228b20e feat: streamline mobile email viewer header layout 2026-04-22 20:50:12 +02:00
Linus Rath 4b069808d6 feat: add isMobile prop to calendar views and time grid interactions 2026-04-22 20:37:00 +02:00
Linus Rath 468851ff25 feat: show full folder path in move/drop toast 2026-04-22 00:14:49 +02:00
Linus Rath b7374570c8 feat: implement bulk email moving functionality in email store 2026-04-22 00:10:40 +02:00
Linus Rath 3c9fa5dc25 feat: implement batch archiving of emails 2026-04-22 00:05:38 +02:00
Linus Rath 3ade1c6473 fix: update mailbox retrieval logic in archive handling to use fresh state 2026-04-22 00:00:43 +02:00
Linus Rath 1810a474a2 fix: enhance error message for mailbox creation failure 2026-04-22 00:00:01 +02:00
Linus Rath 40982bc37b fix: align hover styles and selection-toggle target with focused item 2026-04-21 23:50:55 +02:00
Linus Rath 76f6149841 feat: add setting to toggle alphabetical grouping in contacts list 2026-04-21 23:22:18 +02:00
Linus Rath 7d6a3c8c76 feat: group contacts by first letter with sticky section headers 2026-04-21 23:15:13 +02:00
Linus Rath 361ad49f5f fix: read matchMedia synchronously on client to prevent layout flicker 2026-04-21 23:02:21 +02:00
Linus Rath f9aa5cbaee fix: skip checkAuth on route change when already authenticated 2026-04-21 22:54:02 +02:00
Linus Rath 3f36045990 feat: reorganize settings into 6 groups with clearer tabs 2026-04-21 22:22:02 +02:00
Linus Rath 9a44babcf1 feat: add collapsible details to calendar invitation banner and update sanitization config for iframe rendering 2026-04-21 21:21:42 +02:00
Linus Rath f032758303 feat: revamp contact detail, filters, and add photo/print/duplicate 2026-04-21 21:16:54 +02:00
Linus Rath 92c7f74420 feat: add right-click context menu to contacts 2026-04-21 19:33:00 +02:00
Linus Rath c2e4518cfa feat: add account addition button to navigation rail 2026-04-21 19:20:50 +02:00
Linus Rath 00f33afdf9 feat: add contact activity component with recent emails and upcoming events 2026-04-21 19:18:36 +02:00
Linus Rath e566cfe687 feat: add API Keys management and IP allowlist for App Passwords 2026-04-21 18:59:47 +02:00
Linus Rath 6b7c849332 refactor: remove Stalwart API URL configuration 2026-04-21 17:50:18 +02:00
Linus Rath 30c4afb977 i18n: add missing translation keys 2026-04-21 17:42:52 +02:00
Linus Rath 1f60671886 fix: clean up unused imports and improve TOTP QR code rendering 2026-04-21 17:30:00 +02:00
Linus Rath 794001fdbd feat: migrate Stalwart management API to JMAP x: methods (0.16)
Drops the 0.15 REST management API and routes all account/auth/crypto/
principal operations through Stalwart 0.16's schema-driven JMAP
endpoint via a single passthrough (/api/account/stalwart/jmap).

- New client helper `stalwartJmap` + typed `requireResult`
- account-security-store rewritten against x:AccountPassword, x:AppPassword,
  x:AccountSettings, x:Account (with currentSecret for TOTP ops)
- Client-side TOTP setup via `otpauth`; server-generated app password
  secrets shown once on create
- Admin check switched to /api/account permissions
  (sysAccountQuery/sysTenantQuery/sysSystemSettingsGet)
- Removed sieve vacation-overwrite workaround (fixed upstream #1251)
- Deleted old REST routes, StalwartClient, stale tests; added new
  tests for passthrough + store
2026-04-21 17:29:23 +02:00
Linus Rath 9ad2facad3 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-21 14:12:03 +02:00
Linus Rath 89d8282846 fix: include original attachments when forwarding an email #214 2026-04-21 13:58:02 +02:00
Denis TeyssierandLinus Rath c3960a99be fix: removed allow-same-origin to avoid anti-pattern permissions on iframe sandbox #209 2026-04-20 14:55:50 +02:00
Denis TeyssierandLinus Rath e73ffa7449 fix: added sandbox allow-scripts to PDF file preview modal #209 2026-04-20 14:55:50 +02:00
Denis TeyssierandLinus Rath 37bd490072 fix: added media-src CSP rule for media attachments #209 2026-04-20 14:55:50 +02:00
Linus Rath 24c53e5ce7 fix: apply archive context-menu action to all selected emails # 212 2026-04-20 14:35:56 +02:00
Linus Rath aa7f886795 refactor: remove deprecated push notification API routes and related logic 2026-04-20 12:41:38 +02:00
Linus Rath 00dec8c5a0 fix: correct regex escape for hyphen in FCM token validation 2026-04-20 12:08:41 +02:00
Linus Rath 578e60c0bc feat: nevermind, migrate push notification handling from UnifiedPush to FCM 2026-04-20 12:06:30 +02:00
Linus Rath 8b21851353 feat: migrate to UnifiedPush 2026-04-20 10:54:26 +02:00
Linus Rath 15006086d2 feat: implement JMAP push notification handling and subscription management 2026-04-20 08:26:09 +02:00
Linus Rath bc3b923945 fix: apply mobile toolbar spacing fix to email viewer action bar 2026-04-19 17:59:58 +02:00
Linus Rath 76ba9e5f85 fix: retry session fetch with Authorization after redirect strips it #210 2026-04-19 16:40:55 +02:00
Linus Rath 44eb5fced2 fix: stop birthday calendar from re-showing after manual hide #204 2026-04-19 15:04:27 +02:00
Linus Rath 172d8267ef fix: persist typed calendar participant on save without requiring Enter #206 2026-04-19 13:47:17 +02:00
Linus Rath f162f1e3d4 feat: refresh JMAP on reload gestures, fix mobile bottom nav spacing 2026-04-19 13:43:44 +02:00
Linus Rath 6fa0029d0b feat: add right-click context menu on calendar events 2026-04-18 13:46:49 +02:00
nesgarboandLinus Rath 028e78a0c9 fix: serve PWA manifest dynamically to respect runtime APP_NAME env var
Delete static public/manifest.json (hardcoded "Bulwark Webmail") and mark
app/manifest.ts as force-dynamic so Next.js evaluates APP_NAME at request
time instead of build time, fixing the native browser install prompt.

Closes #207
2026-04-18 13:20:14 +02:00
Linus Rath 440a4e919a Revert "perf: make root HTML cacheable by dropping per-request CSP nonce"
This reverts commit 522bf6a019.
2026-04-18 13:10:42 +02:00
Linus Rath b8f39198e1 fix: allow blob: in frame-src CSP for attachment previews #209 2026-04-18 13:04:25 +02:00
Linus Rath 966bbe3957 fix: add public JavaScript files to ESLint ignore list 2026-04-18 13:01:52 +02:00
Linus Rath 522bf6a019 perf: make root HTML cacheable by dropping per-request CSP nonce 2026-04-18 13:01:12 +02:00
Linus Rath 1689315c3a feat: add setting to hide inline images from attachment list 2026-04-18 01:07:34 +02:00
Linus Rath d4f7ae522e fix: use cid references for inline images #163 2026-04-18 00:57:46 +02:00
Linus Rath f05f70a9e5 fix: prevent XSS via quote injection in plain-text email linkifier 2026-04-18 00:35:21 +02:00
Linus Rath 850ee73048 fix: preserve Nextcloud Mail filter markers across saves #201 2026-04-17 02:08:04 +02:00
Linus Rath 5842f3f914 fix: preserve birthday calendar visibility across navigation #204 2026-04-17 01:54:08 +02:00
Linus Rath f303478850 chore: update version to 1.4.14 2026-04-16 19:08:42 +02:00
Linus Rath 8bdadc7ba3 fix: standardize punctuation 2026-04-16 19:07:42 +02:00
Linus Rath 6b57118add chore: update version to 1.4.14 2026-04-16 19:05:20 +02:00
Linus Rath ffb645671c chore: update README 2026-04-16 19:04:18 +02:00
Linus Rath e63ce25f5f chore: update version to 1.4.14 2026-04-16 18:59:23 +02:00
Linus Rath d31b30ba4a Merge branch 'dev' 2026-04-16 18:51:01 +02:00
Linus Rath 31eff96614 feat: Enhance external rule handling in Sieve parser and store #201 2026-04-16 17:22:52 +02:00
nesgarboandLinus Rath 2ea8054240 fix: improve CalDAV task detection for external clients (Thunderbird) #84
Two issues prevented tasks created in Thunderbird (or other CalDAV
clients) from appearing in the task view:

1. percentComplete was not in CALENDAR_TASK_PROPERTIES, so it was
   never requested from the server and the heuristic check for it
   was always false (dead code).

2. The hasTaskFields heuristic used strict value checks:
   - 'progress' in obj && typeof obj.progress === 'string'
     → fails when Stalwart returns progress: null instead of the
       RFC 8984 default "needs-action"
   - 'due' in obj && obj.due != null
     → fails when Stalwart includes due: null for tasks without a
       DUE date (key present, value null)

RFC 8984 §5.2 defines due, progress and percentComplete as Task-only
properties — a VEVENT will never include them in a JMAP response.
Checking for key presence alone (even when null) is therefore a
reliable discriminator, regardless of the actual value.
2026-04-16 16:54:09 +02:00
nesgarboandLinus Rath 5a2e141ed6 fix: send iMIP invitation emails when creating or updating calendar events #192
sendImipInvitation() was fully implemented but never called after
createEvent or updateEvent — only sendImipCancellation was wired up
(in deleteEvent). This meant that even when the "send invitation"
checkbox was checked and participants were correctly saved on the
server, no invitation email was dispatched to attendees.

Apply the same pattern already used by deleteEvent: after a successful
create/update, if sendSchedulingMessages is true and the event has
participants, call sendImipInvitation() in a best-effort try/catch so
that email failures do not roll back the calendar operation.

For createEvent, the raw server response (created) is used directly
since it is already available and matches the CalendarEvent type
expected by sendImipInvitation.

For updateEvent, the updated event is reconstructed by merging the
existing store event with the incoming patch, avoiding an extra API
round-trip.
2026-04-16 16:54:09 +02:00
nesgarboandLinus Rath fa343e0768 fix: hide ICS attachments from email attachment list when invitation banner is shown
When an email contains a calendar invitation, the raw .ics MIME parts
(text/calendar, application/ics, application/icalendar) were showing
up in the attachment list alongside the calendar invitation banner,
which is confusing — the banner already provides the relevant UI.

Filter those MIME types out of the displayed attachment list whenever
the calendar invitation banner is active, reusing the existing
isCalendarMimeType utility from lib/calendar-invitation.ts.
2026-04-16 16:54:09 +02:00
nesgarboandLinus Rath 8969338b2a fix: RFC 5545/6047 compliance for outgoing iMIP calendar emails
Three issues addressed in sendImipReply, sendImipInvitation and
sendImipCancellation:

1. Line folding (RFC 5545 §3.1)
   Add foldIcsLine() helper that wraps iCalendar content lines at
   74 characters, inserting CRLF + SPACE as required by the spec.
   Previously, long lines (e.g. ATTENDEE with a full CN and mailto
   URI) could exceed the 75-octet limit and cause strict parsers to
   silently reject the ICS.

2. MIME wrapper type (RFC 6047 §3 + CalConnect iMIP Best Practices)
   Change bodyStructure from multipart/alternative to multipart/mixed.
   The CalConnect interoperability guide recommends multipart/mixed as
   the outer wrapper for messages carrying a text/calendar part; many
   clients skip iTIP processing when they see multipart/alternative.

3. Calendar part metadata
   Add charset=UTF-8 to the text/calendar Content-Type, disposition
   inline, and a descriptive filename (reply.ics / invite.ics /
   cancel.ics) to each outgoing calendar MIME part.

Note: Gmail-to-Gmail events are handled by Google's internal scheduling
API and cannot be updated via iMIP regardless of MIME structure. This
fix improves interoperability with Outlook, Thunderbird, Fastmail and
standard CalDAV servers.
2026-04-16 16:54:09 +02:00
nesgarboandLinus Rath 4c720d6855 fix: export isCalendarMimeType for use in email attachment filtering
Previously isCalendarMimeType was a module-private function in
lib/calendar-invitation.ts. Exporting it allows the email viewer
to reuse the same MIME type detection logic when filtering out
calendar attachments, avoiding duplication of the type set.
2026-04-16 16:54:09 +02:00
Linus Rath ad175d20e3 feat: enhance email deletion and spam handling with improved parameterization 2026-04-16 16:50:42 +02:00
nesgarboandLinus Rath fb2f0c9158 fix: use 'company' consistently in .env.example branding comments 2026-04-15 11:25:16 +02:00
nesgarboandLinus Rath 7daa46e73e docs: document PWA and branding env vars in .env.example
Reorganize the Branding section with subsections (App identity, Icons &
favicon, PWA appearance, Logos, Login page) and document the new variables
APP_SHORT_NAME, APP_DESCRIPTION, PWA_ICON_URL, PWA_THEME_COLOR and
PWA_BACKGROUND_COLOR.
2026-04-15 11:25:16 +02:00
nesgarboandLinus Rath 195185dc52 feat: show app name and logo in PWA install prompt
Use runtime config (appName, appLogoLightUrl, appLogoDarkUrl, faviconUrl)
instead of the hardcoded 'Bulwark' string and download icon.
2026-04-15 11:25:16 +02:00
nesgarboandLinus Rath 8a9dce1a99 feat: dynamic PWA manifest with configurable name, description and icons
- Add app/manifest.ts to serve /manifest.webmanifest dynamically at runtime
- Name, short_name, description, theme_color and background_color are read
  from env vars (APP_NAME, APP_SHORT_NAME, APP_DESCRIPTION, PWA_THEME_COLOR,
  PWA_BACKGROUND_COLOR) with Bulwark defaults as fallback
- Add /api/pwa-icon/[size] route that auto-generates 192x192 and 512x512 PNG
  icons from PWA_ICON_URL (or FAVICON_URL as fallback) using Sharp; results
  are cached in memory
- Remove static manifest: '/manifest.json' from layout metadata; Next.js
  injects the link automatically from app/manifest.ts
- Fix pre-existing ESLint no-undef on RequestInit in browser-navigation.ts
2026-04-15 11:25:16 +02:00
shukiandLinus Rath c690e8eb76 fix: skip intl middleware for paths already containing a locale prefix
When localePrefix is 'always' (or 'as-needed' with a non-default locale),
paths like /en/settings already have the locale in the URL. Running them
through the next-intl middleware a second time can trigger rewrite loops,
especially when combined with a proxy basePath where the middleware's
detection of the 'current' path conflicts with the rewritten one.

Skip the intl middleware in this case — the path is already in the
canonical locale-prefixed form and no further rewriting is needed.

This makes NEXT_PUBLIC_LOCALE_PREFIX=always reliable for sub-path
deployments.
2026-04-14 18:09:37 +02:00
shukiandLinus Rath 9d867cbff6 feat: configurable localePrefix via NEXT_PUBLIC_LOCALE_PREFIX
Allow the next-intl localePrefix mode to be set via environment
variable at build time, defaulting to the existing 'never' behavior.

This is useful when proxying Bulwark under a sub-path (where
'never' can trigger rewrite loops) or when users prefer
URL-embedded locales (/en/settings vs /settings).

Usage:
  NEXT_PUBLIC_LOCALE_PREFIX=always npm run build

Accepted values: 'never' (default), 'always', 'as-needed'.
2026-04-14 18:09:37 +02:00
Linus Rath f22699fe20 feat: add unified mailbox across accounts and sidebar icons toggle 2026-04-14 17:36:13 +02:00
shukiandLinus Rath a7db3883aa feat: apiFetch helper for mount-prefix-aware API calls
Makes every client-side fetch('/api/...') call respect the mount prefix
when Bulwark is served behind a reverse proxy at a sub-path (e.g.
`/webmail`).

### Problem

`getPathPrefix()` (added in 1.4.13 by #XXX / d762b94) already fixes
router navigation and redirect URIs for reverse-proxy deployments.
Client-side `fetch()` calls, though, still target the browser origin:

    await fetch('/api/foo')
    // Browser at /webmail/en/inbox → hits /api/foo (not proxied → 404)

That means the login flow, session establishment, settings save, plugin
loader, calendar import, etc. all break the moment you front Bulwark
with nginx (or any proxy) at a sub-path.

### Fix

Add `apiFetch(input, init)` next to `getPathPrefix()` in
`lib/browser-navigation.ts`. It prepends the mount prefix to any
absolute path at call time:

    await apiFetch('/api/foo')
    // /webmail/en/inbox → /webmail/api/foo
    // /en/inbox         → /api/foo

Same runtime-detection model as `getPathPrefix()` — the built bundle
works at any mount point without rebuilding or env-var config.
Protocol-relative (`//cdn...`) and absolute (`https://...`) URLs pass
through unchanged. Server-side route handlers are untouched (the mount
prefix is a browser-only concept).

### Migration

Mechanical rewrite of every client-side `fetch('/api/...')` call in
hooks/, lib/, stores/, components/, app/ — 99 call sites across
26 files. `route.ts` handlers and other server-only files are skipped.

### Compat

- No behaviour change when mounted at `/` (the common case): an empty
  prefix + raw path is identical to raw path.
- No new config knobs, env vars, or build flags.
- Supersedes PR #181 (which required a build-time `NEXT_PUBLIC_BASE_PATH`)
  — will close #181 after this lands.

### Testing

Should run the existing suite; smoke-tested by Jabali Panel which
reverse-proxies Bulwark at `/webmail/` (https://github.com/shukiv/jabali-panel).
2026-04-14 14:37:19 +02:00
Linus Rath 7fcefa53c9 Merge branch 'dev' of https://github.com/bulwarkmail/webmail into dev 2026-04-14 14:30:22 +02:00
Linus Rath bdb76c3d90 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-14 14:28:29 +02:00
Linus Rath 168b36d419 fix: add calendarAddress and replyTo to calendar participants for Stalwart compatibility #189 #192 2026-04-14 14:26:28 +02:00
chrilep a4f57e7a5c fix: add missing lang texts, feat: add ukrainian lang, add flags 2026-04-14 09:39:44 +02:00
Linus Rath 6678501501 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-13 00:51:21 +02:00
Linus Rath fa0045e01b fix: use onSuccessUpdateEmail to send before storing in Sent #188 2026-04-13 00:50:13 +02:00
Linus Rath 1b816d3185 fix: standardize tag naming and fix unknown keyword display #184 #185 2026-04-12 15:37:57 +02:00
Linus Rath 24949e183f feat: add i18n API, render hooks, and new intercept hooks to plugin system 2026-04-12 13:57:20 +02:00
Linus Rath 9207ec563c Merge branch 'dev' 2026-04-12 02:19:38 +02:00
Linus Rath 44e0e17203 chore: update version to 1.4.13 2026-04-12 02:17:54 +02:00
Linus Rath b9c9901643 chore: update version to 1.4.13 2026-04-12 02:13:55 +02:00
Linus Rath a201c1617b feat: add onAvatarResolve plugin hook 2026-04-12 01:59:53 +02:00
Linus Rath d24f402b0c fix: style links in plain text emails 2026-04-12 01:38:53 +02:00
Linus Rath 5cfc905f10 fix: seed list history entry when app initializes on an email view 2026-04-12 01:33:28 +02:00
Linus Rath 4531cfe47c fix: resolve TS errors from missing createAddressBook in demo client and client ref in thread view 2026-04-12 01:14:25 +02:00
Linus Rath 927a3b8b11 feat: store trusted senders in a dedicated JMAP address book #176 2026-04-12 01:10:28 +02:00
Linus Rath 4ad8396adc fix: remount composer on draft edit and preserve identity #60 2026-04-12 00:11:36 +02:00
shukiandLinus Rath 6aaeb34272 feat: publish standalone tarball on release
Adds a GitHub Action that builds the Next.js standalone output on
release and attaches architecture-specific tarballs (amd64 + arm64)
as release assets. Downstream projects can download and extract
instead of building from source.

Closes #178
2026-04-11 23:28:39 +02:00
Linus Rath ab57966a94 fix: search all folders when filtering emails by tag #175 2026-04-11 23:21:12 +02:00
Linus Rath 18d9b9adf6 fix: include mount prefix in SSO redirect URI when app is served under a subpath 2026-04-11 22:05:56 +02:00
Linus Rath ed311d79e3 fix: resolve TS error from optional name.components in vcard parser 2026-04-11 20:56:45 +02:00
Linus Rath 457400ceee fix: also display contact names stored in name.full #179 2026-04-11 20:15:49 +02:00
Linus Rath 88d87be685 fix: correct PWA icons with proper sizing, transparency, and dark/light mode support 2026-04-10 19:26:12 +02:00
Linus Rath 5ddb2acfc7 fix: category dropdown blocking Save button in contact form #177 2026-04-10 18:40:32 +02:00
Linus Rath 5f150f039d feat: warn on send when attachment keyword found but no file attached #172 2026-04-10 18:24:52 +02:00
Linus Rath 4f54f768e8 Feat: enable keyword reordering #174 and multi-tag support per email #173 2026-04-10 17:31:13 +02:00
Linus Rath c24762c7a3 feat: separate branch containers into beta and dev packages 2026-04-10 14:51:17 +02:00
Linus Rath 3e85e07363 feat: separate branch containers into beta and dev packages 2026-04-10 14:46:04 +02:00
Linus Rath facef97fcc feat: publish main and dev branches as separate GHCR packages 2026-04-10 14:33:18 +02:00
Linus Rath 89d580b90d feat: add don't remind me again option to install prompt for PWA 2026-04-10 14:20:48 +02:00
Linus Rath 734155c939 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-09 22:22:48 +02:00
Linus Rath 790d7084af fix: restore service worker and add manifest icons 2026-04-09 22:19:56 +02:00
Pascal DietrichandLinus Rath 60efb047d8 docs: add SESSION_SECRET_FILE and OAUTH_CLIENT_SECRET_FILE env vars 2026-04-09 22:13:08 +02:00
Pascal DietrichandLinus Rath f9052eb23f feat: add SESSION_SECRET_FILE and OAUTH_CLIENT_SECRET_FILE env vars 2026-04-09 22:13:08 +02:00
Linus Rath 9c5daa3918 chore: update version to 1.4.12 2026-04-09 20:08:03 +02:00
Linus Rath 23870801fc chore: update version to 1.4.12 2026-04-09 20:04:06 +02:00
Davi MacielandLinus Rath ae793551ba fix(email-list): show recipient in Sent and Drafts folders
When viewing the Sent or Drafts mailbox, the list items always
displayed the sender (email.from[0]) — which is always the logged-in
user — instead of the recipient. This makes it impossible to
identify messages by who they were sent to.

This change detects the current mailbox role from the store and
swaps the displayed person to email.to[0] when the role is 'sent'
or 'drafts'. For multi-email threads, participant names are
computed from the collected recipients of the thread's emails.

Affected components:
- EmailListItem (flat list, threading disabled)
- SingleEmailItem (single-email thread)
- ThreadListItem (multi-email thread header + avatar)

This matches the behaviour of Gmail, Outlook, Apple Mail, and
every other mainstream mail client.
2026-04-09 19:52:09 +02:00
Linus Rath f19aaf6207 fix: embed dropped images as data URLs and prevent duplicate attachment #163 2026-04-09 19:50:42 +02:00
Linus Rath d0cc439fd1 feat: add automatic identity synchronization (#167) 2026-04-09 19:39:04 +02:00
Linus Rath f42f57b8d0 fix: preserve search filters on push-triggered mailbox refresh #164 2026-04-09 19:10:34 +02:00
Linus Rath 0d4b588fd0 feat: add Latvian (lv) locale support 2026-04-08 22:42:56 +02:00
Linus Rath 6a725dde58 feat: add Latvian (lv) locale support 2026-04-08 22:42:15 +02:00
Linus Rath 6499da6281 feat: sync mail view to browser history for back/forward navigation 2026-04-08 22:20:36 +02:00
Linus Rath b2379fb03f fix: align shared account folders with primary folders #151 2026-04-08 13:45:31 +02:00
Linus Rath b29e71124d fix: prune recent files against server nodes on refresh #146 2026-04-08 13:22:51 +02:00
Linus Rath 63593e2146 fix: use fetch param type instead of RequestInit 2026-04-08 13:18:07 +02:00
Linus Rath 10cbe7a637 fix: stream WebDAV PUT uploads to avoid buffering in memory #162 2026-04-08 13:16:28 +02:00
Linus Rath 79418f013a fix: allow blob images in CSP for inline drag-and-drop #163 2026-04-08 13:10:04 +02:00
Linus Rath 4024732696 fix: handle 403 on principal fetch without console error 2026-04-08 13:08:46 +02:00
Linus Rath ffad3ea78b feat: Ability to rename address book #152 2026-04-08 13:05:42 +02:00
Linus Rath a9b9aeb44d fix: clamp Feb 29 birthdays in non-leap years #163 2026-04-08 12:47:24 +02:00
Linus Rath 3d76fe6d75 fix: focused mode expanding to the right of the screen #156 2026-04-08 12:43:48 +02:00
Linus RathandGitHub 58cafe28ad Update CONTRIBUTING.md 2026-04-07 10:17:30 +02:00
Linus RathandGitHub 60f9a3dc4d Update CONTRIBUTING.md 2026-04-07 02:28:06 +02:00
Linus RathandGitHub 11d9db5580 Update CONTRIBUTING.md 2026-04-07 02:27:19 +02:00
lingxhandLinus Rath 05d3f0b469 add zh_cn 2026-04-07 01:11:04 +02:00
Sienna Meridian SatterwhiteandLinus Rath c3f60448ad feat: configurable OAuth scopes and cookie security via env vars
Add three environment variables for deployments with external identity
providers (Keycloak, Authentik, Ory Hydra, etc.):

- OAUTH_EXTRA_SCOPES: append additional scopes to the default
  "openid email profile" (e.g. "offline_access" for refresh tokens)
- OAUTH_SCOPES: full override of the requested OAuth scopes
- COOKIE_SECURE: override the Secure flag on auth cookies (useful
  for reverse proxy setups where the internal hop is HTTP)

Without these, deploying Bulwark with an external OIDC provider that
requires `offline_access` for refresh tokens is impossible — sessions
die on every page refresh because no refresh token is issued.

All three are backwards-compatible: unset = identical to current behavior.
2026-04-06 23:22:06 +02:00
p.kwiatkowskiandLinus Rath 523711cca3 feat: add Polish language support 2026-04-06 23:20:25 +02:00
Linus Rath bcb487810a feat: add Korean language support 2026-04-05 16:46:17 +02:00
rldn12134andLinus Rath 7e1d644f7d feat: Add Korean translation file 2026-04-05 13:51:22 +02:00
Linus Rath 4aac65d7d2 feat: add birthday calendar feature with settings and localization 2026-04-03 22:45:07 +02:00
Linus Rath 14ecae61dc feat: enhance error handling in Stalwart API responses 2026-04-03 15:10:11 +02:00
Linus Rath c53ff5a30a fix: file deletion logic to update recent files and handle errors #146 2026-04-03 14:20:06 +02:00
Linus Rath 5aad97d64e feat: add PWA support with service worker and install prompt 2026-04-03 04:39:19 +02:00
Linus Rath 081c865018 fix: guard against undefined trigger in calendar event alert popover #143 2026-04-03 03:15:55 +02:00
Linus Rath 050f38b1fa fix: use dynamic batch size for calendar event retrieval 2026-04-02 23:29:37 +02:00
Linus Rath d657bdfa75 fix: improve calendar event retrieval by batching requests to avoid server limits #141 2026-04-02 22:55:07 +02:00
Linus Rath bbf724d9e1 fix: correct logic for marking email as read in EmailViewer component 2026-04-02 22:50:04 +02:00
Linus Rath 9d8c6044e3 fix: optimize htmlToPlainText function using DOMParser 2026-04-02 17:32:53 +02:00
Linus Rath 2d17ca71e3 fix: replace random ID generation with generateUUID() 2026-04-02 17:29:00 +02:00
Linus Rath d77dd1e3e1 fix: replace non-UUID fallback 2026-04-02 17:25:10 +02:00
Linus Rath 9d97b74684 fix: adjust max width of calendar invitation banner for better layout 2026-04-02 14:56:04 +02:00
Linus Rath c4673acb65 fix: replace unguarded crypto.randomUUID() with safe generateUUID() utility 2026-04-02 14:51:21 +02:00
Linus Rath 1bdc51dcc7 feat: implement version badge and easter egg in settings 2026-04-02 14:31:25 +02:00
Linus Rath 6ee0f6a40a feat: disable plugins by default, require admin approval 2026-04-02 13:50:02 +02:00
Linus Rath 9ee25c930e fix: compute per-occurrence utcStart/utcEnd in recurrence expansion #116 2026-04-02 13:35:26 +02:00
Linus Rath 1b2c70a90b fix: de-namespace addressBookIds for shared contacts in create and update operations #133 2026-04-02 11:34:26 +02:00
Linus Rath cdea876992 fix: fetch mailboxes on mount in FolderSettings when store is empty 2026-04-02 11:26:21 +02:00
Linus Rath 16557830ae fix: mailbox deletion error handling 2026-04-02 11:24:11 +02:00
Linus Rath 8836f9cdca fix: wrap handleArchive call to prevent MouseEvent leaking as argument 2026-04-02 11:19:36 +02:00
Linus Rath 26bfe9cb6c fix: extend file drop zone to fill remaining viewport height 2026-04-02 11:14:38 +02:00
Linus Rath 05eaaad61f fix: fallback to application/octet-stream for long MIME types in createFileNode 2026-04-02 11:04:59 +02:00
Linus Rath 2e0852b272 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-02 10:57:03 +02:00
Linus Rath 6173a9ad13 fix: resolve settings sync Identity mismatch for OAuth/SSO sessions #127 2026-04-02 10:54:35 +02:00
Linus RathandGitHub e3560d9cb4 Add funding configuration for Bulwark Webmail 2026-04-02 00:37:58 +02:00
Niklas VossandLinus Rath 52326326e2 fix: check plugin http.post url against origin and add regression tests 2026-04-01 11:26:12 +02:00
Niklas VossandLinus Rath 2734fa08b7 feat: instead of exposing auth headers, offer a http proxy api for plugins 2026-04-01 11:26:12 +02:00
Niklas VossandLinus Rath 67a0d622bc feat: add auth:read permission and ability to retrieve auth headers 2026-04-01 11:26:12 +02:00
chrilepandGitHub 58968a1cbf updated to show latest version in badge 2026-04-01 09:49:23 +02:00
Linus Rath 01c8afbfe8 Merge branch 'dev' 2026-03-31 18:34:49 +02:00
Linus Rath be2e0f2b68 chore: update version to 1.4.11 2026-03-31 18:24:25 +02:00
Linus Rath aa40c8be26 fix: harden proxy auth and SSRF defenses 2026-03-31 17:47:09 +02:00
Linus Rath b3d4c9241c i18n: add missing translation keys to all non-English locales 2026-03-31 16:40:36 +02:00
Linus Rath 34dd5122b3 feat: logging to include categories for better log management 2026-03-31 16:31:27 +02:00
Linus Rath dab3606b04 fix: strip server-immutable fields from updates before sending to JMAP #128 2026-03-31 16:15:56 +02:00
Linus Rath 0f7638055c fix: auto-focus input fields in email composer for improved user experience #126 2026-03-31 16:10:14 +02:00
Linus Rath 66fe7fd359 fix: enhance security by blocking plugins with dangerous JS patterns and enforcing strict session secret length 2026-03-31 15:56:32 +02:00
Linus Rath 1b2ee7da3a fix: prevent orphaning of nested mailboxes by restricting deduplication to root-level folders 2026-03-31 15:29:09 +02:00
Linus Rath f6bec519f4 fix: update file feature disabled messages and add stability warnings 2026-03-31 15:18:13 +02:00
Linus Rath 7102add194 fix: add self-signed certificate detection and update status messages for S/MIME signatures 2026-03-31 15:13:48 +02:00
Linus Rath a3d894730b fix: hardened security, CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, PDF iframe sandbox 2026-03-31 15:11:38 +02:00
Linus Rath 79b99ed4f8 Merge branch 'dev' 2026-03-31 00:53:10 +02:00
Linus Rath 68214c3e91 chore: update version to 1.4.10 2026-03-31 00:51:36 +02:00
Linus Rath 5da0e2bdf1 fix: patch critical auth bypass and credential leak vulnerabilities 2026-03-31 00:46:15 +02:00
Linus Rath 92ada0460a Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-03-31 00:27:45 +02:00
Linus Rath 1cce5c3c8a fix: JSCalendar 2.0 recurrenceRule single-object compatibility 116 2026-03-31 00:11:04 +02:00
Linus RathandGitHub 40fd7799e9 Update security vulnerability reporting contact 2026-03-30 23:47:01 +02:00
Linus Rath aaa283357e feat: implemented plugin configuration UI and calendar event action slot
- Add configSchema support to plugin manifest and ServerPlugin registry
- Add schema-driven admin config page (string, secret, boolean, number, select fields)
- Add per-plugin config storage backend (JSON files + REST API)
- Add calendar-event-actions and admin-plugin-page slot names to plugin store
- Add registerCalendarEventAction and registerAdminPage to plugin API
- Add calendarFormHooks (onCalendarEventFormOpen/Save) to hook bus
- Add PluginSlot in calendar event modal for plugin action buttons
- Style calendar event action buttons to match Bulwark outline button design
- Add Configure link per plugin in admin plugins dashboard
- Add Jitsi Meet plugin with tests (repos/plugins/jitsi-meet)
- Exclude data/admin/plugins from ESLint (deployed plugin bundles)
2026-03-30 21:12:05 +02:00
Linus Rath 066ab1bc32 debug: add diagnostic logging to 3DES S/MIME decrypt path
Log legacy key import success/failure, CMS algorithm OIDs (content
encryption + key transport), legacy key availability during decrypt
attempts, and detailed error messages from both RSA-OAEP and
RSAES-PKCS1-v1_5 decrypt paths.
2026-03-30 19:15:02 +02:00
Linus Rath 92fb9bf132 fix: enhance calendar event handling to distinguish between events and tasks 2026-03-30 18:04:49 +02:00
Linus Rath 4c804d5d2b fix: align tag counts with mailbox folder counts in sidebar 2026-03-30 17:31:26 +02:00
Linus Rath 63b464641b fix: link existing events to target calendar during iCal import instead of skipping #113 2026-03-30 16:47:08 +02:00
Linus Rath 67210c9924 fix: handle 2FA/TOTP session expiry with basic auth #117 2026-03-30 16:37:07 +02:00
Linus Rath 8937777bcd fix: support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys #35 2026-03-30 16:00:27 +02:00
Linus Rath 789d211a71 fix: enhance mailbox handling with logging 2026-03-30 15:46:54 +02:00
Linus Rath c512352f77 fix: add replyToAddresses to email composer 2026-03-30 15:27:50 +02:00
Linus Rath c859230862 feat: add virtual location input #121 2026-03-30 15:19:47 +02:00
Linus Rath 0fb8b81acd fix: deduplicate UIDs during iCal import to prevent mass failures #113 2026-03-30 15:06:28 +02:00
Linus Rath 1eebec292a fix: enhance email loading and deduplication logic in email store #119 2026-03-30 08:32:46 +02:00
Linus Rath 2d983c9853 fix: improve mailbox tree logic #118 2026-03-29 23:47:52 +02:00
Linus Rath a453f7fa67 fix: events disappearing after iCal import/subscription refresh 2026-03-29 18:33:25 +02:00
Linus Rath 4c853f176b feat: add iCal subscription editing and batch event import 2026-03-29 18:27:16 +02:00
Linus Rath 5583d95ecc feat: preserve activation state when updating or creating Sieve scripts to avoid deactivating server-managed vacation scripts 2026-03-29 17:25:15 +02:00
Linus Rath 9d1302ef0a feat: skip server-managed vacation script in Sieve script handling 2026-03-29 17:18:33 +02:00
Linus Rath 75b5d31414 feat: implement client-side recurrence expansion for calendar events 2026-03-29 17:12:30 +02:00
Linus Rath 1e90885f3f i18n: add missing translation keys to all non-English locales 2026-03-29 14:20:41 +02:00
Linus Rath 02577c7502 feat: add calendar hover preview settings and functionality 2026-03-29 14:10:28 +02:00
Linus Rath 375c0a7339 feat: add JMAP server endpoint labels and hints in multiple languages 2026-03-29 00:52:42 +01:00
Linus Rath 99d5276cfa feat: add support for custom JMAP server endpoints in login and settings 2026-03-29 00:47:35 +01:00
Linus Rath 60b2cf5911 fix: adjust account list spacing and remove push connection indicator 2026-03-28 23:33:44 +01:00
Linus Rath 8d7cd26fa9 feat: enhance compose functionality with button integration and translations 2026-03-28 22:53:10 +01:00
Linus Rath 1ba8e2fd47 feat: add folder expansion state management and settings navigation 2026-03-28 22:32:01 +01:00
Linus Rath c7c22bd210 fix: account isolation, auto-import signer certs, and no-key error handling #35 2026-03-28 17:32:06 +01:00
Linus Rath 6e0c79ca2c i18n: add 17 missing translation keys to all non-English locales 2026-03-28 15:35:24 +01:00
Linus Rath 9140110435 feat: add options to hide account switcher and show account avatars on navigation rail 2026-03-28 15:20:25 +01:00
Linus Rath e49f7c1a75 fix: improve dark mode handling for media elements and background images 2026-03-28 14:57:04 +01:00
Linus Rath 098127148e fix(tasks): work around Stalwart not returning Task objects via CalendarEvent/query
Stalwart's CalendarEvent/query ignores Task-type objects and does not
support the 'types' filter (returns unsupportedFilter error).  This
caused tasks  both locally created and from external clients like
Thunderbird  to disappear on reload.

Root causes:
- CalendarEvent/query only returns @type:'Event' objects on Stalwart,
  so tasks were invisible to the query endpoint.
- CALENDAR_EVENT_PROPERTIES lacked Task-specific fields (due, progress,
  progressUpdated, priority), causing garbled data when tasks were
  fetched with Event properties (e.g. utcStart:'32548-12-04T15:30:07Z').

Changes:
- Add CALENDAR_TASK_PROPERTIES with Task-specific fields (due, progress,
  progressUpdated, priority).
- Rewrite getCalendarTasks() to first try CalendarEvent/query with
  types:['Task'] filter, then fall back to CalendarEvent/get ids:null
  which returns all calendar objects regardless of @type per JMAP spec.
- Rewrite createCalendarTask() to fetch back created tasks using
  CALENDAR_TASK_PROPERTIES instead of piggybacking on createCalendarEvent.
- Add comprehensive debug logging throughout the task fetch/create flow
  (TaskStore, JMAP client) visible when Debug Mode is enabled.
- Add 'types' field to CalendarEventFilter interface.
2026-03-28 14:17:54 +01:00
Linus Rath 308adf0101 feat: add mail layout settings and update email list components 2026-03-28 01:40:41 +01:00
Linus Rath 574bd7ecef fix: ensure draft editing function is called correctly in EmailViewer component #60 2026-03-28 00:52:28 +01:00
Linus Rath d6b0714b4e fix: match hover action background to selected row state 2026-03-28 00:42:42 +01:00
Linus Rath af8ea8349e fix: use UTC timestamps for timed event rendering 2026-03-28 00:33:38 +01:00
Linus Rath 8eeabfc995 Fix: nested button in theme card 2026-03-28 00:17:15 +01:00
Linus Rath ddb636ed73 feat: add auto-select reply identity feature with settings and localization 2026-03-27 23:58:42 +01:00
Linus Rath 92ff5fe449 feat: enhance calendar event handling with full-day detection and layout adjustments 2026-03-27 22:04:57 +01:00
Linus Rath edcd5aa2a7 chore: update version to 1.4.9 2026-03-27 19:46:48 +01:00
Linus Rath b5d471e9c8 feat: fix: enforce admin theme locks and repair theme ZIP bundles 2026-03-27 19:42:30 +01:00
Linus Rath f084b484b7 fix: create all-day calendar events with JSCalendar midnight start and full fetch verification 2026-03-27 18:51:34 +01:00
Linus Rath b868ad591d feat: add calendar event normalization and sanitization functions with tests 2026-03-27 18:46:20 +01:00
Linus Rath 42734f16c3 feat: implement vacation responder functionality with UI integration and localization 2026-03-27 18:32:02 +01:00
Linus Rath b70c727bae feat: enhance vacation syncing in filter store with error handling and script preservation 2026-03-27 18:18:41 +01:00
Linus Rath 9b0598b051 fix: ensure unique ID generation for ICalSubscription in useCalendarStore 2026-03-27 18:04:48 +01:00
Linus Rath 66cdfe64ce fix: prevent infinite re-render loop on calendar page 2026-03-27 17:40:28 +01:00
Linus Rath f6536573ae feat: add vacation support to Sieve script generation and parsing 2026-03-27 17:36:30 +01:00
Linus Rath 415c961937 feat: enforce forced/managed plugins and policy. Split user upload permission 2026-03-27 16:33:17 +01:00
Linus Rath ba6b25f3f1 fix: respect markAsReadDelay setting in EmailViewer #95 2026-03-27 02:09:00 +01:00
Linus Rath a9002763e2 feat: show admin panel in sidebar for Stalwart admin users 2026-03-27 01:53:16 +01:00
Linus Rath b75d064f20 feat: add plain text only setting for email composer #105 2026-03-27 00:38:11 +01:00
Linus Rath a46fb9c8af feat: implement rate limiting handling in JMAPClient #104 2026-03-27 00:22:40 +01:00
Linus Rath 6696636df8 feat: implement Stalwart admin authentication and role check across admin routes 2026-03-26 20:21:12 +01:00
Linus Rath 37bc88dbad feat: implement force enable/disable functionality for plugins and themes 2026-03-26 18:59:30 +01:00
Linus Rath 7a191cf78b fix: resolve all 69 ESLint warnings across 40 files
- Remove unused imports (Mail, CalendarIcon, Plus, Circle, cn, Link,
  MessageCircle, User, Tag, FolderUp, useEffect, LogOut, GripVertical,
  Check, X, HoverActionsMode, HoverActionsCorner, asn1js, Convert, etc.)
- Prefix unused variables/params with underscore to satisfy no-unused-vars
- Add missing React hook dependencies (exhaustive-deps)
- Wrap handleNavigateUp in useCallback and selectedGroupMembers in useMemo
- Remove unused eslint-disable directives in jmap/client.ts
- Replace as any with typed casts in filter-store and smime-store tests
- Remove dead code (macOk assignment, unused now variable)
2026-03-26 18:35:56 +01:00
Linus Rath 733e99f094 feat: replace Date constructor with parseISO for improved date handling across calendar components #25 2026-03-26 17:42:44 +01:00
Linus Rath 9b25b6d03e fix: adjust contact and calendar page for mobile layout #103 2026-03-26 16:30:20 +01:00
Linus Rath 05f848ee23 feat: add hover actions display mode and corner selection options in settings #58 2026-03-26 13:04:34 +01:00
Linus Rath 4da1b7d8fc feat: update file upload size handling to use dynamic max size #96 2026-03-26 13:04:34 +01:00
Linus Rath 4ff2bff974 feat: enhance error handling for network-related issues in JMAPClient and auth-store #100 2026-03-26 13:04:34 +01:00
Linus Rath 47918aab1b feat: add option to disable conversation threading #97 2026-03-26 13:04:34 +01:00
Linus Rath e48672cf77 feat: implement drag-and-drop functionality for sidebar apps #101 2026-03-26 13:04:34 +01:00
Norbert KamańczykandLinus Rath 763abf43b9 feat: OAuth app passwords support and Stalwart probe fix
- Fix Stalwart probe and API routes for OAuth by passing Bearer token
  and JMAP headers from the client-side auth store
- Show only App Passwords and Email Client Setup sections for OAuth users,
  hiding Password Change, Display Name, TOTP, and Encryption
- Skip principal/crypto API fetches for OAuth to avoid 403 errors
- Add Email Client Setup section with copyable JMAP username

Made-with: Cursor
2026-03-25 23:33:03 +01:00
Linus RathandGitHub 649261c386 Delete COMMIT_MSG.txt 2026-03-25 23:13:25 +01:00
Linus Rath e02371d2bf Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-03-25 23:11:41 +01:00
Linus Rath 4af20b8dc0 refactor: update styling and color classes across components
- Changed error message styling in ICalImportModal to use new color classes.
- Updated task completion styling in TaskListView to use new success color classes.
- Modified selected styling in ContactListItem tests to reflect new background class.
- Adjusted duplicate warning styling in ContactImportDialog to use new warning color classes.
- Refactored background and border colors in CalendarInvitationBanner for various statuses.
- Updated email list item and viewer components to use new warning and success color classes.
- Refined styling for unread indicators in email components.
- Enhanced error fallback styling in error components to use new warning color classes.
- Updated filter rule modal button hover styles to use new destructive color classes.
- Added experimental feature descriptions in Plugins and Themes settings.
- Refined vacation settings validation warning styling to use new warning color classes.
- Updated toast component styles to use new color classes for different states.
- Introduced a new built-in theme 'Qui' with specific color variables.
- Adjusted email security status colors to use new warning color classes.
2026-03-25 23:11:30 +01:00
Linus Rath b5172802bb refactor: update styling and color classes across components
- Changed error message styling in ICalImportModal to use new color classes.
- Updated task completion styling in TaskListView to use new success color classes.
- Modified selected styling in ContactListItem tests to reflect new background class.
- Adjusted duplicate warning styling in ContactImportDialog to use new warning color classes.
- Refactored background and border colors in CalendarInvitationBanner for various statuses.
- Updated email list item and viewer components to use new warning and success color classes.
- Refined styling for unread indicators in email components.
- Enhanced error fallback styling in error components to use new warning color classes.
- Updated filter rule modal button hover styles to use new destructive color classes.
- Added experimental feature descriptions in Plugins and Themes settings.
- Refined vacation settings validation warning styling to use new warning color classes.
- Updated toast component styles to use new color classes for different states.
- Introduced a new built-in theme 'Qui' with specific color variables.
- Adjusted email security status colors to use new warning color classes.
2026-03-25 23:10:47 +01:00
Linus Rath f64306be5e feat: integrate policy checks for feature enablement in calendar, folder, and plugins settings 2026-03-25 11:06:35 +01:00
Linus Rath 05f6d61cea feat: reorganize admin layout and enhance dashboard with settings sections 2026-03-25 10:58:05 +01:00
Linus Rath 0174051f3e fix: enhance draft editing by fetching full email content when necessary #60 2026-03-25 10:36:47 +01:00
Linus Rath 136686f230 feat(settings): enhance error handling for settings operations and improve logging 2026-03-25 10:21:05 +01:00
Linus Rath 54981950b0 feat(plugins): resizable collapsible detail sidebar, auto-init on refresh
- Add email-detail-sidebar slot type for right-side plugin panels
- Plugin sidebar inside EmailViewer with ResizeHandle (200-500px, drag/dbl-click reset)
- Collapse/expand toggle with PanelRightClose/PanelRightOpen icons
- Call initializePlugins() on mount so plugins survive page refresh
- Fix plugin loader: call exposePluginExternals() before loading
- Fix CSP: add blob: to script-src for plugin bundle loading
- Fix enablePlugin: wire slot registration bridge before loading
- Fix email-banner PluginSlot: pass email via extraProps
- Fix sidebar-widget PluginSlot: move inside scrollable area
- Emit emailHooks.onEmailOpen/onEmailClose from selectEmail()
2026-03-25 00:44:05 +01:00
Linus Rath 29a222eef4 feat: add plugin/theme disable gates and move policy controls to their admin pages
- Add `pluginsEnabled` and `themesEnabled` master feature gates to FeatureGates
- Move theme policy UI (default theme, built-in/admin theme toggles, user uploads toggle) from policy page to themes admin page
- Add plugin policy UI (plugins enabled toggle) to plugins admin page
- Remove theme policy section and plugin/theme gates from policy page (with note directing to respective pages)
- Hide Themes and Plugins settings tabs when their feature gate is disabled
- Fix dark mode visibility of all admin toggle switches (bg-white → bg-background, increase off-state track opacity)
2026-03-25 00:44:04 +01:00
Linus Rath 76b21147e4 feat: add plugin/theme harness and admin dashboard
Plugin & Theme System:
- Add plugin type definitions, permissions (30+), and validation constants
- Add IndexedDB storage layer for plugin code, theme CSS, and previews
- Add theme CSS sanitization, injection, and safety validation
- Add HookBus event system with 130+ hooks across 20 domains
- Add plugin ZIP extraction and manifest validation with JS security checks
- Add sandboxed PluginAPI factory with scoped storage, logging, and permission gating
- Add plugin loader with blob URL dynamic import and auto-disable circuit breaker
- Add 3 built-in themes (Nord, Catppuccin, Solarized)
- Add Zustand plugin store with install/uninstall/enable/disable lifecycle
- Add PluginSlot, PluginSlotRenderer, and PluginErrorBoundary components
- Add plugins and themes settings UI panels
- Integrate plugin slots into email viewer, composer, navigation rail, sidebar, and context menu
- Extend theme store with custom theme installation and activation

Admin Dashboard:
- Add admin authentication with scrypt password hashing and AES-256-GCM sessions
- Add rate-limited login (5 attempts/15min per IP)
- Add config manager with admin override > env var > default priority
- Add settings policy system with feature gates and per-setting restrictions
- Add audit logging with rotation
- Add admin API routes (login, logout, config, policy, audit, password change)
- Add admin UI pages (login, dashboard, config, policy, audit)
- Add policy store for client-side feature gate enforcement
- Wire admin password initialization into server instrumentation

Tests:
- Add 139 tests across 10 test files covering all plugin/theme modules
2026-03-25 00:44:03 +01:00
chrilep 78bcf8db1b added missing language strings for added tasks feature 2026-03-24 15:48:17 +01:00
chrilep 39c43e3b3d Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-03-24 15:14:11 +01:00
chrilep 55ee99e811 adding german translation for tasks feature 2026-03-24 15:14:09 +01:00
Linus Rath 31e7ecceda Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-03-24 14:23:58 +01:00
Linus Rath d666bddbc4 fix: improve error handling for user settings retrieval in GET and settings store 2026-03-24 14:20:10 +01:00
chrilep 1cac71fae7 added russian language #86 2026-03-24 12:53:03 +01:00
chrilep 79ff95565d added russian language #86 2026-03-24 12:49:57 +01:00
chrilep 808328f5a4 added russian language issue #86 2026-03-24 11:35:53 +01:00
Linus RathandGitHub ddbeadd3c8 Merge pull request #93 from jeremiah/main
Update elliptic dependency from git to version 6.6.1
2026-03-23 21:50:45 +01:00
Jeremiah C. FosterandGitHub 501d69500d Update elliptic dependency from git to version 6.6.1
Kept getting  TypeError: Invalid comparator: with the git+URL.
2026-03-23 21:11:30 +01:00
Linus Rath e07365b066 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-03-23 18:05:04 +01:00
Linus Rath 2baf5e84a5 fix: enhance mailbox handling with path mapping and add tests for sieve generator #91 2026-03-23 18:04:07 +01:00
chrilep 024487bf10 chore: update version to 1.4.8 and enhance README with new features and demo mode 2026-03-23 17:43:57 +01:00
Linus Rath 97bc26a332 Merge dev into main - version 1.4.8 2026-03-23 17:08:19 +01:00
Linus Rath 55c8d430ca chore: update version to 1.4.8 2026-03-23 16:59:24 +01:00
Linus Rath ef45140d32 fix: enhance account settings with username and authentication method display #90 2026-03-23 16:37:56 +01:00
Linus Rath 0effb97691 refactor: fix bugs in calendar logic across duration parsing, RFC compliance, and event handling
- Fix buildDuration() trailing "T" producing invalid ISO 8601 durations
- Fix DURATION_RE missing week (W) support in alerts and invitation parsing
- Fix computeFireTime() end fallback when utcEnd is missing
- Fix recurrenceOverrides patch escaping per RFC 6901 (updateEvent/rsvpEvent)
- Fix layoutOverlappingEvents endMin overflow past 1440
- Fix addDurationToDate() to support weeks and use UTC methods for UTC inputs
- Fix getEffectiveAlerts() null guard on calendarIds
- Fix buildAllDayDuration() DST-safe day calculation using differenceInCalendarDays
- Fix participant matching to check calendarAddress and sendTo (not just email)
- Fix buildParticipantMap() using crypto.randomUUID() instead of hardcoded IDs
- Fix overnight preview negative endMin in week view
- Fix sendImipInvitation() to emit DURATION when utcEnd is absent
- Fix sendImipCancellation() to validate status before sending
- Fix createEvent() to remap all calendarIds for shared calendars
- Fix getCalendarTasks() to clone before mutating @type
- Fix importEvents() error matching to include 'duplicate' and 'conflict'
- Fix looksLikeReply() false positive by requiring organizer + responded attendee
- Fix alert offset regex to require T before minutes
- Fix handleDuplicate() to generate new UID
- Fix formatSnapTime() input clamping
- Replace console.log/error with debug.log/error/warn in iMIP functions
2026-03-23 16:22:15 +01:00
Linus Rath f7ee204262 feat: add support for marking emails as answered or forwarded and update UI accordingly 2026-03-23 15:45:37 +01:00
Linus Rath 0c1f182b6b fix: detect tasks created by external CalDAV clients (Thunderbird)
Tasks created in Thunderbird via CalDAV were not visible because
getCalendarTasks() used a strict @type === 'Task' check. Stalwart
may not set @type when converting VTODO from CalDAV to JMAP.

- Use case-insensitive @type matching for server variations
- Add fallback heuristic: detect tasks by presence of 'progress'
  property (exclusive to JSCalendar Task, never on Event objects)
- Normalize @type to 'Task' on detected tasks for consistent
  downstream handling
- Refresh task store on CalendarEvent state changes so tasks
  created externally appear without manual page refresh

Fixes #84
2026-03-24 14:54:37 +01:00
Linus Rath 13010c158d feat: implement path prefix handling for OAuth and login redirects 2026-03-24 14:44:42 +01:00
Linus Rath de26e6da2e feat: enhance identity selection by supporting sub-addressing in email options 2026-03-24 14:33:50 +01:00
Linus Rath ddb3422852 feat: add default mail program settings with localization support 2026-03-23 23:07:19 +01:00
Linus RathandGitHub d9a2529261 Update badge links in README.md 2026-03-22 17:25:29 +01:00
Linus RathandGitHub e70224317d Update Discord badge link in README.md 2026-03-22 17:16:10 +01:00
Linus RathandGitHub a9ecf164ab Add Discord badge to README 2026-03-22 17:15:47 +01:00
Linus RathandGitHub 0db3cbc959 Merge pull request #82 from harrytang/feature/new-email-per-page-option
feat: add 10 as an additional Emails Per Page option
2026-03-22 12:39:15 +01:00
Harry Tang 387273288c feat: add 10 as an additional Emails Per Page option 2026-03-22 13:15:23 +02:00
Linus Rath 8eff9fdfab feat: add notification settings with sound picker and preview 2026-03-22 01:55:21 +01:00
Linus Rath d915e5fb64 feat: add all multi-part TLDs for domain validation #81 2026-03-21 21:33:55 +01:00
Linus Rath 5530cfe7fe Merge dev into main - version 1.4.7 2026-03-21 20:46:33 +01:00
Linus Rath 31eee4bab9 chore: bump version to 1.4.7 2026-03-21 20:45:57 +01:00
Linus Rath f04b97d52a fix: handle updates and deletions for synthetic JMAP IDs in calendar events 2026-03-21 20:45:23 +01:00
Linus Rath df272e38ef feat: add resizable image component and rich text editor with image upload support 2026-03-21 20:45:23 +01:00
Linus Rath a835af2d71 fix: update .env.example to clarify Docker volume mounting for settings data directory 2026-03-21 20:45:22 +01:00
Linus Rath 30284859c7 feat: add task management features to calendar 2026-03-21 20:45:22 +01:00
Linus Rath 64c3d7e384 fix: update getContacts test to use mockFetchOnce 2026-03-21 20:45:20 +01:00
Linus Rath e6d09546b1 fix: extend CryptoEngine to support legacy algorithms and integrate with LinerEngine for decryption 2026-03-21 20:45:20 +01:00
Linus Rath 83a0a1e235 feat: add non-interactive SSO login flow for embedded/iframe deployments (closes #69) 2026-03-21 20:45:19 +01:00
Linus Rath 7c3c3b5f7b fix: handle synthetic ID errors when updating calendar events with fallback to destroy and recreate 2026-03-21 20:45:18 +01:00
Linus Rath 8b1b3ad57b fix: update iframe sandbox attributes to allow popups to escape sandbox 2026-03-21 20:45:18 +01:00
Linus Rath afefbb8d46 feat: add expanded visual view for filter rules
- Add VisualRuleSummary component showing conditions and actions as
  labeled inline pills with IF/THEN flow layout
- Add expandedFilterView toggle to settings store (persisted)
- Fix RuleSummary to allow multi-line wrapping instead of truncating
- Use items-start on rule cards so drag handle and toggle align to top
- Add translations for expanded view keys in all 8 locales
2026-03-21 20:45:17 +01:00
Linus Rath 4c2d185be4 fix: refactor logout to use synchronous flow with full page redirect
- Rewrite logout() from async to synchronous to prevent React re-renders with stale state
- Replace router.push('/login') with redirectToLogin() (window.location.replace) in all page auth guards for reliable navigation in Edge/Safari
- Add performFullLogout() helper that clears auth state, feature stores, and localStorage
- Fix persist middleware partialize to return {} when not authenticated, preventing state resurrection
- Use keepalive fetch for background cookie/token cleanup so redirect fires immediately
- Remove unused useRouter imports from page.tsx and contacts/page.tsx
- Simplify all page logout handlers to directly call logout()

Fixes #63
2026-03-21 20:45:17 +01:00
Linus Rath c73940e22a feat: add option to show week numbers in mini-calendar 2026-03-21 20:45:16 +01:00
Linus Rath 09eda86d3f fix: add missing translation keys across all locales 2026-03-21 20:45:15 +01:00
Linus Rath 0d28d811a8 feat: support uploading folders via drag-and-drop and toolbar button 2026-03-21 20:45:14 +01:00
Linus RathandGitHub 45a485a1fa Merge pull request #71 from xnilsit/workflow-on-release
feat: re-add release workflow
2026-03-21 11:20:58 +01:00
xnilsit bc202498d5 feat: re-add release workflow 2026-03-21 11:06:14 +01:00
Linus RathandGitHub 721556e777 Update version badge to 1.4.6 2026-03-21 03:29:43 +01:00
Linus RathandGitHub 8c9cf3a66b Bump app version from 1.4.3 to 1.4.6 2026-03-21 03:29:17 +01:00
Linus Rath b88026de82 Merge dev into main - version 1.4.6 2026-03-21 03:08:47 +01:00
Linus Rath 6ed8ae5812 chore: bump version to 1.4.6 2026-03-21 03:08:19 +01:00
Linus Rath 089583a9ef feat(contacts): add no-category filter, drag-drop to category, and category combo box
- Add 'No Category' sidebar item to filter uncategorized contacts
- Categories section now always visible (not just when keywords exist)
- Add drag-and-drop support on category items in sidebar to assign keywords
- Fix effectAllowed mismatch (move -> copyMove) for category drop targets
- Replace plain text categories input with combo box in contact edit form
  - Shows existing categories as clickable suggestions
  - Displays assigned categories as removable badges
  - Supports adding new categories inline
- Add translations for all 8 locales
2026-03-21 03:04:36 +01:00
Linus Rath 2d834213ee feat: enhance certificate extraction and legacy PBE support in crypto engine 2026-03-21 02:48:12 +01:00
Linus Rath 439a4dbe8a feat: add hover actions for emails and update settings for quick actions 2026-03-21 02:31:42 +01:00
Linus Rath 8350bad2a6 fix: adjust padding and size of sidebar buttons for improved layout 2026-03-21 02:06:52 +01:00
Linus Rath 2d56cc9be9 feat: implement keyword migration functionality and update related components 2026-03-21 01:58:48 +01:00
Linus Rath 2547c10060 feat: add demo data for emails, files, filters, identities, mailboxes, vacation responses, and JMAP client interface
- Created demo emails with various states (inbox, sent, drafts, trash, etc.) in `emails.ts`.
- Added demo file nodes representing directories and files in `files.ts`.
- Implemented demo Sieve capabilities and scripts in `filters.ts`.
- Defined demo identities for users in `identities.ts`.
- Established demo mailboxes with permissions and counts in `mailboxes.ts`.
- Created a demo vacation response in `vacation.ts`.
- Introduced a comprehensive JMAP client interface in `client-interface.ts` to standardize interactions with the JMAP API.
2026-03-21 01:38:42 +01:00
Linus RathandGitHub 01779fa59e Update SHA tag prefix in docker-publish.yml
Fix the tag prefix format for SHA in Docker publish workflow.
2026-03-21 00:15:01 +01:00
Linus RathandGitHub fc38427ed0 Merge pull request #68 from xnilsit/main
feat: add separate docker build for releases
2026-03-21 00:08:49 +01:00
xnilsit 3cbfb70860 feat: also build images for dev branch 2026-03-21 00:08:32 +01:00
xnilsit c32b740dac chore: rename release workflow 2026-03-20 23:11:41 +01:00
xnilsit a02091a7ad fix: don't tag releases with hash 2026-03-20 22:48:37 +01:00
xnilsit bc311adf6a feat: add separate docker build for releases 2026-03-20 22:35:37 +01:00
Linus Rath 8aca1623f4 Merge dev into main - version 1.4.5 (build fix) 2026-03-20 18:17:53 +01:00
Linus Rath a8be40579e fix: add missing showTimeInMonthView and showOnMobile type definitions to settings store 2026-03-20 18:17:31 +01:00
Linus Rath 705b942800 Merge dev into main - version 1.4.5 2026-03-20 18:10:08 +01:00
Linus Rath d68b81e6b8 chore: bump version to 1.4.5 2026-03-20 18:10:00 +01:00
Linus Rath 616e4d018d fix: expand recurring events in CalendarEvent/query (closes #65)
Add expandRecurrences: true to CalendarEvent/query when a date range
filter is provided, so the JMAP server returns individual occurrences
of recurring events instead of only the master event.
2026-03-20 17:53:30 +01:00
Linus Rath 68e141b787 feat: add mobile visibility toggle for sidebar apps and update related components 2026-03-20 17:44:26 +01:00
Linus Rath 1e6f5e2c8c fix: correct JSX syntax in CalendarToolbar component 2026-03-20 17:25:01 +01:00
Linus Rath 9495b34430 feat: add prev/next navigation buttons and date label to desktop calendar toolbar
Closes #59
2026-03-20 17:23:22 +01:00
Linus Rath 65fc489b9c feat: add pending event preview functionality to calendar views and event modal 2026-03-20 17:21:44 +01:00
Linus Rath bd686c092c fix: validate event start field when fetching calendar events 2026-03-20 17:11:36 +01:00
Linus Rath 6cff98ddb8 feat: implement pagination for fetching contacts and add maxObjectsInGet capability 2026-03-20 16:47:27 +01:00
Linus Rath dcc35335f5 feat: add setting to show event start time in month view 2026-03-20 16:42:07 +01:00
Linus Rath 8a54ae2456 feat: add NotFound component to handle 404 errors and redirect unauthenticated users 2026-03-20 16:31:57 +01:00
Linus Rath e26654a005 fix: enhance account switching logic and clear stores on account change 2026-03-20 16:29:32 +01:00
Linus Rath c1c06c68bb fix: improve draft handling in email composer and enhance session cookie verification logic 2026-03-20 15:45:25 +01:00
Linus Rath 74cf642182 Merge branch 'dev' 2026-03-20 00:16:41 +01:00
Linus Rath c5b1731a63 feat: add attachment position setting in email settings
- Introduced a new setting for attachment position in email settings, allowing users to choose between displaying attachments beside the sender or below the header.
- Updated the settings store to include the new attachment position type and default value.
- Added translations for the new setting in multiple languages (de, en, es, fr, it, ja, nl, pt).
2026-03-19 21:14:18 +01:00
Linus Rath 40cf164df3 fix: update calendar agenda view to auto-scroll to today's events and ensure today's date is included in the groups 2026-03-19 19:25:34 +01:00
Linus Rath ff56245db8 fix: improve account restoration logic and handle stale accounts in auth store 2026-03-19 19:21:08 +01:00
Linus Rath 9b4de4d152 fix: remove claude directory from .dockerignore and .gitignore 2026-03-19 19:06:31 +01:00
Linus Rath 0c9e60db8b fix: update flatted to 3.4.2 2026-03-19 19:01:40 +01:00
Linus Rath def8ee89fa Merge branch 'dev' 2026-03-19 18:57:57 +01:00
Linus Rath e7e07a38d7 fix: use native ARM runners instead of QEMU for Docker builds 2026-03-19 18:57:17 +01:00
Linus Rath a009e5ae32 fix: enhance health check functionality with detailed memory diagnostics and stable liveness probe 2026-03-19 18:50:59 +01:00
423 changed files with 67120 additions and 98833 deletions
-2
View File
@@ -5,9 +5,7 @@ node_modules
.env* .env*
!.env.example !.env.example
!.env.dev.example !.env.dev.example
.claude/
scripts/ scripts/
TODO.md TODO.md
CLAUDE.md
*.md *.md
!README.md !README.md
+3 -3
View File
@@ -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
+80 -23
View File
@@ -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,12 +6,19 @@
# 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) # URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
JMAP_SERVER_URL=https://your-jmap-server.com JMAP_SERVER_URL=https://your-jmap-server.com
# Allow users to specify a custom JMAP server URL on the login form.
# When enabled, a "JMAP Server" field appears on the login page.
# Users can connect to any JMAP-compatible server.
# NOTE: External JMAP servers must include this domain in their CORS
# Access-Control-Allow-Origin header, or browser requests will be blocked.
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
# ============================================================================= # =============================================================================
# Stalwart Mail Server Integration # Stalwart Mail Server Integration
# ============================================================================= # =============================================================================
@@ -20,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)
# ============================================================================= # =============================================================================
@@ -41,6 +43,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# OAuth client secret (server-side only, never exposed to the browser) # OAuth client secret (server-side only, never exposed to the browser)
# OAUTH_CLIENT_SECRET=your-client-secret # OAUTH_CLIENT_SECRET=your-client-secret
# Alternatively, you can specify the path to a file containing the OAuth client secret.
# OAUTH_CLIENT_SECRET_FILE=/oauth-client-secret
# OpenID Connect issuer URL for discovery # OpenID Connect issuer URL for discovery
# OAUTH_ISSUER_URL=https://your-idp.example.com # OAUTH_ISSUER_URL=https://your-idp.example.com
@@ -53,6 +57,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Required for both "Remember me" and settings sync features. # Required for both "Remember me" and settings sync features.
# Generate with: openssl rand -base64 32 # Generate with: openssl rand -base64 32
# SESSION_SECRET=your-secret-key-here # SESSION_SECRET=your-secret-key-here
# Alternatively, you can specify the path to a file containing the session secret.
# SESSION_SECRET_FILE=/session-secret
# ============================================================================= # =============================================================================
# Settings Sync # Settings Sync
@@ -64,7 +70,10 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# SETTINGS_SYNC_ENABLED=true # SETTINGS_SYNC_ENABLED=true
# Directory for storing encrypted settings files (default: ./data/settings). # Directory for storing encrypted settings files (default: ./data/settings).
# For Docker, mount a persistent volume at this path. # For Docker, the working directory is /app, so the default resolves to
# /app/data/settings - mount a persistent volume there:
# volumes:
# - bulwark-settings:/app/data/settings
# SETTINGS_DATA_DIR=./data/settings # SETTINGS_DATA_DIR=./data/settings
# ============================================================================= # =============================================================================
@@ -92,43 +101,91 @@ 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
# =============================================================================
# Extension Directory / Marketplace
# =============================================================================
# URL of the BulwarkMail extension directory for the admin marketplace.
# Set this to enable browsing and installing plugins/themes from the directory.
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
# ============================================================================= # =============================================================================
# Legacy Build-time Variables (still supported as fallback) # Legacy Build-time Variables (still supported as fallback)
# ============================================================================= # =============================================================================
+4
View File
@@ -0,0 +1,4 @@
# Bulwark Webmail Funding configuration
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
github: [bulwarkmail]
@@ -0,0 +1,118 @@
name: Publish Docker Image on Release
on:
release:
types: [published]
workflow_dispatch:
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=semver,pattern=v{{version}}
type=semver,pattern={{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern=v{{major}}
type=semver,pattern={{major}}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
+92 -19
View File
@@ -2,7 +2,9 @@ name: Publish Docker Image
on: on:
push: push:
branches: [main] branches:
- main
- dev
paths: paths:
- "Dockerfile" - "Dockerfile"
- ".dockerignore" - ".dockerignore"
@@ -17,12 +19,34 @@ on:
- "package.json" - "package.json"
- "package-lock.json" - "package-lock.json"
- ".github/workflows/docker-publish.yml" - ".github/workflows/docker-publish.yml"
tags: ["v*.*.*"]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build-and-push: prepare:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
image_name: ${{ steps.set.outputs.image_name }}
steps:
- name: Set image name
id: set
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "image_name=ghcr.io/${{ github.repository }}-beta" >> $GITHUB_OUTPUT
else
echo "image_name=ghcr.io/${{ github.repository }}-${{ github.ref_name }}" >> $GITHUB_OUTPUT
fi
build:
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -31,9 +55,6 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -48,21 +69,73 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: | images: ${{ needs.prepare.outputs.image_name }}
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
- name: Build and push - name: Build and push by digest
id: build
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: ${{ matrix.platform }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
cache-to: type=gha,mode=max cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: [prepare, build]
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ needs.prepare.outputs.image_name }}
tags: |
type=raw,value=latest
type=sha
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ needs.prepare.outputs.image_name }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ needs.prepare.outputs.image_name }}:${{ steps.meta.outputs.version }}
+68
View File
@@ -0,0 +1,68 @@
name: Publish Standalone Tarball on Release
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
arch: amd64
- os: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build standalone
run: npm run build
- name: Package tarball
env:
REF_NAME: ${{ github.ref_name }}
ARCH: ${{ matrix.arch }}
run: |
VERSION="${REF_NAME#v}"
TARBALL="bulwark-standalone-${VERSION}-linux-${ARCH}.tar.gz"
mkdir -p bulwark-standalone
cp -r .next/standalone/. bulwark-standalone/
cp -r .next/static bulwark-standalone/.next/static
cp -r public bulwark-standalone/public
tar -czf "$TARBALL" bulwark-standalone/
echo "TARBALL=$TARBALL" >> "$GITHUB_ENV"
- name: Upload release asset
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" "$TARBALL" --clobber
- name: Upload artifact (workflow_dispatch)
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
name: standalone-${{ matrix.arch }}
path: ${{ env.TARBALL }}
retention-days: 7
+4 -4
View File
@@ -42,11 +42,11 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# claude code
.claude/
# settings sync data # settings sync data
/data/ /data/
# Local private data # Local private data
/local-data/ /local-data/
# Sibling repos
/repos/
-7
View File
@@ -1,7 +0,0 @@
# Check for AI attribution in commit message
if grep -qi "co-authored-by.*claude\|co-authored-by.*anthropic\|claude code\|claude sonnet\|claude opus" "$1"; then
echo "❌ ERROR: Commit message contains AI attribution (Claude/Anthropic)"
echo " This violates project policy in CLAUDE.md"
echo " Remove 'Co-Authored-By: Claude' and similar references"
exit 1
fi
+359 -2
View File
@@ -1,5 +1,362 @@
# Changelog # Changelog
## 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)
Thank you for your donations:
**One-time**
- [@boris22100](https://github.com/boris22100)
- [@mkorthaus-private](https://github.com/mkorthaus-private)
**Monthly**
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
### Features
- **Contacts**: Store trusted senders in a dedicated JMAP address book (#176)
- **Email**: Warn on send when attachment keyword found but no file attached (#172)
- **Email**: Enable keyword reordering (#174) and multi-tag support per email (#173)
- **PWA**: Add "don't remind me again" option to install prompt
- **Auth**: Add `SESSION_SECRET_FILE` and `OAUTH_CLIENT_SECRET_FILE` environment variable support
- **Plugins**: Add `onAvatarResolve` plugin hook
- **Docker**: Publish main and dev branches as separate GHCR packages
### Fixes
- **Email**: Style links in plain text emails
- **Email**: Seed list history entry when app initializes on an email view
- **Email**: Remount composer on draft edit and preserve identity (#60)
- **Contacts**: Display contact names stored in `name.full` (#179)
- **Contacts**: Fix category dropdown blocking Save button in contact form (#177)
- **Contacts**: Resolve TS error from optional `name.components` in vCard parser
- **Search**: Search all folders when filtering emails by tag (#175)
- **Auth**: Include mount prefix in SSO redirect URI when app is served under a subpath
- **PWA**: Correct PWA icons with proper sizing, transparency, and dark/light mode support
## 1.4.12 (2026-04-09)
Thank you for your donations:
**One-time**
- [@mkorthaus-private](https://github.com/mkorthaus-private)
**Monthly**
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
### Features
- **PWA**: Add PWA support with service worker and install prompt
- **Calendar**: Add birthday calendar feature with settings and localization
- **Calendar**: Clamp February 29 birthdays in non-leap years
- **Identity**: Add automatic identity synchronization (#167)
- **Plugins**: Disable plugins by default and require admin approval
- **Plugins**: Replace auth header exposure with a secure HTTP proxy API for plugins
- **Auth**: Add configurable OAuth scopes and cookie security via environment variables
- **Email**: Sync mail view to browser history for back/forward navigation
- **Contacts**: Add ability to rename address books (#152)
- **UI**: Add version badge in settings
- **i18n**: Add Latvian (lv) locale support
- **i18n**: Add Polish language support
- **i18n**: Add Korean language support
- **i18n**: Add Simplified Chinese (zh_CN) locale support
### Fixes
- **Email**: Show recipient instead of sender in Sent and Drafts folder lists
- **Email**: Embed dropped images as data URLs and prevent duplicate attachments (#163)
- **Email**: Fix logic for marking email as read in EmailViewer
- **Email**: Fix archive action passing MouseEvent as argument
- **Mailbox**: Preserve search filters on push-triggered mailbox refresh (#164)
- **Mailbox**: Align shared account folders with primary folders (#151)
- **Mailbox**: Fetch mailboxes on mount in FolderSettings when store is empty
- **Mailbox**: Improve mailbox deletion error handling
- **Calendar**: Improve calendar event retrieval by batching requests to avoid server limits (#141)
- **Calendar**: Compute per-occurrence UTC start/end in recurrence expansion (#116)
- **Calendar**: Guard against undefined trigger in calendar event alert popover (#143)
- **Files**: Stream WebDAV PUT uploads to avoid buffering in memory (#162)
- **Files**: Prune recent files against server nodes on refresh (#146)
- **Files**: Fix file deletion logic to update recent files and handle errors (#146)
- **Files**: Extend file drop zone to fill remaining viewport height
- **Files**: Fallback to application/octet-stream for long MIME types
- **Security**: Replace unguarded crypto.randomUUID() with safe generateUUID() utility
- **Security**: Validate plugin HTTP post URL against origin with regression tests
- **Security**: Allow blob images in CSP for inline drag-and-drop (#163)
- **Auth**: Resolve settings sync identity mismatch for OAuth/SSO sessions (#127)
- **Contacts**: Fix address book ID namespacing for shared contacts in create and update operations (#133)
- **UI**: Fix focused mode expanding beyond screen bounds (#156)
- **API**: Handle 403 on principal fetch without console error
- **API**: Enhance error handling in Stalwart API responses
## 1.4.11 (2026-03-31)
### Features
- **Logging**: Add logging categories for better log management
### Fixes
- **Security**: Harden security with CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, and PDF iframe sandbox
- **Security**: Harden proxy authentication and SSRF defenses
- **Security**: Block plugins with dangerous JS patterns and enforce strict session secret length validation
- **S/MIME**: Add self-signed certificate detection and update status messages for S/MIME signatures
- **Email**: Auto-focus input fields in email composer for improved user experience (#126)
- **Mailbox**: Prevent orphaning of nested mailboxes by restricting deduplication to root-level folders
- **JMAP**: Strip server-immutable fields from updates before sending to JMAP (#128)
- **Files**: Update file feature disabled messages and add stability warnings
- **i18n**: Add missing translation keys to all non-English locales
## 1.4.10 (2026-03-31)
### Features
- **Plugins**: Add plugin configuration UI with schema-driven admin config page, calendar event action slot, and Jitsi Meet plugin
- **Calendar**: Implement client-side recurrence expansion for calendar events
- **Calendar**: Add iCal subscription editing and batch event import
- **Calendar**: Add hover preview settings and functionality
- **Calendar**: Add virtual location input for calendar events (#121)
- **Email**: Add reply-to addresses support in email composer
- **Email**: Add mail layout settings and update email list components
- **Email**: Add auto-select reply identity feature with settings and localization
- **Email**: Enhance compose functionality with button integration and translations
- **Filters**: Preserve activation state when updating or creating Sieve scripts to avoid deactivating server-managed vacation scripts
- **Filters**: Skip server-managed vacation script in Sieve script handling
- **Settings**: Add support for custom JMAP server endpoints in login and settings
- **Settings**: Add folder expansion state management and settings navigation
- **UI**: Add options to hide account switcher and show account avatars on navigation rail
- **i18n**: Add JMAP server endpoint labels and hints in multiple languages
- **i18n**: Add missing translation keys to all non-English locales
### Fixes
- **Security**: Patch critical auth bypass and credential leak vulnerabilities
- **Security**: Support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys and add diagnostic logging (#35)
- **Security**: Account isolation, auto-import signer certs, and no-key error handling (#35)
- **Calendar**: Fix JSCalendar 2.0 recurrenceRule single-object compatibility (#116)
- **Calendar**: Enhance calendar event handling to distinguish between events and tasks
- **Calendar**: Link existing events to target calendar during iCal import instead of skipping (#113)
- **Calendar**: Deduplicate UIDs during iCal import to prevent mass failures (#113)
- **Calendar**: Fix events disappearing after iCal import/subscription refresh
- **Calendar**: Enhance calendar event handling with full-day detection and layout adjustments
- **Calendar**: Use UTC timestamps for timed event rendering
- **Calendar**: Work around Stalwart not returning Task objects via CalendarEvent/query
- **Email**: Enhance email loading and deduplication logic in email store (#119)
- **Email**: Ensure draft editing function is called correctly in EmailViewer component (#60)
- **Email**: Match hover action background to selected row state
- **Email**: Align tag counts with mailbox folder counts in sidebar
- **Auth**: Handle 2FA/TOTP session expiry with basic auth (#117)
- **Mailbox**: Improve mailbox tree logic and enhance mailbox handling with logging (#118)
- **UI**: Improve dark mode handling for media elements and background images
- **UI**: Adjust account list spacing and remove push connection indicator
- **UI**: Fix nested button in theme card
## 1.4.9 (2026-03-27)
### Features
- **Admin**: Add Stalwart admin authentication, sidebar access, and a reorganized dashboard with dedicated policy sections
- **Plugins**: Add plugin/theme admin dashboard, harness tooling, forced enable or disable controls, managed policy enforcement, and a resizable detail sidebar
- **Filters**: Add vacation responder management with Sieve generation and parsing, UI integration, and improved sync preservation
- **Email**: Add plain text only composer mode, optional conversation threading disable, configurable hover action placement, and OAuth app password support
- **UI**: Add drag-and-drop customization for sidebar apps
- **Files**: Use dynamic server-configured maximum upload sizes
- **i18n**: Add Russian locale support and complete missing translation strings for recent task features
### Fixes
- **Calendar**: Improve date parsing and event normalization, prevent calendar page re-render loops, ensure unique ICal subscription IDs, and create all-day events with correct JSCalendar midnight handling
- **Email**: Respect the configured mark-as-read delay in EmailViewer and fetch full email content when needed while editing drafts (#60, #95)
- **Auth**: Improve network error handling, add JMAP rate limiting handling, and enhance settings retrieval and persistence diagnostics (#100, #104)
- **UI**: Improve mobile layout behavior on contacts and calendar pages (#103)
- **Themes**: Repair theme ZIP bundle handling and enforce admin theme locks correctly
- **Code Quality**: Resolve outstanding ESLint warnings across the codebase
## 1.4.8 (2026-03-23)
### Features
- **Email**: Add support for marking emails as answered or forwarded and display status icons in email list and thread views
- **Email**: Enhance identity selection by supporting sub-addressing (plus addressing) in email composer
- **Settings**: Add notification settings with sound picker, preview playback, and configurable alert sounds
- **Settings**: Add default mail program settings with localization support across all locales
- **Auth**: Implement path prefix handling for OAuth callbacks and login redirects, enabling reverse proxy deployments
- **Validation**: Add all multi-part TLDs for domain validation in favicon API (#81)
### Fixes
- **Calendar**: Fix bugs in duration parsing, RFC compliance, and event handling across calendar components
- **Calendar**: Detect tasks created by external CalDAV clients such as Thunderbird
- **Settings**: Enhance account settings with username and authentication method display (#90)
## 1.4.7 (2026-03-21)
### Features
- **Calendar**: Add task management features with task creation, editing, and status tracking
- **Calendar**: Add option to show week numbers in mini-calendar
- **Email**: Add resizable image component and rich text editor with image upload support
- **Files**: Support uploading folders via drag-and-drop and toolbar button
- **Filters**: Add expanded visual view for filter rules
- **Auth**: Add non-interactive SSO login flow for embedded/iframe deployments (#69)
- **DevOps**: Add separate Docker build workflow for releases and dev branch images
### Fixes
- **Calendar**: Handle updates and deletions for synthetic JMAP IDs in calendar events with fallback to destroy and recreate
- **Security**: Extend CryptoEngine to support legacy algorithms and integrate with LinerEngine for decryption
- **Auth**: Refactor logout to use synchronous flow with full page redirect
- **Email**: Update iframe sandbox attributes to allow popups to escape sandbox
- **i18n**: Add missing translation keys across all locales
- **Docker**: Update .env.example to clarify Docker volume mounting for settings data directory
## 1.4.6 (2026-03-21)
### Features
- **Demo**: Add full demo mode with fixture data for emails, calendars, contacts, files, filters, identities, mailboxes, and vacation responses
- **Demo**: Implement JMAP client interface abstraction to support demo and live backends
- **Contacts**: Add no-category filter, drag-and-drop to category, and category combo box in contact form
- **Email**: Add hover actions for emails with configurable quick-action buttons
- **Settings**: Implement keyword migration functionality for upgrading legacy email tags
- **Security**: Enhance S/MIME certificate extraction and add legacy PBE (password-based encryption) support
- **Tour**: Add interactive guided tour overlay for new user onboarding
### Fixes
- **Settings**: Add missing `showTimeInMonthView` and `showOnMobile` type definitions to settings store
- **UI**: Adjust padding and size of sidebar buttons for improved layout
## 1.4.5 (2026-03-20)
### Features
- **Calendar**: Add prev/next navigation buttons and date label to desktop calendar toolbar
- **Calendar**: Add pending event preview functionality to calendar views and event modal
- **Calendar**: Add setting to show event start time in month view
- **Contacts**: Implement pagination for fetching contacts with maxObjectsInGet capability
- **Email**: Add attachment position setting in email settings
- **Layout**: Add mobile visibility toggle for sidebar apps
- **Error**: Add NotFound component to handle 404 errors and redirect unauthenticated users
### Fixes
- **Auth**: Enhance account switching logic and clear stores on account change
- **Auth**: Improve account restoration logic and handle stale accounts
- **Auth**: Improve draft handling in email composer and enhance session cookie verification
- **Calendar**: Expand recurring events in CalendarEvent/query so individual occurrences are returned (#65)
- **Calendar**: Validate event start field when fetching calendar events
- **Calendar**: Auto-scroll agenda view to today's events and include today's date in groups
- **Calendar**: Correct JSX syntax in CalendarToolbar component
- **Dependencies**: Update flatted to 3.4.2
- **DevOps**: Use native ARM runners instead of QEMU for Docker builds
- **DevOps**: Enhance health check with detailed memory diagnostics and stable liveness probe
## 1.4.4 (2026-03-19) ## 1.4.4 (2026-03-19)
### Features ### Features
@@ -186,7 +543,7 @@
### 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)
@@ -196,7 +553,7 @@
- **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)
+15 -4
View File
@@ -1,9 +1,9 @@
<div align="center"> <div align="center">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20White%20and%20Color.svg" /> <source media="(prefers-color-scheme: dark)" srcset="public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" /> <source media="(prefers-color-scheme: light)" srcset="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="public/branding/Bulwark%20Logo%20with%20Lettering%20Dark%20Color.svg" alt="Bulwark Webmail" width="220" /> <img src="public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
</picture> </picture>
</div> </div>
@@ -12,6 +12,17 @@
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors. Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors.
## Join our Community
**New to the project or looking for a place to start?** You don't need to be an expert to contribute! Whether you need help setting up your environment, want to report a bug, or are interested in helping with translations, our Discord is the best place to connect.
* **Get Support:** Get real-time help with development hurdles.
* **Contribute:** Share ideas, suggest features, or help us improve documentation.
* **Collaborate:** Meet the team and other contributors working to make Bulwark better.
[**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
---
## Getting Started ## Getting Started
### Development Setup ### Development Setup
@@ -195,7 +206,7 @@ webmail/
- **Never commit sensitive data** (API keys, passwords, etc.) - **Never commit sensitive data** (API keys, passwords, etc.)
- **Sanitize user input** and email content - **Sanitize user input** and email content
- **Block external content** by default for privacy - **Block external content** by default for privacy
- Report security vulnerabilities privately - Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
## Questions? ## Questions?
+1
View File
@@ -26,6 +26,7 @@ RUN apk upgrade --no-cache && \
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN mkdir -p /app/data/settings /app/data/admin && chown -R nextjs:nodejs /app/data
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
+124
View File
@@ -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
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · 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
+121 -194
View File
@@ -8,12 +8,12 @@
# 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: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg)](LICENSE) [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.4.3-green.svg)](CHANGELOG.md) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue)](https://ghcr.io/bulwarkmail/webmail) [![Version](https://img.shields.io/badge/version-1.5.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div> </div>
@@ -21,185 +21,56 @@ 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> ## Overview
<summary>More screenshots</summary>
<table>
<tr>
<td width="50%">
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox — light mode"> 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:
**Light mode** — Full theme support with intelligent color transformation - **Mail** threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
- **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
</td> Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 14 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard.
<td width="50%">
<img src="screenshots/settings.png" width="100%" alt="Settings"> Full feature list: **[FEATURES.md](FEATURES.md)**.
**Settings** — Appearance, identities, filters, templates, and more
</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
- **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
- **Notifications** with configurable sound and alert persistence
- **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
- **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
- **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
--- ---
## Quick Start ## Quick Start
### Docker (recommended) ### Docker
```bash ```bash
docker run -d -p 3000:3000 \ docker run -d -p 3000:3000 \
@@ -211,7 +82,7 @@ Or with Docker Compose:
```bash ```bash
cp .env.example .env.local cp .env.example .env.local
# Edit .env.local set JMAP_SERVER_URL # Edit .env.local set JMAP_SERVER_URL
docker compose up -d docker compose up -d
``` ```
@@ -222,21 +93,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
@@ -246,26 +117,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_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik) OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
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`.
@@ -273,13 +143,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 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>
@@ -313,23 +247,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).
## Roadmap
See [ROADMAP.md](ROADMAP.md) for planned features and current status.
## 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.
+1 -1
View File
@@ -1 +1 @@
1.4.4 1.5.1
+65 -35
View File
@@ -4,6 +4,7 @@ import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { getPathPrefix } from "@/lib/browser-navigation";
import { Loader2, AlertCircle } from "lucide-react"; import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
@@ -13,7 +14,7 @@ function OAuthCallbackInner() {
const params = useParams(); const params = useParams();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const t = useTranslations("login"); const t = useTranslations("login");
const { loginWithOAuth } = useAuthStore(); const { loginWithOAuth, loginWithServerSso } = useAuthStore();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -32,44 +33,73 @@ function OAuthCallbackInner() {
} }
const savedState = sessionStorage.getItem("oauth_state"); const savedState = sessionStorage.getItem("oauth_state");
if (!state || state !== savedState) {
setError("invalid_state");
return;
}
const codeVerifier = sessionStorage.getItem("oauth_code_verifier"); if (savedState) {
const serverUrl = sessionStorage.getItem("oauth_server_url"); // Classic flow - sessionStorage has the PKCE state (same-tab OAuth)
if (!state || state !== savedState) {
setError("invalid_state");
return;
}
if (!codeVerifier || !serverUrl) { const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
setError("missing_params"); const serverUrl = sessionStorage.getItem("oauth_server_url");
return;
}
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; if (!codeVerifier || !serverUrl) {
setError("missing_params");
return;
}
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri) const prefix = getPathPrefix(params.locale as string);
.then((success) => { const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
if (success) {
sessionStorage.removeItem("oauth_state"); loginWithOAuth(serverUrl, code, codeVerifier, redirectUri)
sessionStorage.removeItem("oauth_code_verifier"); .then((success) => {
sessionStorage.removeItem("oauth_server_url"); if (success) {
sessionStorage.removeItem("oauth_add_account_mode"); sessionStorage.removeItem("oauth_state");
let redirectTo = `/${params.locale}`; sessionStorage.removeItem("oauth_code_verifier");
try { sessionStorage.removeItem("oauth_server_url");
const saved = sessionStorage.getItem('redirect_after_login'); sessionStorage.removeItem("oauth_add_account_mode");
if (saved) { let redirectTo = `${prefix}/${params.locale}`;
sessionStorage.removeItem('redirect_after_login'); try {
redirectTo = saved; const saved = sessionStorage.getItem('redirect_after_login');
} if (saved) {
} catch { /* sessionStorage may be unavailable */ } sessionStorage.removeItem('redirect_after_login');
router.push(redirectTo); redirectTo = saved;
} else { }
} catch { /* sessionStorage may be unavailable */ }
router.push(redirectTo);
} else {
setError("token_exchange_failed");
}
})
.catch(() => {
setError("token_exchange_failed"); setError("token_exchange_failed");
} });
}) } else if (state) {
.catch(() => { // Server-side SSO flow - state was stored in encrypted httpOnly cookie
setError("token_exchange_failed"); const ssoPrefix = getPathPrefix(params.locale as string);
}); loginWithServerSso(code, state)
.then((success) => {
if (success) {
let redirectTo = `${ssoPrefix}/${params.locale}`;
try {
const saved = sessionStorage.getItem('redirect_after_login');
if (saved) {
sessionStorage.removeItem('redirect_after_login');
redirectTo = saved;
}
} catch { /* sessionStorage may be unavailable */ }
router.push(redirectTo);
} else {
setError("token_exchange_failed");
}
})
.catch(() => {
setError("token_exchange_failed");
});
} else {
setError("invalid_state");
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
if (error) { if (error) {
@@ -87,7 +117,7 @@ function OAuthCallbackInner() {
</p> </p>
<Button <Button
variant="outline" variant="outline"
onClick={() => router.push(`/${params.locale}/login`)} onClick={() => router.push(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)}
> >
{t("oauth_error.back_to_login")} {t("oauth_error.back_to_login")}
</Button> </Button>
+461 -50
View File
@@ -7,11 +7,11 @@ import { Plus } from "lucide-react";
import { import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays, addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
format, parseISO, startOfDay, format, parseISO,
} from "date-fns"; } from "date-fns";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { isCalendarViewMode } from "@/stores/calendar-store"; import { isCalendarViewMode } from "@/stores/calendar-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
@@ -23,10 +23,17 @@ import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
import { CalendarWeekView } from "@/components/calendar/calendar-week-view"; import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
import { CalendarDayView } from "@/components/calendar/calendar-day-view"; import { CalendarDayView } from "@/components/calendar/calendar-day-view";
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view"; import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
import { TaskListView } from "@/components/calendar/task-list-view";
import { TaskToolbar } from "@/components/calendar/task-toolbar";
import { TaskModal } from "@/components/calendar/task-modal";
import { MiniCalendar } from "@/components/calendar/mini-calendar"; import { MiniCalendar } from "@/components/calendar/mini-calendar";
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel"; import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
import { EventModal } from "@/components/calendar/event-modal"; import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
import { EventDetailPopover } from "@/components/calendar/event-detail-popover"; import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
import { EventContextMenu } from "@/components/calendar/event-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";
@@ -35,9 +42,14 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
import { getEventStartDate } from "@/lib/calendar-utils";
import { useTaskStore } from "@/stores/task-store";
import { 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 { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
import { getUserParticipantId } from "@/lib/calendar-participants"; import { getUserParticipantId } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
type PendingScopeAction = type PendingScopeAction =
@@ -61,10 +73,13 @@ export default function CalendarPage() {
isLoading, isLoadingEvents, supportsCalendar, error, isLoading, isLoadingEvents, supportsCalendar, error,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent, fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
refreshAllSubscriptions, refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore(); } = useCalendarStore();
const { firstDayOfWeek, timeFormat } = useSettingsStore(); const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
const taskStore = useTaskStore();
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
const { identities } = useIdentityStore(); const { identities } = useIdentityStore();
const contacts = useContactStore((s) => s.contacts);
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month"; const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
const currentUserEmails = useMemo(() => const currentUserEmails = useMemo(() =>
@@ -75,6 +90,7 @@ export default function CalendarPage() {
const [showEventModal, setShowEventModal] = useState(false); const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null); const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>(); const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>(); const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
@@ -82,6 +98,12 @@ export default function CalendarPage() {
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null); const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null); const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null); const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
const [showTaskModal, setShowTaskModal] = useState(false);
const [editTask, setEditTask] = useState<import("@/lib/jmap/types").CalendarTask | null>(null);
const [mobileReturnToMonth, setMobileReturnToMonth] = useState(false);
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null);
const [swipeKey, setSwipeKey] = useState(0);
const hasFetched = useRef(false); const hasFetched = useRef(false);
// Sidebar resize state // Sidebar resize state
@@ -94,8 +116,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);
}); });
@@ -104,7 +143,7 @@ export default function CalendarPage() {
useEffect(() => { useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) { if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
router.push("/login"); redirectToLogin();
} else if (client && !supportsCalendar) { } else if (client && !supportsCalendar) {
router.push("/"); router.push("/");
} }
@@ -133,6 +172,18 @@ export default function CalendarPage() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [client, refreshAllSubscriptions]); }, [client, refreshAllSubscriptions]);
// 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(() => {
const wasShown = prevShowBirthdayRef.current;
prevShowBirthdayRef.current = showBirthdayCalendar;
if (!wasShown && showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
}
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
const dateRange = useMemo(() => { const dateRange = useMemo(() => {
const d = selectedDate; const d = selectedDate;
switch (normalizedViewMode) { switch (normalizedViewMode) {
@@ -156,14 +207,27 @@ export default function CalendarPage() {
start: format(d, "yyyy-MM-dd'T'00:00:00"), start: format(d, "yyyy-MM-dd'T'00:00:00"),
end: format(d, "yyyy-MM-dd'T'23:59:59"), end: format(d, "yyyy-MM-dd'T'23:59:59"),
}; };
case "agenda": case "agenda": {
// Agenda always starts from today at the earliest
const today = startOfDay(new Date());
const agendaStart = d >= today ? d : today;
return { return {
start: format(d, "yyyy-MM-dd'T'00:00:00"), start: format(agendaStart, "yyyy-MM-dd'T'00:00:00"),
end: format(addDays(d, 30), "yyyy-MM-dd'T'23:59:59"), end: format(addDays(agendaStart, 30), "yyyy-MM-dd'T'23:59:59"),
}; };
}
case "tasks":
return null;
} }
}, [selectedDate, normalizedViewMode, firstDayOfWeek]); }, [selectedDate, normalizedViewMode, firstDayOfWeek]);
// Fetch tasks when tasks view is active or when tasks are shown on calendar grid
useEffect(() => {
if (client && enableCalendarTasks && (normalizedViewMode === "tasks" || showTasksOnCalendar)) {
fetchTasksFn(client);
}
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
useEffect(() => { useEffect(() => {
if (client && calendars.length > 0 && dateRange) { if (client && calendars.length > 0 && dateRange) {
fetchEvents(client, dateRange.start, dateRange.end); fetchEvents(client, dateRange.start, dateRange.end);
@@ -177,6 +241,7 @@ export default function CalendarPage() {
case "week": next = subWeeks(selectedDate, 1); break; case "week": next = subWeeks(selectedDate, 1); break;
case "day": next = subDays(selectedDate, 1); break; case "day": next = subDays(selectedDate, 1); break;
case "agenda": next = subMonths(selectedDate, 1); break; case "agenda": next = subMonths(selectedDate, 1); break;
case "tasks": return;
} }
setSelectedDate(next); setSelectedDate(next);
setMiniMonth(next); setMiniMonth(next);
@@ -189,6 +254,7 @@ export default function CalendarPage() {
case "week": next = addWeeks(selectedDate, 1); break; case "week": next = addWeeks(selectedDate, 1); break;
case "day": next = addDays(selectedDate, 1); break; case "day": next = addDays(selectedDate, 1); break;
case "agenda": next = addMonths(selectedDate, 1); break; case "agenda": next = addMonths(selectedDate, 1); break;
case "tasks": return;
} }
setSelectedDate(next); setSelectedDate(next);
setMiniMonth(next); setMiniMonth(next);
@@ -207,6 +273,8 @@ export default function CalendarPage() {
const handleTouchEnd = useCallback((e: ReactTouchEvent) => { const handleTouchEnd = useCallback((e: ReactTouchEvent) => {
if (!touchStartRef.current || !isMobile) return; if (!touchStartRef.current || !isMobile) return;
// Week view has its own horizontal scroll, skip swipe navigation
if (normalizedViewMode === 'week') { touchStartRef.current = null; return; }
const touch = e.changedTouches[0]; const touch = e.changedTouches[0];
const dx = touch.clientX - touchStartRef.current.x; const dx = touch.clientX - touchStartRef.current.x;
const dy = touch.clientY - touchStartRef.current.y; const dy = touch.clientY - touchStartRef.current.y;
@@ -215,20 +283,34 @@ export default function CalendarPage() {
// Only trigger swipe if horizontal movement is dominant and fast enough // Only trigger swipe if horizontal movement is dominant and fast enough
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5 && elapsed < 400) { if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5 && elapsed < 400) {
if (dx > 0) navigatePrev(); if (dx > 0) {
else navigateNext(); setSwipeDirection('right');
navigatePrev();
} else {
setSwipeDirection('left');
navigateNext();
}
setSwipeKey(k => k + 1);
// Clear direction after animation completes
setTimeout(() => setSwipeDirection(null), 250);
} }
}, [isMobile, navigatePrev, navigateNext]); }, [isMobile, normalizedViewMode, navigatePrev, navigateNext]);
const handleSelectDate = useCallback((date: Date) => { const handleSelectDate = useCallback((date: Date) => {
setSelectedDate(date); setSelectedDate(date);
setMiniMonth(date); setMiniMonth(date);
// On mobile month view, tapping a date switches to day view // On mobile month view, tapping a date switches to day view
if (isMobile && normalizedViewMode === "month") { if (isMobile && normalizedViewMode === "month") {
setMobileReturnToMonth(true);
setViewMode("day"); setViewMode("day");
} }
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]); }, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
const navigateBackToMonth = useCallback(() => {
setMobileReturnToMonth(false);
setViewMode("month");
}, [setViewMode]);
const handleMiniMonthChange = useCallback((date: Date) => { const handleMiniMonthChange = useCallback((date: Date) => {
setMiniMonth(date); setMiniMonth(date);
setSelectedDate(date); setSelectedDate(date);
@@ -249,6 +331,34 @@ export default function CalendarPage() {
setShowEventModal(true); setShowEventModal(true);
}, []); }, []);
const openCreateTaskModal = useCallback(() => {
setEditTask(null);
setShowTaskModal(true);
}, []);
const openEditTaskModal = useCallback((task: import("@/lib/jmap/types").CalendarTask) => {
setEditTask(task);
setShowTaskModal(true);
}, []);
const handleSaveTask = useCallback(async (data: Partial<import("@/lib/jmap/types").CalendarTask>) => {
if (!client) return;
if (editTask) {
await taskStore.updateTask(client, editTask.id, data);
} else {
await taskStore.createTask(client, data);
}
setShowTaskModal(false);
setEditTask(null);
}, [client, editTask, taskStore]);
const handleDeleteTask = useCallback(async (id: string) => {
if (!client) return;
await taskStore.deleteTask(client, id);
setShowTaskModal(false);
setEditTask(null);
}, [client, taskStore]);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeDetail = useCallback(() => { const closeDetail = useCallback(() => {
@@ -263,19 +373,42 @@ 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 handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => { const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return;
if (calendarHoverPreview === 'off') return;
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
// Don't show hover popover if the sidebar is already open for this event // Don't show hover popover if the sidebar is already open for this event
if (showEventModal && editEvent?.id === event.id) return; if (showEventModal && editEvent?.id === event.id) return;
setDetailEvent(event); if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') {
setDetailAnchorRect(anchorRect); const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000;
}, [showEventModal, editEvent]); hoverTimerRef.current = setTimeout(() => {
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}, ms);
} else {
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}
}, [isMobile, calendarHoverPreview, showEventModal, editEvent]);
const handleHoverLeave = useCallback(() => { const handleHoverLeave = useCallback(() => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
hoverTimerRef.current = setTimeout(() => { hoverTimerRef.current = setTimeout(() => {
setDetailEvent(null); setDetailEvent(null);
setDetailAnchorRect(null); setDetailAnchorRect(null);
}, 200); }, 300);
}, []); }, []);
const handleEditFromDetail = useCallback(() => { const handleEditFromDetail = useCallback(() => {
@@ -312,6 +445,38 @@ 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">) => {
if (!event.start) {
return;
}
const eventDate = getEventStartDate({
start: event.start,
utcStart: event.utcStart ?? null,
showWithoutTime: event.showWithoutTime ?? false,
});
if (Number.isNaN(eventDate.getTime())) {
return;
}
setSelectedDate(eventDate);
setMiniMonth(eventDate);
}, [setSelectedDate]);
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => { const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => {
if (!client) { toast.error(t("notifications.event_error")); return; } if (!client) { toast.error(t("notifications.event_error")); return; }
try { try {
@@ -328,6 +493,9 @@ export default function CalendarPage() {
return; return;
} }
await updateEvent(client, editEvent.id, data, sendSchedulingMessages); await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
if (data.start) {
focusCalendarOnEvent({ start: data.start });
}
toast.success(t("notifications.event_updated")); toast.success(t("notifications.event_updated"));
} else { } else {
const created = await createEvent(client, data, sendSchedulingMessages); const created = await createEvent(client, data, sendSchedulingMessages);
@@ -335,6 +503,7 @@ export default function CalendarPage() {
toast.error(t("notifications.event_error")); toast.error(t("notifications.event_error"));
return; return;
} }
focusCalendarOnEvent(created);
if (sendSchedulingMessages) { if (sendSchedulingMessages) {
toast.success(t("notifications.invitation_sent")); toast.success(t("notifications.invitation_sent"));
} else { } else {
@@ -346,7 +515,7 @@ export default function CalendarPage() {
} catch { } catch {
toast.error(t("notifications.event_error")); toast.error(t("notifications.event_error"));
} }
}, [client, editEvent, createEvent, updateEvent, t]); }, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, t]);
const handleDuplicateEvent = useCallback(async (data: Partial<CalendarEvent>) => { const handleDuplicateEvent = useCallback(async (data: Partial<CalendarEvent>) => {
if (!client) { toast.error(t("notifications.event_error")); return; } if (!client) { toast.error(t("notifications.event_error")); return; }
@@ -356,6 +525,7 @@ export default function CalendarPage() {
toast.error(t("notifications.event_error")); toast.error(t("notifications.event_error"));
return; return;
} }
focusCalendarOnEvent(created);
toast.success(t("notifications.event_duplicated")); toast.success(t("notifications.event_duplicated"));
setEditEvent(created); setEditEvent(created);
setDefaultModalDate(undefined); setDefaultModalDate(undefined);
@@ -364,7 +534,7 @@ export default function CalendarPage() {
setShowEventModal(false); setShowEventModal(false);
setEditEvent(null); setEditEvent(null);
} }
}, [client, createEvent, t]); }, [client, createEvent, focusCalendarOnEvent, t]);
const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => { const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => {
if (!client) { toast.error(t("notifications.event_error")); return; } if (!client) { toast.error(t("notifications.event_error")); return; }
@@ -418,9 +588,22 @@ export default function CalendarPage() {
try { try {
if (type === "edit" && updates) { if (type === "edit" && updates) {
switch (scope) { switch (scope) {
case "this": case "this": {
await updateEvent(client, event.id, updates, sendScheduling); // Synthetic IDs (from expandRecurrences) can't be updated directly.
// Patch the master event's recurrenceOverrides instead.
const master = await findMasterEvent(event);
if (master && event.recurrenceId) {
const patchUpdates: Record<string, unknown> = {};
for (const [key, value] of Object.entries(updates)) {
if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue;
patchUpdates[`recurrenceOverrides/${event.recurrenceId}/${key}`] = value;
}
await updateEvent(client, master.id, patchUpdates as Partial<CalendarEvent>, sendScheduling);
} else {
await updateEvent(client, event.id, updates, sendScheduling);
}
break; break;
}
case "this_and_future": { case "this_and_future": {
const result = await truncateRecurrenceAtEvent(event); const result = await truncateRecurrenceAtEvent(event);
if (!result) { if (!result) {
@@ -478,9 +661,20 @@ export default function CalendarPage() {
toast.success(t("notifications.event_updated")); toast.success(t("notifications.event_updated"));
} else { } else {
switch (scope) { switch (scope) {
case "this": case "this": {
await deleteEvent(client, event.id, sendScheduling); // Synthetic IDs (from expandRecurrences) can't be destroyed directly.
// Exclude the instance via recurrenceOverrides on the master event.
const delMaster = await findMasterEvent(event);
if (delMaster && event.recurrenceId) {
await updateEvent(
client, delMaster.id,
{ [`recurrenceOverrides/${event.recurrenceId}`]: { excluded: true } } as Partial<CalendarEvent>,
);
} else {
await deleteEvent(client, event.id, sendScheduling);
}
break; break;
}
case "this_and_future": { case "this_and_future": {
const result = await truncateRecurrenceAtEvent(event); const result = await truncateRecurrenceAtEvent(event);
if (!result) { if (!result) {
@@ -536,7 +730,7 @@ export default function CalendarPage() {
if (!detailEvent || !client) return; if (!detailEvent || !client) return;
const start = parseISO(detailEvent.start); const start = parseISO(detailEvent.start);
const newStart = addDays(start, 1); const newStart = addDays(start, 1);
const data: Partial<CalendarEvent> = { const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
title: detailEvent.title, title: detailEvent.title,
description: detailEvent.description, description: detailEvent.description,
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"), start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
@@ -547,7 +741,7 @@ export default function CalendarPage() {
status: "confirmed", status: "confirmed",
freeBusyStatus: detailEvent.freeBusyStatus, freeBusyStatus: detailEvent.freeBusyStatus,
privacy: detailEvent.privacy, privacy: detailEvent.privacy,
}; });
if (detailEvent.locations) data.locations = structuredClone(detailEvent.locations); if (detailEvent.locations) data.locations = structuredClone(detailEvent.locations);
if (detailEvent.recurrenceRules) data.recurrenceRules = structuredClone(detailEvent.recurrenceRules); if (detailEvent.recurrenceRules) data.recurrenceRules = structuredClone(detailEvent.recurrenceRules);
if (detailEvent.alerts) data.alerts = structuredClone(detailEvent.alerts); if (detailEvent.alerts) data.alerts = structuredClone(detailEvent.alerts);
@@ -580,6 +774,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);
@@ -592,6 +853,7 @@ export default function CalendarPage() {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return; if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return;
if (target.getAttribute("contenteditable") === "true") return;
if (showEventModal || detailEvent) return; if (showEventModal || detailEvent) return;
switch (e.key) { switch (e.key) {
@@ -602,21 +864,68 @@ export default function CalendarPage() {
case "w": setViewMode("week"); break; case "w": setViewMode("week"); break;
case "d": setViewMode("day"); break; case "d": setViewMode("day"); break;
case "a": setViewMode("agenda"); break; case "a": setViewMode("agenda"); break;
case "k": if (enableCalendarTasks) setViewMode("tasks"); break;
case "n": openCreateModal(); break; case "n": openCreateModal(); break;
} }
}; };
window.addEventListener("keydown", handleKey); window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey);
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent]); }, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]);
const visibleEvents = useMemo(() => const birthdayEvents = useMemo(() => {
events.filter((e) => { if (!showBirthdayCalendar || !dateRange) return [];
if (!e.calendarIds) return false; return generateBirthdayEvents(contacts, dateRange.start, dateRange.end);
}, [showBirthdayCalendar, contacts, dateRange]);
const birthdayCalendarName = (() => {
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
})();
const allCalendars = useMemo(() => {
if (!showBirthdayCalendar) return calendars;
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
const visibleEvents = useMemo(() => {
const filtered = events.filter((e) => {
if (!e.start || !e.calendarIds) return false;
const calIds = Object.keys(e.calendarIds); const calIds = Object.keys(e.calendarIds);
return calIds.some((id) => selectedCalendarIds.includes(id)); return calIds.some((id) => selectedCalendarIds.includes(id));
}), });
[events, selectedCalendarIds] if (showBirthdayCalendar && selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
); return [...filtered, ...birthdayEvents];
}
return filtered;
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]);
useEffect(() => {
const hiddenEvents = events.filter((event) => {
if (!event.start || !event.calendarIds) {
return true;
}
return !Object.keys(event.calendarIds).some((calendarId) => selectedCalendarIds.includes(calendarId));
});
if (hiddenEvents.length === 0) {
return;
}
debug.log('calendar', 'Calendar visibility summary', {
totalEvents: events.length,
visibleEvents: visibleEvents.length,
hiddenEvents: hiddenEvents.length,
selectedCalendarIds,
hiddenSamples: hiddenEvents.slice(0, 5).map((event) => ({
id: event.id,
originalId: event.originalId,
title: event.title,
calendarIds: event.calendarIds,
originalCalendarIds: event.originalCalendarIds,
accountId: event.accountId,
})),
});
}, [events, selectedCalendarIds, visibleEvents]);
if (!isAuthenticated || !supportsCalendar) return null; if (!isAuthenticated || !supportsCalendar) return null;
@@ -636,14 +945,16 @@ export default function CalendarPage() {
<CalendarMonthView <CalendarMonthView
selectedDate={selectedDate} selectedDate={selectedDate}
events={visibleEvents} events={visibleEvents}
calendars={calendars} calendars={allCalendars}
onSelectDate={handleSelectDate} onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onCreateAtTime={openCreateModal} onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek} firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile} isMobile={isMobile}
pendingPreview={pendingPreview}
/> />
); );
case "week": case "week":
@@ -651,15 +962,19 @@ export default function CalendarPage() {
<CalendarWeekView <CalendarWeekView
selectedDate={selectedDate} selectedDate={selectedDate}
events={visibleEvents} events={visibleEvents}
calendars={calendars} calendars={allCalendars}
onSelectDate={handleSelectDate} onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onCreateAtTime={openCreateModal} onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek} firstDayOfWeek={firstDayOfWeek}
timeFormat={timeFormat} timeFormat={timeFormat}
isMobile={isMobile} isMobile={isMobile}
pendingPreview={pendingPreview}
tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined}
onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
/> />
); );
case "day": case "day":
@@ -667,13 +982,17 @@ export default function CalendarPage() {
<CalendarDayView <CalendarDayView
selectedDate={selectedDate} selectedDate={selectedDate}
events={visibleEvents} events={visibleEvents}
calendars={calendars} calendars={allCalendars}
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onCreateAtTime={openCreateModal} onCreateAtTime={openCreateModal}
timeFormat={timeFormat} timeFormat={timeFormat}
isMobile={isMobile} isMobile={isMobile}
pendingPreview={pendingPreview}
tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined}
onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
/> />
); );
case "agenda": case "agenda":
@@ -681,18 +1000,46 @@ export default function CalendarPage() {
<CalendarAgendaView <CalendarAgendaView
selectedDate={selectedDate} selectedDate={selectedDate}
events={visibleEvents} events={visibleEvents}
calendars={calendars} calendars={allCalendars}
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent} onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave} onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
timeFormat={timeFormat} timeFormat={timeFormat}
/> />
); );
case "tasks":
return (
<div className="flex flex-col h-full">
<TaskToolbar
filter={taskStore.filter}
showCompleted={taskStore.showCompleted}
onFilterChange={taskStore.setFilter}
onShowCompletedChange={taskStore.setShowCompleted}
onCreateTask={openCreateTaskModal}
/>
<TaskListView
tasks={taskStore.tasks}
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
filter={taskStore.filter}
showCompleted={taskStore.showCompleted}
onSelectTask={openEditTaskModal}
onToggleComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
selectedTaskId={taskStore.selectedTaskId}
onQuickCreate={(title) => {
if (client) {
taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [calendars[0]?.id ?? ""]: true } });
}
}}
/>
</div>
);
} }
})(); })();
return ( return (
<div className="relative flex-1 flex flex-col overflow-hidden"> <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{viewContent} {viewContent}
{isLoadingEvents && calendars.length > 0 && events.length === 0 && ( {isLoadingEvents && calendars.length > 0 && events.length === 0 && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
@@ -704,7 +1051,7 @@ export default function CalendarPage() {
}; };
return ( return (
<div className="flex h-dvh bg-background overflow-hidden"> <div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
{/* Left Navigation Rail */} {/* Left Navigation Rail */}
{!isMobile && ( {!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
@@ -712,7 +1059,7 @@ export default function CalendarPage() {
collapsed collapsed
quota={quota} quota={quota}
isPushConnected={isPushConnected} isPushConnected={isPushConnected}
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }} onLogout={logout}
onManageApps={handleManageApps} onManageApps={handleManageApps}
onInlineApp={handleInlineApp} onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp} onCloseInlineApp={closeInlineApp}
@@ -742,15 +1089,21 @@ export default function CalendarPage() {
onChangeMonth={handleMiniMonthChange} onChangeMonth={handleMiniMonthChange}
events={events} events={events}
firstDayOfWeek={firstDayOfWeek} firstDayOfWeek={firstDayOfWeek}
showWeekNumbers={showWeekNumbers}
/> />
<CalendarSidebarPanel <CalendarSidebarPanel
calendars={calendars} calendars={allCalendars}
selectedCalendarIds={selectedCalendarIds} selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility} onToggleVisibility={toggleCalendarVisibility}
onColorChange={client ? (calendarId, color) => { onColorChange={client ? (calendarId, color) => {
if (calendarId === BIRTHDAY_CALENDAR_ID) {
updateSetting('birthdayCalendarColor', color);
return;
}
updateCalendar(client, calendarId, { color }); updateCalendar(client, calendarId, { color });
} : undefined} } : undefined}
onSubscribe={() => setShowSubscriptionModal(true)} onSubscribe={() => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client} client={client}
/> />
</div> </div>
@@ -767,29 +1120,41 @@ export default function CalendarPage() {
)} )}
{!inlineApp && ( {!inlineApp && (
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0 min-h-0">
<CalendarToolbar <CalendarToolbar
selectedDate={selectedDate} selectedDate={selectedDate}
viewMode={normalizedViewMode} viewMode={normalizedViewMode}
onPrev={navigatePrev} onPrev={navigatePrev}
onNext={navigateNext} onNext={navigateNext}
onToday={goToToday} onToday={goToToday}
onViewModeChange={setViewMode} onViewModeChange={(mode) => { setMobileReturnToMonth(false); setViewMode(mode); }}
onCreateEvent={() => openCreateModal()} onCreateEvent={() => openCreateModal()}
onImport={() => setShowImportModal(true)} onImport={() => setShowImportModal(true)}
onSubscribe={() => setShowSubscriptionModal(true)} onSubscribe={() => setShowSubscriptionModal(true)}
isMobile={isMobile} isMobile={isMobile}
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
calendars={calendars} calendars={calendars}
selectedCalendarIds={selectedCalendarIds} selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility} onToggleVisibility={toggleCalendarVisibility}
enableCalendarTasks={enableCalendarTasks}
/> />
<div <div
className="flex flex-1 overflow-hidden relative" className="flex flex-1 overflow-hidden relative"
data-tour="calendar-view"
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
> >
{renderView()} <div
key={swipeKey}
className={cn(
"flex flex-1 min-w-0",
isMobile && swipeDirection === 'left' && "animate-slide-in-right",
isMobile && swipeDirection === 'right' && "animate-slide-in-left",
)}
>
{renderView()}
</div>
{/* Desktop event panel */} {/* Desktop event panel */}
{!isMobile && showEventModal && ( {!isMobile && showEventModal && (
@@ -804,13 +1169,29 @@ export default function CalendarPage() {
onDelete={handleDeleteEvent} onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent} onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp} onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); }} onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
onPreviewChange={setPendingPreview}
currentUserEmails={currentUserEmails} currentUserEmails={currentUserEmails}
isMobile={false} isMobile={false}
/> />
</div> </div>
)} )}
{/* Desktop task panel */}
{!isMobile && showTaskModal && (
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
<TaskModal
key={editTask?.id ?? 'new-task'}
task={editTask}
calendars={calendars}
onSave={handleSaveTask}
onDelete={handleDeleteTask}
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
isMobile={false}
/>
</div>
)}
{/* Floating Create Event Button (mobile) */} {/* Floating Create Event Button (mobile) */}
{isMobile && ( {isMobile && (
<Button <Button
@@ -827,12 +1208,30 @@ export default function CalendarPage() {
{/* Mobile Bottom Navigation */} {/* Mobile Bottom Navigation */}
{isMobile && ( {isMobile && (
<NavigationRail <div className="shrink-0">
orientation="horizontal" <NavigationRail
onManageApps={handleManageApps} orientation="horizontal"
onInlineApp={handleInlineApp} onManageApps={handleManageApps}
onCloseInlineApp={closeInlineApp} onInlineApp={handleInlineApp}
activeAppId={inlineApp?.id ?? null} onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
</div>
)}
{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!)}
/> />
)} )}
@@ -887,6 +1286,18 @@ export default function CalendarPage() {
/> />
)} )}
{editingSubscription && client && (() => {
const sub = icalSubscriptions.find(s => s.id === editingSubscription);
if (!sub) return null;
return (
<ICalSubscriptionModal
client={client}
editSubscription={sub}
onClose={() => setEditingSubscription(null)}
/>
);
})()}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<RecurrenceScopeDialog <RecurrenceScopeDialog
isOpen={!!pendingScopeAction} isOpen={!!pendingScopeAction}
+183 -39
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { ArrowLeft, Users } from "lucide-react"; import { ArrowLeft, Users } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -14,18 +13,20 @@ import { ContactGroupForm } from "@/components/contacts/contact-group-form";
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail"; import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar"; import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog"; import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { RenameDialog } from "@/components/files/rename-dialog";
import { exportContacts } from "@/components/contacts/contact-export"; import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils"; import { cn, generateUUID } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail"; import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
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 { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import type { ContactCard, AddressBook } from "@/lib/jmap/types";
type View = type View =
@@ -39,7 +40,6 @@ type View =
| "bulk-add-to-group"; | "bulk-add-to-group";
export default function ContactsPage() { export default function ContactsPage() {
const router = useRouter();
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
@@ -74,12 +74,16 @@ export default function ContactsPage() {
bulkDeleteContacts, bulkDeleteContacts,
bulkAddToGroup, bulkAddToGroup,
moveContactToAddressBook, moveContactToAddressBook,
renameAddressBook,
renameKeyword,
importContacts, importContacts,
} = useContactStore(); } = useContactStore();
const [view, setView] = useState<View>("list"); const [view, setView] = useState<View>("list");
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all"); const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | 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);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -94,13 +98,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);
}); });
@@ -109,9 +120,9 @@ export default function ContactsPage() {
useEffect(() => { useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) { if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
router.push("/login"); redirectToLogin();
} }
}, [initialCheckDone, isAuthenticated, authLoading, router]); }, [initialCheckDone, isAuthenticated, authLoading]);
useEffect(() => { useEffect(() => {
if (client && supportsSync && !hasFetched.current) { if (client && supportsSync && !hasFetched.current) {
@@ -120,15 +131,40 @@ 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;
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null; const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : []; const selectedGroupMembers = useMemo(() => selectedGroupId ? getGroupMembers(selectedGroupId) : [], [selectedGroupId, getGroupMembers]);
// Collect all unique keywords across contacts
const allKeywords = useMemo(() => {
const kws = new Set<string>();
for (const contact of individuals) {
if (!contact.keywords) continue;
for (const [kw, active] of Object.entries(contact.keywords)) {
if (active) kws.add(kw);
}
}
return Array.from(kws).sort((a, b) => a.localeCompare(b));
}, [individuals]);
// Contacts to display based on active category // Contacts to display based on active category
const displayedContacts = useMemo(() => { const displayedContacts = useMemo(() => {
if (activeCategory === "all") return individuals; if (activeCategory === "all") return individuals;
if (activeCategory === "uncategorized") {
return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0);
}
if ("addressBookId" in activeCategory) { if ("addressBookId" in activeCategory) {
const bookId = activeCategory.addressBookId; const bookId = activeCategory.addressBookId;
return individuals.filter(c => { return individuals.filter(c => {
@@ -143,20 +179,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 ("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();
@@ -182,6 +204,31 @@ export default function ContactsPage() {
} }
}, [client, moveContactToAddressBook, t]); }, [client, moveContactToAddressBook, t]);
const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => {
if (!client && supportsSync) return;
try {
for (const contactId of contactIds) {
const contact = contacts.find(c => c.id === contactId);
if (!contact) continue;
const existingKeywords = contact.keywords || {};
if (existingKeywords[keyword]) continue; // already has this keyword
const updatedKeywords = { ...existingKeywords, [keyword]: true };
if (supportsSync && client) {
await updateContact(client, contactId, { keywords: updatedKeywords });
} else {
updateLocalContact(contactId, { keywords: updatedKeywords });
}
}
const msg = contactIds.length === 1
? t("category_added", { name: keyword })
: t("category_added_plural", { count: contactIds.length, name: keyword });
toast.success(msg);
} catch (error) {
console.error('Failed to add contacts to category:', error);
toast.error(t("toast.error_update"));
}
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => { const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
return importContacts( return importContacts(
supportsSync && client ? client : null, supportsSync && client ? client : null,
@@ -204,9 +251,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"),
@@ -217,25 +262,67 @@ 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);
toast.success(t("toast.created")); toast.success(t("toast.created"));
} else { } else {
const localContact: ContactCard = { const localContact: ContactCard = {
id: `local-${crypto.randomUUID()}`, id: `local-${generateUUID()}`,
addressBookIds: {}, addressBookIds: {},
...data, ...data,
}; };
@@ -268,7 +355,7 @@ export default function ContactsPage() {
} }
}; };
const handleSelectGroup = (id: string) => { const _handleSelectGroup = (id: string) => {
setSelectedGroupId(id); setSelectedGroupId(id);
setActiveCategory({ groupId: id }); setActiveCategory({ groupId: id });
setView("group-detail"); setView("group-detail");
@@ -430,7 +517,7 @@ export default function ContactsPage() {
const renderRightPanel = () => { const renderRightPanel = () => {
switch (view) { switch (view) {
case "create": case "create":
return <ContactForm addressBooks={addressBooks} onSave={handleSaveNew} onCancel={handleCancel} />; return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
case "edit": case "edit":
if (!selectedContact) return null; if (!selectedContact) return null;
@@ -438,6 +525,7 @@ export default function ContactsPage() {
<ContactForm <ContactForm
contact={selectedContact} contact={selectedContact}
addressBooks={addressBooks} addressBooks={addressBooks}
allKeywords={allKeywords}
onSave={handleSaveEdit} onSave={handleSaveEdit}
onCancel={handleCancel} onCancel={handleCancel}
/> />
@@ -529,6 +617,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}
/> />
); );
@@ -544,7 +642,7 @@ export default function ContactsPage() {
}; };
return ( return (
<div className="flex h-dvh bg-background overflow-hidden"> <div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
{/* Navigation Rail - desktop only */} {/* Navigation Rail - desktop only */}
{!isMobile && ( {!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
@@ -552,7 +650,7 @@ export default function ContactsPage() {
collapsed collapsed
quota={quota} quota={quota}
isPushConnected={isPushConnected} isPushConnected={isPushConnected}
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }} onLogout={logout}
onManageApps={handleManageApps} onManageApps={handleManageApps}
onInlineApp={handleInlineApp} onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp} onCloseInlineApp={closeInlineApp}
@@ -561,7 +659,7 @@ export default function ContactsPage() {
</div> </div>
)} )}
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0 min-h-0">
{inlineApp && ( {inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} /> <InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
)} )}
@@ -590,6 +688,9 @@ export default function ContactsPage() {
onEditGroup={handleEditGroupFromSidebar} onEditGroup={handleEditGroupFromSidebar}
onDeleteGroup={handleDeleteGroupFromSidebar} onDeleteGroup={handleDeleteGroupFromSidebar}
onDropContacts={handleDropContacts} onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
/> />
</div> </div>
<ResizeHandle <ResizeHandle
@@ -606,6 +707,7 @@ export default function ContactsPage() {
{/* Panel 2: Contact list */} {/* Panel 2: Contact list */}
<div <div
data-tour="contacts-list"
className={cn( className={cn(
"border-r border-border bg-background flex flex-col flex-shrink-0", "border-r border-border bg-background flex flex-col flex-shrink-0",
isMobile ? "w-full" : "", isMobile ? "w-full" : "",
@@ -620,7 +722,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}
@@ -630,6 +731,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>
@@ -641,7 +745,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"); }}
/> />
)} )}
</> </>
@@ -659,7 +763,7 @@ export default function ContactsPage() {
className="touch-manipulation" className="touch-manipulation"
> >
<ArrowLeft className="w-4 h-4 mr-2" /> <ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_mail")} {t("back_to_contacts")}
</Button> </Button>
</div> </div>
)} )}
@@ -683,6 +787,46 @@ export default function ContactsPage() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
{renamingKeyword !== null && (
<RenameDialog
currentName={renamingKeyword}
title={t("rename_category")}
label={t("category_name_label")}
onCancel={() => setRenamingKeyword(null)}
onConfirm={async (newName) => {
try {
await renameKeyword(supportsSync && client ? client : null, renamingKeyword, newName);
toast.success(t("category_renamed"));
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === renamingKeyword) {
setActiveCategory({ keyword: newName.trim() });
}
setRenamingKeyword(null);
} catch (err) {
console.error("Failed to rename category:", err);
toast.error(t("category_rename_failed"));
}
}}
/>
)}
{renamingAddressBook && (
<RenameDialog
currentName={renamingAddressBook.name}
title={t("address_books.rename")}
label={t("address_books.name_label")}
onCancel={() => setRenamingAddressBook(null)}
onConfirm={async (newName) => {
if (!client) return;
try {
await renameAddressBook(client, renamingAddressBook, newName);
toast.success(t("address_books.renamed"));
setRenamingAddressBook(null);
} catch (err) {
console.error("Failed to rename address book:", err);
toast.error(t("address_books.rename_failed"));
}
}}
/>
)}
{showImportDialog && ( {showImportDialog && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden"> <div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
+64 -26
View File
@@ -7,25 +7,29 @@ import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail"; import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
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 { 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";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog"; import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog"; import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { AlertTriangle } from "lucide-react";
export default function FilesPage() { export default function FilesPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("files"); const t = useTranslations("files");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
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);
@@ -45,7 +49,7 @@ export default function FilesPage() {
navigateByPath, navigateByPath,
refresh, refresh,
createDirectory, createDirectory,
uploadFile, uploadFile: _uploadFile,
uploadFiles, uploadFiles,
uploadFolder, uploadFolder,
deleteResource, deleteResource,
@@ -101,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);
}); });
@@ -112,9 +123,9 @@ export default function FilesPage() {
useEffect(() => { useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) { if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
router.push("/login"); redirectToLogin();
} }
}, [initialCheckDone, isAuthenticated, authLoading, router]); }, [initialCheckDone, isAuthenticated, authLoading]);
// Initialize JMAP files client // Initialize JMAP files client
useEffect(() => { useEffect(() => {
@@ -124,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(() => {
@@ -156,39 +176,43 @@ export default function FilesPage() {
} }
}, [createDirectory, t]); }, [createDirectory, t]);
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB const maxSizeUpload = client?.getMaxSizeUpload() || 0;
const handleUploadFiles = useCallback(async (files: File[]) => { const handleUploadFiles = useCallback(async (files: File[]) => {
const oversized = files.filter(f => f.size > MAX_FILE_SIZE); if (maxSizeUpload > 0) {
const valid = files.filter(f => f.size <= MAX_FILE_SIZE); const oversized = files.filter(f => f.size > maxSizeUpload);
if (oversized.length > 0) { files = files.filter(f => f.size <= maxSizeUpload);
toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" })); if (oversized.length > 0) {
toast.error(t("file_too_large", { name: oversized[0].name, max: formatFileSize(maxSizeUpload) }));
}
} }
if (valid.length === 0) return; if (files.length === 0) return;
try { try {
await uploadFiles(valid); await uploadFiles(files);
toast.success(t("upload_success", { count: valid.length })); toast.success(t("upload_success", { count: files.length }));
} catch (err) { } catch (err) {
console.error("Failed to upload files:", err); console.error("Failed to upload files:", err);
toast.error(t("upload_error")); toast.error(t("upload_error"));
} }
}, [uploadFiles, t]); }, [uploadFiles, t, maxSizeUpload]);
const handleUploadFolder = useCallback(async (files: File[]) => { const handleUploadFolder = useCallback(async (files: File[]) => {
const oversized = files.filter(f => f.size > MAX_FILE_SIZE); if (maxSizeUpload > 0) {
const valid = files.filter(f => f.size <= MAX_FILE_SIZE); const oversized = files.filter(f => f.size > maxSizeUpload);
if (oversized.length > 0) { files = files.filter(f => f.size <= maxSizeUpload);
toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" })); if (oversized.length > 0) {
toast.error(t("file_too_large", { name: oversized[0].name, max: formatFileSize(maxSizeUpload) }));
}
} }
if (valid.length === 0) return; if (files.length === 0) return;
try { try {
await uploadFolder(valid); await uploadFolder(files);
toast.success(t("upload_success", { count: valid.length })); toast.success(t("upload_success", { count: files.length }));
} catch (err) { } catch (err) {
console.error("Failed to upload folder:", err); console.error("Failed to upload folder:", err);
toast.error(t("upload_error")); toast.error(t("upload_error"));
} }
}, [uploadFolder, t]); }, [uploadFolder, t, maxSizeUpload]);
const handleDelete = useCallback(async (name: string) => { const handleDelete = useCallback(async (name: string) => {
const confirmed = await confirmDialog({ const confirmed = await confirmDialog({
@@ -357,7 +381,7 @@ export default function FilesPage() {
collapsed collapsed
quota={quota} quota={quota}
isPushConnected={isPushConnected} isPushConnected={isPushConnected}
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }} onLogout={logout}
onManageApps={handleManageApps} onManageApps={handleManageApps}
onInlineApp={handleInlineApp} onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp} onCloseInlineApp={closeInlineApp}
@@ -388,12 +412,25 @@ export default function FilesPage() {
</div> </div>
)} )}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0 flex flex-col">
{supportsFiles === false ? ( {!filesEnabled ? (
<div className="flex items-center justify-center h-full">
<div className="max-w-lg text-center space-y-3 px-4">
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
<p className="text-sm font-medium">{t("disabled_title")}</p>
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
</div>
</div>
) : supportsFiles === false ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("not_available")}</p> <p className="text-sm text-muted-foreground">{t("not_available")}</p>
</div> </div>
) : ( ) : (
<div className="flex flex-col flex-1 min-h-0">
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-3 py-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0 mt-0.5" />
<p className="text-xs text-yellow-700 dark:text-yellow-400">{t("stability_warning")}</p>
</div>
<FileBrowser <FileBrowser
currentPath={currentPath} currentPath={currentPath}
resources={resources} resources={resources}
@@ -438,6 +475,7 @@ export default function FilesPage() {
onToggleDetails={handleToggleDetails} onToggleDetails={handleToggleDetails}
detailResource={detailResource} detailResource={detailResource}
/> />
</div>
)} )}
</div> </div>
</div> </div>
+10 -1
View File
@@ -2,6 +2,9 @@ import { notFound } from "next/navigation";
import { IntlProvider } from "@/components/providers/intl-provider"; import { IntlProvider } from "@/components/providers/intl-provider";
import { ThemeProvider } from "@/components/providers/theme-provider"; import { ThemeProvider } from "@/components/providers/theme-provider";
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider"; import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
import { TourProvider } from "@/components/tour/tour-provider";
import { locales } from "@/i18n/routing"; import { locales } from "@/i18n/routing";
export default async function LocaleLayout({ export default async function LocaleLayout({
@@ -26,7 +29,13 @@ export default async function LocaleLayout({
<IntlProvider locale={locale} messages={messages}> <IntlProvider locale={locale} messages={messages}>
<ThemeProvider> <ThemeProvider>
<CalendarAlertProvider> <CalendarAlertProvider>
{children} <RateLimitToastProvider>
<EmbeddedBridgeProvider>
<TourProvider>
{children}
</TourProvider>
</EmbeddedBridgeProvider>
</RateLimitToastProvider>
</CalendarAlertProvider> </CalendarAlertProvider>
</ThemeProvider> </ThemeProvider>
</IntlProvider> </IntlProvider>
+336 -18
View File
@@ -10,13 +10,15 @@ 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 { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield } from "lucide-react"; import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
const APP_VERSION = "1.4.3"; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
const THEME_OPTIONS = [ const THEME_OPTIONS = [
{ value: "light" as const, icon: Sun, label: "Light" }, { value: "light" as const, icon: Sun, label: "Light" },
@@ -24,21 +26,57 @@ const THEME_OPTIONS = [
{ value: "system" as const, icon: Monitor, label: "System" }, { value: "system" as const, icon: Monitor, label: "System" },
]; ];
function VersionBadge() {
const [copied, setCopied] = useState(false);
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`;
const handleCopy = () => {
navigator.clipboard.writeText(versionInfo).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="relative inline-flex justify-center">
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default">
v{APP_VERSION}
</p>
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
<div className="flex items-center gap-2">
<div className="space-y-0.5">
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
</div>
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label="Copy version info"
>
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
</div>
);
}
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("login"); const t = useTranslations("login");
const params = useParams(); const params = useParams();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const isAddAccountMode = searchParams.get("mode") === "add-account"; const isAddAccountMode = searchParams.get("mode") === "add-account";
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig(); const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
username: "", username: "",
password: "", password: "",
}); });
const [jmapEndpoint, setJmapEndpoint] = useState("");
const [totpCode, setTotpCode] = useState(""); const [totpCode, setTotpCode] = useState("");
const [showTotpField, setShowTotpField] = useState(false); const [showTotpField, setShowTotpField] = useState(false);
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
@@ -54,6 +92,7 @@ export default function LoginPage() {
const [oauthMetadata, setOauthMetadata] = useState<OAuthMetadata | null>(null); const [oauthMetadata, setOauthMetadata] = useState<OAuthMetadata | null>(null);
const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false); const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false);
const [oauthLoading, setOauthLoading] = useState(false); const [oauthLoading, setOauthLoading] = useState(false);
const [demoLoading, setDemoLoading] = useState(false);
const suggestionsRef = useRef<HTMLDivElement>(null); const suggestionsRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -72,6 +111,12 @@ export default function LoginPage() {
} }
}, [appName, serverUrl]); }, [appName, serverUrl]);
useEffect(() => {
if (serverUrl && !jmapEndpoint) {
setJmapEndpoint(serverUrl);
}
}, [serverUrl, jmapEndpoint]);
useEffect(() => { useEffect(() => {
try { try {
if (sessionStorage.getItem('session_expired') === 'true') { if (sessionStorage.getItem('session_expired') === 'true') {
@@ -90,6 +135,14 @@ export default function LoginPage() {
prevError.current = error; prevError.current = error;
}, [error]); }, [error]);
// Auto-show and focus TOTP field when server requires it
useEffect(() => {
if (error === 'totp_required') {
setShowTotpField(true);
setTimeout(() => totpInputRef.current?.focus(), 100);
}
}, [error]);
useEffect(() => { useEffect(() => {
if (!serverUrl) return; if (!serverUrl) return;
const saved = localStorage.getItem("webmail_usernames"); const saved = localStorage.getItem("webmail_usernames");
@@ -172,6 +225,64 @@ export default function LoginPage() {
}); });
}, [oauthEnabled, serverUrl, oauthIssuerUrl]); }, [oauthEnabled, serverUrl, oauthIssuerUrl]);
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
const ssoError = searchParams.get("sso_error");
const autoSsoTriggered = useRef(false);
const startServerSideSso = useCallback(async () => {
setOauthLoading(true);
try {
const prefix = getPathPrefix(params.locale as string);
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
const res = await apiFetch('/api/auth/sso/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ redirect_uri: redirectUri, locale: params.locale }),
});
if (!res.ok) {
setOauthLoading(false);
return;
}
const { authorize_url } = await res.json();
// Navigate to the authorize URL
const isIframe = (() => { try { return window.self !== window.top; } catch { return true; } })();
if (isIframe) {
// In an iframe, try top-level navigation
try {
window.top!.location.href = authorize_url;
} catch {
// Cross-origin restriction - fall back to current frame
window.location.href = authorize_url;
}
} else {
window.location.href = authorize_url;
}
} catch {
setOauthLoading(false);
}
}, [params.locale]);
useEffect(() => {
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
if (ssoError || isAddAccountMode || isAuthenticated) return;
if (autoSsoTriggered.current) return;
// Guard against redirect loops
try {
if (sessionStorage.getItem("sso_attempted")) return;
sessionStorage.setItem("sso_attempted", "1");
// Clear the flag after 30 seconds so retries are possible
setTimeout(() => { try { sessionStorage.removeItem("sso_attempted"); } catch { /* ignore */ } }, 30000);
} catch { /* sessionStorage unavailable */ }
autoSsoTriggered.current = true;
startServerSideSso();
}, [autoSsoEnabled, oauthOnly, oauthDiscoveryDone, oauthMetadata, ssoError, isAddAccountMode, isAuthenticated, startServerSideSso]);
const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => { const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => {
setTheme(newTheme); setTheme(newTheme);
setShowThemeMenu(false); setShowThemeMenu(false);
@@ -206,7 +317,7 @@ export default function LoginPage() {
); );
} }
if (!serverUrl) { if (!serverUrl && !demoMode && !allowCustomJmapEndpoint) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<div className="w-full max-w-md mx-auto px-4 text-center"> <div className="w-full max-w-md mx-auto px-4 text-center">
@@ -304,7 +415,7 @@ export default function LoginPage() {
sessionStorage.setItem("oauth_code_verifier", verifier); sessionStorage.setItem("oauth_code_verifier", verifier);
sessionStorage.setItem("oauth_state", state); sessionStorage.setItem("oauth_state", state);
sessionStorage.setItem("oauth_server_url", serverUrl!); sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
if (isAddAccountMode) { if (isAddAccountMode) {
sessionStorage.setItem("oauth_add_account_mode", "true"); sessionStorage.setItem("oauth_add_account_mode", "true");
} }
@@ -324,8 +435,9 @@ export default function LoginPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
const success = await login( const success = await login(
serverUrl, effectiveServerUrl,
formData.username, formData.username,
formData.password, formData.password,
totpCode || undefined, totpCode || undefined,
@@ -353,9 +465,165 @@ export default function LoginPage() {
} }
}; };
const handleDemoLogin = async () => {
setDemoLoading(true);
const success = await loginDemo();
if (success) {
router.push('/');
}
setDemoLoading(false);
};
const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2]; const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
const CurrentThemeIcon = currentThemeOption.icon; const CurrentThemeIcon = currentThemeOption.icon;
// Demo-only mode: show only a large demo login button
if (demoMode && !isAddAccountMode) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
{/* Theme toggle */}
<div className="absolute top-5 right-5" ref={themeMenuRef} suppressHydrationWarning>
<button
type="button"
onClick={() => setShowThemeMenu(!showThemeMenu)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm transition-all duration-200",
showThemeMenu
? "bg-secondary border-border text-foreground shadow-md"
: "bg-background/60 backdrop-blur-sm border-border/50 text-muted-foreground hover:text-foreground hover:bg-secondary/80 hover:border-border"
)}
aria-label={`Theme: ${currentThemeOption.label}`}
aria-expanded={showThemeMenu}
aria-haspopup="listbox"
>
<CurrentThemeIcon className="w-4 h-4" />
<span className="hidden sm:inline" suppressHydrationWarning>{currentThemeOption.label}</span>
</button>
{showThemeMenu && (
<div
className="absolute right-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50"
role="listbox"
aria-label="Theme selection"
>
{THEME_OPTIONS.map((option) => {
const Icon = option.icon;
const isActive = theme === option.value;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={isActive}
onClick={() => handleThemeSelect(option.value)}
className={cn(
"w-full flex items-center gap-3 px-3.5 py-2.5 text-sm transition-colors",
isActive
? "bg-primary/10 text-foreground font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Icon className="w-4 h-4" />
<span className="flex-1 text-left">{option.label}</span>
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
);
})}
</div>
)}
</div>
<div className="w-full max-w-[440px] mx-auto">
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
{/* Header with logo */}
<div className="px-8 pt-12 pb-4 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
<img
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
alt={appName}
className="max-w-20 max-h-20 object-contain"
/>
</div>
<h1 className="text-3xl font-bold text-foreground tracking-tight">
{appName}
</h1>
<p className="text-base text-muted-foreground mt-2 max-w-xs mx-auto leading-relaxed">
{t("demo_tagline")}
</p>
</div>
{/* Large demo button */}
<div className="px-8 pb-10 pt-4">
{error && (
<div className={cn(
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3",
shakeError && "animate-shake"
)}>
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
{t(`error.${error}`) || t("error.generic")}
</p>
</div>
)}
<Button
type="button"
className="w-full h-14 font-semibold text-lg bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/30 hover:scale-[1.02] active:scale-[0.98]"
onClick={handleDemoLogin}
disabled={demoLoading || isLoading}
>
{demoLoading ? (
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 animate-spin" />
{t("demo_launching")}
</div>
) : (
<div className="flex items-center gap-3">
<Play className="w-5 h-5" />
{t("demo_login_button")}
</div>
)}
</Button>
<p className="text-center text-sm text-muted-foreground mt-4 leading-relaxed">
{t("demo_no_signup")}
</p>
</div>
</div>
{/* Footer */}
<div className="mt-6 flex flex-col items-center gap-2">
{loginCompanyName && (
<p className="text-center text-xs text-muted-foreground/60 font-medium">
{loginCompanyName}
</p>
)}
{(loginImprintUrl || loginPrivacyPolicyUrl || loginWebsiteUrl) && (
<div className="flex items-center gap-3 flex-wrap justify-center">
{loginWebsiteUrl && (
<a href={loginWebsiteUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
{t("website")}
</a>
)}
{loginImprintUrl && (
<a href={loginImprintUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
{t("imprint")}
</a>
)}
{loginPrivacyPolicyUrl && (
<a href={loginPrivacyPolicyUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
{t("privacy_policy")}
</a>
)}
</div>
)}
<VersionBadge />
</div>
</div>
</div>
);
}
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4"> <div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
{/* Theme toggle - top right, dropdown style */} {/* Theme toggle - top right, dropdown style */}
@@ -491,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 ? (
@@ -517,9 +785,9 @@ export default function LoginPage() {
)} )}
</Button> </Button>
) : oauthDiscoveryDone ? ( ) : oauthDiscoveryDone ? (
<div className="p-3.5 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-2"> <div className="p-3.5 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" /> <AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-700 dark:text-amber-400"> <p className="text-sm text-warning">
{t("error.oauth_discovery_failed")} {t("error.oauth_discovery_failed")}
</p> </p>
</div> </div>
@@ -533,6 +801,27 @@ export default function LoginPage() {
/* Login Form */ /* Login Form */
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<fieldset disabled={isLoading} className="space-y-4"> <fieldset disabled={isLoading} className="space-y-4">
{/* JMAP Endpoint field (when custom endpoints are allowed) */}
{allowCustomJmapEndpoint && (
<div className="space-y-1.5">
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
{t("jmap_endpoint_label")}
</label>
<Input
id="jmap-endpoint"
type="url"
value={jmapEndpoint}
onChange={(e) => setJmapEndpoint(e.target.value)}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
placeholder={t("jmap_endpoint_placeholder")}
required
/>
<p className="text-[11px] text-muted-foreground leading-snug">
{t("jmap_endpoint_cors_hint")}
</p>
</div>
)}
{/* Username field */} {/* Username field */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label htmlFor="username" className="block text-sm font-medium text-foreground"> <label htmlFor="username" className="block text-sm font-medium text-foreground">
@@ -645,7 +934,10 @@ export default function LoginPage() {
maxLength={6} maxLength={6}
value={totpCode} value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))} onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest" className={cn(
"h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest",
error === 'totp_required' && "border-primary ring-2 ring-primary/30"
)}
placeholder={t("totp_placeholder")} placeholder={t("totp_placeholder")}
autoComplete="one-time-code" autoComplete="one-time-code"
aria-label={t("totp_label")} aria-label={t("totp_label")}
@@ -725,9 +1017,9 @@ export default function LoginPage() {
)} )}
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && ( {oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
<div className="mt-2 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-2"> <div className="mt-2 p-3 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" /> <AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-700 dark:text-amber-400"> <p className="text-sm text-warning">
{t("error.oauth_discovery_failed")} {t("error.oauth_discovery_failed")}
</p> </p>
</div> </div>
@@ -747,6 +1039,34 @@ export default function LoginPage() {
</Button> </Button>
</div> </div>
)} )}
{/* Demo Mode Button */}
{demoMode && !isAddAccountMode && (
<div className="mt-4 pt-4 border-t border-border/40">
<Button
type="button"
variant="outline"
className="w-full h-11 font-medium text-[15px] rounded-xl border-border/60 hover:bg-muted/50"
onClick={handleDemoLogin}
disabled={demoLoading || isLoading}
>
{demoLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t("demo_launching")}
</div>
) : (
<div className="flex items-center gap-2">
<Play className="w-4 h-4" />
{t("try_demo")}
</div>
)}
</Button>
<p className="text-center text-xs text-muted-foreground mt-2">
{t("demo_description")}
</p>
</div>
)}
</div> </div>
</div> </div>
@@ -791,9 +1111,7 @@ export default function LoginPage() {
)} )}
</div> </div>
)} )}
<p className="text-center text-xs text-muted-foreground/40"> <VersionBadge />
v{APP_VERSION}
</p>
</div> </div>
</div> </div>
</div> </div>
+713 -129
View File
File diff suppressed because it is too large Load Diff
+188 -77
View File
@@ -9,7 +9,6 @@ import {
LogOut, LogOut,
Settings as SettingsIcon, Settings as SettingsIcon,
Palette, Palette,
Mail,
User, User,
Shield, Shield,
UserPen, UserPen,
@@ -20,23 +19,37 @@ import {
FolderOpen, FolderOpen,
Tags, Tags,
HardDrive, HardDrive,
Wrench,
BookUser, BookUser,
KeyRound, KeyRound,
PanelLeftClose, PanelLeftClose,
Bell,
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';
import { CalendarSettings } from '@/components/settings/calendar-settings'; import { CalendarSettings } from '@/components/settings/calendar-settings';
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings'; import { CalendarManagementSettings } from '@/components/settings/calendar-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';
@@ -44,7 +57,10 @@ import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings'; import { ContactsSettings } from '@/components/settings/contacts-settings';
import { SmimeSettings } from '@/components/settings/smime-settings'; import { SmimeSettings } from '@/components/settings/smime-settings';
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings'; import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
import { useAuthStore } from '@/stores/auth-store'; import { NotificationSettings } from '@/components/settings/notification-settings';
import { ThemesSettings } from '@/components/settings/themes-settings';
import { PluginsSettings } from '@/components/settings/plugins-settings';
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store'; import { useEmailStore } from '@/stores/email-store';
import { useIsDesktop } from '@/hooks/use-media-query'; import { useIsDesktop } from '@/hooks/use-media-query';
import { NavigationRail } from '@/components/layout/navigation-rail'; import { NavigationRail } from '@/components/layout/navigation-rail';
@@ -53,38 +69,94 @@ import { InlineAppView } from '@/components/layout/inline-app-view';
import { useSidebarApps } from '@/hooks/use-sidebar-apps'; import { useSidebarApps } from '@/hooks/use-sidebar-apps';
import { ResizeHandle } from '@/components/layout/resize-handle'; import { ResizeHandle } from '@/components/layout/resize-handle';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { usePolicyStore } from '@/stores/policy-store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'advanced'; type Tab =
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;
label: string; label: string;
icon: LucideIcon; icon: LucideIcon;
group: TabGroup; group: TabGroup;
experimental?: boolean;
} }
const tabIcons: Record<Tab, LucideIcon> = { const tabIcons: Record<Tab, LucideIcon> = {
appearance: Palette,
email: Mail,
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,
advanced: Wrench, about_data: Info,
themes: Palette,
plugins: Puzzle,
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();
@@ -95,13 +167,8 @@ export default function SettingsPage() {
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore(); const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig(); const { stalwartFeaturesEnabled } = useConfig();
const [activeTab, setActiveTab] = useState<Tab>(() => { const { isFeatureEnabled } = usePolicyStore();
try { const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
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();
@@ -112,19 +179,39 @@ 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 (with legacy migration)
useEffect(() => {
const handler = (e: Event) => {
const raw = (e as CustomEvent).detail as string;
if (!raw) return;
const tab = (LEGACY_TAB_MAP[raw] ?? raw) as Tab;
setActiveTab(tab);
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
};
window.addEventListener('settings-tab-change', handler);
return () => window.removeEventListener('settings-tab-change', handler);
}, []);
useEffect(() => { useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) { if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
router.push('/login'); redirectToLogin();
} }
}, [initialCheckDone, isAuthenticated, authLoading, router]); }, [initialCheckDone, isAuthenticated, authLoading]);
if (!isAuthenticated) { if (!isAuthenticated) {
return null; return null;
@@ -136,22 +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: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' }, { id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []), { id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
{ id: 'encryption', label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' }, // Appearance
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []), { id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []), { id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
{ id: 'templates', label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' },
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' }, // Mail
{ id: 'keywords', label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' }, { id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
{ 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 }] : []),
{ id: 'sidebar_apps', label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' }, ...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
// Advanced
{ 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
@@ -163,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 */ }
@@ -171,36 +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 === 'account' && <AccountSettings />} {effectiveActiveTab === 'notifications' && <NotificationSettings />}
{activeTab === 'security' && <AccountSecuritySettings />} {effectiveActiveTab === 'appearance' && <AppearanceSettings />}
{activeTab === 'identities' && <IdentitySettings />} {effectiveActiveTab === 'layout' && <LayoutSettings />}
{activeTab === 'encryption' && <SmimeSettings />} {effectiveActiveTab === 'reading' && <ReadingSettings />}
{activeTab === 'vacation' && <VacationSettings />} {effectiveActiveTab === 'composing' && <ComposingSettings />}
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>} {effectiveActiveTab === 'identities' && <IdentitySettings />}
{activeTab === 'contacts' && <ContactsSettings />} {effectiveActiveTab === 'vacation' && <VacationSettings />}
{activeTab === 'filters' && <FilterSettings />} {effectiveActiveTab === 'filters' && <FilterSettings />}
{activeTab === 'templates' && <TemplateSettings />} {effectiveActiveTab === 'templates' && <TemplateSettings />}
{activeTab === 'folders' && <FolderSettings />} {effectiveActiveTab === 'folders' && <FolderSettings />}
{activeTab === 'keywords' && <KeywordSettings />} {effectiveActiveTab === 'keywords' && <KeywordSettings />}
{activeTab === 'files' && <FilesSettingsComponent />} {effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />} {effectiveActiveTab === 'encryption' && <SmimeSettings />}
{activeTab === 'advanced' && <AdvancedSettings />} {effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{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"
@@ -213,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}
@@ -233,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"
@@ -252,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) => (
@@ -274,6 +385,11 @@ export default function SettingsPage() {
<span className="flex items-center gap-3"> <span className="flex items-center gap-3">
<Icon className="w-4 h-4 text-muted-foreground" /> <Icon className="w-4 h-4 text-muted-foreground" />
{tab.label} {tab.label}
{tab.experimental && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning">
Experimental
</span>
)}
</span> </span>
<ChevronRight className="w-4 h-4 text-muted-foreground" /> <ChevronRight className="w-4 h-4 text-muted-foreground" />
</button> </button>
@@ -283,10 +399,9 @@ 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(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }} onClick={logout}
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150" className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
@@ -295,7 +410,6 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* Bottom Navigation */}
<NavigationRail <NavigationRail
orientation="horizontal" orientation="horizontal"
onManageApps={handleManageApps} onManageApps={handleManageApps}
@@ -311,13 +425,12 @@ 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
quota={quota} quota={quota}
isPushConnected={isPushConnected} isPushConnected={isPushConnected}
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }} onLogout={logout}
onManageApps={handleManageApps} onManageApps={handleManageApps}
onInlineApp={handleInlineApp} onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp} onCloseInlineApp={closeInlineApp}
@@ -330,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",
@@ -338,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"
@@ -351,8 +462,7 @@ 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">
<div className="px-2 space-y-0.5"> <div className="px-2 space-y-0.5">
{groupedTabs.map((group, groupIndex) => ( {groupedTabs.map((group, groupIndex) => (
<div key={group.group}> <div key={group.group}>
@@ -370,16 +480,21 @@ 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 && (
<span className="ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning shrink-0">
Experimental
</span>
)}
</button> </button>
); );
})} })}
@@ -389,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)))}
@@ -400,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" />
@@ -411,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>
+383
View File
@@ -0,0 +1,383 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry {
value: unknown;
source: 'admin' | 'env' | 'default';
}
export default function AdminAuthPage() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => { fetchConfig(); }, []);
async function fetchConfig() {
setLoading(true);
const res = await apiFetch('/api/admin/config');
if (res.ok) setConfig(await res.json());
setLoading(false);
}
function handleChange(key: string, value: unknown) {
setEdits(prev => ({ ...prev, [key]: value }));
setMessage(null);
}
function currentValue(key: string): unknown {
if (key in edits) return edits[key];
return config[key]?.value;
}
async function handleSave() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
setMessage(null);
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Authentication settings saved.' });
setEdits({});
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
async function handleRevert(key: string) {
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setEdits(prev => { const next = { ...prev }; delete next[key]; return next; });
await fetchConfig();
}
}
const [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;
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
</div>
{hasEdits && (
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save changes
</button>
)}
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
{/* 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://…'}/&lt;locale&gt;/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 */}
<Section title="OAuth / OpenID Connect">
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" />
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
</Section>
{/* SSO */}
<Section title="Single Sign-On">
<Toggle label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
</Section>
{/* Session & Security */}
<Section title="Session & Security">
<Select label="Cookie SameSite" configKey="cookieSameSite" value={currentValue('cookieSameSite') as string} source={config.cookieSameSite?.source} options={['lax', 'strict', 'none']} onChange={handleChange} onRevert={handleRevert} />
<Text label="Allowed Frame Ancestors" configKey="allowedFrameAncestors" value={currentValue('allowedFrameAncestors') as string} source={config.allowedFrameAncestors?.source} onChange={handleChange} onRevert={handleRevert} placeholder="'none' or https://..." />
<Text label="Parent Origin" description="For embedded mode communication" configKey="parentOrigin" value={currentValue('parentOrigin') as string} source={config.parentOrigin?.source} onChange={handleChange} onRevert={handleRevert} />
</Section>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">{title}</h2>
</div>
<div className="divide-y divide-border">{children}</div>
</div>
);
}
function SourceBadge({ source }: { source?: string }) {
if (!source || source === 'default') return null;
return (
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
{source}
</span>
);
}
function Text({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text' }: {
label: string; description?: string; configKey: string; value: string; source?: string;
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string;
}) {
return (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex items-center gap-2">
<input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder}
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
)}
</div>
</div>
);
}
function Toggle({ label, description, configKey, value, source, onChange, onRevert }: {
label: string; description?: string; configKey: string; value: boolean; source?: string;
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
}) {
return (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex items-center gap-2">
<button onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
)}
</div>
</div>
);
}
function Select({ label, configKey, value, source, options, onChange, onRevert }: {
label: string; configKey: string; value: string; source?: string; options: string[];
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
}) {
return (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
<div className="flex items-center gap-2">
<select value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{options.map(o => <option key={o} value={o}>{o}</option>)}
</select>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
)}
</div>
</div>
);
}
+300
View File
@@ -0,0 +1,300 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry {
value: unknown;
source: 'admin' | 'env' | 'default';
}
const IMAGE_FIELDS = [
{ key: 'faviconUrl', label: 'Favicon', accept: '.svg,.png,.ico,.webp' },
{ key: 'appLogoLightUrl', label: 'App Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
];
const TEXT_FIELDS = [
{ key: 'loginCompanyName', label: 'Company Name' },
{ key: 'loginImprintUrl', label: 'Imprint URL' },
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
];
export default function AdminBrandingPage() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
useEffect(() => {
fetchConfig();
}, []);
async function fetchConfig() {
setLoading(true);
const res = await apiFetch('/api/admin/config');
if (res.ok) setConfig(await res.json());
setLoading(false);
}
function handleChange(key: string, value: string) {
setEdits(prev => ({ ...prev, [key]: value }));
setMessage(null);
}
function currentValue(key: string): string {
if (key in edits) return edits[key] as string;
return (config[key]?.value as string) ?? '';
}
async function handleSave() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
setMessage(null);
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' });
setEdits({});
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
async function handleUpload(slot: string, file: File) {
setUploading(slot);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
formData.append('slot', slot);
const res = await apiFetch('/api/admin/branding', {
method: 'POST',
body: formData,
});
if (res.ok) {
const data = await res.json();
setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
// Remove any pending URL edit for this slot since upload sets it
setEdits(prev => {
const next = { ...prev };
delete next[slot];
return next;
});
// Update config to reflect the uploaded URL
setConfig(prev => ({
...prev,
[slot]: { value: data.url, source: 'admin' },
}));
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Upload failed' });
}
setUploading(null);
}
async function handleDeleteUpload(slot: string) {
setMessage(null);
const res = await apiFetch('/api/admin/branding', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slot }),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Uploaded file removed. Reverted to default.' });
setEdits(prev => {
const next = { ...prev };
delete next[slot];
return next;
});
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to remove' });
}
}
async function handleRevert(key: string) {
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setEdits(prev => {
const next = { ...prev };
delete next[key];
return next;
});
await fetchConfig();
}
}
const isUploadedFile = (key: string): boolean => {
const val = currentValue(key);
return val.startsWith('/api/admin/branding/');
};
const hasEdits = Object.keys(edits).length > 0;
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Branding</h1>
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
</div>
{hasEdits && (
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save changes
</button>
)}
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Images & Logos</h2>
<p className="text-xs text-muted-foreground mt-0.5">Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)</p>
</div>
<div className="divide-y divide-border">
{IMAGE_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
{config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder="Enter URL or upload a file"
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<input
ref={el => { fileInputRefs.current[field.key] = el; }}
type="file"
accept={field.accept}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(field.key, file);
e.target.value = '';
}}
/>
<button
onClick={() => fileInputRefs.current[field.key]?.click()}
disabled={uploading === field.key}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
title="Upload file"
>
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
</button>
{isUploadedFile(field.key) && (
<button
onClick={() => handleDeleteUpload(field.key)}
className="text-muted-foreground hover:text-destructive transition-colors"
title="Remove uploaded file"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Preview */}
{currentValue(field.key) && (
<div className="mt-2 flex items-center gap-2">
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
<img
src={currentValue(field.key)}
alt={field.label}
className="max-h-6 max-w-[200px] object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
</div>
)}
</div>
))}
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
</div>
<div className="divide-y divide-border">
{TEXT_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
{config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
)}
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
className="h-8 w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{config[field.key]?.source === 'admin' && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Lock } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
export default function ChangePasswordPage() {
const router = useRouter();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setSuccess(false);
if (newPassword.length < 8) {
setError('New password must be at least 8 characters.');
return;
}
if (newPassword !== confirmPassword) {
setError('New passwords do not match.');
return;
}
setLoading(true);
const res = await apiFetch('/api/admin/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword }),
});
if (res.ok) {
setSuccess(true);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setTimeout(() => router.push('/admin'), 2000);
} else {
const data = await res.json().catch(() => ({}));
setError(data.error || 'Failed to change password.');
}
setLoading(false);
}
return (
<div className="max-w-md mx-auto space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Change Password</h1>
<p className="text-sm text-muted-foreground mt-1">Update your admin password.</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Current Password</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
required
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
autoComplete="current-password"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">New Password</label>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
required
minLength={8}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
autoComplete="new-password"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Confirm New Password</label>
<input
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
required
minLength={8}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
autoComplete="new-password"
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
{success && (
<p className="text-sm text-green-600">Password changed. Redirecting...</p>
)}
<button
type="submit"
disabled={loading}
className="w-full h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{loading ? 'Changing...' : 'Change Password'}
</button>
</form>
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import Link from 'next/link';
import {
LayoutDashboard,
Settings,
Palette,
Shield,
Scale,
ScrollText,
LogOut,
KeyRound,
Puzzle,
SwatchBook,
Mail,
Calendar,
BookUser,
HardDrive,
ArrowLeft,
Store,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useAuthStore } from '@/stores/auth-store';
import { apiFetch } from '@/lib/browser-navigation';
const NAV_GROUPS = [
{
label: 'Overview',
items: [
{ href: '/admin', label: 'Dashboard', icon: LayoutDashboard },
],
},
{
label: 'Configuration',
items: [
{ href: '/admin/settings', label: 'Settings', icon: Settings },
{ href: '/admin/branding', label: 'Branding', icon: Palette },
{ href: '/admin/auth', label: 'Authentication', icon: Shield },
{ href: '/admin/policy', label: 'Policy', icon: Scale },
],
},
{
label: 'Extensions',
items: [
{ href: '/admin/plugins', label: 'Plugins', icon: Puzzle },
{ href: '/admin/themes', label: 'Themes', icon: SwatchBook },
{ href: '/admin/marketplace', label: 'Marketplace', icon: Store },
],
},
{
label: 'System',
items: [
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
],
},
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = resolvedTheme === 'dark'
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
useEffect(() => {
if (pathname === '/admin/login') return;
let cancelled = false;
async function checkAuth() {
try {
const jmapHeaders = getActiveAccountSlotHeaders();
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
const data = await res.json();
if (cancelled) return;
const stalwartAdmin = data.stalwartAdmin === true;
setIsStalwartAdmin(stalwartAdmin);
// If neither password-based admin nor Stalwart admin, redirect away
if (!data.enabled && !stalwartAdmin) {
router.replace('/');
return;
}
if (data.authenticated) {
setAuthenticated(true);
return;
}
// If Stalwart admin but not yet authenticated, auto-login
if (stalwartAdmin) {
const loginRes = await 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() {
await apiFetch('/api/admin/auth', { method: 'DELETE' });
router.replace('/admin/login');
}
// Don't gate the login page
if (pathname === '/admin/login') {
return <>{children}</>;
}
return (
<div className="min-h-screen flex bg-background">
{/* Slim webmail nav rail */}
<nav className="w-14 bg-secondary flex flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
) : (
<div className="w-7 h-7 mb-2" />
)}
<a
href="/"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Mail"
>
<Mail className="w-[18px] h-[18px]" />
</a>
<a
href="/calendar"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Calendar"
>
<Calendar className="w-[18px] h-[18px]" />
</a>
<a
href="/contacts"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Contacts"
>
<BookUser className="w-[18px] h-[18px]" />
</a>
<a
href="/files"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Files"
>
<HardDrive className="w-[18px] h-[18px]" />
</a>
<div className="mt-auto flex flex-col items-center gap-2">
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
<Shield className="w-[18px] h-[18px]" />
</div>
<a
href="/settings"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Settings"
>
<Settings className="w-[18px] h-[18px]" />
</a>
</div>
</nav>
{/* Admin Sidebar */}
<aside className="w-60 border-r border-border bg-secondary flex flex-col sticky top-0 h-screen">
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
)}
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
</div>
<div className="flex-1 overflow-y-auto py-2">
<div className="px-2 space-y-0.5">
{NAV_GROUPS.map((group, groupIndex) => (
<div key={group.label}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map(({ href, label, icon: Icon }) => {
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
active
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
active ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{label}
</Link>
);
})}
</div>
))}
</div>
</div>
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
{!isStalwartAdmin && (
<Link
href="/admin/change-password"
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<KeyRound className={cn(
'w-4 h-4 shrink-0',
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
Change Password
</Link>
)}
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
>
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
Sign out
</button>
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<div className="max-w-4xl mx-auto p-6">
{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>
</main>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { Shield } from 'lucide-react';
import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
import { apiFetch } from '@/lib/browser-navigation';
export default function AdminLoginPage() {
const router = useRouter();
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl;
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await apiFetch('/api/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Login failed');
return;
}
router.push('/admin');
} catch {
setError('Network error. Please try again.');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-8 h-8 object-contain" />
) : (
<Shield className="w-6 h-6 text-primary" />
)}
</div>
<h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1>
<p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring"
placeholder="Enter admin password"
required
autoFocus
autoComplete="current-password"
/>
</div>
{error && (
<div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
{error}
</div>
)}
<button
type="submit"
disabled={loading || !password}
className="w-full h-10 rounded-md bg-primary text-primary-foreground font-medium text-sm hover:bg-primary/90 disabled:opacity-50 disabled:pointer-events-none transition-all duration-200 shadow-sm"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { RefreshCw } from 'lucide-react';
import type { AuditEntry } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
export default function AdminLogsPage() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [actionFilter, setActionFilter] = useState('');
const limit = 50;
const fetchLogs = useCallback(async () => {
setLoading(true);
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (actionFilter) params.set('action', actionFilter);
const res = await apiFetch(`/api/admin/audit?${params}`);
if (res.ok) {
const data = await res.json();
setEntries(data.entries || []);
setTotal(data.total || 0);
}
setLoading(false);
}, [page, actionFilter]);
useEffect(() => { fetchLogs(); }, [fetchLogs]);
const totalPages = Math.max(1, Math.ceil(total / limit));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
</div>
<button
onClick={fetchLogs}
className="inline-flex items-center gap-2 h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-accent transition-colors"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{/* Filter */}
<div className="flex items-center gap-3">
<select
value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">All actions</option>
<option value="admin.login">Login</option>
<option value="admin.logout">Logout</option>
<option value="admin.login_failed">Login Failed</option>
<option value="admin.login_blocked">Login Blocked</option>
<option value="admin.change-password">Password Change</option>
<option value="config.update">Config Update</option>
<option value="config.revert">Config Revert</option>
<option value="policy.update">Policy Update</option>
</select>
</div>
{/* Table */}
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Time</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Action</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading && entries.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">Loading...</td>
</tr>
) : entries.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">No entries found</td>
</tr>
) : (
entries.map((entry, i) => (
<tr key={i} className="hover:bg-muted/20">
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">
{new Date(entry.ts).toLocaleString()}
</td>
<td className="px-4 py-2">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground">
{entry.action}
</span>
</td>
<td className="px-4 py-2 text-xs text-foreground max-w-xs truncate">
{formatDetail(entry.detail)}
</td>
<td className="px-4 py-2 text-xs text-muted-foreground font-mono">
{entry.ip}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
>
Previous
</button>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
>
Next
</button>
</div>
</div>
)}
</div>
);
}
function formatDetail(detail: Record<string, unknown>): string {
if (!detail || Object.keys(detail).length === 0) return '-';
if (detail.reason) return String(detail.reason);
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)}${JSON.stringify(detail.new)}`;
if (detail.changes && Array.isArray(detail.changes)) {
return detail.changes.map((c: Record<string, unknown>) => `${c.key}`).join(', ');
}
if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`;
return JSON.stringify(detail).slice(0, 100);
}
+355
View File
@@ -0,0 +1,355 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface Extension {
slug: string;
name: string;
type: 'plugin' | 'theme';
pluginType: string | null;
description: string;
permissions: string[];
tags: string[];
totalDownloads: number;
featured: boolean;
minAppVersion: string | null;
latestVersion: string | null;
installed: boolean;
author: {
displayName: string;
githubLogin: string;
avatarUrl: string | null;
} | null;
}
interface SearchResult {
data: Extension[];
meta: {
page: number;
perPage: number;
total: number;
};
}
type TypeFilter = 'all' | 'plugin' | 'theme';
export default function AdminMarketplacePage() {
const [extensions, setExtensions] = useState<Extension[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [perPage] = useState(12);
const [installing, setInstalling] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const fetchExtensions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (query) params.set('q', query);
if (typeFilter !== 'all') params.set('type', typeFilter);
params.set('page', String(page));
params.set('perPage', String(perPage));
params.set('sort', 'newest');
const res = await apiFetch(`/api/admin/marketplace?${params}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.error || 'Failed to connect to extension directory');
setExtensions([]);
return;
}
const data: SearchResult = await res.json();
setExtensions(data.data || []);
setTotal(data.meta?.total || 0);
} catch {
setError('Failed to connect to extension directory. Make sure it is running.');
setExtensions([]);
} finally {
setLoading(false);
}
}, [query, typeFilter, page, perPage]);
useEffect(() => {
fetchExtensions();
}, [fetchExtensions]);
// Debounced search
const [searchInput, setSearchInput] = useState('');
useEffect(() => {
const t = setTimeout(() => {
setQuery(searchInput);
setPage(1);
}, 300);
return () => clearTimeout(t);
}, [searchInput]);
async function handleInstall(ext: Extension) {
setInstalling(ext.slug);
setMessage(null);
try {
const res = await apiFetch('/api/admin/marketplace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: ext.slug,
version: ext.latestVersion || '1.0.0',
type: ext.type,
}),
});
const data = await res.json();
if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` });
// Mark as installed in the UI
setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e));
} else {
setMessage({ type: 'error', text: data.error || 'Installation failed' });
}
} catch {
setMessage({ type: 'error', text: 'Installation failed - network error' });
} finally {
setInstalling(null);
}
}
const totalPages = Math.ceil(total / perPage);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Marketplace</h1>
<p className="text-sm text-muted-foreground mt-1">
Browse and install plugins and themes from the BulwarkMail extension directory
</p>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
{/* Search & Filters */}
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Search extensions..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
/>
</div>
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5">
{(['all', 'plugin', 'theme'] as const).map((t) => (
<button
key={t}
onClick={() => { setTypeFilter(t); setPage(1); }}
className={`h-8 px-3 rounded text-sm font-medium transition-colors ${
typeFilter === t
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t === 'all' ? 'All' : t === 'plugin' ? 'Plugins' : 'Themes'}
</button>
))}
</div>
</div>
{/* Error State */}
{error && (
<div className="border border-border rounded-lg p-12 text-center">
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">{error}</p>
<p className="text-xs text-muted-foreground mt-1">
Start the extension directory server on the configured port
</p>
<button
onClick={fetchExtensions}
className="mt-4 inline-flex items-center gap-2 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90"
>
Retry
</button>
</div>
)}
{/* Loading State */}
{loading && !error && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Searching extensions...</span>
</div>
)}
{/* Empty State */}
{!loading && !error && extensions.length === 0 && (
<div className="border border-border rounded-lg p-12 text-center">
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No extensions found</p>
{query && (
<p className="text-xs text-muted-foreground mt-1">
Try a different search term
</p>
)}
</div>
)}
{/* Extension Grid */}
{!loading && !error && extensions.length > 0 && (
<>
<div className="text-xs text-muted-foreground">
{total} extension{total !== 1 ? 's' : ''} found
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{extensions.map((ext) => (
<ExtensionCard
key={ext.slug}
extension={ext}
installing={installing === ext.slug}
onInstall={() => handleInstall(ext)}
/>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
)}
</>
)}
</div>
);
}
function ExtensionCard({
extension,
installing,
onInstall,
}: {
extension: Extension;
installing: boolean;
onInstall: () => void;
}) {
const isPlugin = extension.type === 'plugin';
return (
<div className="border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
<div className="p-4">
{/* Header */}
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0">
{isPlugin ? (
<Puzzle className="w-5 h-5 text-muted-foreground" />
) : (
<SwatchBook className="w-5 h-5 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground truncate">{extension.name}</span>
{extension.featured && (
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
isPlugin
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
}`}>
{isPlugin ? (extension.pluginType || 'plugin') : 'theme'}
</span>
{extension.author && (
<span className="text-xs text-muted-foreground truncate">
by {extension.author.displayName}
</span>
)}
</div>
</div>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground mt-3 line-clamp-2">
{extension.description}
</p>
{/* Tags */}
{extension.tags && extension.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{extension.tags.slice(0, 3).map(tag => (
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{tag}
</span>
))}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Download className="w-3 h-3" />
{extension.totalDownloads.toLocaleString()}
</span>
{extension.permissions && extension.permissions.length > 0 && (
<span title={extension.permissions.join(', ')}>
{extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''}
</span>
)}
</div>
{extension.installed ? (
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
<Check className="w-3 h-3" />
Installed
</span>
) : (
<button
onClick={onInstall}
disabled={installing}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{installing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
Install
</button>
)}
</div>
</div>
</div>
);
}
+212
View File
@@ -0,0 +1,212 @@
'use client';
import { useEffect, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
import type { AuditEntry } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
interface AdminStatus {
enabled: boolean;
authenticated: boolean;
lastLogin: string | null;
passwordChangedAt: string | null;
}
interface ConfigData {
appName?: string;
jmapServerUrl?: string;
settingsSyncEnabled?: boolean;
stalwartFeaturesEnabled?: boolean;
oauthEnabled?: boolean;
devMode?: boolean;
}
export default function AdminDashboardPage() {
const [status, setStatus] = useState<AdminStatus | null>(null);
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
const [config, setConfig] = useState<ConfigData | null>(null);
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const [pluginCount, setPluginCount] = useState(0);
const [themeCount, setThemeCount] = useState(0);
const [policyRuleCount, setPolicyRuleCount] = useState(0);
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
useEffect(() => {
fetchDashboardData();
}, []);
async function fetchDashboardData() {
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
apiFetch('/api/admin/auth'),
apiFetch('/api/admin/audit?limit=10'),
apiFetch('/api/config'),
apiFetch('/api/admin/config'),
apiFetch('/api/admin/plugins').catch(() => null),
apiFetch('/api/admin/themes').catch(() => null),
apiFetch('/api/admin/policy').catch(() => null),
]);
if (statusRes.ok) setStatus(await statusRes.json());
if (auditRes.ok) {
const data = await auditRes.json();
setRecentActivity(data.entries || []);
}
let configData: ConfigData | null = null;
if (configRes.ok) {
configData = await configRes.json();
setConfig(configData);
}
if (pluginRes?.ok) {
const plugins = await pluginRes.json();
setPluginCount(Array.isArray(plugins) ? plugins.length : 0);
}
if (themeRes?.ok) {
const themes = await themeRes.json();
setThemeCount(Array.isArray(themes) ? themes.length : 0);
}
if (policyRes?.ok) {
const policy = await policyRes.json();
const restrictionCount = policy.restrictions ? Object.keys(policy.restrictions).length : 0;
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
setPolicyRuleCount(restrictionCount + disabledGates);
}
if (configData?.jmapServerUrl) {
try {
const jmapRes = await apiFetch('/api/config');
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
} catch {
setJmapHealth('error');
}
}
const w: string[] = [];
if (adminConfigRes.ok) {
const sources = await adminConfigRes.json();
setConfigSources(sources);
const sessionSecret = sources?.sessionSecret;
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') {
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
}
const adminPassword = sources?.adminPassword;
if (adminPassword?.value && adminPassword.source === 'env') {
w.push('ADMIN_PASSWORD is still set in environment variables. Remove it now that the hash is stored securely.');
}
}
setWarnings(w);
}
const jmapUrl = config?.jmapServerUrl || '-';
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
return (
<div className="max-w-3xl space-y-8">
{/* Warnings */}
{warnings.map((msg, i) => (
<div key={i} className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">{msg}</p>
</div>
))}
{status && !status.lastLogin && (
<div className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-warning">First login detected</p>
<p className="text-sm text-warning/80 mt-0.5">
Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely.
</p>
</div>
</div>
)}
{/* Server Info */}
<SettingsSection title="Server" description="Application and connection details">
<SettingItem label="Application">
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
</SettingItem>
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
<span className="text-sm text-foreground">{jmapHostname}</span>
</SettingItem>
<SettingItem label="JMAP Connection">
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${
jmapHealth === 'ok' ? 'text-green-600 dark:text-green-400' : jmapHealth === 'error' ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground'
}`}>
<span className={`w-2 h-2 rounded-full ${
jmapHealth === 'ok' ? 'bg-green-500' : jmapHealth === 'error' ? 'bg-red-500' : 'bg-muted-foreground/40'
}`} />
{jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'}
</span>
</SettingItem>
<SettingItem label="Last Login">
<span className="text-sm text-foreground">
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
</span>
</SettingItem>
</SettingsSection>
{/* Features */}
<SettingsSection title="Features" description="Enabled integrations and modules">
<SettingItem label="Admin Panel" description="Administrative access to server configuration">
<ToggleSwitch checked={!!status?.enabled} onChange={() => {}} disabled />
</SettingItem>
<SettingItem label="Settings Sync" description="Synchronize user settings across devices">
<ToggleSwitch checked={!!config?.settingsSyncEnabled} onChange={() => {}} disabled />
</SettingItem>
<SettingItem label="OAuth" description="OAuth authentication provider">
<ToggleSwitch checked={!!config?.oauthEnabled} onChange={() => {}} disabled />
</SettingItem>
<SettingItem label="Stalwart Integration" description="Stalwart mail server features">
<ToggleSwitch checked={config?.stalwartFeaturesEnabled !== false} onChange={() => {}} disabled />
</SettingItem>
</SettingsSection>
{/* Extensions */}
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
<SettingItem label="Plugins">
<span className="text-sm text-foreground">{pluginCount}</span>
</SettingItem>
<SettingItem label="Themes">
<span className="text-sm text-foreground">{themeCount}</span>
</SettingItem>
<SettingItem label="Policy Rules">
<span className="text-sm text-foreground">{policyRuleCount}</span>
</SettingItem>
</SettingsSection>
{/* Recent Activity */}
<SettingsSection title="Recent Activity" description="Latest administrative actions">
{recentActivity.length === 0 ? (
<div className="py-4 text-sm text-muted-foreground">
No activity recorded yet
</div>
) : (
recentActivity.map((entry, i) => (
<SettingItem
key={i}
label={entry.action}
description={formatDetail(entry.detail) || undefined}
>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{entry.ip}</span>
<span>{new Date(entry.ts).toLocaleString()}</span>
</div>
</SettingItem>
))
)}
</SettingsSection>
</div>
);
}
function formatDetail(detail: Record<string, unknown>): string {
if (!detail || Object.keys(detail).length === 0) return '';
if (detail.key) return `${detail.key}: ${detail.old}${detail.new}`;
if (detail.reason) return String(detail.reason);
if (detail.changes && Array.isArray(detail.changes)) return `${detail.changes.length} setting(s) changed`;
return JSON.stringify(detail).slice(0, 80);
}
+286
View File
@@ -0,0 +1,286 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
import Link from 'next/link';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigField {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
label: string;
description?: string;
required?: boolean;
default?: unknown;
placeholder?: string;
options?: { label: string; value: string }[];
}
interface PluginConfig {
[key: string]: unknown;
}
interface PluginInfo {
id: string;
name: string;
description: string;
version: string;
author: string;
type: string;
permissions: string[];
enabled: boolean;
configSchema?: Record<string, ConfigField>;
}
export default function PluginConfigPage() {
const params = useParams();
const pluginId = params.id as string;
const [plugin, setPlugin] = useState<PluginInfo | null>(null);
const [config, setConfig] = useState<PluginConfig>({});
const [formValues, setFormValues] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginId]);
// Initialize form values from config + schema defaults when data loads
useEffect(() => {
if (!plugin?.configSchema) return;
const initial: Record<string, string> = {};
for (const [key, field] of Object.entries(plugin.configSchema)) {
const stored = config[key];
if (stored !== undefined && stored !== null) {
initial[key] = String(stored);
} else if (field.default !== undefined) {
initial[key] = String(field.default);
} else {
initial[key] = '';
}
}
setFormValues(initial);
}, [plugin, config]);
async function fetchData() {
setLoading(true);
try {
const [pluginsRes, configRes] = await Promise.all([
apiFetch('/api/admin/plugins'),
apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
]);
if (pluginsRes.ok) {
const plugins: PluginInfo[] = await pluginsRes.json();
setPlugin(plugins.find(p => p.id === pluginId) || null);
}
if (configRes.ok) {
setConfig(await configRes.json());
}
} finally {
setLoading(false);
}
}
async function handleSaveAll() {
if (!plugin?.configSchema) return;
setSaving(true);
setMessage(null);
// Validate required fields
for (const [key, field] of Object.entries(plugin.configSchema)) {
if (field.required && !formValues[key]?.trim()) {
setMessage({ type: 'error', text: `"${field.label}" is required` });
setSaving(false);
return;
}
}
try {
// Save each changed field
let hasError = false;
for (const [key, field] of Object.entries(plugin.configSchema)) {
const newVal = formValues[key] ?? '';
const oldVal = config[key] !== undefined ? String(config[key]) : '';
// Skip unchanged fields (and skip secret fields that show as empty when they have a stored value)
if (newVal === oldVal) continue;
if (field.type === 'secret' && !newVal && config[key]) continue;
// Convert types
let value: unknown = newVal;
if (field.type === 'boolean') value = newVal === 'true';
else if (field.type === 'number') value = Number(newVal);
// Delete if clearing a non-required field
if (!newVal && !field.required) {
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setConfig(prev => { const next = { ...prev }; delete next[key]; return next; });
} else {
hasError = true;
}
continue;
}
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
});
if (res.ok) {
setConfig(prev => ({ ...prev, [key]: value }));
} else {
hasError = true;
}
}
setMessage(hasError
? { type: 'error', text: 'Some settings failed to save' }
: { type: 'success', text: 'Configuration saved' }
);
} catch {
setMessage({ type: 'error', text: 'Failed to save configuration' });
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Loading...
</div>
);
}
if (!plugin) {
return (
<div className="space-y-4">
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="w-4 h-4" /> Back to Plugins
</Link>
<p className="text-sm text-destructive">Plugin not found: {pluginId}</p>
</div>
);
}
const schema = plugin.configSchema;
const hasSchema = schema && Object.keys(schema).length > 0;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="w-4 h-4" />
</Link>
<div>
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
<Puzzle className="w-5 h-5" />
{plugin.name} Configuration
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
v{plugin.version} by {plugin.author}
</p>
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
{hasSchema ? (
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Settings</h2>
</div>
<div className="p-4 space-y-5">
{Object.entries(schema).map(([key, field]) => (
<div key={key}>
<label className="text-sm font-medium text-foreground block mb-1">
{field.label}
{field.required && <span className="text-destructive ml-0.5">*</span>}
</label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
)}
{field.type === 'boolean' ? (
<select
value={formValues[key] ?? String(field.default ?? 'false')}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
) : field.type === 'select' && field.options ? (
<select
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">- Select -</option>
{field.options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : field.type === 'secret' ? (
<div className="relative">
<input
type={revealSecrets[key] ? 'text' : 'password'}
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
/>
<button
type="button"
onClick={() => setRevealSecrets(prev => ({ ...prev, [key]: !prev[key] }))}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
aria-label={revealSecrets[key] ? 'Hide' : 'Show'}
>
{revealSecrets[key] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
) : (
<input
type={field.type === 'number' ? 'number' : 'text'}
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
placeholder={field.placeholder || ''}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
)}
</div>
))}
<button
onClick={handleSaveAll}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
Save Configuration
</button>
</div>
</div>
) : (
<div className="border border-border rounded-lg p-8 text-center">
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
</div>
)}
</div>
);
}
+458
View File
@@ -0,0 +1,458 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import Link from 'next/link';
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
interface PluginEntry {
id: string;
name: string;
version: string;
author: string;
description: string;
type: string;
enabled: boolean;
forceEnabled?: boolean;
permissions: string[];
installedAt: string;
updatedAt: string;
}
export default function AdminPluginsPage() {
const [plugins, setPlugins] = useState<PluginEntry[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [policyDirty, setPolicyDirty] = useState(false);
const [savingPolicy, setSavingPolicy] = useState(false);
useEffect(() => { fetchPlugins(); fetchPolicy(); }, []);
async function fetchPolicy() {
try {
const res = await apiFetch('/api/admin/policy');
if (res.ok) {
const data = await res.json();
setPolicy(data);
}
} catch { /* ignore */ }
}
function togglePluginsEnabled() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, pluginsEnabled: !prev.features.pluginsEnabled },
}));
setPolicyDirty(true);
setMessage(null);
}
function togglePluginsUploadEnabled() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, pluginsUploadEnabled: !prev.features.pluginsUploadEnabled },
}));
setPolicyDirty(true);
setMessage(null);
}
function toggleRequirePluginApproval() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
}));
setPolicyDirty(true);
setMessage(null);
}
async function handleSavePolicy() {
setSavingPolicy(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Plugin policy saved. Users will see changes on next login.' });
setPolicyDirty(false);
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
}
} catch {
setMessage({ type: 'error', text: 'Failed to save policy' });
} finally {
setSavingPolicy(false);
}
}
async function fetchPlugins() {
setLoading(true);
try {
const res = await apiFetch('/api/admin/plugins');
if (res.ok) setPlugins(await res.json());
} finally {
setLoading(false);
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
try {
const res = await apiFetch('/api/admin/plugins', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
await fetchPlugins();
} else {
setMessage({ type: 'error', text: data.error || 'Upload failed' });
}
} catch {
setMessage({ type: 'error', text: 'Upload failed' });
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
}
async function togglePlugin(id: string, enabled: boolean) {
setMessage(null);
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, enabled }),
});
if (res.ok) {
setPlugins(prev => prev.map(p => p.id === id ? { ...p, enabled } : p));
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Update failed' });
}
}
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
setMessage(null);
// If force-enabling, also ensure the plugin is enabled
const body: Record<string, unknown> = { id, forceEnabled };
if (forceEnabled) body.enabled = true;
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p));
// Also update policy
setPolicy(prev => {
const current = prev.forceEnabledPlugins || [];
return {
...prev,
forceEnabledPlugins: forceEnabled
? [...current.filter(pid => pid !== id), id]
: current.filter(pid => pid !== id),
};
});
setPolicyDirty(true);
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Update failed' });
}
}
async function forceEnableAll() {
setMessage(null);
const disabled = plugins.filter(p => !p.enabled);
if (disabled.length === 0) {
setMessage({ type: 'success', text: 'All plugins are already enabled' });
return;
}
let failed = 0;
for (const p of disabled) {
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id, enabled: true }),
});
if (!res.ok) failed++;
}
setPlugins(prev => prev.map(p => failed === 0 ? { ...p, enabled: true } : p));
if (failed === 0) {
await fetchPlugins();
setMessage({ type: 'success', text: `All ${disabled.length} plugin(s) enabled` });
} else {
await fetchPlugins();
setMessage({ type: 'error', text: `${failed} plugin(s) failed to enable` });
}
}
async function forceDisableAll() {
setMessage(null);
const enabled = plugins.filter(p => p.enabled);
if (enabled.length === 0) {
setMessage({ type: 'success', text: 'All plugins are already disabled' });
return;
}
let failed = 0;
for (const p of enabled) {
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id, enabled: false }),
});
if (!res.ok) failed++;
}
if (failed === 0) {
await fetchPlugins();
setMessage({ type: 'success', text: `All ${enabled.length} plugin(s) disabled` });
} else {
await fetchPlugins();
setMessage({ type: 'error', text: `${failed} plugin(s) failed to disable` });
}
}
async function deletePlugin(id: string, name: string) {
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
setMessage(null);
const res = await apiFetch('/api/admin/plugins', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (res.ok) {
setPlugins(prev => prev.filter(p => p.id !== id));
setMessage({ type: 'success', text: `Plugin "${name}" removed` });
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Delete failed' });
}
}
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
</div>
<div className="flex items-center gap-2">
{policyDirty && (
<button
onClick={handleSavePolicy}
disabled={savingPolicy}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Policy
</button>
)}
<label className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 cursor-pointer transition-all shadow-sm">
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
Upload Plugin
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleUpload}
disabled={uploading}
className="sr-only"
/>
</label>
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
{/* Plugin Policy */}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Plugin Policy</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
</div>
<div className="divide-y divide-border">
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<span className="text-sm text-foreground">Plugins Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
</div>
<button onClick={togglePluginsEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<span className="text-sm text-foreground">User Plugin Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
</div>
<button onClick={togglePluginsUploadEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsUploadEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsUploadEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<span className="text-sm text-foreground">Require Admin Approval</span>
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
</div>
<button onClick={toggleRequirePluginApproval}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{/* Force enable / disable all */}
{plugins.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={forceEnableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
>
<Power className="w-3.5 h-3.5" />
Enable All
</button>
<button
onClick={forceDisableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
>
<PowerOff className="w-3.5 h-3.5" />
Disable All
</button>
</div>
</div>
)}
</div>
</div>
{/* Deployed Plugins */}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Deployed Plugins</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded plugins for all users</p>
</div>
{plugins.length === 0 ? (
<div className="p-12 text-center">
<Package className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No plugins installed</p>
<p className="text-xs text-muted-foreground mt-1">Upload a plugin ZIP file to get started</p>
</div>
) : (
<div className="divide-y divide-border">
{plugins.map(plugin => (
<div key={plugin.id} className="px-4 py-4 flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{plugin.name}</span>
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
{plugin.enabled ? 'Enabled' : 'Disabled'}
</span>
{plugin.forceEnabled && (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
<Lock className="w-3 h-3" /> Forced
</span>
)}
</div>
{plugin.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{plugin.description}</p>
)}
<div className="text-xs text-muted-foreground mt-1">
by {plugin.author} &middot; {plugin.type} &middot; installed {new Date(plugin.installedAt).toLocaleDateString()}
</div>
{plugin.permissions.length > 0 && (
<div className="flex items-center gap-1 mt-1">
<AlertTriangle className="w-3 h-3 text-warning" />
<span className="text-xs text-warning">
Permissions: {plugin.permissions.join(', ')}
</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Link
href={`/admin/plugins/${plugin.id}`}
title="Configure"
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<Settings className="w-4 h-4" />
</Link>
<button
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
className={`p-2 rounded-md transition-colors ${plugin.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
>
{plugin.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
</button>
<button
onClick={() => togglePlugin(plugin.id, !plugin.enabled)}
title={plugin.enabled ? 'Disable' : 'Enable'}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<Power className="w-4 h-4" />
</button>
<button
onClick={() => deletePlugin(plugin.id, plugin.name)}
title="Remove"
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+219
View File
@@ -0,0 +1,219 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, Loader2, Lock } from 'lucide-react';
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
// Feature gates managed on their own admin pages (excluded from this list)
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
};
const RESTRICTABLE_SETTINGS = [
{ key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] },
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
{ key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' },
{ key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
];
export default function AdminPolicyPage() {
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [dirty, setDirty] = useState(false);
useEffect(() => { fetchPolicy(); }, []);
async function fetchPolicy() {
setLoading(true);
try {
const res = await apiFetch('/api/admin/policy');
if (res.ok) {
const data = await res.json();
setPolicy(data);
}
} finally {
setLoading(false);
}
}
function toggleFeature(key: keyof FeatureGates) {
setPolicy(prev => ({
...prev,
features: { ...prev.features, [key]: !prev.features[key] },
}));
setDirty(true);
setMessage(null);
}
function toggleLocked(settingKey: string) {
setPolicy(prev => {
const existing = prev.restrictions[settingKey] || {};
const newRestrictions = { ...prev.restrictions };
if (existing.locked) {
delete newRestrictions[settingKey];
} else {
newRestrictions[settingKey] = { ...existing, locked: true };
}
return { ...prev, restrictions: newRestrictions };
});
setDirty(true);
setMessage(null);
}
function toggleHidden(settingKey: string) {
setPolicy(prev => {
const existing = prev.restrictions[settingKey] || {};
const newRestrictions = { ...prev.restrictions };
newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden };
if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) {
delete newRestrictions[settingKey];
}
return { ...prev, restrictions: newRestrictions };
});
setDirty(true);
setMessage(null);
}
async function handleSave() {
setSaving(true);
setMessage(null);
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Policy saved. Users will see changes on next login.' });
setDirty(false);
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
</div>
{dirty && (
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save policy
</button>
)}
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
{/* Feature Gates */}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Feature Gates</h2>
<p className="text-xs text-muted-foreground mt-0.5">Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.</p>
</div>
<div className="divide-y divide-border">
{(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[])
.filter(key => !EXCLUDED_FEATURE_GATES.includes(key))
.map(key => {
const meta = FEATURE_GATE_LABELS[key];
if (!meta) return null;
const { label, description } = meta;
const enabled = policy.features[key];
return (
<div key={key} className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<span className="text-sm text-foreground">{label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div>
<button onClick={() => toggleFeature(key)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
);
})}
</div>
</div>
{/* Setting Restrictions */}
{categories.map(category => (
<div key={category} className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">{category}</h2>
</div>
<div className="divide-y divide-border">
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
const restriction = policy.restrictions[setting.key] || {};
return (
<div key={setting.key} className="px-4 py-3 flex items-center justify-between gap-4">
<span className="text-sm text-foreground">{setting.label}</span>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
className="rounded border-input" />
<Lock className="w-3 h-3" /> Lock
</label>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input type="checkbox" checked={!!restriction.hidden} onChange={() => toggleHidden(setting.key)}
className="rounded border-input" />
Hide
</label>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
);
}
+248
View File
@@ -0,0 +1,248 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, RotateCcw, Loader2 } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry {
value: unknown;
source: 'admin' | 'env' | 'default';
}
export default function AdminSettingsPage() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
fetchConfig();
}, []);
async function fetchConfig() {
setLoading(true);
const res = await apiFetch('/api/admin/config');
if (res.ok) {
setConfig(await res.json());
}
setLoading(false);
}
function handleChange(key: string, value: unknown) {
setEdits(prev => ({ ...prev, [key]: value }));
setMessage(null);
}
function currentValue(key: string): unknown {
if (key in edits) return edits[key];
return config[key]?.value;
}
async function handleSave() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
setMessage(null);
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Settings saved. Changes take effect on next page load.' });
setEdits({});
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
async function handleRevert(key: string) {
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setEdits(prev => {
const next = { ...prev };
delete next[key];
return next;
});
await fetchConfig();
setMessage({ type: 'success', text: `${key} reverted to default` });
}
}
const hasEdits = Object.keys(edits).length > 0;
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
<p className="text-sm text-muted-foreground mt-1">General server configuration</p>
</div>
{hasEdits && (
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save changes
</button>
)}
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
{/* General */}
<SettingsSection title="General">
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
{!!currentValue('allowCustomJmapEndpoint') && (
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
</p>
</div>
)}
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
{/* Logging */}
<SettingsSection title="Logging">
<SelectSetting label="Log Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
<SelectSetting label="Log Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
{/* Settings Sync */}
<SettingsSection title="Settings Sync">
<ToggleSetting label="Settings Sync Enabled" description="Requires SESSION_SECRET to be set" configKey="settingsSyncEnabled" value={currentValue('settingsSyncEnabled') as boolean} source={config.settingsSyncEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
</div>
);
}
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">{title}</h2>
</div>
<div className="divide-y divide-border">
{children}
</div>
</div>
);
}
function SourceBadge({ source }: { source?: string }) {
if (!source || source === 'default') return null;
return (
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
{source}
</span>
);
}
function TextSetting({ label, configKey, value, source, onChange, onRevert, placeholder }: {
label: string; configKey: string; value: string; source?: string;
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string;
}) {
return (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{label}</label>
<SourceBadge source={source} />
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)}
placeholder={placeholder}
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
}
function ToggleSetting({ label, description, configKey, value, source, onChange, onRevert }: {
label: string; description?: string; configKey: string; value: boolean; source?: string;
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) {
return (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}
>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
}
function SelectSetting({ label, configKey, value, source, options, onChange, onRevert }: {
label: string; configKey: string; value: string; source?: string; options: string[];
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) {
return (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
<div className="flex items-center gap-2">
<select
value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
}
+553
View File
@@ -0,0 +1,553 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
const BUILTIN_THEME_OPTIONS = [
{ id: 'builtin-nord', name: 'Nord' },
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
{ id: 'builtin-solarized', name: 'Solarized' },
];
interface ThemeEntry {
id: string;
name: string;
version: string;
author: string;
description: string;
variants: string[];
enabled: boolean;
forceEnabled?: boolean;
installedAt: string;
updatedAt: string;
}
export default function AdminThemesPage() {
const [themes, setThemes] = useState<ThemeEntry[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [policyDirty, setPolicyDirty] = useState(false);
const [savingPolicy, setSavingPolicy] = useState(false);
useEffect(() => { fetchThemes(); fetchPolicy(); }, []);
async function fetchPolicy() {
try {
const res = await apiFetch('/api/admin/policy');
if (res.ok) {
const data = await res.json();
setPolicy({
...data,
themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) },
});
}
} catch { /* ignore */ }
}
function toggleThemesEnabled() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, themesEnabled: !prev.features.themesEnabled },
}));
setPolicyDirty(true);
setMessage(null);
}
function toggleUserThemeUploads() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, userThemesEnabled: !prev.features.userThemesEnabled },
}));
setPolicyDirty(true);
setMessage(null);
}
function toggleBuiltinTheme(themeId: string) {
setPolicy(prev => {
const disabled = prev.themePolicy?.disabledBuiltinThemes || [];
const isDisabled = disabled.includes(themeId);
return {
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
disabledBuiltinThemes: isDisabled
? disabled.filter((id: string) => id !== themeId)
: [...disabled, themeId],
},
};
});
setPolicyDirty(true);
setMessage(null);
}
function toggleAdminTheme(themeId: string) {
setPolicy(prev => {
const disabled = prev.themePolicy?.disabledThemes || [];
const isDisabled = disabled.includes(themeId);
return {
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
disabledThemes: isDisabled
? disabled.filter((id: string) => id !== themeId)
: [...disabled, themeId],
},
};
});
setPolicyDirty(true);
setMessage(null);
}
function setDefaultTheme(themeId: string | null) {
setPolicy(prev => ({
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
defaultThemeId: themeId,
},
}));
setPolicyDirty(true);
setMessage(null);
}
async function handleSavePolicy() {
setSavingPolicy(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Theme policy saved. Users will see changes on next login.' });
setPolicyDirty(false);
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
}
} catch {
setMessage({ type: 'error', text: 'Failed to save policy' });
} finally {
setSavingPolicy(false);
}
}
async function fetchThemes() {
setLoading(true);
try {
const res = await apiFetch('/api/admin/themes');
if (res.ok) setThemes(await res.json());
} finally {
setLoading(false);
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
try {
const res = await apiFetch('/api/admin/themes', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `Theme "${data.theme.name}" installed${warnings}` });
await fetchThemes();
} else {
setMessage({ type: 'error', text: data.error || 'Upload failed' });
}
} catch {
setMessage({ type: 'error', text: 'Upload failed' });
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
}
async function toggleTheme(id: string, enabled: boolean) {
setMessage(null);
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, enabled }),
});
if (res.ok) {
setThemes(prev => prev.map(t => t.id === id ? { ...t, enabled } : t));
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Update failed' });
}
}
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
setMessage(null);
const body: Record<string, unknown> = { id, forceEnabled };
if (forceEnabled) body.enabled = true;
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
setThemes(prev => prev.map(t => t.id === id ? { ...t, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : t));
setPolicy(prev => {
const current = prev.forceEnabledThemes || [];
return {
...prev,
forceEnabledThemes: forceEnabled
? [...current.filter(tid => tid !== id), id]
: current.filter(tid => tid !== id),
};
});
setPolicyDirty(true);
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Update failed' });
}
}
async function forceEnableAll() {
setMessage(null);
const disabled = themes.filter(t => !t.enabled);
if (disabled.length === 0) {
setMessage({ type: 'success', text: 'All themes are already enabled' });
return;
}
let failed = 0;
for (const t of disabled) {
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: t.id, enabled: true }),
});
if (!res.ok) failed++;
}
if (failed === 0) {
await fetchThemes();
setMessage({ type: 'success', text: `All ${disabled.length} theme(s) enabled` });
} else {
await fetchThemes();
setMessage({ type: 'error', text: `${failed} theme(s) failed to enable` });
}
}
async function forceDisableAll() {
setMessage(null);
const enabled = themes.filter(t => t.enabled);
if (enabled.length === 0) {
setMessage({ type: 'success', text: 'All themes are already disabled' });
return;
}
let failed = 0;
for (const t of enabled) {
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: t.id, enabled: false }),
});
if (!res.ok) failed++;
}
if (failed === 0) {
await fetchThemes();
setMessage({ type: 'success', text: `All ${enabled.length} theme(s) disabled` });
} else {
await fetchThemes();
setMessage({ type: 'error', text: `${failed} theme(s) failed to disable` });
}
}
async function deleteTheme(id: string, name: string) {
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
setMessage(null);
const res = await apiFetch('/api/admin/themes', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (res.ok) {
setThemes(prev => prev.filter(t => t.id !== id));
setMessage({ type: 'success', text: `Theme "${name}" removed` });
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Delete failed' });
}
}
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
const themesEnabled = policy.features.themesEnabled ?? true;
const userThemesEnabled = policy.features.userThemesEnabled ?? true;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Themes</h1>
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
</div>
<div className="flex items-center gap-2">
{policyDirty && (
<button
onClick={handleSavePolicy}
disabled={savingPolicy}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Policy
</button>
)}
<label className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 cursor-pointer transition-all shadow-sm">
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
Upload Theme
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleUpload}
disabled={uploading}
className="sr-only"
/>
</label>
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
{/* Theme Policy */}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Theme Policy</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Control theme availability and defaults for users</p>
</div>
<div className="divide-y divide-border">
{/* Master toggle */}
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<span className="text-sm text-foreground">Themes Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
</div>
<button onClick={toggleThemesEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{/* User uploads toggle */}
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<span className="text-sm text-foreground">User Theme Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
</div>
<button onClick={toggleUserThemeUploads}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{/* Force enable / disable all */}
{themes.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={forceEnableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
>
<Power className="w-3.5 h-3.5" />
Enable All
</button>
<button
onClick={forceDisableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
>
<PowerOff className="w-3.5 h-3.5" />
Disable All
</button>
</div>
</div>
)}
{/* Default Theme */}
<div className="px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<span className="text-sm text-foreground">Default Theme</span>
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
</div>
<select
value={policy.themePolicy?.defaultThemeId || ''}
onChange={(e) => setDefaultTheme(e.target.value || null)}
className="h-8 px-2 rounded-md border border-input bg-background text-sm text-foreground"
>
<option value="">System Default</option>
<optgroup label="Built-in">
{BUILTIN_THEME_OPTIONS
.filter(t => !(policy.themePolicy?.disabledBuiltinThemes || []).includes(t.id))
.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</optgroup>
{themes.length > 0 && (
<optgroup label="Admin-deployed">
{themes
.filter(t => !(policy.themePolicy?.disabledThemes || []).includes(t.id))
.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</optgroup>
)}
</select>
</div>
</div>
{/* Built-in themes */}
<div className="px-4 py-3">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Built-in Themes</span>
<div className="mt-2 space-y-2">
{BUILTIN_THEME_OPTIONS.map(theme => {
const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id);
return (
<div key={theme.id} className="flex items-center justify-between gap-4">
<span className="text-sm text-foreground">{theme.name}</span>
<button onClick={() => toggleBuiltinTheme(theme.id)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
);
})}
</div>
</div>
{/* Admin-deployed themes */}
{themes.length > 0 && (
<div className="px-4 py-3">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Admin-deployed Themes</span>
<div className="mt-2 space-y-2">
{themes.map(theme => {
const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id);
return (
<div key={theme.id} className="flex items-center justify-between gap-4">
<span className="text-sm text-foreground">{theme.name}</span>
<button onClick={() => toggleAdminTheme(theme.id)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
{/* Deployed Themes */}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Deployed Themes</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded themes available to all users</p>
</div>
{themes.length === 0 ? (
<div className="p-12 text-center">
<Palette className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No themes installed</p>
<p className="text-xs text-muted-foreground mt-1">Upload a theme ZIP file to get started</p>
</div>
) : (
<div className="divide-y divide-border">
{themes.map(theme => (
<div key={theme.id} className="px-4 py-4 flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{theme.name}</span>
<span className="text-xs text-muted-foreground">v{theme.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
{theme.enabled ? 'Enabled' : 'Disabled'}
</span>
{theme.forceEnabled && (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
<Lock className="w-3 h-3" /> Forced
</span>
)}
</div>
{theme.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{theme.description}</p>
)}
<div className="text-xs text-muted-foreground mt-1">
by {theme.author} &middot; {theme.variants.join(', ')} &middot; installed {new Date(theme.installedAt).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => toggleForceEnabled(theme.id, !theme.forceEnabled)}
title={theme.forceEnabled ? 'Remove force-enable (users can deactivate)' : 'Force enable (users cannot deactivate)'}
className={`p-2 rounded-md transition-colors ${theme.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
>
{theme.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
</button>
<button
onClick={() => toggleTheme(theme.id, !theme.enabled)}
title={theme.enabled ? 'Disable' : 'Enable'}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<Power className="w-4 h-4" />
</button>
<button
onClick={() => deleteTheme(theme.id, theme.name)}
title="Remove"
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
-72
View File
@@ -1,72 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* 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();
logger.warn('Stalwart auth info failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to fetch auth info', details: text },
{ 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 });
}
}
-72
View File
@@ -1,72 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* 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();
logger.warn('Stalwart crypto info failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to fetch crypto info', details: text },
{ 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 });
}
}
+46
View File
@@ -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,83 +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, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
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
if (creds.hasSessionCookie) {
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, newToken, COOKIE_OPTIONS);
}
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,81 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* 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();
logger.warn('Stalwart principal fetch failed', { status: response.status });
return NextResponse.json(
{ error: 'Failed to fetch principal', details: text },
{ 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 });
}
}
-44
View File
@@ -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 });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/admin/session';
import { readAuditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
/**
* GET /api/admin/audit - Get paginated audit log entries (admin-protected)
*/
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
const limit = Math.min(200, Math.max(1, parseInt(request.nextUrl.searchParams.get('limit') || '50', 10)));
const action = request.nextUrl.searchParams.get('action') || undefined;
const { entries, total } = await readAuditLog(page, limit, action);
return NextResponse.json({ entries, total, page, limit }, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Audit log read error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
Binary file not shown.
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
const MIME_TYPES: Record<string, string> = {
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
};
/**
* GET /api/admin/branding/[filename] - Serve uploaded branding images
*
* This endpoint is public (no admin auth) so browsers can load images.
* Only files in the branding directory are served; directory traversal is prevented.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ filename: string }> },
) {
try {
const { filename } = await params;
// Sanitize: only allow basename, no path separators
const safe = path.basename(filename);
if (safe !== filename || filename.includes('..')) {
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
}
const ext = path.extname(safe).toLowerCase();
const contentType = MIME_TYPES[ext];
if (!contentType) {
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
}
const filePath = path.join(BRANDING_DIR, safe);
// Ensure resolved path is still within BRANDING_DIR
const resolved = path.resolve(filePath);
if (!resolved.startsWith(path.resolve(BRANDING_DIR))) {
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
}
const fileStat = await stat(resolved).catch(() => null);
if (!fileStat || !fileStat.isFile()) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const buffer = await readFile(resolved);
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600, must-revalidate',
'Content-Length': String(buffer.length),
},
});
} catch {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
}
+146
View File
@@ -0,0 +1,146 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
import { writeFile, unlink, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
const ALLOWED_MIME_TYPES = new Set([
'image/svg+xml',
'image/png',
'image/jpeg',
'image/webp',
'image/x-icon',
'image/vnd.microsoft.icon',
]);
/** Slots that correspond to branding config keys */
const VALID_SLOTS = new Set([
'faviconUrl',
'appLogoLightUrl',
'appLogoDarkUrl',
'loginLogoLightUrl',
'loginLogoDarkUrl',
]);
function sanitizeFilename(name: string): string {
// Strip directory traversal, keep only safe chars
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
}
/**
* POST /api/admin/branding - Upload a branding image file
*
* Expects multipart/form-data with:
* - file: the image file
* - slot: which branding field this is for (e.g. "faviconUrl")
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const ip = getClientIP(request);
const formData = await request.formData();
const file = formData.get('file') as File | null;
const slot = formData.get('slot') as string | null;
if (!file || !slot) {
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
}
if (!VALID_SLOTS.has(slot)) {
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
}
if (!ALLOWED_MIME_TYPES.has(file.type)) {
return NextResponse.json(
{ error: `Unsupported file type: ${file.type}. Allowed: SVG, PNG, JPEG, WebP, ICO` },
{ status: 400 },
);
}
// Determine extension from mime type
const extMap: Record<string, string> = {
'image/svg+xml': '.svg',
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/x-icon': '.ico',
'image/vnd.microsoft.icon': '.ico',
};
const ext = extMap[file.type] || '.png';
const safeName = sanitizeFilename(`${slot}${ext}`);
const filePath = path.join(BRANDING_DIR, safeName);
// Ensure branding directory exists
if (!existsSync(BRANDING_DIR)) {
await mkdir(BRANDING_DIR, { recursive: true });
}
// Write file to disk
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
// Update config to point to the served URL
const servedUrl = `/api/admin/branding/${safeName}`;
await configManager.ensureLoaded();
await configManager.setAdminConfig({ [slot]: servedUrl });
await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip);
return NextResponse.json({ url: servedUrl, filename: safeName });
} catch (error) {
logger.error('Branding upload error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* DELETE /api/admin/branding - Remove an uploaded branding file
*
* Expects JSON body: { slot: string }
*/
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { slot } = await request.json();
if (!slot || !VALID_SLOTS.has(slot)) {
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
}
// Find and remove matching files for this slot
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
let removed = false;
for (const ext of possibleExts) {
const filePath = path.join(BRANDING_DIR, `${slot}${ext}`);
if (existsSync(filePath)) {
await unlink(filePath);
removed = true;
}
}
// Clear the config override so it falls back to default/env
await configManager.ensureLoaded();
await configManager.removeAdminOverride(slot);
await auditLog('branding_delete', { slot, fileRemoved: removed }, ip);
return NextResponse.json({ success: true });
} catch (error) {
logger.error('Branding delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { changeAdminPassword } from '@/lib/admin/password';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
/**
* POST /api/admin/change-password - Change admin password
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { currentPassword, newPassword } = await request.json();
if (!currentPassword || !newPassword || typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
return NextResponse.json({ error: 'Both current and new password are required' }, { status: 400 });
}
if (newPassword.length < 8) {
return NextResponse.json({ error: 'New password must be at least 8 characters' }, { status: 400 });
}
const success = await changeAdminPassword(currentPassword, newPassword);
if (!success) {
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 });
}
await auditLog('admin.change-password', {}, ip);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Admin change password error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+94
View File
@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
import { logger } from '@/lib/logger';
/**
* GET /api/admin/config - Get full config with sources (admin-protected)
*/
export async function GET() {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
await configManager.ensureLoaded();
const config = configManager.getAllWithSources();
return NextResponse.json(config, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Admin config read error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PATCH /api/admin/config - Update config overrides (admin-protected)
*/
export async function PATCH(request: NextRequest) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const ip = getClientIP(request);
const updates = await request.json();
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
return NextResponse.json({ error: 'Request body must be an object' }, { status: 400 });
}
// Validate keys
const validKeys = Object.keys(CONFIG_ENV_MAP);
const invalidKeys = Object.keys(updates).filter(k => !validKeys.includes(k));
if (invalidKeys.length > 0) {
return NextResponse.json({ error: `Unknown config keys: ${invalidKeys.join(', ')}` }, { status: 400 });
}
// Get old values for audit
const oldValues: Record<string, unknown> = {};
for (const key of Object.keys(updates)) {
oldValues[key] = configManager.get(key);
}
await configManager.setAdminConfig(updates);
await auditLog('config.update', { changes: Object.keys(updates).map(k => ({ key: k, old: oldValues[k], new: updates[k] })) }, ip);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Admin config update error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* DELETE /api/admin/config - Remove admin override for a key (revert to env/default)
*/
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { key } = await request.json();
if (!key || typeof key !== 'string') {
return NextResponse.json({ error: 'Key is required' }, { status: 400 });
}
if (!CONFIG_ENV_MAP[key]) {
return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 });
}
const oldValue = configManager.get(key);
await configManager.removeAdminOverride(key);
await auditLog('config.revert', { key, oldValue }, ip);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Admin config revert error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+252
View File
@@ -0,0 +1,252 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import {
savePlugin,
saveTheme,
getPluginRegistry,
getThemeRegistry,
type ServerPlugin,
type ServerTheme,
} from '@/lib/admin/plugin-registry';
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
/**
* GET /api/admin/marketplace - Search/browse the extension directory
* Proxies to the extension directory API
*/
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const { searchParams } = request.nextUrl;
const url = new URL('/api/v1/extensions', DIRECTORY_URL);
// Forward all search params
for (const [key, value] of searchParams.entries()) {
url.searchParams.set(key, value);
}
const res = await fetch(url.toString(), {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
return NextResponse.json(
{ error: 'Failed to fetch from extension directory' },
{ status: 502 }
);
}
const data = await res.json();
// Enrich with install status
const [pluginRegistry, themeRegistry] = await Promise.all([
getPluginRegistry(),
getThemeRegistry(),
]);
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
if (data.data) {
data.data = data.data.map((ext: Record<string, unknown>) => ({
...ext,
installed: ext.type === 'theme'
? installedThemes.has(ext.slug as string)
: installedPlugins.has(ext.slug as string),
}));
}
return NextResponse.json(data, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Marketplace search error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Failed to connect to extension directory' }, { status: 502 });
}
}
/**
* POST /api/admin/marketplace - Install an extension from the directory
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { slug, version, type } = await request.json();
if (!slug || !version || !type) {
return NextResponse.json({ error: 'Missing slug, version, or type' }, { status: 400 });
}
if (type !== 'plugin' && type !== 'theme') {
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
}
// Download the bundle from the directory
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL);
const bundleRes = await fetch(bundleUrl.toString(), {
signal: AbortSignal.timeout(30000),
});
if (!bundleRes.ok) {
return NextResponse.json(
{ error: `Failed to download bundle: ${bundleRes.status}` },
{ status: 502 }
);
}
const buffer = await bundleRes.arrayBuffer();
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
if (buffer.byteLength > maxSize) {
return NextResponse.json(
{ error: `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit` },
{ status: 400 }
);
}
// Parse the ZIP
let zip: JSZip;
try {
zip = await JSZip.loadAsync(buffer);
} catch {
return NextResponse.json({ error: 'Invalid ZIP file from directory' }, { status: 400 });
}
// Find root directory
const entries = Object.keys(zip.files);
const topDirs = new Set(entries.map(e => e.split('/')[0]));
let root = '';
if (topDirs.size === 1) {
const dir = [...topDirs][0];
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
root = dir + '/';
}
}
// Read manifest
const manifestFile = zip.file(root + 'manifest.json');
if (!manifestFile) {
return NextResponse.json({ error: 'Bundle missing manifest.json' }, { status: 400 });
}
let manifest: Record<string, unknown>;
try {
manifest = JSON.parse(await manifestFile.async('string'));
} catch {
return NextResponse.json({ error: 'Invalid manifest.json in bundle' }, { status: 400 });
}
const now = new Date().toISOString();
if (type === 'theme') {
// Read theme.css
const cssFile = zip.file(root + 'theme.css');
if (!cssFile) {
return NextResponse.json({ error: 'Theme bundle missing theme.css' }, { status: 400 });
}
let css = await cssFile.async('string');
// Validate and sanitize CSS
const warnings: string[] = [];
const safety = validateThemeCSSSafety(css);
if (!safety.valid) {
const sanitized = sanitizeThemeCSS(css);
css = sanitized.css;
warnings.push(...sanitized.warnings);
}
const theme: ServerTheme = {
id: (manifest.id as string) || slug,
name: (manifest.name as string) || slug,
version: (manifest.version as string) || version,
author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '',
variants: (manifest.variants as string[]) || ['light', 'dark'],
enabled: true,
installedAt: now,
updatedAt: now,
};
await saveTheme(theme, css);
await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip);
return NextResponse.json({ success: true, theme, warnings });
} else {
// Plugin installation
// Read entrypoint JS
const entrypoint = (manifest.entrypoint as string) || 'index.js';
const jsFile = zip.file(root + entrypoint);
if (!jsFile) {
return NextResponse.json({ error: `Bundle missing entrypoint: ${entrypoint}` }, { status: 400 });
}
const code = await jsFile.async('string');
// Block plugins with dangerous JS patterns
const DANGEROUS_JS_PATTERNS = [
{ pattern: /\beval\s*\(/g, label: 'eval()' },
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
{ pattern: /document\.cookie/g, label: 'document.cookie' },
{ pattern: /document\.write/g, label: 'document.write' },
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
];
const dangerousFindings: string[] = [];
for (const { pattern, label } of DANGEROUS_JS_PATTERNS) {
if (pattern.test(code)) dangerousFindings.push(label);
pattern.lastIndex = 0;
}
if (dangerousFindings.length > 0) {
return NextResponse.json(
{ error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` },
{ status: 400 },
);
}
// Validate permissions
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
const unknownPerms = permissions.filter(p => !validPerms.has(p));
const warnings: string[] = [];
if (unknownPerms.length > 0) {
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
}
const plugin: ServerPlugin = {
id: (manifest.id as string) || slug,
name: (manifest.name as string) || slug,
version: (manifest.version as string) || version,
author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '',
type: (manifest.type as string) || 'hook',
permissions,
entrypoint,
enabled: true,
installedAt: now,
updatedAt: now,
};
await savePlugin(plugin, code);
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip);
return NextResponse.json({ success: true, plugin, warnings });
}
} catch (error) {
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Installation failed' }, { status: 500 });
}
}
+253
View File
@@ -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 },
);
}
}
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
/**
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
*
* Public endpoint so the client-side plugin loader can fetch bundles.
* Only serves plugins that exist in the registry and are enabled.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
// Validate ID format to prevent path traversal
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
}
const plugin = await getPlugin(id);
if (!plugin) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
if (!plugin.enabled) {
return NextResponse.json({ error: 'Plugin is disabled' }, { status: 403 });
}
const code = await getPluginBundle(id);
if (!code) {
return NextResponse.json({ error: 'Bundle not found' }, { status: 404 });
}
return new NextResponse(code, {
headers: {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'public, max-age=3600, must-revalidate',
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
},
});
} catch {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
}
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlugin } from '@/lib/admin/plugin-registry';
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
import { requireAdminAuth } from '@/lib/admin/session';
/**
* GET /api/admin/plugins/[id]/config - Read all config for a plugin
*
* Returns the full config object for admin-configured plugin settings.
* This endpoint is accessible from the client-side plugin API.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
}
const plugin = await getPlugin(id);
if (!plugin) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
const config = await getPluginConfig(id);
return NextResponse.json(config, {
headers: { 'Cache-Control': 'no-store' },
});
} catch {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PUT /api/admin/plugins/[id]/config - Set a config key
*
* Body: { key: string, value: unknown }
* Requires admin authentication (checked via admin session).
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const { id } = await params;
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
}
const plugin = await getPlugin(id);
if (!plugin) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
let body: { key?: string; value?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
if (!body.key || typeof body.key !== 'string') {
return NextResponse.json({ error: 'key is required and must be a string' }, { status: 400 });
}
// Validate key format (alphanumeric, hyphens, underscores, dots)
if (!/^[a-zA-Z0-9._-]+$/.test(body.key)) {
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
}
await setPluginConfig(id, body.key, body.value);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* DELETE /api/admin/plugins/[id]/config - Delete a config key
*
* Body: { key: string }
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const { id } = await params;
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
}
let body: { key?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
if (!body.key || typeof body.key !== 'string') {
return NextResponse.json({ error: 'key is required' }, { status: 400 });
}
await deletePluginConfigKey(id, body.key);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+247
View File
@@ -0,0 +1,247 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import {
getPluginRegistry,
savePlugin,
deletePlugin as removePlugin,
type ServerPlugin,
} from '@/lib/admin/plugin-registry';
// Server-side extraction using the same validation logic
// ZIP parsing needs to happen on the server for admin-uploaded plugins
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
const SUSPICIOUS_JS_PATTERNS = [
{ pattern: /\beval\s*\(/g, label: 'eval()' },
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
{ pattern: /document\.cookie/g, label: 'document.cookie' },
{ pattern: /document\.write/g, label: 'document.write' },
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
];
/**
* GET /api/admin/plugins - List all admin-managed plugins
*/
export async function GET() {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const registry = await getPluginRegistry();
return NextResponse.json(registry.plugins, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Plugin list error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/admin/plugins - Upload and install a plugin ZIP
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const ip = getClientIP(request);
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
}
if (file.size > MAX_PLUGIN_SIZE) {
return NextResponse.json({ error: 'Plugin ZIP exceeds 5 MB size limit' }, { status: 400 });
}
// Extract and validate ZIP
let zip: JSZip;
try {
const buffer = await file.arrayBuffer();
zip = await JSZip.loadAsync(buffer);
} catch {
return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
}
// Find root
const entries = Object.keys(zip.files);
const topDirs = new Set(entries.map(e => e.split('/')[0]));
let root = '';
if (topDirs.size === 1) {
const dir = [...topDirs][0];
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
root = dir + '/';
}
}
// Read manifest
const manifestFile = zip.file(root + 'manifest.json');
if (!manifestFile) {
return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 });
}
let manifest: Record<string, unknown>;
try {
manifest = JSON.parse(await manifestFile.async('string'));
} catch {
return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 });
}
// Validate manifest
const errors: string[] = [];
if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') errors.push('Missing or invalid "entrypoint"');
const validTypes = ['ui-extension', 'sidebar-app', 'hook'];
if (!validTypes.includes(manifest.type as string)) {
errors.push(`Invalid type. Must be one of: ${validTypes.join(', ')}`);
}
if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
}
if (manifest.permissions && Array.isArray(manifest.permissions)) {
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p));
if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')}`);
}
if (errors.length > 0) {
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
}
// Check file extensions
for (const [filePath, entry] of Object.entries(zip.files)) {
if (entry.dir) continue;
const ext = filePath.lastIndexOf('.') >= 0 ? filePath.slice(filePath.lastIndexOf('.')).toLowerCase() : '';
if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) {
errors.push(`Disallowed file type: ${filePath}`);
}
}
if (errors.length > 0) {
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
}
// Read entrypoint code
const entryFile = zip.file(root + (manifest.entrypoint as string));
if (!entryFile) {
return NextResponse.json({ error: `Missing entrypoint: ${manifest.entrypoint}` }, { status: 400 });
}
const code = await entryFile.async('string');
// Security: block plugins containing dangerous JS patterns
const warnings: string[] = [];
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
if (pattern.test(code)) warnings.push(`Contains ${label}`);
pattern.lastIndex = 0;
}
if (warnings.length > 0) {
return NextResponse.json(
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
{ status: 400 },
);
}
const now = new Date().toISOString();
const plugin: ServerPlugin = {
id: manifest.id as string,
name: manifest.name as string,
version: manifest.version as string,
author: manifest.author as string,
description: (manifest.description as string) || '',
type: manifest.type as string,
permissions: (manifest.permissions as string[]) || [],
entrypoint: manifest.entrypoint as string,
enabled: true,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
installedAt: now,
updatedAt: now,
};
await savePlugin(plugin, code);
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
return NextResponse.json({ plugin });
} catch (error) {
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PATCH /api/admin/plugins - Update plugin metadata (enable/disable)
* Body: { id: string, enabled: boolean }
*/
export async function PATCH(request: NextRequest) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { id, enabled, forceEnabled } = await request.json();
if (!id || typeof id !== 'string') {
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
}
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') {
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
}
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
if (typeof enabled === 'boolean') updates.enabled = enabled;
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
const updated = await updatePluginMeta(id, updates);
if (!updated) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
await auditLog('plugin.update', { id, ...updates }, ip);
return NextResponse.json({ plugin: updated });
} catch (error) {
logger.error('Plugin update error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* DELETE /api/admin/plugins - Remove a plugin
* Body: { id: string }
*/
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { id } = await request.json();
if (!id || typeof id !== 'string') {
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
}
const removed = await removePlugin(id);
if (!removed) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
await auditLog('plugin.delete', { id }, ip);
return NextResponse.json({ success: true });
} catch (error) {
logger.error('Plugin delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import type { SettingsPolicy } from '@/lib/admin/types';
/**
* GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this)
*/
export async function GET() {
try {
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
return NextResponse.json(policy, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Policy read error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PUT /api/admin/policy - Update settings policy (admin-protected)
*/
export async function PUT(request: NextRequest) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const ip = getClientIP(request);
const policy = await request.json() as SettingsPolicy;
if (!policy || typeof policy !== 'object') {
return NextResponse.json({ error: 'Invalid policy object' }, { status: 400 });
}
// Basic validation
if (policy.restrictions && typeof policy.restrictions !== 'object') {
return NextResponse.json({ error: 'restrictions must be an object' }, { status: 400 });
}
if (policy.features && typeof policy.features !== 'object') {
return NextResponse.json({ error: 'features must be an object' }, { status: 400 });
}
if (policy.themePolicy && typeof policy.themePolicy !== 'object') {
return NextResponse.json({ error: 'themePolicy must be an object' }, { status: 400 });
}
await configManager.setPolicy(policy);
await auditLog('policy.update', { restrictionCount: Object.keys(policy.restrictions || {}).length }, ip);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Policy update error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
import { logger } from '@/lib/logger';
/**
* GET /api/admin/themes/[id]/css - Serve theme CSS to clients
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Validate ID format
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
return NextResponse.json({ error: 'Invalid theme ID' }, { status: 400 });
}
// Verify theme exists and is enabled
const registry = await getThemeRegistry();
const theme = registry.themes.find(t => t.id === id);
if (!theme) {
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
}
if (!theme.enabled) {
return NextResponse.json({ error: 'Theme is disabled' }, { status: 403 });
}
const css = await getThemeCSS(id);
if (!css) {
return NextResponse.json({ error: 'Theme CSS not found' }, { status: 404 });
}
return new NextResponse(css, {
headers: {
'Content-Type': 'text/css; charset=utf-8',
'Cache-Control': 'public, max-age=3600',
'X-Content-Type-Options': 'nosniff',
},
});
} catch (error) {
logger.error('Theme CSS serve error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+217
View File
@@ -0,0 +1,217 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import {
getThemeRegistry,
saveTheme,
deleteTheme as removeTheme,
type ServerTheme,
} from '@/lib/admin/plugin-registry';
import JSZip from 'jszip';
import { MAX_THEME_SIZE } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
/**
* GET /api/admin/themes - List all admin-managed themes
*/
export async function GET() {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const registry = await getThemeRegistry();
return NextResponse.json(registry.themes, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Theme list error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/admin/themes - Upload and install a theme ZIP
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const ip = getClientIP(request);
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
}
if (file.size > MAX_THEME_SIZE) {
return NextResponse.json({ error: 'Theme ZIP exceeds 1 MB size limit' }, { status: 400 });
}
// Extract and validate ZIP
let zip: JSZip;
try {
const buffer = await file.arrayBuffer();
zip = await JSZip.loadAsync(buffer);
} catch {
return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
}
// Find root
const entries = Object.keys(zip.files);
const topDirs = new Set(entries.map(e => e.split('/')[0]));
let root = '';
if (topDirs.size === 1) {
const dir = [...topDirs][0];
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
root = dir + '/';
}
}
// Read manifest
const manifestFile = zip.file(root + 'manifest.json');
if (!manifestFile) {
return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 });
}
let manifest: Record<string, unknown>;
try {
manifest = JSON.parse(await manifestFile.async('string'));
} catch {
return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 });
}
// Validate manifest
const errors: string[] = [];
if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
if (manifest.type !== 'theme') {
errors.push(`Expected type "theme", got "${manifest.type}"`);
}
if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
}
if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) {
errors.push('Missing or empty "variants" array');
} else {
const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark');
if (!valid) errors.push('Variants must be "light" or "dark"');
}
if (errors.length > 0) {
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
}
// Read theme.css
const cssFile = zip.file(root + 'theme.css');
if (!cssFile) {
return NextResponse.json({ error: 'Missing theme.css' }, { status: 400 });
}
let css = await cssFile.async('string');
// Validate and sanitize CSS
const warnings: string[] = [];
const safety = validateThemeCSSSafety(css);
if (!safety.valid) {
const sanitized = sanitizeThemeCSS(css);
css = sanitized.css;
warnings.push(...sanitized.warnings);
}
const now = new Date().toISOString();
const theme: ServerTheme = {
id: manifest.id as string,
name: manifest.name as string,
version: manifest.version as string,
author: manifest.author as string,
description: (manifest.description as string) || '',
variants: manifest.variants as string[],
enabled: true,
installedAt: now,
updatedAt: now,
};
await saveTheme(theme, css);
await auditLog('theme.install', { id: theme.id, name: theme.name, version: theme.version, warnings }, ip);
return NextResponse.json({ theme, warnings });
} catch (error) {
logger.error('Theme install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PATCH /api/admin/themes - Update theme metadata (enable/disable)
* Body: { id: string, enabled: boolean }
*/
export async function PATCH(request: NextRequest) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { id, enabled, forceEnabled } = await request.json();
if (!id || typeof id !== 'string') {
return NextResponse.json({ error: 'Missing theme id' }, { status: 400 });
}
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') {
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
}
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
if (typeof enabled === 'boolean') updates.enabled = enabled;
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
const { updateThemeMeta } = await import('@/lib/admin/plugin-registry');
const updated = await updateThemeMeta(id, updates);
if (!updated) {
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
}
await auditLog('theme.update', { id, ...updates }, ip);
return NextResponse.json({ theme: updated });
} catch (error) {
logger.error('Theme update error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* DELETE /api/admin/themes - Remove a theme
* Body: { id: string }
*/
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth();
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { id } = await request.json();
if (!id || typeof id !== 'string') {
return NextResponse.json({ error: 'Missing theme id' }, { status: 400 });
}
const removed = await removeTheme(id);
if (!removed) {
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
}
await auditLog('theme.delete', { id }, ip);
return NextResponse.json({ success: true });
} catch (error) {
logger.error('Theme delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+82 -6
View File
@@ -3,12 +3,16 @@ import { cookies } from 'next/headers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { encryptSession, decryptSession } from '@/lib/auth/crypto'; import { encryptSession, decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie'; import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import {
clearStalwartAuthContextInStore,
setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
const COOKIE_OPTIONS = { const COOKIE_OPTIONS = {
httpOnly: true, ...getCookieOptions(),
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: SESSION_COOKIE_MAX_AGE, maxAge: SESSION_COOKIE_MAX_AGE,
}; };
@@ -22,7 +26,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 });
} }
@@ -33,12 +39,23 @@ export async function POST(request: NextRequest) {
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 token = encryptSession(serverUrl, username, password); const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
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);
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
});
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' }); logger.error('Session store 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 });
} }
@@ -58,9 +75,66 @@ export async function GET(request: NextRequest) {
const credentials = decryptSession(token); const credentials = decryptSession(token);
if (!credentials) { if (!credentials) {
cookieStore.delete(cookieName); cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
} }
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
});
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
const { serverUrl, username } = credentials;
return NextResponse.json(
{ serverUrl, username },
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
);
} catch (error) {
logger.error('Session read error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* PUT - retrieve full credentials (including password) for session restoration.
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
* browser fetch() requests succeed. Non-browser clients cannot forge these.
*/
export async function PUT(request: NextRequest) {
try {
// Require all Sec-Fetch-* headers to match a same-origin fetch() call.
// Browsers set these automatically and they cannot be overridden by JS.
const secFetchSite = request.headers.get('sec-fetch-site');
const secFetchMode = request.headers.get('sec-fetch-mode');
const secFetchDest = request.headers.get('sec-fetch-dest');
if (secFetchSite !== 'same-origin' || secFetchMode !== 'cors' || secFetchDest !== 'empty') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const slot = getSlot(request);
const cookieName = sessionCookieName(slot);
const cookieStore = await cookies();
const token = cookieStore.get(cookieName)?.value;
if (!token) {
return NextResponse.json({ error: 'No session' }, { status: 401 });
}
const credentials = decryptSession(token);
if (!credentials) {
cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
});
return NextResponse.json(credentials, { return NextResponse.json(credentials, {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' }, headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
}); });
@@ -79,10 +153,12 @@ export async function DELETE(request: NextRequest) {
// Delete all session cookies (slots 0-4) // Delete all session cookies (slots 0-4)
for (let i = 0; i <= 4; i++) { for (let i = 0; i <= 4; i++) {
cookieStore.delete(sessionCookieName(i)); cookieStore.delete(sessionCookieName(i));
clearStalwartAuthContextInStore(cookieStore, i);
} }
} else { } else {
const slot = getSlot(request); const slot = getSlot(request);
cookieStore.delete(sessionCookieName(slot)); cookieStore.delete(sessionCookieName(slot));
clearStalwartAuthContextInStore(cookieStore, slot);
} }
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptPayload } from '@/lib/auth/crypto';
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
try {
const { code, state } = await request.json();
if (!code || !state) {
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
}
// Read and decrypt the pending SSO cookie
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
if (!pendingCookie) {
logger.warn('SSO complete: no pending cookie found');
return NextResponse.json({ error: 'No pending SSO session. Please start the login flow again.' }, { status: 400 });
}
const pending = decryptPayload(pendingCookie);
if (!pending) {
cookieStore.delete(SSO_PENDING_COOKIE);
return NextResponse.json({ error: 'Invalid SSO session' }, { status: 400 });
}
// Validate state
if (pending.state !== state) {
logger.warn('SSO complete: state mismatch');
cookieStore.delete(SSO_PENDING_COOKIE);
return NextResponse.json({ error: 'State mismatch' }, { status: 400 });
}
// Validate TTL
const createdAt = pending.created_at as number;
if (!createdAt || Date.now() - createdAt > SSO_PENDING_MAX_AGE_MS) {
logger.warn('SSO complete: pending session expired');
cookieStore.delete(SSO_PENDING_COOKIE);
return NextResponse.json({ error: 'SSO session expired. Please try again.' }, { status: 400 });
}
const codeVerifier = pending.code_verifier as string;
const redirectUri = pending.redirect_uri as string;
if (!codeVerifier || !redirectUri) {
cookieStore.delete(SSO_PENDING_COOKIE);
return NextResponse.json({ error: 'Invalid SSO session data' }, { status: 400 });
}
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
// Store refresh token
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(0);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
}
// Delete pending cookie
cookieStore.delete(SSO_PENDING_COOKIE);
return NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
});
} catch (error) {
// Clean up pending cookie on any error
cookieStore.delete(SSO_PENDING_COOKIE);
logger.error('SSO complete error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 });
}
}
+89
View File
@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptPayload } from '@/lib/auth/crypto';
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
export async function POST(request: NextRequest) {
try {
if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) {
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
}
const { redirect_uri, locale } = await request.json();
if (!redirect_uri || typeof redirect_uri !== 'string') {
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
}
// Validate redirect_uri origin matches the request origin to prevent open redirects
const requestOrigin = request.headers.get('origin') || request.nextUrl.origin;
try {
const redirectOrigin = new URL(redirect_uri).origin;
if (redirectOrigin !== requestOrigin) {
logger.warn('SSO start: redirect_uri origin mismatch', { redirectOrigin, requestOrigin });
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
}
} catch {
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
}
const { clientId, discoveryUrl } = getRequiredConfig();
const metadata = await discoverOAuth(discoveryUrl);
if (!metadata?.authorization_endpoint) {
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
}
// Generate PKCE + state server-side
const codeVerifier = generateCodeVerifierServer();
const codeChallenge = generateCodeChallengeServer(codeVerifier);
const state = generateStateServer();
// Encrypt and store in httpOnly cookie
const pendingData = {
state,
code_verifier: codeVerifier,
redirect_uri,
created_at: Date.now(),
};
const encrypted = encryptPayload(pendingData);
const cookieStore = await cookies();
const baseCookieOpts = getCookieOptions();
cookieStore.set(SSO_PENDING_COOKIE, encrypted, {
...baseCookieOpts,
maxAge: SSO_PENDING_MAX_AGE,
});
// Build authorize URL
const authUrl = new URL(metadata.authorization_endpoint);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirect_uri);
authUrl.searchParams.set('scope', OAUTH_SCOPES);
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
if (locale) {
authUrl.searchParams.set('ui_locales', locale);
}
return NextResponse.json({
authorize_url: authUrl.toString(),
state,
});
} catch (error) {
logger.error('SSO start error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
return bodySlot;
}
const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0;
const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot;
}
export async function POST(request: NextRequest) {
try {
const { serverUrl, username, authHeader, slot: bodySlot } = await request.json();
if (!serverUrl || !username || !authHeader) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const slot = getSlot(request, bodySlot);
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
logger.error('Failed to store Stalwart auth context', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+6 -78
View File
@@ -1,18 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { refreshTokenCookieName } from '@/lib/oauth/tokens'; import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || ''; import { getCookieOptions } from '@/lib/oauth/cookie-config';
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: 30 * 24 * 60 * 60,
};
function getSlot(request: NextRequest): number { function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot'); const raw = request.nextUrl.searchParams.get('slot');
@@ -22,44 +13,6 @@ function getSlot(request: NextRequest): number {
return slot; return slot;
} }
function getRequiredConfig() {
const clientId = process.env.OAUTH_CLIENT_ID;
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
const issuerUrl = process.env.OAUTH_ISSUER_URL;
if (!clientId || !serverUrl) {
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
}
const discoveryUrl = issuerUrl?.trim() || serverUrl;
if (issuerUrl !== undefined && !issuerUrl.trim()) {
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
}
return { clientId, serverUrl, discoveryUrl };
}
async function getTokenEndpoint(): Promise<string> {
const { discoveryUrl } = getRequiredConfig();
const metadata = await discoverOAuth(discoveryUrl);
if (!metadata?.token_endpoint) {
throw new Error('OAuth token endpoint not found');
}
return metadata.token_endpoint;
}
async function getMetadata(): Promise<import('@/lib/oauth/discovery').OAuthMetadata | null> {
const { discoveryUrl } = getRequiredConfig();
return discoverOAuth(discoveryUrl);
}
function buildOAuthParams(base: Record<string, string>): URLSearchParams {
const { clientId } = getRequiredConfig();
const params = new URLSearchParams({ ...base, client_id: clientId });
if (CLIENT_SECRET) {
params.set('client_secret', CLIENT_SECRET);
}
return params;
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json(); const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json();
@@ -69,43 +22,18 @@ export async function POST(request: NextRequest) {
} }
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 tokenEndpoint = await getTokenEndpoint();
const params = buildOAuthParams({ const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri);
grant_type: 'authorization_code',
code,
redirect_uri,
code_verifier,
});
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
logger.error('Token exchange failed', { status: tokenResponse.status, error: errorText });
return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 });
}
const tokens = await tokenResponse.json();
if (!tokens.access_token) {
logger.error('Token response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) });
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
}
const response = NextResponse.json({ const response = NextResponse.json({
access_token: tokens.access_token, access_token: tokens.access_token,
expires_in: tokens.expires_in || 3600, expires_in: tokens.expires_in,
}); });
if (tokens.refresh_token) { if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot); const cookieName = refreshTokenCookieName(slot);
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS); cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
} }
return response; return response;
@@ -154,7 +82,7 @@ export async function PUT(request: NextRequest) {
} }
if (tokens.refresh_token) { if (tokens.refresh_token) {
cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS); cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
} }
return NextResponse.json({ return NextResponse.json({
+190
View File
@@ -0,0 +1,190 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
/**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
*
* This allows 2FA users who log in with basic auth + TOTP to upgrade
* to token-based auth, avoiding session expiry when the TOTP rotates.
*
* Tries three strategies:
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
* 2. ROPC grant without client_id
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
*/
async function tryTokenRequest(
tokenEndpoint: string,
params: URLSearchParams,
extraHeaders?: Record<string, string>,
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
try {
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers,
body: params.toString(),
});
if (!response.ok) {
const errorText = await response.text();
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
}
const tokens = await response.json();
if (!tokens.access_token) {
return { ok: false, status: 502, error: 'Response missing access_token' };
}
return { ok: true, tokens };
} catch (err) {
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
}
}
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
// 1. Try OAuth discovery
const metadata = await discoverOAuth(serverUrl);
if (metadata?.token_endpoint) return metadata.token_endpoint;
// 2. Try common Stalwart token endpoint paths directly
const candidates = [
`${serverUrl}/auth/token`,
`${serverUrl}/api/oauth/token`,
];
for (const url of candidates) {
try {
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
if (probe.status !== 404 && probe.status !== 405) {
return url;
}
} catch {
// Network error - endpoint not reachable
}
}
return null;
}
export async function POST(request: NextRequest) {
try {
const { serverUrl, username, password, slot: bodySlot } = await request.json();
if (!serverUrl || !username || !password) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
}
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
// Use the server-side JMAP_SERVER_URL if set (may differ from the
// public URL the browser uses, e.g. inside Docker).
const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl;
const tokenEndpoint = await findTokenEndpoint(internalServerUrl);
if (!tokenEndpoint) {
// Also try with the client-provided URL in case the internal one differs
const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null;
if (!clientEndpoint) {
logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl });
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
}
return await attemptAllStrategies(clientEndpoint, username, password, slot);
}
return await attemptAllStrategies(tokenEndpoint, username, password, slot);
} catch (error) {
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
async function attemptAllStrategies(
tokenEndpoint: string,
username: string,
password: string,
slot: number,
): Promise<NextResponse> {
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
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 attempts: Array<{ strategy: string; error: string }> = [];
// Strategy 1: ROPC with client_id (if configured)
if (clientId) {
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
if (clientSecret) params.set('client_secret', clientSecret);
const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
}
// Strategy 2: ROPC without client_id
{
const params = new URLSearchParams({ grant_type: 'password', username, password });
const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
}
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
{
const params = new URLSearchParams({ grant_type: 'password' });
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) {
logger.info('TOTP token exchange succeeded (Basic Auth header)');
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'Basic Auth header', error: result.error });
}
// Strategy 4: client_credentials with Basic Auth (last resort)
{
const params = new URLSearchParams({ grant_type: 'client_credentials' });
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) {
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
}
logger.warn('TOTP token exchange: all strategies failed', { attempts });
return NextResponse.json({
error: 'token_exchange_failed',
detail: 'All token exchange strategies failed',
attempts,
}, { status: 502 });
}
async function storeAndRespond(
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
slot: number,
): Promise<NextResponse> {
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
const cookieStore = await cookies();
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
}
return NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in || 3600,
has_refresh_token: !!tokens.refresh_token,
});
}
+41 -24
View File
@@ -1,39 +1,56 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
/** /**
* Runtime configuration endpoint * Runtime configuration endpoint
* *
* This endpoint serves configuration values that can be set at runtime * This endpoint serves configuration values that can be set at runtime
* via environment variables, enabling post-build configuration for * via environment variables or admin dashboard overrides, enabling
* Docker deployments. * post-build configuration for Docker deployments.
* *
* Priority order: * Priority order:
* 1. Runtime env vars (APP_NAME, JMAP_SERVER_URL) * 1. Admin dashboard overrides (data/admin/config.json)
* 2. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL) * 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
* 3. Default values * 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
* 4. Default values
*/ */
export async function GET() { export async function GET() {
logger.debug('Config requested'); logger.debug('Config requested');
await configManager.ensureLoaded();
const appName = configManager.get<string>('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
return NextResponse.json({ return NextResponse.json({
appName: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail', appName,
jmapServerUrl: process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '', jmapServerUrl,
oauthEnabled: process.env.OAUTH_ENABLED === 'true', oauthEnabled,
oauthOnly: process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true', oauthOnly,
oauthClientId: process.env.OAUTH_CLIENT_ID || '', oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: process.env.OAUTH_ISSUER_URL || '', oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
rememberMeEnabled: !!process.env.SESSION_SECRET, rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
settingsSyncEnabled: process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET, settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
stalwartFeaturesEnabled: process.env.STALWART_FEATURES !== 'false', stalwartFeaturesEnabled,
devMode: process.env.DEV_MOCK_JMAP === 'true', devMode: configManager.get<boolean>('devMode', false),
faviconUrl: process.env.FAVICON_URL || '/branding/Bulwark_Favicon.svg', faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
appLogoLightUrl: process.env.APP_LOGO_LIGHT_URL || '', appLogoLightUrl: configManager.get<string>('appLogoLightUrl', ''),
appLogoDarkUrl: process.env.APP_LOGO_DARK_URL || '', appLogoDarkUrl: configManager.get<string>('appLogoDarkUrl', ''),
loginLogoLightUrl: process.env.LOGIN_LOGO_LIGHT_URL || '/branding/Bulwark_Logo_Color.svg', loginLogoLightUrl: configManager.get<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
loginLogoDarkUrl: process.env.LOGIN_LOGO_DARK_URL || '/branding/Bulwark_Logo_White.svg', loginLogoDarkUrl: configManager.get<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
loginCompanyName: process.env.LOGIN_COMPANY_NAME || '', loginCompanyName: configManager.get<string>('loginCompanyName', ''),
loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '', loginImprintUrl: configManager.get<string>('loginImprintUrl', ''),
loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '', loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '', loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''),
}); });
} }
File diff suppressed because one or more lines are too long
+352 -26
View File
@@ -40,32 +40,358 @@ function isValidDomain(domain: string): boolean {
// Known multi-part TLDs where the registrable domain includes one extra label. // Known multi-part TLDs where the registrable domain includes one extra label.
const MULTI_PART_TLDS = new Set([ const MULTI_PART_TLDS = new Set([
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk", // .ac
"co.jp", "or.jp", "ne.jp", "ac.jp", "go.jp", "com.ac", "gov.ac", "mil.ac", "net.ac", "org.ac",
"co.kr", "or.kr", "go.kr", "ac.kr", // .ae
"co.in", "net.in", "org.in", "ac.in", "gov.in", "ac.ae", "co.ae", "gov.ae", "mil.ae", "name.ae", "net.ae", "org.ae", "pro.ae", "sch.ae",
"co.nz", "org.nz", "net.nz", "govt.nz", "ac.nz", // .af
"co.za", "org.za", "net.za", "gov.za", "ac.za", "com.af", "edu.af", "gov.af", "net.af", "org.af",
"com.au", "net.au", "org.au", "edu.au", "gov.au", // .al
"com.br", "net.br", "org.br", "edu.br", "gov.br", "com.al", "edu.al", "gov.al", "mil.al", "net.al", "org.al",
"com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", // .ao
"com.mx", "net.mx", "org.mx", "gob.mx", "edu.mx", "co.ao", "ed.ao", "gv.ao", "it.ao", "og.ao", "pb.ao",
"com.ar", "net.ar", "org.ar", "gob.ar", "edu.ar", // .ar
"com.tw", "net.tw", "org.tw", "edu.tw", "gov.tw", "com.ar", "edu.ar", "gob.ar", "gov.ar", "int.ar", "mil.ar", "net.ar", "org.ar", "tur.ar",
"com.hk", "net.hk", "org.hk", "edu.hk", "gov.hk", // .at
"com.sg", "net.sg", "org.sg", "edu.sg", "gov.sg", "ac.at", "co.at", "gv.at", "or.at",
"com.my", "net.my", "org.my", "edu.my", "gov.my", // .au
"com.ph", "net.ph", "org.ph", "edu.ph", "gov.ph", "asn.au", "com.au", "csiro.au", "edu.au", "gov.au", "id.au", "net.au", "org.au",
"com.pk", "net.pk", "org.pk", "edu.pk", "gov.pk", // .ba
"com.ng", "net.ng", "org.ng", "edu.ng", "gov.ng", "co.ba", "com.ba", "edu.ba", "gov.ba", "mil.ba", "net.ba", "org.ba", "rs.ba",
"co.il", "org.il", "net.il", "ac.il", "gov.il", "unbi.ba", "unmo.ba", "unsa.ba", "untz.ba", "unze.ba",
"co.th", "or.th", "ac.th", "go.th", "in.th", // .bb
"co.id", "or.id", "ac.id", "go.id", "web.id", "biz.bb", "co.bb", "com.bb", "edu.bb", "gov.bb", "info.bb", "net.bb", "org.bb",
"com.tr", "net.tr", "org.tr", "edu.tr", "gov.tr", "store.bb", "tv.bb",
"com.ua", "net.ua", "org.ua", "edu.ua", "gov.ua", // .bh
"com.eg", "net.eg", "org.eg", "edu.eg", "gov.eg", "biz.bh", "cc.bh", "com.bh", "edu.bh", "gov.bh", "info.bh", "net.bh", "org.bh",
"com.sa", "net.sa", "org.sa", "edu.sa", "gov.sa", // .bn
"co.ke", "or.ke", "ac.ke", "go.ke", "ne.ke", "com.bn", "edu.bn", "gov.bn", "net.bn", "org.bn",
// .bo
"com.bo", "edu.bo", "gob.bo", "gov.bo", "int.bo", "mil.bo", "net.bo", "org.bo", "tv.bo",
// .br
"adm.br", "adv.br", "agr.br", "am.br", "arq.br", "art.br", "ato.br", "b.br",
"bio.br", "blog.br", "bmd.br", "cim.br", "cng.br", "cnt.br", "com.br", "coop.br",
"ecn.br", "edu.br", "eng.br", "esp.br", "etc.br", "eti.br", "far.br", "flog.br",
"fm.br", "fnd.br", "fot.br", "fst.br", "g12.br", "ggf.br", "gov.br", "imb.br",
"ind.br", "inf.br", "jor.br", "jus.br", "lel.br", "mat.br", "med.br", "mil.br",
"mus.br", "net.br", "nom.br", "not.br", "ntr.br", "odo.br", "org.br", "ppg.br",
"pro.br", "psc.br", "psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br",
"trd.br", "tur.br", "tv.br", "vet.br", "vlog.br", "wiki.br", "zlg.br",
// .bs
"com.bs", "edu.bs", "gov.bs", "net.bs", "org.bs",
// .bz
"com.bz", "edu.bz", "gov.bz", "net.bz", "org.bz",
// .ca
"ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca",
"nu.ca", "on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca",
// .ck
"biz.ck", "co.ck", "edu.ck", "gen.ck", "gov.ck", "info.ck", "net.ck", "org.ck",
// .cn
"ac.cn", "ah.cn", "bj.cn", "com.cn", "cq.cn", "edu.cn", "fj.cn", "gd.cn",
"gov.cn", "gs.cn", "gx.cn", "gz.cn", "ha.cn", "hb.cn", "he.cn", "hi.cn",
"hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mil.cn", "net.cn",
"nm.cn", "nx.cn", "org.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn",
"sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn",
// .co
"com.co", "edu.co", "gov.co", "mil.co", "net.co", "nom.co", "org.co",
// .cr
"ac.cr", "co.cr", "ed.cr", "fi.cr", "go.cr", "or.cr", "sa.cr",
// .cy
"ac.cy", "biz.cy", "com.cy", "ekloges.cy", "gov.cy", "ltd.cy", "name.cy",
"net.cy", "org.cy", "parliament.cy", "press.cy", "pro.cy", "tm.cy",
// .do
"art.do", "com.do", "edu.do", "gob.do", "gov.do", "mil.do", "net.do", "org.do",
"sld.do", "web.do",
// .dz
"art.dz", "asso.dz", "com.dz", "edu.dz", "gov.dz", "net.dz", "org.dz", "pol.dz",
// .ec
"com.ec", "edu.ec", "fin.ec", "gov.ec", "info.ec", "med.ec", "mil.ec", "net.ec",
"org.ec", "pro.ec",
// .eg
"com.eg", "edu.eg", "eun.eg", "gov.eg", "mil.eg", "name.eg", "net.eg", "org.eg", "sci.eg",
// .er
"com.er", "edu.er", "gov.er", "ind.er", "mil.er", "net.er", "org.er", "rochest.er", "w.er",
// .es
"com.es", "edu.es", "gob.es", "nom.es", "org.es",
// .et
"biz.et", "com.et", "edu.et", "gov.et", "info.et", "name.et", "net.et", "org.et",
// .fj
"ac.fj", "biz.fj", "com.fj", "info.fj", "mil.fj", "name.fj", "net.fj", "org.fj", "pro.fj",
// .fk
"ac.fk", "co.fk", "gov.fk", "net.fk", "nom.fk", "org.fk",
// .fr
"asso.fr", "com.fr", "gouv.fr", "nom.fr", "prd.fr", "presse.fr", "tm.fr",
// .gg
"co.gg", "net.gg", "org.gg",
// .gh
"com.gh", "edu.gh", "gov.gh", "mil.gh", "org.gh",
// .gn
"ac.gn", "com.gn", "gov.gn", "net.gn", "org.gn",
// .gr
"com.gr", "edu.gr", "gov.gr", "mil.gr", "net.gr", "org.gr",
// .gt
"com.gt", "edu.gt", "gob.gt", "ind.gt", "mil.gt", "net.gt", "org.gt",
// .gu
"com.gu", "edu.gu", "gov.gu", "net.gu", "org.gu",
// .hk
"com.hk", "edu.hk", "gov.hk", "idv.hk", "net.hk", "org.hk",
// .id
"ac.id", "co.id", "go.id", "mil.id", "net.id", "or.id", "sch.id", "web.id",
// .il
"ac.il", "co.il", "gov.il", "idf.il", "k12.il", "muni.il", "net.il", "org.il",
// .in
"4fd.in", "ac.in", "co.in", "edu.in", "ernet.in", "firm.in", "gen.in", "gov.in",
"ind.in", "mil.in", "net.in", "nic.in", "org.in", "res.in",
// .iq
"com.iq", "edu.iq", "gov.iq", "mil.iq", "net.iq", "org.iq",
// .ir
"ac.ir", "co.ir", "dnssec.ir", "gov.ir", "id.ir", "net.ir", "org.ir", "sch.ir",
// .it
"edu.it", "gov.it",
// .je
"co.je", "net.je", "org.je",
// .jo
"com.jo", "edu.jo", "gov.jo", "mil.jo", "name.jo", "net.jo", "org.jo", "sch.jo",
// .jp
"ac.jp", "ad.jp", "co.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp", "ne.jp", "or.jp",
// .ke
"ac.ke", "co.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "ne.ke", "or.ke", "sc.ke",
// .kh
"com.kh", "edu.kh", "gov.kh", "mil.kh", "net.kh", "org.kh", "per.kh",
// .ki
"biz.ki", "com.ki", "de.ki", "edu.ki", "gov.ki", "info.ki", "mob.ki", "net.ki",
"org.ki", "tel.ki",
// .km
"asso.km", "com.km", "coop.km", "edu.km", "gouv.km", "medecin.km", "mil.km",
"nom.km", "notaires.km", "pharmaciens.km", "presse.km", "tm.km", "veterinaire.km",
// .kn
"edu.kn", "gov.kn", "net.kn", "org.kn",
// .kr
"ac.kr", "busan.kr", "chungbuk.kr", "chungnam.kr", "co.kr", "daegu.kr",
"daejeon.kr", "es.kr", "gangwon.kr", "go.kr", "gwangju.kr", "gyeongbuk.kr",
"gyeonggi.kr", "gyeongnam.kr", "hs.kr", "incheon.kr", "jeju.kr", "jeonbuk.kr",
"jeonnam.kr", "kg.kr", "mil.kr", "ms.kr", "ne.kr", "or.kr", "pe.kr", "re.kr",
"sc.kr", "seoul.kr", "ulsan.kr",
// .kw
"com.kw", "edu.kw", "gov.kw", "net.kw", "org.kw",
// .ky
"com.ky", "edu.ky", "gov.ky", "net.ky", "org.ky",
// .kz
"com.kz", "edu.kz", "gov.kz", "mil.kz", "net.kz", "org.kz",
// .lb
"com.lb", "edu.lb", "gov.lb", "net.lb", "org.lb",
// .lk
"assn.lk", "com.lk", "edu.lk", "gov.lk", "grp.lk", "hotel.lk", "int.lk", "ltd.lk",
"net.lk", "ngo.lk", "org.lk", "sch.lk", "soc.lk", "web.lk",
// .lr
"com.lr", "edu.lr", "gov.lr", "net.lr", "org.lr",
// .lv
"asn.lv", "com.lv", "conf.lv", "edu.lv", "gov.lv", "id.lv", "mil.lv", "net.lv", "org.lv",
// .ly
"com.ly", "edu.ly", "gov.ly", "id.ly", "med.ly", "net.ly", "org.ly", "plc.ly", "sch.ly",
// .ma
"ac.ma", "co.ma", "gov.ma", "net.ma", "org.ma", "press.ma",
// .mc
"asso.mc", "tm.mc",
// .me
"ac.me", "co.me", "edu.me", "gov.me", "its.me", "net.me", "org.me", "priv.me",
// .mg
"com.mg", "edu.mg", "gov.mg", "mil.mg", "nom.mg", "org.mg", "prd.mg", "tm.mg",
// .mk
"com.mk", "edu.mk", "gov.mk", "inf.mk", "name.mk", "net.mk", "org.mk", "pro.mk",
// .ml
"com.ml", "edu.ml", "gov.ml", "net.ml", "org.ml", "presse.ml",
// .mn
"edu.mn", "gov.mn", "org.mn",
// .mo
"com.mo", "edu.mo", "gov.mo", "net.mo", "org.mo",
// .mt
"com.mt", "edu.mt", "gov.mt", "net.mt", "org.mt",
// .mu
"ac.mu", "co.mu", "com.mu", "gov.mu", "net.mu", "or.mu", "org.mu",
// .mv
"aero.mv", "biz.mv", "com.mv", "coop.mv", "edu.mv", "gov.mv", "info.mv",
"int.mv", "mil.mv", "museum.mv", "name.mv", "net.mv", "org.mv", "pro.mv",
// .mw
"ac.mw", "co.mw", "com.mw", "coop.mw", "edu.mw", "gov.mw", "int.mw",
"museum.mw", "net.mw", "org.mw",
// .mx
"com.mx", "edu.mx", "gob.mx", "net.mx", "org.mx",
// .my
"com.my", "edu.my", "gov.my", "mil.my", "name.my", "net.my", "org.my", "sch.my",
// .mz
"ac.mz", "co.mz", "edu.mz", "gov.mz", "org.mz",
// .na
"co.na", "com.na",
// .nf
"arts.nf", "com.nf", "firm.nf", "info.nf", "net.nf", "other.nf", "per.nf",
"rec.nf", "store.nf", "web.nf",
// .ng
"biz.ng", "com.ng", "edu.ng", "gov.ng", "mil.ng", "mobi.ng", "name.ng",
"net.ng", "org.ng", "sch.ng",
// .ni
"ac.ni", "co.ni", "com.ni", "edu.ni", "gob.ni", "mil.ni", "net.ni", "nom.ni", "org.ni",
// .np
"com.np", "edu.np", "gov.np", "mil.np", "net.np", "org.np",
// .nr
"biz.nr", "com.nr", "edu.nr", "gov.nr", "info.nr", "net.nr", "org.nr",
// .nz
"ac.nz", "co.nz", "cri.nz", "geek.nz", "gen.nz", "govt.nz", "health.nz",
"iwi.nz", "maori.nz", "mil.nz", "net.nz", "org.nz", "parliament.nz", "school.nz",
// .om
"ac.om", "biz.om", "co.om", "com.om", "edu.om", "gov.om", "med.om", "mil.om",
"museum.om", "net.om", "org.om", "pro.om", "sch.om",
// .pa
"abo.pa", "ac.pa", "com.pa", "edu.pa", "gob.pa", "ing.pa", "med.pa", "net.pa",
"nom.pa", "org.pa", "sld.pa",
// .pe
"com.pe", "edu.pe", "gob.pe", "mil.pe", "net.pe", "nom.pe", "org.pe", "sld.pe",
// .ph
"com.ph", "edu.ph", "gov.ph", "i.ph", "mil.ph", "net.ph", "ngo.ph", "org.ph",
// .pk
"biz.pk", "com.pk", "edu.pk", "fam.pk", "gob.pk", "gok.pk", "gon.pk", "gop.pk",
"gos.pk", "gov.pk", "net.pk", "org.pk", "web.pk",
// .pl
"art.pl", "bialystok.pl", "biz.pl", "com.pl", "edu.pl", "gda.pl", "gdansk.pl",
"gorzow.pl", "gov.pl", "info.pl", "katowice.pl", "krakow.pl", "lodz.pl",
"lublin.pl", "mil.pl", "net.pl", "ngo.pl", "olsztyn.pl", "org.pl", "poznan.pl",
"pwr.pl", "radom.pl", "slupsk.pl", "szczecin.pl", "torun.pl", "warszawa.pl",
"waw.pl", "wroc.pl", "wroclaw.pl", "zgora.pl",
// .pr
"ac.pr", "biz.pr", "com.pr", "edu.pr", "est.pr", "gov.pr", "info.pr", "isla.pr",
"name.pr", "net.pr", "org.pr", "pro.pr", "prof.pr",
// .ps
"com.ps", "edu.ps", "gov.ps", "net.ps", "org.ps", "plo.ps", "sec.ps",
// .pt
"com.pt", "edu.pt", "gov.pt", "int.pt", "net.pt", "nome.pt", "org.pt", "publ.pt",
// .pw
"belau.pw", "co.pw", "ed.pw", "go.pw", "ne.pw", "or.pw",
// .py
"com.py", "edu.py", "gov.py", "mil.py", "net.py", "org.py",
// .qa
"com.qa", "edu.qa", "gov.qa", "mil.qa", "net.qa", "org.qa",
// .re
"asso.re", "com.re", "nom.re",
// .ro
"arts.ro", "com.ro", "firm.ro", "info.ro", "nom.ro", "nt.ro", "org.ro",
"rec.ro", "store.ro", "tm.ro", "www.ro",
// .rs
"ac.rs", "co.rs", "edu.rs", "gov.rs", "in.rs", "org.rs",
// .ru
"ac.ru", "adygeya.ru", "altai.ru", "amur.ru", "arkhangelsk.ru", "astrakhan.ru",
"bashkiria.ru", "belgorod.ru", "bir.ru", "bryansk.ru", "buryatia.ru", "cbg.ru",
"chel.ru", "chelyabinsk.ru", "chita.ru", "chukotka.ru", "chuvashia.ru", "com.ru",
"dagestan.ru", "e-burg.ru", "edu.ru", "gov.ru", "grozny.ru", "int.ru",
"irkutsk.ru", "ivanovo.ru", "izhevsk.ru", "jar.ru", "joshkar-ola.ru",
"kalmykia.ru", "kaluga.ru", "kamchatka.ru", "karelia.ru", "kazan.ru", "kchr.ru",
"kemerovo.ru", "khabarovsk.ru", "khakassia.ru", "khv.ru", "kirov.ru",
"koenig.ru", "komi.ru", "kostroma.ru", "kranoyarsk.ru", "kuban.ru", "kurgan.ru",
"kursk.ru", "lipetsk.ru", "magadan.ru", "mari.ru", "mari-el.ru", "marine.ru",
"mil.ru", "mordovia.ru", "mosreg.ru", "msk.ru", "murmansk.ru", "nalchik.ru",
"net.ru", "nnov.ru", "nov.ru", "novosibirsk.ru", "nsk.ru", "omsk.ru",
"orenburg.ru", "org.ru", "oryol.ru", "penza.ru", "perm.ru", "pp.ru", "pskov.ru",
"ptz.ru", "rnd.ru", "ryazan.ru", "sakhalin.ru", "samara.ru", "saratov.ru",
"simbirsk.ru", "smolensk.ru", "spb.ru", "stavropol.ru", "stv.ru", "surgut.ru",
"tambov.ru", "tatarstan.ru", "tom.ru", "tomsk.ru", "tsaritsyn.ru", "tsk.ru",
"tula.ru", "tuva.ru", "tver.ru", "tyumen.ru", "udm.ru", "udmurtia.ru",
"ulan-ude.ru", "vladikavkaz.ru", "vladimir.ru", "vladivostok.ru", "volgograd.ru",
"vologda.ru", "voronezh.ru", "vrn.ru", "vyatka.ru", "yakutia.ru", "yamal.ru",
"yekaterinburg.ru", "yuzhno-sakhalinsk.ru",
// .rw
"ac.rw", "co.rw", "com.rw", "edu.rw", "gouv.rw", "gov.rw", "int.rw", "mil.rw", "net.rw",
// .sa
"com.sa", "edu.sa", "gov.sa", "med.sa", "net.sa", "org.sa", "pub.sa", "sch.sa",
// .sb
"com.sb", "edu.sb", "gov.sb", "net.sb", "org.sb",
// .sc
"com.sc", "edu.sc", "gov.sc", "net.sc", "org.sc",
// .sd
"com.sd", "edu.sd", "gov.sd", "info.sd", "med.sd", "net.sd", "org.sd", "tv.sd",
// .se
"a.se", "ac.se", "b.se", "bd.se", "c.se", "d.se", "e.se", "f.se", "g.se",
"h.se", "i.se", "k.se", "l.se", "m.se", "n.se", "o.se", "org.se", "p.se",
"parti.se", "pp.se", "press.se", "r.se", "s.se", "t.se", "tm.se", "u.se",
"w.se", "x.se", "y.se", "z.se",
// .sg
"com.sg", "edu.sg", "gov.sg", "idn.sg", "net.sg", "org.sg", "per.sg",
// .sh
"co.sh", "com.sh", "edu.sh", "gov.sh", "net.sh", "nom.sh", "org.sh",
// .sl
"com.sl", "edu.sl", "gov.sl", "net.sl", "org.sl",
// .sn
"art.sn", "com.sn", "edu.sn", "gouv.sn", "org.sn", "perso.sn", "univ.sn",
// .st
"co.st", "com.st", "consulado.st", "edu.st", "embaixada.st", "gov.st", "mil.st",
"net.st", "org.st", "principe.st", "saotome.st", "store.st",
// .sv
"com.sv", "edu.sv", "gob.sv", "org.sv", "red.sv",
// .sy
"com.sy", "edu.sy", "gov.sy", "mil.sy", "net.sy", "news.sy", "org.sy",
// .sz
"ac.sz", "co.sz", "org.sz",
// .th
"ac.th", "co.th", "go.th", "in.th", "mi.th", "net.th", "or.th",
// .tj
"ac.tj", "biz.tj", "co.tj", "com.tj", "edu.tj", "go.tj", "gov.tj", "info.tj",
"int.tj", "mil.tj", "name.tj", "net.tj", "nic.tj", "org.tj", "test.tj", "web.tj",
// .tn
"agrinet.tn", "com.tn", "defense.tn", "edunet.tn", "ens.tn", "fin.tn", "gov.tn",
"ind.tn", "info.tn", "intl.tn", "mincom.tn", "nat.tn", "net.tn", "org.tn",
"perso.tn", "rnrt.tn", "rns.tn", "rnu.tn", "tourism.tn",
// .tr
"av.tr", "bbs.tr", "bel.tr", "biz.tr", "com.tr", "dr.tr", "edu.tr", "gen.tr",
"gov.tr", "info.tr", "k12.tr", "name.tr", "net.tr", "org.tr", "pol.tr",
"tel.tr", "tsk.tr", "tv.tr", "web.tr",
// .tt
"aero.tt", "biz.tt", "cat.tt", "co.tt", "com.tt", "coop.tt", "edu.tt", "gov.tt",
"info.tt", "int.tt", "jobs.tt", "mil.tt", "mobi.tt", "museum.tt", "name.tt",
"net.tt", "org.tt", "pro.tt", "tel.tt", "travel.tt",
// .tw
"club.tw", "com.tw", "ebiz.tw", "edu.tw", "game.tw", "gov.tw", "idv.tw",
"mil.tw", "net.tw", "org.tw",
// .tz
"ac.tz", "co.tz", "go.tz", "ne.tz", "or.tz",
// .ua
"biz.ua", "cherkassy.ua", "chernigov.ua", "chernovtsy.ua", "ck.ua", "cn.ua",
"co.ua", "com.ua", "crimea.ua", "cv.ua", "dn.ua", "dnepropetrovsk.ua",
"donetsk.ua", "dp.ua", "edu.ua", "gov.ua", "if.ua", "in.ua",
"ivano-frankivsk.ua", "kh.ua", "kharkov.ua", "kherson.ua", "khmelnitskiy.ua",
"kiev.ua", "kirovograd.ua", "km.ua", "kr.ua", "ks.ua", "kv.ua", "lg.ua",
"lugansk.ua", "lutsk.ua", "lviv.ua", "me.ua", "mk.ua", "net.ua",
"nikolaev.ua", "od.ua", "odessa.ua", "org.ua", "pl.ua", "poltava.ua", "pp.ua",
"rovno.ua", "rv.ua", "sebastopol.ua", "sumy.ua", "te.ua", "ternopil.ua",
"uzhgorod.ua", "vinnica.ua", "vn.ua", "zaporizhzhe.ua", "zhitomir.ua",
"zp.ua", "zt.ua",
// .ug
"ac.ug", "co.ug", "go.ug", "ne.ug", "or.ug", "org.ug", "sc.ug",
// .uk
"ac.uk", "bl.uk", "british-library.uk", "co.uk", "cym.uk", "gov.uk", "govt.uk",
"icnet.uk", "jet.uk", "lea.uk", "ltd.uk", "me.uk", "mil.uk", "mod.uk",
"national-library-scotland.uk", "nel.uk", "net.uk", "nhs.uk", "nic.uk",
"nls.uk", "org.uk", "orgn.uk", "parliament.uk", "plc.uk", "police.uk",
"sch.uk", "scot.uk", "soc.uk",
// .us
"4fd.us", "dni.us", "fed.us", "isa.us", "kids.us", "nsn.us",
// .uy
"com.uy", "edu.uy", "gub.uy", "mil.uy", "net.uy", "org.uy",
// .ve
"co.ve", "com.ve", "edu.ve", "gob.ve", "info.ve", "mil.ve", "net.ve", "org.ve", "web.ve",
// .vi
"co.vi", "com.vi", "k12.vi", "net.vi", "org.vi",
// .vn
"ac.vn", "biz.vn", "com.vn", "edu.vn", "gov.vn", "health.vn", "info.vn",
"int.vn", "name.vn", "net.vn", "org.vn", "pro.vn",
// .ye
"co.ye", "com.ye", "gov.ye", "ltd.ye", "me.ye", "net.ye", "org.ye", "plc.ye",
// .yu
"ac.yu", "co.yu", "edu.yu", "gov.yu", "org.yu",
// .za
"ac.za", "agric.za", "alt.za", "bourse.za", "city.za", "co.za", "cybernet.za",
"db.za", "edu.za", "gov.za", "grondar.za", "iaccess.za", "imt.za", "inca.za",
"landesign.za", "law.za", "mil.za", "net.za", "ngo.za", "nis.za", "nom.za",
"olivetti.za", "org.za", "pix.za", "school.za", "tm.za", "web.za",
// .zm
"ac.zm", "co.zm", "com.zm", "edu.zm", "gov.zm", "net.zm", "org.zm", "sch.zm",
]); ]);
function getRootDomain(domain: string): string { function getRootDomain(domain: string): string {
+86 -21
View File
@@ -1,9 +1,39 @@
import { lookup } from 'node:dns/promises';
import { BlockList, isIP } from 'node:net';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
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;
function isValidExternalUrl(urlString: string): boolean { 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; let url: URL;
try { try {
url = new URL(urlString); url = new URL(urlString);
@@ -15,21 +45,16 @@ function isValidExternalUrl(urlString: string): boolean {
return false; return false;
} }
const hostname = url.hostname.toLowerCase(); const hostname = normalizeHostname(url.hostname);
// Block private/internal hostnames // Block private/internal hostnames
if ( if (
hostname === 'localhost' || hostname === 'localhost' ||
hostname === '127.0.0.1' || hostname.endsWith('.localhost') ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.endsWith('.local') || hostname.endsWith('.local') ||
hostname.endsWith('.internal') || hostname.endsWith('.internal') ||
hostname.endsWith('.arpa') || hostname.endsWith('.arpa') ||
hostname.startsWith('10.') || hostname.endsWith('.localdomain')
hostname.startsWith('192.168.') ||
hostname.startsWith('169.254.') ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
) { ) {
return false; return false;
} }
@@ -39,7 +64,24 @@ function isValidExternalUrl(urlString: string): boolean {
return false; return false;
} }
return true; 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) {
@@ -56,7 +98,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 (!isValidExternalUrl(url)) { if (!(await isValidExternalUrl(url))) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 }); return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
} }
@@ -64,20 +106,43 @@ export async function POST(request: NextRequest) {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const response = await fetch(url, { const MAX_REDIRECTS = 5;
signal: controller.signal, let currentUrl = url;
headers: { let response: Response | undefined;
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher', for (let i = 0; i <= MAX_REDIRECTS; i++) {
}, if (!(await isValidExternalUrl(currentUrl))) {
redirect: 'follow', clearTimeout(timeout);
}); return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
}
response = await fetch(currentUrl, {
signal: controller.signal,
headers: {
'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
},
redirect: 'manual',
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) {
clearTimeout(timeout);
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
}
// Resolve relative redirects
currentUrl = new URL(location, currentUrl).toString();
continue;
}
break;
}
clearTimeout(timeout); clearTimeout(timeout);
if (!response.ok) { if (!response || !response.ok) {
return NextResponse.json( return NextResponse.json(
{ error: `Remote server returned ${response.status}` }, { error: `Remote server returned ${response?.status ?? 'unknown'}` },
{ status: 502 } { status: 502 }
); );
} }
+26 -35
View File
@@ -1,10 +1,21 @@
import v8 from 'node:v8';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
// Health check thresholds const MEMORY_WARNING_THRESHOLD = 0.85;
const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage const MEMORY_CRITICAL_THRESHOLD = 0.95;
const MEMORY_CRITICAL_THRESHOLD = 0.95; // 95% heap usage
function getHeapUsagePercent(heapUsed: number, heapTotal: number): number {
const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
const denominator = heapSizeLimit > 0 ? heapSizeLimit : heapTotal;
if (denominator <= 0) {
return 0;
}
return (heapUsed / denominator) * 100;
}
interface HealthStatus { interface HealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy'; status: 'healthy' | 'degraded' | 'unhealthy';
@@ -14,6 +25,7 @@ interface HealthStatus {
memory?: { memory?: {
heapUsed: number; heapUsed: number;
heapTotal: number; heapTotal: number;
heapSizeLimit: number;
rss: number; rss: number;
external: number; external: number;
heapUsagePercent: number; heapUsagePercent: number;
@@ -27,14 +39,9 @@ interface HealthStatus {
/** /**
* Health check endpoint for container orchestration * Health check endpoint for container orchestration
* *
* GET /api/health - Basic health check (returns 200 OK or 503 Service Unavailable) * GET /api/health - Liveness probe for container orchestration
* GET /api/health?detailed=true - Detailed diagnostics with memory stats * GET /api/health?detailed=true - Diagnostics with advisory memory warnings
* HEAD /api/health - Lightweight health check (status code only) * HEAD /api/health - Lightweight liveness probe (status code only)
*
* Health status based on Node.js heap usage:
* - Healthy (200): < 85% heap usage
* - Degraded (200): 85-95% heap usage (warnings in detailed mode)
* - Unhealthy (503): > 95% heap usage
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
@@ -43,38 +50,31 @@ export async function GET(request: NextRequest) {
try { try {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const memUsage = process.memoryUsage(); const memUsage = process.memoryUsage();
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100; const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
const heapUsagePercent = getHeapUsagePercent(memUsage.heapUsed, memUsage.heapTotal);
// Determine health status based on memory usage
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy'; let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
const warnings: string[] = []; const warnings: string[] = [];
let httpStatus = 200;
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) { if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
status = 'unhealthy'; status = 'degraded';
httpStatus = 503; warnings.push(`V8 heap usage is very high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
} else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) { } else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) {
status = 'degraded'; status = 'degraded';
warnings.push(`Memory usage high: ${heapUsagePercent.toFixed(1)}%`); warnings.push(`V8 heap usage is high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
} }
// Build response
const response: HealthStatus = { const response: HealthStatus = {
status, status: detailed ? status : 'healthy',
timestamp, timestamp,
}; };
if (status === 'unhealthy') {
response.reason = `Memory usage critical: ${heapUsagePercent.toFixed(1)}%`;
}
// Add detailed information if requested
if (detailed) { if (detailed) {
response.uptime = process.uptime(); response.uptime = process.uptime();
response.version = process.env.npm_package_version || '0.1.0'; response.version = process.env.npm_package_version || '0.1.0';
response.memory = { response.memory = {
heapUsed: memUsage.heapUsed, heapUsed: memUsage.heapUsed,
heapTotal: memUsage.heapTotal, heapTotal: memUsage.heapTotal,
heapSizeLimit,
rss: memUsage.rss, rss: memUsage.rss,
external: memUsage.external, external: memUsage.external,
heapUsagePercent: Number(heapUsagePercent.toFixed(2)), heapUsagePercent: Number(heapUsagePercent.toFixed(2)),
@@ -87,10 +87,8 @@ export async function GET(request: NextRequest) {
} }
} }
logger.info('Health check', { status, detailed });
return NextResponse.json(response, { return NextResponse.json(response, {
status: httpStatus, status: 200,
headers: { headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate', 'Cache-Control': 'no-store, no-cache, must-revalidate',
'Pragma': 'no-cache', 'Pragma': 'no-cache',
@@ -116,13 +114,6 @@ export async function GET(request: NextRequest) {
*/ */
export async function HEAD() { export async function HEAD() {
try { try {
const memUsage = process.memoryUsage();
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
return new Response(null, { status: 503 });
}
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} catch { } catch {
return new Response(null, { status: 503 }); return new Response(null, { status: 503 });
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
import { logger } from '@/lib/logger';
/**
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
*
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
* No admin auth required - this is how regular users receive plugins/themes.
*/
export async function GET() {
try {
const [pluginRegistry, themeRegistry] = await Promise.all([
getPluginRegistry(),
getThemeRegistry(),
]);
// Only serve enabled plugins
const plugins = pluginRegistry.plugins
.filter(p => p.enabled)
.map(p => ({
id: p.id,
name: p.name,
version: p.version,
author: p.author,
description: p.description,
type: p.type,
permissions: p.permissions,
entrypoint: p.entrypoint,
forceEnabled: p.forceEnabled || false,
settingsSchema: undefined, // Will be read from the bundle's manifest
}));
// Only serve enabled themes
const themes = themeRegistry.themes
.filter(t => t.enabled)
.map(t => ({
id: t.id,
name: t.name,
version: t.version,
author: t.author,
description: t.description,
variants: t.variants,
forceEnabled: t.forceEnabled || false,
}));
return NextResponse.json(
{ plugins, themes },
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (error) {
logger.error('Plugin list error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+66
View File
@@ -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 });
}
}
+112 -19
View File
@@ -2,27 +2,91 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { decryptSession } from '@/lib/auth/crypto'; import { decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE } from '@/lib/auth/session-cookie'; import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync'; import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
function classifyError(error: unknown): { message: string; status: number } {
const code = (error as NodeJS.ErrnoException).code;
const msg = error instanceof Error ? error.message : 'Unknown error';
switch (code) {
case 'EACCES':
case 'EPERM':
return {
message: 'Write permission denied on settings data directory. Check filesystem permissions for the SETTINGS_DATA_DIR (or data/settings/).',
status: 500,
};
case 'EROFS':
return {
message: 'Filesystem is read-only. Settings cannot be saved. Ensure the data directory is on a writable volume.',
status: 500,
};
case 'ENOSPC':
return {
message: 'No disk space available to save settings.',
status: 507,
};
case 'ENOENT':
return {
message: 'Settings data directory does not exist and could not be created. Check SETTINGS_DATA_DIR configuration.',
status: 500,
};
default:
if (msg.includes('SESSION_SECRET')) {
return {
message: 'Server configuration error: SESSION_SECRET is not set.',
status: 500,
};
}
return {
message: `Internal server error: ${msg}`,
status: 500,
};
}
}
function isEnabled(): boolean { function isEnabled(): boolean {
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET; return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE));
}
/** Strip trailing slashes so differently-formatted URLs still match. */
function normalizeUrl(url: string): string {
return url.replace(/\/+$/, '');
} }
/** /**
* Verify identity against the session cookie if available. * Verify identity against session cookies across all account slots.
* Returns true if no session cookie exists (can't verify) or if identity matches. * With multi-account, the requesting account may be on any slot (0-4).
* Returns false if session cookie exists but identity doesn't match. * Checks both basic-auth session cookies and stalwart auth context cookies
* (used by OAuth/SSO and TOTP-upgraded sessions).
* Returns true only if a matching cookie is found.
*/ */
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> { async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const sessionToken = cookieStore.get(SESSION_COOKIE)?.value; const normalizedServerUrl = normalizeUrl(serverUrl);
if (!sessionToken) return true; // No session cookie, can't verify (same-origin protection applies)
const session = decryptSession(sessionToken); for (let slot = 0; slot <= 4; slot++) {
if (!session) return true; // Invalid session cookie, skip verification // Check basic-auth session cookie
const token = cookieStore.get(sessionCookieName(slot))?.value;
if (token) {
const session = decryptSession(token);
if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) {
return true;
}
}
return session.username === username && session.serverUrl === serverUrl; // Check stalwart auth context cookie (set for all auth modes)
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
return true;
}
}
// No matching session found (or no cookies at all) → reject
return false;
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -42,15 +106,13 @@ export async function GET(request: NextRequest) {
try { try {
const settings = await loadUserSettings(username, serverUrl); const settings = await loadUserSettings(username, serverUrl);
if (!settings) { return NextResponse.json({ settings: settings || null });
return NextResponse.json({ error: 'No settings found' }, { status: 404 });
}
return NextResponse.json({ settings });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
const code = (error as NodeJS.ErrnoException).code; const code = (error as NodeJS.ErrnoException).code;
logger.error('Settings load error', { error: message, code }); logger.error('Settings load error', { error: message, code });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); const classified = classifyError(error);
return NextResponse.json({ error: classified.message }, { status: classified.status });
} }
} }
@@ -73,13 +135,41 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 }); return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
} }
await saveUserSettings(username, serverUrl, settings); // Enforce admin policy - strip locked settings so users can't override them
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
const filteredSettings = { ...settings };
for (const key of Object.keys(filteredSettings)) {
const restriction = policy.restrictions[key];
if (!restriction) continue;
if (restriction.locked) {
delete filteredSettings[key];
continue;
}
const value = filteredSettings[key];
if (restriction.allowedValues && restriction.allowedValues.length > 0) {
if (!restriction.allowedValues.includes(value)) {
delete filteredSettings[key];
}
}
if (typeof value === 'number') {
if (restriction.min !== undefined && value < restriction.min) {
delete filteredSettings[key];
}
if (restriction.max !== undefined && value > restriction.max) {
delete filteredSettings[key];
}
}
}
await saveUserSettings(username, serverUrl, filteredSettings);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
const code = (error as NodeJS.ErrnoException).code; const code = (error as NodeJS.ErrnoException).code;
logger.error('Settings save error', { error: message, code }); logger.error('Settings save error', { error: message, code });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); const classified = classifyError(error);
return NextResponse.json({ error: classified.message }, { status: classified.status });
} }
} }
@@ -101,7 +191,10 @@ export async function DELETE(request: NextRequest) {
await deleteUserSettings(username, serverUrl); await deleteUserSettings(username, serverUrl);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
logger.error('Settings delete error', { error: error instanceof Error ? error.message : 'Unknown error' }); const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); const code = (error as NodeJS.ErrnoException).code;
logger.error('Settings delete error', { error: message, code });
const classified = classifyError(error);
return NextResponse.json({ error: classified.message }, { status: classified.status });
} }
} }
+43 -13
View File
@@ -4,6 +4,32 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']); const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
function normalizeDavRelativePath(rawPath: string): string {
const sanitized = rawPath.replace(/\\/g, '/').split(/[?#]/, 1)[0] ?? '';
const segments = sanitized.split('/').filter(Boolean);
return segments.map((segment) => {
let decoded: string;
try {
decoded = decodeURIComponent(segment);
} catch {
throw new Error('Invalid WebDAV path encoding');
}
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\') || decoded.includes('\0')) {
throw new Error('Invalid WebDAV path segment');
}
return encodeURIComponent(decoded);
}).join('/');
}
function buildDavTargetUrl(baseUrl: string, username: string, rawPath: string): string {
const rootUrl = new URL(`${baseUrl.replace(/\/$/, '')}/dav/file/${encodeURIComponent(username)}/`);
const relativePath = normalizeDavRelativePath(rawPath);
return relativePath ? new URL(relativePath, rootUrl).toString() : rootUrl.toString();
}
/** /**
* POST /api/webdav * POST /api/webdav
* Proxies WebDAV requests to the Stalwart server. * Proxies WebDAV requests to the Stalwart server.
@@ -29,11 +55,8 @@ export async function POST(request: NextRequest) {
} }
const davPath = request.headers.get('X-WebDAV-Path') || '/'; const davPath = request.headers.get('X-WebDAV-Path') || '/';
const cleanPath = davPath.replace(/^\/+/, ''); const baseUrl = creds.serverUrl.replace(/\/$/, '');
const baseUrl = creds.apiUrl.replace(/\/$/, ''); const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
const targetUrl = cleanPath
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
// Build headers for the upstream request // Build headers for the upstream request
const upstreamHeaders: Record<string, string> = { const upstreamHeaders: Record<string, string> = {
@@ -50,19 +73,20 @@ export async function POST(request: NextRequest) {
// For MOVE/COPY, construct the full Destination URL from the relative path // For MOVE/COPY, construct the full Destination URL from the relative path
const destination = request.headers.get('X-WebDAV-Destination'); const destination = request.headers.get('X-WebDAV-Destination');
if (destination) { if (destination) {
const cleanDest = destination.replace(/^\/+/, ''); upstreamHeaders['Destination'] = buildDavTargetUrl(baseUrl, creds.username, destination);
upstreamHeaders['Destination'] = cleanDest
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
} }
const overwrite = request.headers.get('Overwrite'); const overwrite = request.headers.get('Overwrite');
if (overwrite) upstreamHeaders['Overwrite'] = overwrite; if (overwrite) upstreamHeaders['Overwrite'] = overwrite;
// Forward request body for methods that need it // Forward request body for methods that need it.
let body: ArrayBuffer | null = null; // PUT streams directly to upstream to avoid buffering large uploads in memory.
if (method === 'PROPFIND' || method === 'PUT') { // PROPFIND bodies are small XML and are read fully.
let body: ArrayBuffer | ReadableStream<Uint8Array> | null = null;
if (method === 'PROPFIND') {
body = await request.arrayBuffer(); body = await request.arrayBuffer();
} else if (method === 'PUT') {
body = request.body;
} }
const response = await fetch(targetUrl, { const response = await fetch(targetUrl, {
@@ -70,7 +94,9 @@ export async function POST(request: NextRequest) {
headers: upstreamHeaders, headers: upstreamHeaders,
body, body,
redirect: 'follow', redirect: 'follow',
}); // `duplex: 'half'` is required by undici when sending a streaming request body.
...(method === 'PUT' ? { duplex: 'half' } : {}),
} as Parameters<typeof fetch>[1] & { duplex?: 'half' });
// For file downloads (GET), stream the response back // For file downloads (GET), stream the response back
if (method === 'GET') { if (method === 'GET') {
@@ -104,6 +130,10 @@ export async function POST(request: NextRequest) {
status: response.status, status: response.status,
}); });
} catch (error) { } catch (error) {
if (error instanceof Error && error.message.startsWith('Invalid WebDAV path')) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
} }
+237 -47
View File
@@ -20,6 +20,41 @@
--color-accent-foreground: #1e40af; --color-accent-foreground: #1e40af;
--color-destructive: #ef4444; --color-destructive: #ef4444;
--color-destructive-foreground: #ffffff; --color-destructive-foreground: #ffffff;
--color-popover: #ffffff;
--color-popover-foreground: #0f172a;
/* Sidebar */
--color-sidebar: #f8fafc;
--color-sidebar-foreground: #0f172a;
--color-sidebar-border: #e2e8f0;
--color-sidebar-accent: #f1f5f9;
--color-sidebar-accent-foreground: #0f172a;
/* Card */
--color-card: #ffffff;
--color-card-foreground: #0f172a;
/* Semantic status colors */
--color-success: #22c55e;
--color-success-foreground: #ffffff;
--color-warning: #eab308;
--color-warning-foreground: #ffffff;
--color-info: #3b82f6;
--color-info-foreground: #ffffff;
/* Selection states */
--color-selection: #dbeafe;
--color-selection-foreground: #1e40af;
/* Unread indicator */
--color-unread: #3b82f6;
/* Chart colors */
--color-chart-1: #3b82f6;
--color-chart-2: #22c55e;
--color-chart-3: #f59e0b;
--color-chart-4: #ef4444;
--color-chart-5: #8b5cf6;
/* Settings variables */ /* Settings variables */
--font-size-base: 16px; --font-size-base: 16px;
@@ -50,6 +85,41 @@
--color-accent-foreground: #dbeafe; --color-accent-foreground: #dbeafe;
--color-destructive: #ef4444; --color-destructive: #ef4444;
--color-destructive-foreground: #fafafa; --color-destructive-foreground: #fafafa;
--color-popover: #1c1c1c;
--color-popover-foreground: #fafafa;
/* Sidebar */
--color-sidebar: #0a0a0a;
--color-sidebar-foreground: #fafafa;
--color-sidebar-border: #262626;
--color-sidebar-accent: #1a1a1a;
--color-sidebar-accent-foreground: #fafafa;
/* Card */
--color-card: #141414;
--color-card-foreground: #fafafa;
/* Semantic status colors */
--color-success: #16a34a;
--color-success-foreground: #ffffff;
--color-warning: #ca8a04;
--color-warning-foreground: #ffffff;
--color-info: #60a5fa;
--color-info-foreground: #ffffff;
/* Selection states */
--color-selection: rgba(59, 130, 246, 0.25);
--color-selection-foreground: #93c5fd;
/* Unread indicator */
--color-unread: #60a5fa;
/* Chart colors */
--color-chart-1: #60a5fa;
--color-chart-2: #4ade80;
--color-chart-3: #fbbf24;
--color-chart-4: #f87171;
--color-chart-5: #a78bfa;
} }
@theme inline { @theme inline {
@@ -68,6 +138,29 @@
--color-accent-foreground: var(--color-accent-foreground); --color-accent-foreground: var(--color-accent-foreground);
--color-destructive: var(--color-destructive); --color-destructive: var(--color-destructive);
--color-destructive-foreground: var(--color-destructive-foreground); --color-destructive-foreground: var(--color-destructive-foreground);
--color-popover: var(--color-popover);
--color-popover-foreground: var(--color-popover-foreground);
--color-sidebar: var(--color-sidebar);
--color-sidebar-foreground: var(--color-sidebar-foreground);
--color-sidebar-border: var(--color-sidebar-border);
--color-sidebar-accent: var(--color-sidebar-accent);
--color-sidebar-accent-foreground: var(--color-sidebar-accent-foreground);
--color-card: var(--color-card);
--color-card-foreground: var(--color-card-foreground);
--color-success: var(--color-success);
--color-success-foreground: var(--color-success-foreground);
--color-warning: var(--color-warning);
--color-warning-foreground: var(--color-warning-foreground);
--color-info: var(--color-info);
--color-info-foreground: var(--color-info-foreground);
--color-selection: var(--color-selection);
--color-selection-foreground: var(--color-selection-foreground);
--color-unread: var(--color-unread);
--color-chart-1: var(--color-chart-1);
--color-chart-2: var(--color-chart-2);
--color-chart-3: var(--color-chart-3);
--color-chart-4: var(--color-chart-4);
--color-chart-5: var(--color-chart-5);
} }
* { * {
@@ -115,6 +208,15 @@ body {
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
} }
.email-content-text a {
color: #2563eb;
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",
@@ -157,21 +259,16 @@ body {
} }
.email-content blockquote { .email-content blockquote {
border-left: 3px solid #d1d5db; border-left: 3px solid var(--color-border);
padding-left: 1rem; padding-left: 1rem;
margin: 1rem 0; margin: 1rem 0;
color: #4b5563; color: var(--color-muted-foreground);
font-style: italic; font-style: italic;
} }
.dark .email-content blockquote {
border-left-color: #4b5563;
color: #9ca3af;
}
.email-content pre { .email-content pre {
background-color: #f5f5f7; background-color: var(--color-muted);
border: 1px solid #e5e7eb; border: 1px solid var(--color-border);
border-radius: 0.375rem; border-radius: 0.375rem;
padding: 1rem; padding: 1rem;
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace; font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
@@ -180,23 +277,13 @@ body {
margin: 1rem 0; margin: 1rem 0;
} }
.dark .email-content pre {
background-color: #1f2937;
border-color: #374151;
}
.email-content code { .email-content code {
background-color: #f3f4f6; background-color: var(--color-muted);
padding: 0.125rem 0.375rem; padding: 0.125rem 0.375rem;
border-radius: 0.25rem; border-radius: 0.25rem;
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace; font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
font-size: 0.875rem; font-size: 0.875rem;
color: #dc2626; color: var(--color-destructive);
}
.dark .email-content code {
background-color: #374151;
color: #f87171;
} }
.email-content h1, .email-content h1,
@@ -276,26 +363,15 @@ body {
.email-content table[border="1"] th, .email-content table[border="1"] th,
.email-content table[border="1"] td { .email-content table[border="1"] td {
padding: 0.625rem; padding: 0.625rem;
border: 1px solid #e5e7eb; border: 1px solid var(--color-border);
text-align: left; text-align: left;
} }
.email-content table.data-table th, .email-content table.data-table th,
.email-content table[border="1"] th { .email-content table[border="1"] th {
background-color: #f9fafb; background-color: var(--color-muted);
font-weight: 600; font-weight: 600;
color: #374151; color: var(--color-foreground);
}
.dark .email-content table.data-table th,
.dark .email-content table[border="1"] th {
background-color: #1f2937;
color: #d1d5db;
}
.dark .email-content table.data-table td,
.dark .email-content table[border="1"] td {
border-color: #374151;
} }
/* Reset styles for layout tables (commonly used in HTML emails) */ /* Reset styles for layout tables (commonly used in HTML emails) */
@@ -317,28 +393,19 @@ body {
.email-content hr { .email-content hr {
border: none; border: none;
border-top: 1px solid #e5e7eb; border-top: 1px solid var(--color-border);
margin: 1.5rem 0; margin: 1.5rem 0;
} }
.dark .email-content hr {
border-top-color: #374151;
}
/* Email thread quoted text */ /* Email thread quoted text */
.email-content .quoted-text { .email-content .quoted-text {
border-left: 3px solid #d1d5db; border-left: 3px solid var(--color-border);
padding-left: 1rem; padding-left: 1rem;
margin: 1rem 0; margin: 1rem 0;
color: #4b5563; color: var(--color-muted-foreground);
opacity: 0.8; opacity: 0.8;
} }
.dark .email-content .quoted-text {
border-left-color: #4b5563;
color: #9ca3af;
}
/* Toast animations */ /* Toast animations */
@keyframes toast-enter { @keyframes toast-enter {
from { from {
@@ -471,6 +538,37 @@ body {
animation: slide-in-from-left 0.3s ease-out; animation: slide-in-from-left 0.3s ease-out;
} }
/* Calendar swipe animations (subtler slide for view transitions) */
@keyframes cal-slide-in-right {
from {
opacity: 0;
transform: translateX(30%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes cal-slide-in-left {
from {
opacity: 0;
transform: translateX(-30%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-in-right {
animation: cal-slide-in-right 0.25s ease-out;
}
.animate-slide-in-left {
animation: cal-slide-in-left 0.25s ease-out;
}
/* Reduced motion: respect user OS preference */ /* Reduced motion: respect user OS preference */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
@@ -490,3 +588,95 @@ body {
-webkit-backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
} }
} }
/* TipTap Rich Text Editor */
.tiptap {
outline: none;
}
.tiptap p {
margin: 0.25rem 0;
}
.tiptap h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 0.5rem 0;
}
.tiptap h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0.5rem 0;
}
.tiptap ul {
list-style-type: disc;
padding-left: 1.5rem;
margin: 0.25rem 0;
}
.tiptap ol {
list-style-type: decimal;
padding-left: 1.5rem;
margin: 0.25rem 0;
}
.tiptap li {
margin: 0.125rem 0;
}
.tiptap blockquote {
border-left: 3px solid var(--color-border);
padding-left: 1rem;
margin: 0.5rem 0;
color: var(--color-muted-foreground);
}
.tiptap pre {
background-color: var(--color-muted);
border: 1px solid var(--color-border);
border-radius: 0.375rem;
padding: 0.75rem;
font-family: monospace;
font-size: 0.875rem;
overflow-x: auto;
margin: 0.5rem 0;
}
.tiptap code {
background-color: var(--color-muted);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.875rem;
}
.tiptap a {
color: var(--color-primary);
text-decoration: underline;
cursor: pointer;
}
.tiptap img {
max-width: 100%;
height: auto;
}
.tiptap hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 1rem 0;
}
.tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--color-muted-foreground);
pointer-events: none;
height: 0;
}
.tiptap .ProseMirror-selectednode img {
outline: none;
}
+19 -2
View File
@@ -1,3 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Gravit.io --> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clipPath_Sdk6Fary7vPYZxd20WRVIKca0qb2ogEk)"><rect width="1000" height="1000" style="fill:rgb(0,0,0)" fill-opacity="0"/><path d=" M 483.028 563.647 L 63.713 187.183 C 59.029 182.978 55.226 174.454 55.226 168.159 L 55.226 122.542 C 55.226 116.247 60.206 109.988 66.339 108.573 L 215.181 74.225 C 221.314 72.809 226.293 76.77 226.293 83.065 L 226.293 176.92 L 352.034 147.895 C 358.167 146.479 363.147 140.219 363.147 133.925 L 363.147 51.483 C 363.147 45.189 368.126 38.93 374.259 37.514 L 488.888 11.061 C 495.021 9.646 504.979 9.646 511.112 11.061 L 625.741 37.514 C 631.874 38.93 636.853 45.189 636.853 51.483 L 636.853 133.925 C 636.853 140.219 641.833 146.479 647.966 147.895 L 773.707 176.92 L 773.707 83.065 C 773.707 76.77 778.686 72.809 784.819 74.225 L 933.661 108.573 C 939.794 109.988 944.774 116.247 944.774 122.542 L 944.774 168.159 C 944.774 174.454 940.971 182.978 936.287 187.183 L 516.972 563.647 C 507.605 572.056 492.395 572.056 483.028 563.647 Z " fill="rgb(219,45,84)"/><path d=" M 944.774 332.832 L 944.774 396.969 C 944.774 403.263 941.16 411.987 936.709 416.437 L 884.411 468.736 C 879.96 473.186 876.347 481.91 876.347 488.204 L 876.347 682.08 C 876.345 718.462 866.664 750.017 847.953 778.668 L 658.833 589.547 L 944.774 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 55.226 332.832 L 55.226 385.564 C 55.226 398.153 62.453 415.6 71.355 424.501 L 107.525 460.671 C 116.426 469.573 123.653 487.02 123.653 499.609 L 123.653 682.08 C 123.655 718.462 133.336 750.017 152.047 778.668 L 341.167 589.547 L 55.226 332.832 Z " fill="rgb(219,45,84)"/><path d=" M 765.645 857.641 C 701.996 901.327 612.26 941.932 500 990 Q 500 990 500 990 C 387.74 941.932 298.004 901.327 234.355 857.641 L 427.917 664.079 C 435.604 668.703 443.74 672.579 452.215 675.636 C 467.543 681.179 483.703 684.007 500 683.996 C 516.297 684.007 532.457 681.179 547.785 675.636 C 556.261 672.579 564.396 668.703 572.083 664.079 L 765.645 857.641 Z " fill="rgb(219,45,84)"/></g></svg> <defs>
<clipPath id="_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV"><rect width="1000" height="1000"/></clipPath>
<style>
.icon-bg { fill: #ffffff; }
.icon-mark { fill: rgb(219,45,84); }
@media (prefers-color-scheme: dark) {
.icon-bg { fill: #18181b; }
}
</style>
</defs>
<g clip-path="url(#_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV)">
<rect width="1000" height="1000" class="icon-bg"/>
<path d=" M 489.315 575.068 L 225.342 338.071 C 222.394 335.424 220 330.058 220 326.095 L 220 297.377 C 220 293.415 223.135 289.474 226.996 288.583 L 320.697 266.96 C 324.558 266.069 327.692 268.563 327.692 272.525 L 327.692 331.61 L 406.851 313.338 C 410.712 312.446 413.846 308.506 413.846 304.543 L 413.846 252.643 C 413.846 248.681 416.981 244.741 420.842 243.85 L 493.004 227.197 C 496.865 226.306 503.135 226.306 506.996 227.197 L 579.158 243.85 C 583.019 244.741 586.154 248.681 586.154 252.643 L 586.154 304.543 C 586.154 308.506 589.288 312.446 593.149 313.338 L 672.308 331.61 L 672.308 272.525 C 672.308 268.563 675.442 266.069 679.303 266.96 L 773.004 288.583 C 776.865 289.474 780 293.415 780 297.377 L 780 326.095 C 780 330.058 777.606 335.424 774.658 338.071 L 510.685 575.068 C 504.788 580.362 495.212 580.362 489.315 575.068 Z " class="icon-mark"/>
<path d=" M 780 429.762 L 780 470.138 C 780 474.101 777.725 479.593 774.923 482.394 L 742 515.318 C 739.198 518.12 736.923 523.612 736.923 527.574 L 736.923 649.625 C 736.922 672.529 730.827 692.394 719.048 710.431 L 599.991 591.373 L 780 429.762 Z " class="icon-mark"/>
<path d=" M 220 429.762 L 220 462.959 C 220 470.884 224.55 481.867 230.153 487.471 L 252.924 510.241 C 258.527 515.845 263.077 526.829 263.077 534.754 L 263.077 649.625 C 263.078 672.529 269.173 692.394 280.952 710.431 L 400.009 591.373 L 220 429.762 Z " class="icon-mark"/>
<path d=" M 667.232 760.147 C 627.163 787.649 570.672 813.211 500 843.472 Q 500 843.472 500 843.472 C 429.328 813.211 372.837 787.649 332.768 760.147 L 454.622 638.293 C 459.461 641.204 464.582 643.644 469.918 645.569 C 479.567 649.058 489.741 650.839 500 650.832 C 510.259 650.839 520.433 649.058 530.082 645.569 C 535.418 643.644 540.539 641.204 545.378 638.293 L 667.232 760.147 Z " class="icon-mark"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+24
View File
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { getLocale } from "next-intl/server"; import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -20,6 +22,14 @@ 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",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
},
formatDetection: {
telephone: false,
},
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}), ...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
}; };
} }
@@ -31,10 +41,22 @@ export default async function RootLayout({
}) { }) {
const locale = await getLocale(); const locale = await getLocale();
const nonce = (await headers()).get("x-nonce") ?? ""; const nonce = (await headers()).get("x-nonce") ?? "";
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
return ( return (
<html lang={locale} suppressHydrationWarning> <html lang={locale} suppressHydrationWarning>
<head> <head>
<meta name="theme-color" content="#ffffff" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-title"
content={process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail"}
/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
{parentOrigin && (
<meta name="parent-origin" content={parentOrigin} />
)}
<script <script
nonce={nonce} nonce={nonce}
suppressHydrationWarning suppressHydrationWarning
@@ -59,7 +81,9 @@ export default async function RootLayout({
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<ServiceWorkerRegistration />
{children} {children}
<PWAInstallPrompt />
</body> </body>
</html> </html>
); );
+55
View File
@@ -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" },
],
};
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useEffect } from "react";
import { useAuthStore } from "@/stores/auth-store";
export default function NotFound() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
useEffect(() => {
if (!isAuthenticated) {
// Don't redirect admin routes to the webmail login page
const isAdminRoute = window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/');
if (!isAdminRoute) {
window.location.href = "/login";
}
}
}, [isAuthenticated]);
if (!isAuthenticated) {
// Allow admin routes to render the 404 without redirecting
const isAdmin = typeof window !== 'undefined' &&
(window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/'));
if (!isAdmin) return null;
}
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
<p className="text-muted-foreground mb-6">This page could not be found.</p>
<a
href="/"
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
>
Go home
</a>
</div>
</div>
);
}
+50 -18
View File
@@ -1,12 +1,12 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo, useRef, useEffect, useCallback } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { format, parseISO, isToday, isTomorrow } from "date-fns"; import { format, isToday, isTomorrow, startOfDay } from "date-fns";
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react"; import { MapPin, Users } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { parseDuration, getEventColor } from "./event-card"; import { parseDuration, getEventColor } from "./event-card";
import { getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { getParticipantCount } from "@/lib/calendar-participants"; import { getParticipantCount } from "@/lib/calendar-participants";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -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";
} }
@@ -27,11 +28,13 @@ interface DayGroup {
} }
export function CalendarAgendaView({ export function CalendarAgendaView({
selectedDate,
events, events,
calendars, calendars,
onSelectEvent, onSelectEvent,
onHoverEvent, onHoverEvent,
onHoverLeave, onHoverLeave,
onContextMenuEvent,
timeFormat = "24h", timeFormat = "24h",
}: CalendarAgendaViewProps) { }: CalendarAgendaViewProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
@@ -43,9 +46,12 @@ export function CalendarAgendaView({
return map; return map;
}, [calendars]); }, [calendars]);
const todayRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const grouped = useMemo(() => { const grouped = useMemo(() => {
const sorted = [...events].sort((a, b) => const sorted = [...events].sort((a, b) =>
new Date(a.start).getTime() - new Date(b.start).getTime() getEventStartDate(a).getTime() - getEventStartDate(b).getTime()
); );
const groups: DayGroup[] = []; const groups: DayGroup[] = [];
@@ -69,10 +75,38 @@ export function CalendarAgendaView({
} catch { /* skip invalid dates */ } } catch { /* skip invalid dates */ }
}); });
// Always include today's date in the groups so the view has a "Today" anchor
const todayKey = format(new Date(), "yyyy-MM-dd");
if (!groupMap.has(todayKey)) {
const todayGroup = { date: startOfDay(new Date()), dateKey: todayKey, events: [] as CalendarEvent[] };
groupMap.set(todayKey, todayGroup);
groups.push(todayGroup);
}
groups.sort((a, b) => a.date.getTime() - b.date.getTime()); groups.sort((a, b) => a.date.getTime() - b.date.getTime());
return groups; return groups;
}, [events]); }, [events]);
// Auto-scroll to today's section on mount and when selectedDate changes to today
const scrollToToday = useCallback(() => {
if (todayRef.current) {
todayRef.current.scrollIntoView({ block: "start" });
}
}, []);
useEffect(() => {
// Scroll to today on mount
const frame = requestAnimationFrame(scrollToToday);
return () => cancelAnimationFrame(frame);
}, [scrollToToday]);
useEffect(() => {
// Scroll to today when selectedDate changes to today
if (isToday(selectedDate)) {
scrollToToday();
}
}, [selectedDate, scrollToToday]);
const formatDateHeader = (date: Date): string => { const formatDateHeader = (date: Date): string => {
if (isToday(date)) return t("events.today_header"); if (isToday(date)) return t("events.today_header");
if (isTomorrow(date)) return t("events.tomorrow_header"); if (isTomorrow(date)) return t("events.tomorrow_header");
@@ -86,19 +120,10 @@ export function CalendarAgendaView({
return format(date, "HH:mm"); return format(date, "HH:mm");
}; };
if (grouped.length === 0) {
return (
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground">
<CalendarIcon className="w-12 h-12 mb-3 opacity-30" />
<p className="text-sm">{t("events.no_events")}</p>
</div>
);
}
return ( return (
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
{grouped.map((group) => ( {grouped.map((group) => (
<div key={group.dateKey}> <div key={group.dateKey} ref={isToday(group.date) ? todayRef : undefined}>
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-4 py-2 border-b border-border"> <div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-4 py-2 border-b border-border">
<span className={cn( <span className={cn(
"text-sm font-medium", "text-sm font-medium",
@@ -111,14 +136,19 @@ export function CalendarAgendaView({
</span> </span>
</div> </div>
{group.events.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
{t("events.no_events")}
</div>
) : (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{group.events.map((ev) => { {group.events.map((ev) => {
const calId = getPrimaryCalendarId(ev); const calId = getPrimaryCalendarId(ev);
const calendar = calId ? calendarMap.get(calId) : undefined; const calendar = calId ? calendarMap.get(calId) : undefined;
const color = getEventColor(ev, calendar); const color = getEventColor(ev, calendar);
const start = parseISO(ev.start); const start = getEventStartDate(ev);
const durMin = parseDuration(ev.duration); const durMin = parseDuration(ev.duration);
const end = new Date(start.getTime() + durMin * 60000); const end = getEventEndDate(ev);
const locationName = ev.locations const locationName = ev.locations
? Object.values(ev.locations)[0]?.name ? Object.values(ev.locations)[0]?.name
: null; : null;
@@ -129,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)' }}
> >
@@ -176,6 +207,7 @@ export function CalendarAgendaView({
); );
})} })}
</div> </div>
)}
</div> </div>
))} ))}
</div> </div>
+134 -35
View File
@@ -2,13 +2,15 @@
import { useMemo, useEffect, useRef, useState } from "react"; import { useMemo, useEffect, useRef, useState } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { format, isToday, parseISO } from "date-fns"; import { format, isSameDay, isToday, parseISO } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { EventCard, parseDuration } from "./event-card"; import { Check } from "lucide-react";
import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input"; import { QuickEventInput } from "./quick-event-input";
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils"; import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal";
interface CalendarDayViewProps { interface CalendarDayViewProps {
selectedDate: Date; selectedDate: Date;
@@ -17,9 +19,13 @@ 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;
onCreateAtTime: (date: Date, endDate?: Date) => void; onCreateAtTime: (date: Date, endDate?: Date) => void;
timeFormat?: "12h" | "24h"; timeFormat?: "12h" | "24h";
isMobile?: boolean; isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
tasks?: CalendarTask[];
onToggleTaskComplete?: (task: CalendarTask) => void;
} }
const HOUR_HEIGHT = 64; const HOUR_HEIGHT = 64;
@@ -32,9 +38,13 @@ export function CalendarDayView({
onSelectEvent, onSelectEvent,
onHoverEvent, onHoverEvent,
onHoverLeave, onHoverLeave,
onContextMenuEvent,
onCreateAtTime, onCreateAtTime,
timeFormat = "24h", timeFormat = "24h",
isMobile, isMobile,
pendingPreview,
tasks,
onToggleTaskComplete,
}: CalendarDayViewProps) { }: CalendarDayViewProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const intlFormatter = useFormatter(); const intlFormatter = useFormatter();
@@ -58,13 +68,23 @@ export function CalendarDayView({
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime(); const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
if (!spansThisDay) return; if (!spansThisDay) return;
if (ev.showWithoutTime) allDay.push(ev); if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev);
else timed.push(ev); else timed.push(ev);
} catch { /* skip invalid dates */ } } catch { /* skip invalid dates */ }
}); });
return { timedEvents: timed, allDayEvents: allDay }; return { timedEvents: timed, allDayEvents: allDay };
}, [events, selectedDate]); }, [events, selectedDate]);
const dayTasks = useMemo(() => {
if (!tasks?.length) return [];
return tasks.filter(task => {
if (!task.due) return false;
try {
return isSameDay(parseISO(task.due), selectedDate);
} catch { return false; }
});
}, [tasks, selectedDate]);
useEffect(() => { useEffect(() => {
if (scrollRef.current) { if (scrollRef.current) {
const now = new Date(); const now = new Date();
@@ -99,6 +119,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 => {
@@ -109,10 +130,10 @@ export function CalendarDayView({
return format(new Date(2000, 0, 1, h), "HH:mm"); return format(new Date(2000, 0, 1, h), "HH:mm");
}; };
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]); const layouted = useMemo(() => layoutOverlappingEvents(timedEvents, selectedDate), [timedEvents, selectedDate]);
return ( return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}> <div className="flex min-h-0 flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}> <div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}> <h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
{isMobile {isMobile
@@ -122,29 +143,68 @@ export function CalendarDayView({
</h3> </h3>
</div> </div>
{allDayEvents.length > 0 && ( {(allDayEvents.length > 0 || dayTasks.length > 0) && (
<div className="px-4 py-2 border-b border-border"> <div className="px-4 py-2 border-b border-border">
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div> {allDayEvents.length > 0 && (
<div className="space-y-1"> <>
{allDayEvents.map((ev) => { <div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
const calId = getPrimaryCalendarId(ev); <div className="space-y-1">
return ( {allDayEvents.map((ev) => {
<EventCard const calId = getPrimaryCalendarId(ev);
key={ev.id} return (
event={ev} <EventCard
calendar={calId ? calendarMap.get(calId) : undefined} key={ev.id}
variant="chip" event={ev}
onClick={(rect) => onSelectEvent(ev, rect)} calendar={calId ? calendarMap.get(calId) : undefined}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} variant="chip"
onMouseLeave={onHoverLeave} onClick={(rect) => onSelectEvent(ev, rect)}
/> onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
); onMouseLeave={onHoverLeave}
})} onContextMenu={onContextMenuEvent}
</div> />
);
})}
</div>
</>
)}
{dayTasks.length > 0 && (
<>
<div className={cn("text-[10px] text-muted-foreground mb-1", allDayEvents.length > 0 && "mt-2")}>{t("tasks.label")}</div>
<div className="space-y-0.5">
{dayTasks.map((task) => {
const isCompleted = task.progress === "completed";
const cal = calendars.find(c => task.calendarIds[c.id]);
const color = cal?.color || "#3b82f6";
return (
<div
key={task.id}
className="flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs cursor-pointer hover:bg-muted/50 transition-colors"
style={{ borderLeft: `3px solid ${color}` }}
>
<button
onClick={(e) => { e.stopPropagation(); onToggleTaskComplete?.(task); }}
className={cn(
"flex-shrink-0 w-3.5 h-3.5 rounded-full border flex items-center justify-center",
isCompleted
? "bg-success border-success text-success-foreground"
: "border-muted-foreground/40 hover:border-primary"
)}
>
{isCompleted && <Check className="h-2.5 w-2.5" />}
</button>
<span className={cn("truncate", isCompleted && "line-through text-muted-foreground")}>
{task.title || t("tasks.no_title")}
</span>
</div>
);
})}
</div>
</>
)}
</div> </div>
)} )}
<div ref={scrollRef} className="flex-1 overflow-y-auto"> <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}> <div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}> <div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
{HOURS.map((h) => ( {HOURS.map((h) => (
@@ -185,13 +245,13 @@ export function CalendarDayView({
/> />
))} ))}
{layouted.map(({ event: ev, column, totalColumns }) => { {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const start = parseISO(ev.start); const durMin = Math.max(15, endMinutes - startMinutes);
const startMin = start.getHours() * 60 + start.getMinutes(); const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
const durMin = Math.max(15, parseDuration(ev.duration));
const top = (startMin / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT); const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; const 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;
@@ -210,13 +270,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}
> >
@@ -232,8 +303,8 @@ export function CalendarDayView({
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }} style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
> >
<div className="flex items-center"> <div className="flex items-center">
<div className="w-2.5 h-2.5 rounded-full bg-red-500 -ml-1" /> <div className="w-2.5 h-2.5 rounded-full bg-destructive -ml-1" />
<div className="flex-1 h-px bg-red-500" /> <div className="flex-1 h-px bg-destructive" />
</div> </div>
</div> </div>
)} )}
@@ -274,6 +345,34 @@ export function CalendarDayView({
</div> </div>
</div> </div>
)} )}
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, selectedDate) && (
(() => {
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
const durationMin = Math.max(15, endMin - startMin);
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
const color = cal?.color || "hsl(var(--primary))";
return (
<div
className="absolute left-2 right-2 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
style={{
top: (startMin / 60) * HOUR_HEIGHT,
height: Math.max(24, (durationMin / 60) * HOUR_HEIGHT),
borderColor: color,
backgroundColor: `${color}10`,
}}
>
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
{pendingPreview.title}
</div>
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
{formatSnapTime(startMin, timeFormat)} {formatSnapTime(startMin + durationMin, timeFormat)}
</div>
</div>
);
})()
)}
</div> </div>
</div> </div>
</div> </div>
+60 -21
View File
@@ -12,6 +12,7 @@ import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/li
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import type { PendingEventPreview } from "./event-modal";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
interface CalendarMonthViewProps { interface CalendarMonthViewProps {
@@ -22,9 +23,11 @@ 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;
onCreateAtTime?: (date: Date) => void; onCreateAtTime?: (date: Date) => void;
firstDayOfWeek?: number; firstDayOfWeek?: number;
isMobile?: boolean; isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
} }
export function CalendarMonthView({ export function CalendarMonthView({
@@ -35,9 +38,11 @@ export function CalendarMonthView({
onSelectEvent, onSelectEvent,
onHoverEvent, onHoverEvent,
onHoverLeave, onHoverLeave,
onContextMenuEvent,
onCreateAtTime, onCreateAtTime,
firstDayOfWeek = 1, firstDayOfWeek = 1,
isMobile, isMobile,
pendingPreview,
}: CalendarMonthViewProps) { }: CalendarMonthViewProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const intlFormatter = useFormatter(); const intlFormatter = useFormatter();
@@ -118,13 +123,14 @@ export function CalendarMonthView({
try { try {
const data = JSON.parse(json); const data = JSON.parse(json);
const originalStart = parseISO(data.originalStart); const originalStart = parseISO(data.originalStart);
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
const isAllDay = event?.showWithoutTime;
const newStart = new Date(day); const newStart = new Date(day);
newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0); newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0);
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss"); const newStartISO = isAllDay ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
if (newStartISO === data.originalStart) return; if (newStartISO === data.originalStart) return;
const client = useAuthStore.getState().client; const client = useAuthStore.getState().client;
if (!client) return; if (!client) return;
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0; const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined); await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
} catch { } catch {
@@ -194,31 +200,63 @@ export function CalendarMonthView({
</span> </span>
</div> </div>
{isMobile ? ( {isMobile ? (
dayEvents.length > 0 && ( <div className="flex items-center justify-center gap-0.5 flex-wrap">
<div className="flex items-center justify-center gap-0.5 flex-wrap"> {dayEvents.slice(0, 3).map((ev) => {
{dayEvents.slice(0, 3).map((ev) => { const calId = getPrimaryCalendarId(ev);
const calId = getPrimaryCalendarId(ev); const cal = calId ? calendarMap.get(calId) : undefined;
const cal = calId ? calendarMap.get(calId) : undefined; const evColor = ev.color || cal?.color || "#3b82f6";
const evColor = ev.color || cal?.color || "#3b82f6"; return (
return ( <span
<span key={ev.id}
key={ev.id} className="w-1.5 h-1.5 rounded-full"
className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: evColor }}
style={{ backgroundColor: evColor }} />
/> );
); })}
})} {dayEvents.length > 3 && (
{dayEvents.length > 3 && ( <span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" /> )}
)} {pendingPreview && isSameDay(pendingPreview.start, day) && (
</div> <span
) className="w-1.5 h-1.5 rounded-full border border-dashed"
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
/>
)}
</div>
) : null} ) : null}
</div> </div>
); );
})} })}
</div> </div>
{!isMobile && pendingPreview && (() => {
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start));
if (previewDayIdx === -1) return null;
const previewRow = rowCount;
const cal = calendarMap.get(pendingPreview.calendarId);
const color = cal?.color || "#3b82f6";
return (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
<div
className="absolute px-0.5"
style={{
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
width: `calc(${(1 / 7) * 100}% - 2px)`,
top: previewRow * 22,
height: 20,
}}
>
<div
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate border-2 border-dashed"
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
>
{pendingPreview.title}
</div>
</div>
</div>
);
})()}
{!isMobile && segments.length > 0 && ( {!isMobile && segments.length > 0 && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}> <div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
{segments.map((segment) => { {segments.map((segment) => {
@@ -243,6 +281,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>
+44 -3
View File
@@ -2,14 +2,16 @@
import { useState, useRef, useEffect, useMemo } from "react"; import { useState, useRef, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Globe, Plus, RefreshCw, Share2, Trash2 } from "lucide-react"; import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils"; import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types"; import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import type { JMAPClient } from "@/lib/jmap/client"; import type { IJMAPClient } from '@/lib/jmap/client-interface';
interface CalendarSidebarPanelProps { interface CalendarSidebarPanelProps {
calendars: Calendar[]; calendars: Calendar[];
@@ -17,7 +19,8 @@ interface CalendarSidebarPanelProps {
onToggleVisibility: (id: string) => void; onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void; onColorChange?: (calendarId: string, color: string) => void;
onSubscribe?: () => void; onSubscribe?: () => void;
client?: JMAPClient | null; onEditSubscription?: (subscriptionId: string) => void;
client?: IJMAPClient | null;
} }
export function CalendarSidebarPanel({ export function CalendarSidebarPanel({
@@ -26,6 +29,7 @@ export function CalendarSidebarPanel({
onToggleVisibility, onToggleVisibility,
onColorChange, onColorChange,
onSubscribe, onSubscribe,
onEditSubscription,
client, client,
}: CalendarSidebarPanelProps) { }: CalendarSidebarPanelProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
@@ -35,6 +39,15 @@ export function CalendarSidebarPanel({
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription); const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription); const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
const timeFormat = useSettingsStore((s) => s.timeFormat); const timeFormat = useSettingsStore((s) => s.timeFormat);
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
const tasks = useTaskStore((s) => s.tasks);
const setViewMode = useCalendarStore((s) => s.setViewMode);
const pendingTaskCount = useMemo(() => tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled').length, [tasks]);
const overdueTaskCount = useMemo(() => {
const now = new Date();
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
}, [tasks]);
const [colorPickerId, setColorPickerId] = useState<string | null>(null); const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null); const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
@@ -152,6 +165,9 @@ export function CalendarSidebarPanel({
)} )}
</> </>
)} )}
{cal.id === BIRTHDAY_CALENDAR_ID && (
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
</button> </button>
{/* Subscription context menu on right-click */} {/* Subscription context menu on right-click */}
@@ -163,6 +179,16 @@ export function CalendarSidebarPanel({
ref={contextMenuRef} ref={contextMenuRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48" className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
> >
<button
onClick={() => {
setContextMenuCalId(null);
onEditSubscription?.(sub.id);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
{tSub('edit')}
</button>
<button <button
onClick={() => handleRefreshSubscription(sub.id)} onClick={() => handleRefreshSubscription(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors" className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
@@ -209,6 +235,21 @@ export function CalendarSidebarPanel({
return ( return (
<div className="mt-4"> <div className="mt-4">
{enableCalendarTasks && (
<button
onClick={() => setViewMode('tasks')}
className="flex items-center gap-2 w-full px-1.5 py-1.5 mb-3 rounded-md text-sm hover:bg-muted transition-colors"
>
<ListTodo className="w-4 h-4 text-muted-foreground" />
<span>{t('tasks.label')}</span>
{pendingTaskCount > 0 && (
<span className="ml-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
)}
{overdueTaskCount > 0 && (
<span className="text-xs text-destructive font-medium">{overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()}</span>
)}
</button>
)}
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1"> <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
{t("my_calendars")} {t("my_calendars")}
</h3> </h3>
+142 -120
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown } from "lucide-react"; import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft } from "lucide-react";
import { addDays, startOfWeek } from "date-fns"; import { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store"; import type { CalendarViewMode } from "@/stores/calendar-store";
@@ -25,6 +25,7 @@ interface CalendarToolbarProps {
calendars?: Calendar[]; calendars?: Calendar[];
selectedCalendarIds?: string[]; selectedCalendarIds?: string[];
onToggleVisibility?: (id: string) => void; onToggleVisibility?: (id: string) => void;
enableCalendarTasks?: boolean;
} }
export function CalendarToolbar({ export function CalendarToolbar({
@@ -39,13 +40,17 @@ export function CalendarToolbar({
onSubscribe, onSubscribe,
isMobile, isMobile,
firstDayOfWeek = 1, firstDayOfWeek = 1,
onNavigateBack,
calendars, calendars,
selectedCalendarIds, selectedCalendarIds,
onToggleVisibility, onToggleVisibility,
enableCalendarTasks,
}: CalendarToolbarProps) { }: CalendarToolbarProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const formatter = useFormatter(); const formatter = useFormatter();
const views: CalendarViewMode[] = ["month", "week", "day", "agenda"]; const views: CalendarViewMode[] = enableCalendarTasks
? ["month", "week", "day", "agenda", "tasks"]
: ["month", "week", "day", "agenda"];
const [showCalendarDropdown, setShowCalendarDropdown] = useState(false); const [showCalendarDropdown, setShowCalendarDropdown] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@@ -86,13 +91,14 @@ export function CalendarToolbar({
return isMobile return isMobile
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" }) ? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" }); : formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
case "tasks":
return t("views.tasks");
} }
}; };
const [showImportDropdown, setShowImportDropdown] = useState(false); const [showImportDropdown, setShowImportDropdown] = useState(false);
const importDropdownRef = useRef<HTMLDivElement>(null); const importDropdownRef = useRef<HTMLDivElement>(null);
const [showViewDropdown, setShowViewDropdown] = useState(false);
const viewDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!showImportDropdown) return; if (!showImportDropdown) return;
@@ -105,95 +111,76 @@ export function CalendarToolbar({
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showImportDropdown]); }, [showImportDropdown]);
useEffect(() => {
if (!showViewDropdown) return;
function handleClickOutside(e: MouseEvent) {
if (viewDropdownRef.current && !viewDropdownRef.current.contains(e.target as Node)) {
setShowViewDropdown(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showViewDropdown]);
return ( return (
<div className={cn("flex items-center gap-1.5 px-2 py-2 border-b border-border flex-wrap", !isMobile && "px-4 py-3 gap-2")}> <div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}>
{/* ── MOBILE TOOLBAR ── */}
{isMobile && ( {isMobile && (
<div className="flex items-center gap-0.5"> <div className="flex flex-col gap-1 px-2 py-2">
<button onClick={onPrev} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}> {/* Row 1: Back / Date nav / Today */}
<ChevronLeft className="w-4 h-4" /> <div className="flex items-center gap-1">
</button> {onNavigateBack && (
<span className={cn("text-sm font-medium text-center min-w-[80px]")}> <button
{getDateLabel()} onClick={onNavigateBack}
</span> className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
<button onClick={onNext} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}> aria-label={t("back_to_month")}
<ChevronRight className="w-4 h-4" /> >
</button> <ArrowLeft className="w-4 h-4" />
</div> </button>
)} )}
<button onClick={onPrev} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-semibold text-center flex-1 select-none truncate">
{getDateLabel()}
</span>
<button onClick={onNext} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</button>
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ml-0.5">
{t("views.today")}
</Button>
</div>
<Button variant="outline" size="sm" onClick={onToday} className="touch-manipulation"> {/* Row 2: View switcher pills + calendar toggle */}
{t("views.today")} <div className="flex items-center gap-1.5">
</Button> <div className="flex flex-1 border border-border rounded-md overflow-hidden">
{views.map((v) => (
<button
key={v}
onClick={() => onViewModeChange(v)}
className={cn(
"flex-1 py-1.5 text-[11px] font-medium transition-colors touch-manipulation",
v === viewMode
? "bg-primary text-primary-foreground"
: "text-muted-foreground active:bg-muted"
)}
>
{t(`views.${v}`)}
</button>
))}
</div>
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && ( {calendars && selectedCalendarIds && onToggleVisibility && (
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<Button <button
variant="outline" onClick={() => setShowCalendarDropdown((v) => !v)}
size="sm" className={cn(
onClick={() => setShowCalendarDropdown((v) => !v)} "p-1.5 rounded-md border border-border transition-colors touch-manipulation",
aria-label={t("my_calendars")} showCalendarDropdown ? "bg-muted" : "hover:bg-muted"
className="touch-manipulation" )}
> aria-label={t("my_calendars")}
<CalendarDays className="w-4 h-4" /> >
</Button> <CalendarDays className="w-4 h-4" />
{showCalendarDropdown && ( </button>
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]"> {showCalendarDropdown && (
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1"> <div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
{t("my_calendars")} <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
</h3> {t("my_calendars")}
<div className="space-y-0.5">
{calendars.filter(c => !c.isShared).map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"hover:bg-muted"
)}
>
<span
className={cn(
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
);
})}
</div>
{(() => {
const shared = calendars.filter(c => c.isShared);
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
for (const c of shared) {
const key = c.accountId || c.accountName || c.id;
if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] });
groups.get(key)!.cals.push(c);
}
return Array.from(groups.values()).map((group) => (
<div key={group.accountName} className="mt-2">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
{group.accountName}
</h3> </h3>
<div className="space-y-0.5"> <div className="space-y-0.5">
{group.cals.map((cal) => { {calendars.filter(c => !c.isShared).map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id); const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6"; const color = cal.color || "#3b82f6";
return ( return (
@@ -219,55 +206,90 @@ export function CalendarToolbar({
); );
})} })}
</div> </div>
{(() => {
const shared = calendars.filter(c => c.isShared);
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
for (const c of shared) {
const key = c.accountId || c.accountName || c.id;
if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] });
groups.get(key)!.cals.push(c);
}
return Array.from(groups.values()).map((group) => (
<div key={group.accountName} className="mt-2">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
{group.accountName}
</h3>
<div className="space-y-0.5">
{group.cals.map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"hover:bg-muted"
)}
>
<span
className={cn(
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
);
})}
</div>
</div>
));
})()}
</div> </div>
)); )}
})()} </div>
</div> )}
)} </div>
</div> </div>
)} )}
{isMobile && ( {/* ── DESKTOP TOOLBAR ── */}
<div className="relative" ref={viewDropdownRef}> {!isMobile && (
<Button <div className="flex items-center gap-1">
variant="outline" <Button variant="outline" size="sm" onClick={onToday} className="h-8 mr-1">
size="sm" {t("views.today")}
onClick={() => setShowViewDropdown((v) => !v)}
className="touch-manipulation capitalize text-xs"
>
{t(`views.${viewMode}`)}
<ChevronLeft className="w-3 h-3 ml-1 rotate-[-90deg]" />
</Button> </Button>
{showViewDropdown && ( <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-1 min-w-[120px]"> <ChevronLeft className="w-4 h-4" />
{views.map((v) => ( </Button>
<button <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
key={v} <ChevronRight className="w-4 h-4" />
onClick={() => { onViewModeChange(v); setShowViewDropdown(false); }} </Button>
className={cn( <span className="text-base font-semibold ml-2 select-none">
"flex items-center w-full px-3 py-2 rounded-md text-sm transition-colors touch-manipulation", {getDateLabel()}
v === viewMode ? "bg-primary text-primary-foreground" : "hover:bg-muted text-foreground" </span>
)}
>
{t(`views.${v}`)}
</button>
))}
</div>
)}
</div> </div>
)} )}
<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"
@@ -281,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" />
@@ -312,7 +334,7 @@ export function CalendarToolbar({
)} )}
{!isMobile && ( {!isMobile && (
<Button size="sm" onClick={onCreateEvent}> <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>
+184 -37
View File
@@ -6,11 +6,13 @@ import {
startOfWeek, addDays, format, isSameDay, isToday, parseISO, startOfWeek, addDays, format, isSameDay, isToday, parseISO,
} from "date-fns"; } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { EventCard, parseDuration } from "./event-card"; import { Check } from "lucide-react";
import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input"; import { QuickEventInput } from "./quick-event-input";
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils"; import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal";
interface CalendarWeekViewProps { interface CalendarWeekViewProps {
selectedDate: Date; selectedDate: Date;
@@ -20,10 +22,14 @@ 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;
onCreateAtTime: (date: Date, endDate?: Date) => void; onCreateAtTime: (date: Date, endDate?: Date) => void;
firstDayOfWeek?: number; firstDayOfWeek?: number;
timeFormat?: "12h" | "24h"; timeFormat?: "12h" | "24h";
isMobile?: boolean; isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
tasks?: CalendarTask[];
onToggleTaskComplete?: (task: CalendarTask) => void;
} }
const HOUR_HEIGHT = 60; const HOUR_HEIGHT = 60;
@@ -37,24 +43,25 @@ export function CalendarWeekView({
onSelectEvent, onSelectEvent,
onHoverEvent, onHoverEvent,
onHoverLeave, onHoverLeave,
onContextMenuEvent,
onCreateAtTime, onCreateAtTime,
firstDayOfWeek = 1, firstDayOfWeek = 1,
timeFormat = "24h", timeFormat = "24h",
isMobile, isMobile,
pendingPreview,
tasks,
onToggleTaskComplete,
}: CalendarWeekViewProps) { }: CalendarWeekViewProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const intlFormatter = useFormatter(); const intlFormatter = useFormatter();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const weekDays = useMemo(() => { const weekDays = useMemo(() => {
if (isMobile) {
// Show 3-day window centered on selected date
return Array.from({ length: 3 }, (_, i) => addDays(selectedDate, i - 1));
}
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart }); const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
return Array.from({ length: 7 }, (_, i) => addDays(start, i)); return Array.from({ length: 7 }, (_, i) => addDays(start, i));
}, [selectedDate, weekStart, isMobile]); }, [selectedDate, weekStart]);
const calendarMap = useMemo(() => { const calendarMap = useMemo(() => {
const map = new Map<string, Calendar>(); const map = new Map<string, Calendar>();
@@ -72,7 +79,7 @@ export function CalendarWeekView({
const cursor = new Date(startDay); const cursor = new Date(startDay);
while (cursor <= endDay) { while (cursor <= endDay) {
const key = format(cursor, "yyyy-MM-dd"); const key = format(cursor, "yyyy-MM-dd");
if (!ev.showWithoutTime) { if (!ev.showWithoutTime && !isTimedEventFullDayOnDate(ev, cursor)) {
const arr = timed.get(key) || []; const arr = timed.get(key) || [];
arr.push(ev); arr.push(ev);
timed.set(key, arr); timed.set(key, arr);
@@ -84,24 +91,70 @@ export function CalendarWeekView({
return timed; return timed;
}, [events]); }, [events]);
const allDaySegments = useMemo(() => buildWeekSegments( const allDaySegments = useMemo(() => {
events.filter((event) => event.showWithoutTime), const explicitAllDay = buildWeekSegmentsRaw(
weekDays, events.filter((event) => event.showWithoutTime),
), [events, weekDays]); weekDays,
);
const timedFullDay = buildTimedFullDayWeekSegments(
events.filter((event) => !event.showWithoutTime),
weekDays,
);
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
}, [events, weekDays]);
const allDayRowCount = useMemo(() => { const allDayRowCount = useMemo(() => {
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0); return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
}, [allDaySegments]); }, [allDaySegments]);
// Tasks grouped by day for the week
const tasksByDay = useMemo(() => {
if (!tasks?.length) return new Map<string, CalendarTask[]>();
const map = new Map<string, CalendarTask[]>();
for (const task of tasks) {
if (!task.due) continue;
try {
const key = format(parseISO(task.due), "yyyy-MM-dd");
const existing = map.get(key) || [];
existing.push(task);
map.set(key, existing);
} catch { /* skip */ }
}
return map;
}, [tasks]);
// Max tasks on any single day in this week
const taskRowCount = useMemo(() => {
let max = 0;
for (const day of weekDays) {
const key = format(day, "yyyy-MM-dd");
const count = tasksByDay.get(key)?.length ?? 0;
if (count > max) max = count;
}
return max;
}, [tasksByDay, weekDays]);
const hasAllDay = useMemo(() => { const hasAllDay = useMemo(() => {
return allDaySegments.length > 0; return allDaySegments.length > 0 || taskRowCount > 0;
}, [allDaySegments]); }, [allDaySegments, taskRowCount]);
useEffect(() => { useEffect(() => {
if (scrollRef.current) { if (scrollRef.current) {
const now = new Date(); const now = new Date();
scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT); scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
} }
// On mobile, scroll horizontally to center today's column
if (isMobile && rootRef.current) {
const todayIdx = weekDays.findIndex(d => isToday(d));
if (todayIdx >= 0) {
const gutter = 40;
const colWidth = (rootRef.current.scrollWidth - gutter) / 7;
const target = gutter + todayIdx * colWidth - rootRef.current.clientWidth / 2 + colWidth / 2;
rootRef.current.scrollLeft = Math.max(0, target);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const [nowMinutes, setNowMinutes] = useState(() => { const [nowMinutes, setNowMinutes] = useState(() => {
@@ -130,6 +183,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 => {
@@ -140,21 +194,29 @@ export function CalendarWeekView({
return format(new Date(2000, 0, 1, h), "HH:mm"); return format(new Date(2000, 0, 1, h), "HH:mm");
}; };
const colCount = isMobile ? 3 : 7; const colCount = 7;
return ( return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}> <div
{hasAllDay && ( ref={rootRef}
className={cn(
"flex min-h-0 min-w-0 flex-col flex-1",
isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
)}
role="grid"
aria-label={t("views.week")}
>
<div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
<div className="flex border-b border-border"> <div className="flex border-b border-border">
<div <div
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10" : "w-14")} className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
style={{ minHeight: Math.max(28, allDayRowCount * 24 + 4) }} style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
> >
{t("events.all_day")} {t("events.all_day")}
</div> </div>
<div <div
className={cn("flex-1 relative grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")} className="flex-1 relative grid gap-px bg-border grid-cols-7"
style={{ minHeight: Math.max(28, allDayRowCount * 24 + 4) }} style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
> >
{weekDays.map((day) => ( {weekDays.map((day) => (
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" /> <div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" />
@@ -183,18 +245,62 @@ 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>
); );
})} })}
</div> </div>
{/* Task chips in all-day area */}
{taskRowCount > 0 && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: allDayRowCount * 24 + 2 }}>
{weekDays.map((day, dayIndex) => {
const key = format(day, "yyyy-MM-dd");
const dayTasks = tasksByDay.get(key) || [];
return dayTasks.map((task, taskIndex) => {
const isCompleted = task.progress === "completed";
const cal = calendars.find(c => task.calendarIds[c.id]);
const color = cal?.color || "#3b82f6";
return (
<div
key={`task-${task.id}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(dayIndex / colCount) * 100}% + 1px)`,
width: `calc(${(1 / colCount) * 100}% - 2px)`,
top: taskIndex * 24,
height: 20,
}}
>
<div
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate flex items-center gap-1 cursor-pointer hover:opacity-80"
style={{ backgroundColor: `${color}20`, borderLeft: `3px solid ${color}` }}
onClick={() => onToggleTaskComplete?.(task)}
>
<span className={cn(
"w-2.5 h-2.5 rounded-full border flex-shrink-0 flex items-center justify-center",
isCompleted ? "bg-success border-success" : "border-current"
)}>
{isCompleted && <Check className="h-2 w-2 text-white" />}
</span>
<span className={cn("truncate", isCompleted && "line-through text-muted-foreground")}>
{task.title}
</span>
</div>
</div>
);
});
})}
</div>
)}
</div> </div>
</div> </div>
)} )}
<div className="flex border-b border-border" role="row"> <div className="flex border-b border-border" role="row">
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")} /> <div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} />
<div className={cn("flex-1 border-l border-border", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}> <div className="flex-1 border-l border-border grid grid-cols-7">
{weekDays.map((day) => { {weekDays.map((day) => {
const todayCol = isToday(day); const todayCol = isToday(day);
const selected = isSameDay(day, selectedDate); const selected = isSameDay(day, selectedDate);
@@ -227,9 +333,9 @@ export function CalendarWeekView({
</div> </div>
</div> </div>
<div ref={scrollRef} className="flex-1 overflow-y-auto"> <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}> <div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")}> <div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
{HOURS.map((h) => ( {HOURS.map((h) => (
<div <div
key={h} key={h}
@@ -245,12 +351,12 @@ export function CalendarWeekView({
))} ))}
</div> </div>
<div className={cn("flex-1 border-l border-border relative", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}> <div className="flex-1 border-l border-border relative grid grid-cols-7">
{weekDays.map((day) => { {weekDays.map((day) => {
const key = format(day, "yyyy-MM-dd"); const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || []; const dayEvents = timedEvents.get(key) || [];
const todayCol = isToday(day); const todayCol = isToday(day);
const layouted = layoutOverlappingEvents(dayEvents); const layouted = layoutOverlappingEvents(dayEvents, day);
return ( return (
<div <div
@@ -277,13 +383,13 @@ export function CalendarWeekView({
/> />
))} ))}
{layouted.map(({ event: ev, column, totalColumns }) => { {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const start = parseISO(ev.start); const durMin = Math.max(15, endMinutes - startMinutes);
const startMin = start.getHours() * 60 + start.getMinutes(); const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
const durMin = Math.max(15, parseDuration(ev.duration));
const top = (startMin / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT); const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; const 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;
@@ -302,13 +408,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}
> >
@@ -324,8 +441,8 @@ export function CalendarWeekView({
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }} style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
> >
<div className="flex items-center"> <div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-red-500 -ml-1" /> <div className="w-2 h-2 rounded-full bg-destructive -ml-1" />
<div className="flex-1 h-px bg-red-500" /> <div className="flex-1 h-px bg-destructive" />
</div> </div>
</div> </div>
)} )}
@@ -366,6 +483,35 @@ export function CalendarWeekView({
</div> </div>
</div> </div>
)} )}
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && (
(() => {
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
let endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
if (endMin <= startMin) endMin = 1440;
const durationMin = Math.max(15, endMin - startMin);
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
const color = cal?.color || "hsl(var(--primary))";
return (
<div
className="absolute left-1 right-1 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
style={{
top: (startMin / 60) * HOUR_HEIGHT,
height: Math.max(20, (durationMin / 60) * HOUR_HEIGHT),
borderColor: color,
backgroundColor: `${color}10`,
}}
>
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
{pendingPreview.title}
</div>
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
{formatSnapTime(startMin, timeFormat)} {formatSnapTime(startMin + durationMin, timeFormat)}
</div>
</div>
);
})()
)}
</div> </div>
); );
})} })}
@@ -373,5 +519,6 @@ export function CalendarWeekView({
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }
+15 -4
View File
@@ -4,9 +4,10 @@ import { useCallback, useState, type CSSProperties, type DragEvent } from "react
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { format, parseISO } from "date-fns"; import { format } from "date-fns";
import { Users } from "lucide-react"; import { Users } from "lucide-react";
import { getParticipantCount } from "@/lib/calendar-participants"; import { getParticipantCount } from "@/lib/calendar-participants";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
interface EventCardProps { interface EventCardProps {
@@ -16,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;
@@ -64,17 +66,18 @@ 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);
const startDate = parseISO(event.start); const startDate = getEventStartDate(event);
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const calendarName = calendar?.name || ""; const calendarName = calendar?.name || "";
const durationMinutes = parseDuration(event.duration); const durationMinutes = parseDuration(event.duration);
const endTime = new Date(startDate.getTime() + durationMinutes * 60000); const endTime = getEventEndDate(event);
const timeString = `${format(startDate, timeFmt)} ${format(endTime, timeFmt)}`; const timeString = `${format(startDate, timeFmt)} ${format(endTime, timeFmt)}`;
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`; const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
@@ -107,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(
@@ -140,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(
@@ -156,6 +163,9 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }} style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
> >
<div className="flex items-center gap-1 min-w-0"> <div className="flex items-center gap-1 min-w-0">
{showTimeInMonthView && !event.showWithoutTime && (
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
)}
<span className="truncate font-medium">{event.title || t("events.no_title")}</span> <span className="truncate font-medium">{event.title || t("events.no_title")}</span>
</div> </div>
</button> </button>
@@ -167,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 -14
View File
@@ -12,6 +12,7 @@ import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card"; import { parseDuration, getEventColor } from "./event-card";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { import {
isOrganizer, isOrganizer,
getUserParticipantId, getUserParticipantId,
@@ -87,7 +88,7 @@ function formatDurationDisplay(minutes: number): string {
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null { function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.alerts) return null; if (!event.alerts) return null;
const first = Object.values(event.alerts)[0]; const first = Object.values(event.alerts)[0];
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null; if (!first || !first.trigger || first.trigger["@type"] !== "OffsetTrigger") return null;
const offset = first.trigger.offset; const offset = first.trigger.offset;
if (offset === "PT0S") return t("alerts.at_time"); if (offset === "PT0S") return t("alerts.at_time");
const minMatch = offset.match(/-?PT?(\d+)M$/); const minMatch = offset.match(/-?PT?(\d+)M$/);
@@ -138,9 +139,9 @@ export function EventDetailPopover({
const [isSavingNote, setIsSavingNote] = useState(false); const [isSavingNote, setIsSavingNote] = useState(false);
const color = getEventColor(event, calendar); const color = getEventColor(event, calendar);
const startDate = parseISO(event.start); const startDate = getEventStartDate(event);
const durationMinutes = parseDuration(event.duration); const durationMinutes = parseDuration(event.duration);
const endDate = new Date(startDate.getTime() + durationMinutes * 60000); const endDate = getEventEndDate(event);
const locationName = useMemo(() => { const locationName = useMemo(() => {
if (!event.locations) return null; if (!event.locations) return null;
@@ -192,6 +193,10 @@ export function EventDetailPopover({
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") onClose();
const target = e.target as HTMLElement;
const tag = target?.tagName?.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return;
if (target?.getAttribute("contenteditable") === "true") return;
if (e.key === "e" && !noteExpanded) { if (e.key === "e" && !noteExpanded) {
e.preventDefault(); e.preventDefault();
onEdit(); onEdit();
@@ -293,7 +298,7 @@ export function EventDetailPopover({
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]"> <p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">
{calendar.name} {calendar.name}
{event.status === "tentative" && ( {event.status === "tentative" && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400"> <span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
{t("detail.tentative")} {t("detail.tentative")}
</span> </span>
)} )}
@@ -503,8 +508,8 @@ export function EventDetailPopover({
onClick={() => onRsvp("accepted")} onClick={() => onRsvp("accepted")}
className={ className={
userCurrentStatus === "accepted" userCurrentStatus === "accepted"
? "bg-green-600 hover:bg-green-700 text-white dark:bg-green-500 dark:hover:bg-green-600" ? "bg-success hover:bg-success/80 text-success-foreground"
: "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950" : "text-success border-success/30 hover:bg-success/10"
} }
> >
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 mr-1" />} {userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 mr-1" />}
@@ -516,8 +521,8 @@ export function EventDetailPopover({
onClick={() => onRsvp("tentative")} onClick={() => onRsvp("tentative")}
className={ className={
userCurrentStatus === "tentative" userCurrentStatus === "tentative"
? "bg-amber-600 hover:bg-amber-700 text-white dark:bg-amber-500 dark:hover:bg-amber-600" ? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-amber-500 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950" : "border border-warning/30 text-warning hover:bg-warning/10"
} }
> >
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 mr-1" />} {userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 mr-1" />}
@@ -529,8 +534,8 @@ export function EventDetailPopover({
onClick={() => onRsvp("declined")} onClick={() => onRsvp("declined")}
className={ className={
userCurrentStatus === "declined" userCurrentStatus === "declined"
? "bg-red-600 hover:bg-red-700 text-white dark:bg-red-500 dark:hover:bg-red-600" ? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950" : "text-destructive hover:bg-destructive/10"
} }
> >
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 mr-1" />} {userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 mr-1" />}
@@ -544,7 +549,7 @@ export function EventDetailPopover({
<div className="px-4 py-2.5 border-t border-border flex items-center gap-1.5"> <div className="px-4 py-2.5 border-t border-border flex items-center gap-1.5">
{showDeleteConfirm ? ( {showDeleteConfirm ? (
<div className="flex items-center gap-2 w-full"> <div className="flex items-center gap-2 w-full">
<span className="text-sm text-red-600 dark:text-red-400 flex-1"> <span className="text-sm text-destructive flex-1">
{t("form.delete_confirm")} {t("form.delete_confirm")}
</span> </span>
<Button <Button
@@ -607,9 +612,9 @@ function ParticipantStatusBadge({
t: ReturnType<typeof useTranslations>; t: ReturnType<typeof useTranslations>;
}) { }) {
const colors: Record<string, string> = { const colors: Record<string, string> = {
accepted: "text-green-600 dark:text-green-400", accepted: "text-success",
declined: "text-red-600 dark:text-red-400", declined: "text-destructive",
tentative: "text-amber-600 dark:text-amber-400", tentative: "text-warning",
"needs-action": "text-muted-foreground", "needs-action": "text-muted-foreground",
}; };
const labels: Record<string, string> = { const labels: Record<string, string> = {
+127 -39
View File
@@ -8,8 +8,8 @@ import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Vid
import { format, parseISO, addHours, addDays } from "date-fns"; import { format, parseISO, addHours, addDays } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card"; import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, 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,
@@ -18,7 +18,17 @@ import {
getStatusCounts, getStatusCounts,
buildParticipantMap, buildParticipantMap,
} from "@/lib/calendar-participants"; } from "@/lib/calendar-participants";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
export interface PendingEventPreview {
start: Date;
end: Date;
title: string;
allDay: boolean;
calendarId: string;
}
interface EventModalProps { interface EventModalProps {
event?: CalendarEvent | null; event?: CalendarEvent | null;
@@ -30,6 +40,7 @@ interface EventModalProps {
onDuplicate?: (data: Partial<CalendarEvent>) => void; onDuplicate?: (data: Partial<CalendarEvent>) => void;
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void; onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
onClose: () => void; onClose: () => void;
onPreviewChange?: (preview: PendingEventPreview | null) => void;
currentUserEmails?: string[]; currentUserEmails?: string[];
isMobile?: boolean; isMobile?: boolean;
} }
@@ -50,10 +61,12 @@ function buildDuration(startDate: Date, endDate: Date): string {
const minutes = totalMinutes % 60; const minutes = totalMinutes % 60;
let dur = "P"; let dur = "P";
if (days > 0) dur += `${days}D`; if (days > 0) dur += `${days}D`;
dur += "T"; if (hours > 0 || minutes > 0) {
if (hours > 0) dur += `${hours}H`; dur += "T";
if (minutes > 0) dur += `${minutes}M`; if (hours > 0) dur += `${hours}H`;
if (dur === "PT") dur = "PT0M"; if (minutes > 0) dur += `${minutes}M`;
}
if (dur === "P") dur = "PT0M";
return dur; return dur;
} }
@@ -74,9 +87,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslation
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null; if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
const offset = first.trigger.offset; const offset = first.trigger.offset;
if (offset === "PT0S") return t("alerts.at_time"); if (offset === "PT0S") return t("alerts.at_time");
const minMatch = offset.match(/-?PT?(\d+)M$/); const minMatch = offset.match(/-?PT(\d+)M$/);
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) }); if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
const hourMatch = offset.match(/-?PT?(\d+)H$/); const hourMatch = offset.match(/-?PT(\d+)H$/);
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) }); if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
const dayMatch = offset.match(/-?P(\d+)D/); const dayMatch = offset.match(/-?P(\d+)D/);
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) }); if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
@@ -105,6 +118,7 @@ export function EventModal({
onDuplicate, onDuplicate,
onRsvp, onRsvp,
onClose, onClose,
onPreviewChange,
currentUserEmails = [], currentUserEmails = [],
isMobile = false, isMobile = false,
}: EventModalProps) { }: EventModalProps) {
@@ -147,7 +161,7 @@ export function EventModal({
}, [event, existingParticipants]); }, [event, existingParticipants]);
const getInitialStart = (): Date => { const getInitialStart = (): Date => {
if (event?.start) return parseISO(event.start); if (event?.start) return getEventStartDate(event);
if (defaultDate) { if (defaultDate) {
const d = new Date(defaultDate); const d = new Date(defaultDate);
if (defaultEndDate) return d; if (defaultEndDate) return d;
@@ -165,9 +179,7 @@ export function EventModal({
if (event.showWithoutTime) { if (event.showWithoutTime) {
return getEventDisplayEndDate(event); return getEventDisplayEndDate(event);
} }
const s = parseISO(event.start); return getEventEndDate(event);
const dur = parseDuration(event.duration);
return new Date(s.getTime() + dur * 60000);
} }
if (defaultEndDate) return new Date(defaultEndDate); if (defaultEndDate) return new Date(defaultEndDate);
return addHours(getInitialStart(), 1); return addHours(getInitialStart(), 1);
@@ -178,6 +190,9 @@ export function EventModal({
const [location, setLocation] = useState( const [location, setLocation] = useState(
event?.locations ? Object.values(event.locations)[0]?.name || "" : "" event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
); );
const [virtualLocation, setVirtualLocation] = useState(
event?.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || "" : ""
);
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart())); const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart())); const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd())); const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
@@ -199,9 +214,9 @@ export function EventModal({
if (first.trigger["@type"] === "OffsetTrigger") { if (first.trigger["@type"] === "OffsetTrigger") {
const offset = first.trigger.offset; const offset = first.trigger.offset;
if (offset === "PT0S") return "at_time"; if (offset === "PT0S") return "at_time";
const minMatch = offset.match(/-?PT?(\d+)M$/); const minMatch = offset.match(/-?PT(\d+)M$/);
if (minMatch) return minMatch[1] as AlertOption; if (minMatch) return minMatch[1] as AlertOption;
const hourMatch = offset.match(/-?PT?(\d+)H$/); const hourMatch = offset.match(/-?PT(\d+)H$/);
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption; if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption;
const dayMatch = offset.match(/-?P(\d+)D/); const dayMatch = offset.match(/-?P(\d+)D/);
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption; if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption;
@@ -218,6 +233,19 @@ 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
useEffect(() => {
if (!onPreviewChange || isEdit) return;
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
const s = new Date(startStr);
const e = new Date(endStr);
if (isNaN(s.getTime()) || isNaN(e.getTime())) return;
onPreviewChange({ start: s, end: e, title: title || "(No title)", allDay, calendarId });
return () => onPreviewChange(null);
}, [startDate, startTime, endDate, endTime, allDay, title, calendarId, isEdit, onPreviewChange]);
const statusCounts = useMemo(() => { const statusCounts = useMemo(() => {
if (!event?.participants) return null; if (!event?.participants) return null;
@@ -237,11 +265,14 @@ 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`;
const start = new Date(startStr); const start = allDay ? parseISO(startStr) : new Date(startStr);
let duration: string; let duration: string;
if (allDay) { if (allDay) {
@@ -274,6 +305,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: {
@@ -291,6 +326,20 @@ export function EventModal({
data.locations = null; data.locations = null;
} }
if (virtualLocation.trim()) {
data.virtualLocations = {
vl1: {
"@type": "VirtualLocation",
name: null,
description: null,
uri: virtualLocation.trim(),
features: null,
},
};
} else if (event && event.virtualLocations && Object.keys(event.virtualLocations).length > 0) {
data.virtualLocations = null;
}
if (recurrence !== "none") { if (recurrence !== "none") {
data.recurrenceRules = [{ data.recurrenceRules = [{
"@type": "RecurrenceRule", "@type": "RecurrenceRule",
@@ -332,25 +381,27 @@ 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);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]); }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => { const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return; if (!event || !userParticipantId || !onRsvp) return;
@@ -360,12 +411,14 @@ export function EventModal({
const handleDuplicate = useCallback(() => { const handleDuplicate = useCallback(() => {
if (!event || !onDuplicate) return; if (!event || !onDuplicate) return;
const start = parseISO(event.start); const start = getEventStartDate(event);
const newStart = addDays(start, 1); const newStart = addDays(start, 1);
const newUid = generateUUID();
const data: Partial<CalendarEvent> = { const data: Partial<CalendarEvent> = {
uid: newUid,
title: event.title, title: event.title,
description: event.description, description: event.description,
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"), start: event.showWithoutTime ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
duration: event.duration, duration: event.duration,
timeZone: event.timeZone, timeZone: event.timeZone,
showWithoutTime: event.showWithoutTime, showWithoutTime: event.showWithoutTime,
@@ -375,6 +428,7 @@ export function EventModal({
privacy: event.privacy, privacy: event.privacy,
}; };
if (event.locations) data.locations = structuredClone(event.locations); if (event.locations) data.locations = structuredClone(event.locations);
if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations);
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules); if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
if (event.alerts) data.alerts = structuredClone(event.alerts); if (event.alerts) data.alerts = structuredClone(event.alerts);
if (event.participants) data.participants = structuredClone(event.participants); if (event.participants) data.participants = structuredClone(event.participants);
@@ -428,9 +482,9 @@ export function EventModal({
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0); const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
if (isAttendeeMode && event) { if (isAttendeeMode && event) {
const startD = parseISO(event.start); const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration); const durMin = parseDuration(event.duration);
const endD = new Date(startD.getTime() + durMin * 60000); const endD = getEventEndDate(event);
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null; const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
const participants = getParticipantList(event); const participants = getParticipantList(event);
@@ -502,8 +556,8 @@ export function EventModal({
variant={userCurrentStatus === "accepted" ? "default" : "outline"} variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => handleRsvp("accepted")} onClick={() => handleRsvp("accepted")}
className={userCurrentStatus === "accepted" className={userCurrentStatus === "accepted"
? "bg-green-600 hover:bg-green-700 text-white dark:bg-green-500 dark:hover:bg-green-600" ? "bg-success hover:bg-success/80 text-success-foreground"
: "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"} : "text-success border-success/30 hover:bg-success/10"}
> >
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />} {userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
{t("participants.accepted")} {t("participants.accepted")}
@@ -513,8 +567,8 @@ export function EventModal({
variant={userCurrentStatus === "tentative" ? "default" : "outline"} variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => handleRsvp("tentative")} onClick={() => handleRsvp("tentative")}
className={userCurrentStatus === "tentative" className={userCurrentStatus === "tentative"
? "bg-amber-600 hover:bg-amber-700 text-white dark:bg-amber-500 dark:hover:bg-amber-600" ? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-amber-500 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950"} : "border border-warning/30 text-warning hover:bg-warning/10"}
> >
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />} {userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
{t("participants.tentative")} {t("participants.tentative")}
@@ -524,8 +578,8 @@ export function EventModal({
variant={userCurrentStatus === "declined" ? "default" : "ghost"} variant={userCurrentStatus === "declined" ? "default" : "ghost"}
onClick={() => handleRsvp("declined")} onClick={() => handleRsvp("declined")}
className={userCurrentStatus === "declined" className={userCurrentStatus === "declined"
? "bg-red-600 hover:bg-red-700 text-white dark:bg-red-500 dark:hover:bg-red-600" ? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"} : "text-destructive hover:bg-destructive/10"}
> >
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />} {userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
{t("participants.declined")} {t("participants.declined")}
@@ -539,9 +593,9 @@ export function EventModal({
// View mode: read-only display of event details with Edit button // View mode: read-only display of event details with Edit button
if (mode === "view" && event) { if (mode === "view" && event) {
const startD = parseISO(event.start); const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration); const durMin = parseDuration(event.duration);
const endD = new Date(startD.getTime() + durMin * 60000); const endD = getEventEndDate(event);
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null; const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null; const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
const viewParticipants = getParticipantList(event); const viewParticipants = getParticipantList(event);
@@ -673,8 +727,8 @@ export function EventModal({
{onDelete && ( {onDelete && (
showDeleteConfirm ? ( showDeleteConfirm ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-red-600 dark:text-red-400">{t("form.delete_confirm")}</span> <span className="text-sm text-destructive">{t("form.delete_confirm")}</span>
<Button variant="outline" size="sm" onClick={() => { onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"> <Button variant="outline" size="sm" onClick={() => { onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-destructive border-destructive/30">
{t("events.delete")} {t("events.delete")}
</Button> </Button>
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}> <Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
@@ -682,7 +736,7 @@ export function EventModal({
</Button> </Button>
</div> </div>
) : ( ) : (
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-red-600 dark:text-red-400"> <Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-destructive">
<Trash2 className="w-4 h-4 mr-1" /> <Trash2 className="w-4 h-4 mr-1" />
{t("events.delete")} {t("events.delete")}
</Button> </Button>
@@ -707,7 +761,7 @@ export function EventModal({
} }
return ( return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}> <div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0"> <div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold"> <h2 className="text-lg font-semibold">
{isEdit ? t("events.edit") : t("events.create")} {isEdit ? t("events.edit") : t("events.create")}
@@ -752,6 +806,39 @@ export function EventModal({
/> />
</div> </div>
<div>
<label className="text-sm font-medium mb-1 block">
<span className="flex items-center gap-1.5">
<Video className="w-4 h-4" />
{t("form.meeting_link")}
</span>
</label>
<Input
type="url"
value={virtualLocation}
onChange={(e) => setVirtualLocation(e.target.value)}
placeholder="https://meet.example.com/..."
maxLength={2000}
/>
<PluginSlot
name="calendar-event-actions"
className="mt-2 flex flex-wrap gap-2"
extraProps={{
eventData: {
title,
description,
start: startDate + 'T' + startTime,
end: endDate + 'T' + endTime,
isAllDay: allDay,
location,
virtualLocation,
calendarId,
},
setVirtualLocation,
}}
/>
</div>
<div> <div>
<label className="text-sm font-medium mb-1 block"> <label className="text-sm font-medium mb-1 block">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
@@ -760,6 +847,7 @@ export function EventModal({
</span> </span>
</label> </label>
<ParticipantInput <ParticipantInput
ref={participantInputRef}
participants={attendees} participants={attendees}
onAdd={handleAddAttendee} onAdd={handleAddAttendee}
onRemove={handleRemoveAttendee} onRemove={handleRemoveAttendee}
@@ -969,9 +1057,9 @@ function StatusBadge({ status, isOrganizer, t }: {
return <span className="text-xs text-primary">{t("participants.organizer")}</span>; return <span className="text-xs text-primary">{t("participants.organizer")}</span>;
} }
const colors: Record<string, string> = { const colors: Record<string, string> = {
accepted: "text-green-600 dark:text-green-400", accepted: "text-success",
declined: "text-red-600 dark:text-red-400", declined: "text-destructive",
tentative: "text-amber-600 dark:text-amber-400", tentative: "text-warning",
"needs-action": "text-muted-foreground", "needs-action": "text-muted-foreground",
}; };
const labels: Record<string, string> = { const labels: Record<string, string> = {
+8 -6
View File
@@ -6,18 +6,20 @@ import { Button } from "@/components/ui/button";
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react"; import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { JMAPClient } from "@/lib/jmap/client"; import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { getEventStartDate } from "@/lib/calendar-utils";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { apiFetch } from "@/lib/browser-navigation";
interface ICalImportModalProps { interface ICalImportModalProps {
calendars: Calendar[]; calendars: Calendar[];
client: JMAPClient; client: IJMAPClient;
onClose: () => void; onClose: () => void;
} }
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ACCEPTED_EXTENSIONS = [".ics", ".ical"]; const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
type ImportStep = "select" | "preview" | "importing"; type ImportStep = "select" | "preview" | "importing";
@@ -125,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 }),
@@ -195,7 +197,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
const formatEventDate = (event: Partial<CalendarEvent>): string => { const formatEventDate = (event: Partial<CalendarEvent>): string => {
if (!event.start) return ""; if (!event.start) return "";
try { try {
const date = parseISO(event.start); const date = getEventStartDate(event as CalendarEvent);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
return event.showWithoutTime return event.showWithoutTime
? format(date, "MMM d, yyyy") ? format(date, "MMM d, yyyy")
@@ -349,7 +351,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
)} )}
{error && ( {error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-md px-3 py-2"> <div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
{error} {error}
</div> </div>
)} )}
+31 -16
View File
@@ -4,25 +4,29 @@ import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X, Loader2, Globe } from "lucide-react"; import { X, Loader2, Globe } from "lucide-react";
import type { JMAPClient } from "@/lib/jmap/client"; import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore, type ICalSubscription } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
interface ICalSubscriptionModalProps { interface ICalSubscriptionModalProps {
client: JMAPClient; client: IJMAPClient;
onClose: () => void; onClose: () => void;
editSubscription?: ICalSubscription;
} }
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) { export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
const t = useTranslations("calendar.subscription"); const t = useTranslations("calendar.subscription");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription); const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
const updateICalSubscription = useCalendarStore((s) => s.updateICalSubscription);
const [url, setUrl] = useState(""); const isEdit = !!editSubscription;
const [name, setName] = useState("");
const [color, setColor] = useState("#3b82f6"); const [url, setUrl] = useState(editSubscription?.url || "");
const [refreshInterval, setRefreshInterval] = useState(60); const [name, setName] = useState(editSubscription?.name || "");
const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
@@ -49,15 +53,26 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval); if (isEdit && editSubscription) {
if (subscription) { const updates: { url?: string; name?: string; color?: string; refreshInterval?: number } = {};
toast.success(t("success", { name: name.trim() })); if (trimmedUrl !== editSubscription.url) updates.url = trimmedUrl;
if (name.trim() !== editSubscription.name) updates.name = name.trim();
if (color !== editSubscription.color) updates.color = color;
if (refreshInterval !== editSubscription.refreshInterval) updates.refreshInterval = refreshInterval;
await updateICalSubscription(client, editSubscription.id, updates);
toast.success(t("updated", { name: name.trim() }));
onClose(); onClose();
} else { } else {
setError(t("error")); const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
if (subscription) {
toast.success(t("success", { name: name.trim() }));
onClose();
} else {
setError(t("error"));
}
} }
} catch { } catch {
setError(t("error")); setError(isEdit ? t("update_error") : t("error"));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -108,7 +123,7 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
<div className="flex items-center justify-between px-6 py-4 border-b border-border"> <div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-primary" /> <Globe className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("title")}</h2> <h2 className="text-lg font-semibold">{isEdit ? t("edit_title") : t("title")}</h2>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
@@ -192,10 +207,10 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Loader2 className="w-4 h-4 animate-spin mr-2" /> <Loader2 className="w-4 h-4 animate-spin mr-2" />
{t("subscribing")} {isEdit ? t("saving") : t("subscribing")}
</> </>
) : ( ) : (
t("subscribe") isEdit ? t("save") : t("subscribe")
)} )}
</Button> </Button>
</div> </div>
+55 -20
View File
@@ -1,15 +1,16 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo, Fragment } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react"; import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
import { import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addYears, subYears, setMonth, setYear, addMonths, subMonths, addYears, subYears, setMonth, setYear,
eachDayOfInterval, getMonth, getYear, eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
isSameDay, isSameMonth, isToday, format, isSameDay, isSameMonth, isToday, format,
} from "date-fns"; } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getEventDayBounds } from "@/lib/calendar-utils";
import type { CalendarEvent } from "@/lib/jmap/types"; import type { CalendarEvent } from "@/lib/jmap/types";
type PickerView = "days" | "months" | "years"; type PickerView = "days" | "months" | "years";
@@ -26,6 +27,7 @@ interface MiniCalendarProps {
onChangeMonth: (date: Date) => void; onChangeMonth: (date: Date) => void;
events?: CalendarEvent[]; events?: CalendarEvent[];
firstDayOfWeek?: number; firstDayOfWeek?: number;
showWeekNumbers?: boolean;
} }
export function MiniCalendar({ export function MiniCalendar({
@@ -35,6 +37,7 @@ export function MiniCalendar({
onChangeMonth, onChangeMonth,
events = [], events = [],
firstDayOfWeek = 1, firstDayOfWeek = 1,
showWeekNumbers = false,
}: MiniCalendarProps) { }: MiniCalendarProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const intlFormatter = useFormatter(); const intlFormatter = useFormatter();
@@ -52,7 +55,14 @@ export function MiniCalendar({
const eventDates = useMemo(() => { const eventDates = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
events.forEach(e => { events.forEach(e => {
try { set.add(format(new Date(e.start), "yyyy-MM-dd")); } catch { /* skip */ } try {
const { startDay, endDay } = getEventDayBounds(e);
const cursor = new Date(startDay);
while (cursor <= endDay) {
set.add(format(cursor, "yyyy-MM-dd"));
cursor.setDate(cursor.getDate() + 1);
}
} catch { /* skip */ }
}); });
return set; return set;
}, [events]); }, [events]);
@@ -61,6 +71,17 @@ export function MiniCalendar({
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const ? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const; : ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
// Compute week numbers for each row (one per 7-day chunk)
const weekNumbers = useMemo(() => {
if (!showWeekNumbers) return [];
const nums: number[] = [];
for (let i = 0; i < days.length; i += 7) {
// Use the first day of each row to determine the week number
nums.push(weekStart === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
}
return nums;
}, [days, showWeekNumbers, weekStart]);
const currentYear = getYear(displayMonth); const currentYear = getYear(displayMonth);
const currentMonth = getMonth(displayMonth); const currentMonth = getMonth(displayMonth);
const decadeStart = Math.floor(currentYear / 10) * 10; const decadeStart = Math.floor(currentYear / 10) * 10;
@@ -135,35 +156,49 @@ export function MiniCalendar({
</div> </div>
{pickerView === "days" && ( {pickerView === "days" && (
<div className="grid grid-cols-7 gap-0"> <div className={cn("grid gap-0", showWeekNumbers ? "grid-cols-[auto_repeat(7,1fr)]" : "grid-cols-7")}>
{showWeekNumbers && (
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
)}
{dayHeaders.map((d) => ( {dayHeaders.map((d) => (
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1"> <div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
{t(`days.${d}`)} {t(`days.${d}`)}
</div> </div>
))} ))}
{days.map((day) => { {days.map((day, index) => {
const inMonth = isSameMonth(day, displayMonth); const inMonth = isSameMonth(day, displayMonth);
const selected = isSameDay(day, selectedDate); const selected = isSameDay(day, selectedDate);
const today = isToday(day); const today = isToday(day);
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd")); const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
const isFirstDayOfRow = index % 7 === 0;
return ( return (
<button <Fragment key={day.toISOString()}>
key={day.toISOString()} {showWeekNumbers && isFirstDayOfRow && (
onClick={() => onSelectDate(day)} <div
className={cn( key={`wk-${index}`}
"relative flex items-center justify-center w-7 h-7 text-xs rounded-full transition-colors", className="flex items-center justify-center w-5 text-[9px] text-muted-foreground/60 font-medium"
!inMonth && "text-muted-foreground/40", >
inMonth && !selected && "hover:bg-muted", {weekNumbers[index / 7]}
today && !selected && "font-bold text-primary", </div>
selected && "bg-primary text-primary-foreground"
)} )}
> <button
{format(day, "d")} key={`day-${day.toISOString()}`}
{hasEvent && !selected && ( onClick={() => onSelectDate(day)}
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" /> className={cn(
)} "relative flex items-center justify-center w-7 h-7 text-xs rounded-full transition-colors",
</button> !inMonth && "text-muted-foreground/40",
inMonth && !selected && "hover:bg-muted",
today && !selected && "font-bold text-primary",
selected && "bg-primary text-primary-foreground"
)}
>
{format(day, "d")}
{hasEvent && !selected && (
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
)}
</button>
</Fragment>
); );
})} })}
</div> </div>
+42 -5
View File
@@ -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>
); );
} });
+51 -6
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { useMemo, useCallback } from "react"; import { useMemo, useCallback, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { format, parseISO, isPast, isToday, isTomorrow } from "date-fns"; import { format, parseISO, isPast, isToday, isTomorrow } from "date-fns";
import { Check, Circle, Flag, CalendarDays, ListTodo } from "lucide-react"; import { Check, Flag, CalendarDays, ListTodo, Plus } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarTask, Calendar } from "@/lib/jmap/types"; import type { CalendarTask, Calendar } from "@/lib/jmap/types";
import type { TaskViewFilter } from "@/stores/task-store"; import type { TaskViewFilter } from "@/stores/task-store";
@@ -18,6 +18,7 @@ interface TaskListViewProps {
onSelectTask: (task: CalendarTask) => void; onSelectTask: (task: CalendarTask) => void;
onToggleComplete: (task: CalendarTask) => void; onToggleComplete: (task: CalendarTask) => void;
selectedTaskId?: string | null; selectedTaskId?: string | null;
onQuickCreate?: (title: string) => void;
} }
function getTaskPriorityIcon(priority: number) { function getTaskPriorityIcon(priority: number) {
@@ -69,9 +70,11 @@ export function TaskListView({
onSelectTask, onSelectTask,
onToggleComplete, onToggleComplete,
selectedTaskId, selectedTaskId,
onQuickCreate,
}: TaskListViewProps) { }: TaskListViewProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const timeFormat = useSettingsStore((s) => s.timeFormat); const timeFormat = useSettingsStore((s) => s.timeFormat);
const [quickAddTitle, setQuickAddTitle] = useState("");
const filteredTasks = useMemo(() => { const filteredTasks = useMemo(() => {
let result = tasks.filter(task => { let result = tasks.filter(task => {
@@ -128,15 +131,57 @@ export function TaskListView({
if (filteredTasks.length === 0) { if (filteredTasks.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground py-12"> <div className="flex flex-col flex-1">
<ListTodo className="h-12 w-12 mb-3 opacity-30" /> {onQuickCreate && (
<p className="text-sm">{t("tasks.no_tasks")}</p> <div className="px-4 py-2 border-b border-border">
<div className="flex items-center gap-2">
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<input
type="text"
value={quickAddTitle}
onChange={(e) => setQuickAddTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && quickAddTitle.trim()) {
onQuickCreate(quickAddTitle.trim());
setQuickAddTitle("");
}
}}
placeholder={t("tasks.quick_add_placeholder")}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
</div>
)}
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground py-12">
<ListTodo className="h-12 w-12 mb-3 opacity-30" />
<p className="text-sm">{t("tasks.no_tasks")}</p>
</div>
</div> </div>
); );
} }
return ( return (
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{onQuickCreate && (
<div className="px-4 py-2 border-b border-border">
<div className="flex items-center gap-2">
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<input
type="text"
value={quickAddTitle}
onChange={(e) => setQuickAddTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && quickAddTitle.trim()) {
onQuickCreate(quickAddTitle.trim());
setQuickAddTitle("");
}
}}
placeholder={t("tasks.quick_add_placeholder")}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
</div>
)}
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{filteredTasks.map(task => { {filteredTasks.map(task => {
const cal = calendars.find(c => task.calendarIds[c.id]); const cal = calendars.find(c => task.calendarIds[c.id]);
@@ -159,7 +204,7 @@ export function TaskListView({
className={cn( className={cn(
"mt-0.5 flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors", "mt-0.5 flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors",
isCompleted isCompleted
? "bg-green-500 border-green-500 text-white" ? "bg-success border-success text-success-foreground"
: "border-muted-foreground/40 hover:border-primary" : "border-muted-foreground/40 hover:border-primary"
)} )}
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")} aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
+321
View File
@@ -0,0 +1,321 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, CalendarDays, Bell, Flag } from "lucide-react";
import { format, parseISO } from "date-fns";
import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types";
interface TaskModalProps {
task?: CalendarTask | null;
calendars: Calendar[];
onSave: (data: Partial<CalendarTask>) => void | Promise<void>;
onDelete?: (id: string) => void;
onClose: () => void;
isMobile?: boolean;
}
type PriorityLevel = "none" | "high" | "medium" | "low";
type AlertOption = "none" | "at_time" | "5" | "15" | "30" | "60" | "1440";
function priorityToLevel(p: number): PriorityLevel {
if (p >= 1 && p <= 4) return "high";
if (p === 5) return "medium";
if (p >= 6 && p <= 9) return "low";
return "none";
}
function levelToPriority(l: PriorityLevel): number {
switch (l) {
case "high": return 1;
case "medium": return 5;
case "low": return 9;
default: return 0;
}
}
export function TaskModal({
task,
calendars,
onSave,
onDelete,
onClose,
isMobile: _isMobile,
}: TaskModalProps) {
const t = useTranslations("calendar");
const isEdit = !!task;
const titleRef = useRef<HTMLInputElement>(null);
const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn);
const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? "";
const [title, setTitle] = useState(task?.title ?? "");
const [description, setDescription] = useState(task?.description ?? "");
const [dueDate, setDueDate] = useState(task?.due ? format(parseISO(task.due), "yyyy-MM-dd") : "");
const [dueTime, setDueTime] = useState(task?.due && !task.showWithoutTime ? format(parseISO(task.due), "HH:mm") : "");
const [showTime, setShowTime] = useState(task?.due ? !task.showWithoutTime : false);
const [priority, setPriority] = useState<PriorityLevel>(priorityToLevel(task?.priority ?? 0));
const [progress, setProgress] = useState<CalendarTask["progress"]>(task?.progress ?? "needs-action");
const [calendarId, setCalendarId] = useState(() => {
if (task) {
const ids = Object.keys(task.calendarIds);
return ids[0] ?? defaultCalendarId;
}
return defaultCalendarId;
});
const [alertOption, setAlertOption] = useState<AlertOption>(() => {
if (!task?.alerts) return "none";
const first = Object.values(task.alerts)[0];
if (!first || first.trigger["@type"] !== "OffsetTrigger") return "none";
const offset = first.trigger.offset;
if (offset === "PT0S") return "at_time";
const m = offset.match(/-?PT?(\d+)M$/);
if (m) return m[1] as AlertOption;
const h = offset.match(/-?PT?(\d+)H$/);
if (h) return String(parseInt(h[1]) * 60) as AlertOption;
const d = offset.match(/-?P(\d+)D/);
if (d) return String(parseInt(d[1]) * 1440) as AlertOption;
return "none";
});
const [saving, setSaving] = useState(false);
useEffect(() => {
titleRef.current?.focus();
}, []);
const handleSave = useCallback(async () => {
if (!title.trim()) return;
setSaving(true);
try {
let due: string | null = null;
let showWithoutTime = true;
if (dueDate) {
if (showTime && dueTime) {
due = `${dueDate}T${dueTime}:00`;
showWithoutTime = false;
} else {
due = dueDate;
showWithoutTime = true;
}
}
let alerts: Record<string, CalendarEventAlert> | null = null;
if (alertOption !== "none") {
const offset = alertOption === "at_time" ? "PT0S" : `-PT${alertOption}M`;
alerts = {
"default-alert": {
"@type": "Alert",
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
action: "display",
acknowledged: null,
relatedTo: null,
},
};
}
const data: Partial<CalendarTask> = {
"@type": "Task",
title: title.trim(),
description: description.trim() || "",
due,
showWithoutTime,
priority: levelToPriority(priority),
progress,
calendarIds: { [calendarId]: true },
alerts,
};
if (isEdit && task) {
data.id = task.id;
}
await onSave(data);
onClose();
} finally {
setSaving(false);
}
}, [title, description, dueDate, dueTime, showTime, priority, progress, calendarId, alertOption, isEdit, task, onSave, onClose]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
handleSave();
}
}, [onClose, handleSave]);
return (
<div className="flex flex-col h-full bg-background" onKeyDown={handleKeyDown}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 className="text-sm font-semibold">
{isEdit ? t("tasks.edit") : t("tasks.create")}
</h2>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Title */}
<Input
ref={titleRef}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("tasks.title_placeholder")}
className="text-base font-medium"
/>
{/* Description */}
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("tasks.description_placeholder")}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
/>
{/* Due Date */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<CalendarDays className="h-3.5 w-3.5" />
{t("tasks.due_date")}
</label>
<div className="flex items-center gap-2">
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
/>
{dueDate && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={showTime}
onChange={(e) => setShowTime(e.target.checked)}
className="rounded"
/>
{t("tasks.include_time")}
</label>
)}
{showTime && (
<input
type="time"
value={dueTime}
onChange={(e) => setDueTime(e.target.value)}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
/>
)}
</div>
</div>
{/* Priority */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Flag className="h-3.5 w-3.5" />
{t("tasks.priority")}
</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value as PriorityLevel)}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
>
<option value="none">{t("tasks.priority_none")}</option>
<option value="high">{t("tasks.priority_high")}</option>
<option value="medium">{t("tasks.priority_medium")}</option>
<option value="low">{t("tasks.priority_low")}</option>
</select>
</div>
{/* Progress */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
{t("tasks.progress")}
</label>
<select
value={progress}
onChange={(e) => setProgress(e.target.value as CalendarTask["progress"])}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
>
<option value="needs-action">{t("tasks.progress_needs_action")}</option>
<option value="in-process">{t("tasks.progress_in_process")}</option>
<option value="completed">{t("tasks.progress_completed")}</option>
<option value="cancelled">{t("tasks.progress_cancelled")}</option>
</select>
</div>
{/* Calendar */}
{writableCalendars.length > 1 && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
{t("tasks.calendar")}
</label>
<select
value={calendarId}
onChange={(e) => setCalendarId(e.target.value)}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
>
{writableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>{cal.name}</option>
))}
</select>
</div>
)}
{/* Alert */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Bell className="h-3.5 w-3.5" />
{t("tasks.alert")}
</label>
<select
value={alertOption}
onChange={(e) => setAlertOption(e.target.value as AlertOption)}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
>
<option value="none">{t("tasks.alert_none")}</option>
<option value="at_time">{t("tasks.alert_at_time")}</option>
<option value="5">{t("tasks.alert_5min")}</option>
<option value="15">{t("tasks.alert_15min")}</option>
<option value="30">{t("tasks.alert_30min")}</option>
<option value="60">{t("tasks.alert_1hr")}</option>
<option value="1440">{t("tasks.alert_1day")}</option>
</select>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
<div>
{isEdit && onDelete && task && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onDelete(task.id)}
>
<Trash2 className="h-4 w-4 mr-1" />
{t("tasks.delete")}
</Button>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onClose}>
{t("tasks.cancel")}
</Button>
<Button size="sm" onClick={handleSave} disabled={!title.trim() || saving}>
{t("tasks.save")}
</Button>
</div>
</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { useTranslations } from "next-intl";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { TaskViewFilter } from "@/stores/task-store";
interface TaskToolbarProps {
filter: TaskViewFilter;
showCompleted: boolean;
onFilterChange: (filter: TaskViewFilter) => void;
onShowCompletedChange: (show: boolean) => void;
onCreateTask: () => void;
}
const FILTERS: TaskViewFilter[] = ["all", "pending", "completed", "overdue"];
export function TaskToolbar({
filter,
showCompleted,
onFilterChange,
onShowCompletedChange,
onCreateTask,
}: TaskToolbarProps) {
const t = useTranslations("calendar");
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border flex-wrap">
<div className="flex border border-border rounded-md overflow-hidden">
{FILTERS.map((f) => (
<button
key={f}
onClick={() => onFilterChange(f)}
className={cn(
"px-3 py-1.5 text-xs font-medium transition-colors",
f === filter
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-muted-foreground"
)}
>
{t(`tasks.filter_${f}`)}
</button>
))}
</div>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ml-2">
<input
type="checkbox"
checked={showCompleted}
onChange={(e) => onShowCompletedChange(e.target.checked)}
className="rounded border-border"
/>
{t("tasks.show_completed")}
</label>
<div className="flex-1" />
<Button size="sm" onClick={onCreateTask}>
<Plus className="w-4 h-4 mr-1" />
{t("tasks.create")}
</Button>
</div>
);
}
@@ -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();
}); });
}); });
@@ -66,7 +66,7 @@ describe('ContactForm', () => {
const onSave = vi.fn().mockResolvedValue(undefined); const onSave = vi.fn().mockResolvedValue(undefined);
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />); render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
const inputs = screen.getAllByRole('textbox'); const _inputs = screen.getAllByRole('textbox');
const givenNameInput = screen.getByPlaceholderText('given_name'); const givenNameInput = screen.getByPlaceholderText('given_name');
fireEvent.change(givenNameInput, { target: { value: 'Jane' } }); fireEvent.change(givenNameInput, { target: { value: 'Jane' } });
@@ -52,7 +52,7 @@ describe('ContactListItem', () => {
it('applies selected styling', () => { it('applies selected styling', () => {
const { container } = render(<ContactListItem contact={contact} {...baseProps} isSelected={true} />); const { container } = render(<ContactListItem contact={contact} {...baseProps} isSelected={true} />);
const div = container.firstElementChild; const div = container.firstElementChild;
expect(div?.className).toContain('bg-blue-200'); expect(div?.className).toContain('bg-selection');
}); });
it('shows email as display name when no name exists', () => { it('shows email as display name when no name exists', () => {
@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { ContactList } from '../contact-list'; import { ContactList } from '../contact-list';
import type { ContactCard } from '@/lib/jmap/types'; import type { ContactCard } from '@/lib/jmap/types';
@@ -22,7 +22,7 @@ const bob = makeContact({
emails: { e0: { address: 'bob@example.com' } }, emails: { e0: { address: 'bob@example.com' } },
}); });
const group = makeContact({ const _group = makeContact({
id: '3', id: '3',
kind: 'group', kind: 'group',
name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true }, name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true },
@@ -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();
});
}); });
+306
View File
@@ -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>
);
}
+435 -291
View File
@@ -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, Link, Users, Briefcase, Heart, Languages, MessageCircle, User, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react"; import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, 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">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</p> <p className="text-sm text-muted-foreground truncate">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</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}
> >

Some files were not shown because too many files have changed in this diff Show More