Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79b99ed4f8 | ||
|
|
68214c3e91 | ||
|
|
5da0e2bdf1 | ||
|
|
92ada0460a | ||
|
|
1cce5c3c8a | ||
|
|
40fd7799e9 | ||
|
|
aaa283357e | ||
|
|
066ab1bc32 | ||
|
|
92fb9bf132 | ||
|
|
4c804d5d2b | ||
|
|
63b464641b | ||
|
|
67210c9924 | ||
|
|
8937777bcd | ||
|
|
789d211a71 | ||
|
|
c512352f77 | ||
|
|
c859230862 | ||
|
|
0fb8b81acd | ||
|
|
1eebec292a | ||
|
|
2d983c9853 | ||
|
|
a453f7fa67 | ||
|
|
4c853f176b | ||
|
|
5583d95ecc | ||
|
|
9d1302ef0a | ||
|
|
75b5d31414 | ||
|
|
1e90885f3f | ||
|
|
02577c7502 | ||
|
|
375c0a7339 | ||
|
|
99d5276cfa | ||
|
|
60b2cf5911 | ||
|
|
8d7cd26fa9 | ||
|
|
1ba8e2fd47 | ||
|
|
c7c22bd210 | ||
|
|
6e0c79ca2c | ||
|
|
9140110435 | ||
|
|
e49f7c1a75 | ||
|
|
098127148e | ||
|
|
308adf0101 | ||
|
|
574bd7ecef | ||
|
|
d6b0714b4e | ||
|
|
af8ea8349e | ||
|
|
8eeabfc995 | ||
|
|
ddb636ed73 | ||
|
|
92ff5fe449 | ||
|
|
edcd5aa2a7 | ||
|
|
b5d471e9c8 | ||
|
|
f084b484b7 | ||
|
|
b868ad591d | ||
|
|
42734f16c3 | ||
|
|
b70c727bae | ||
|
|
9b0598b051 | ||
|
|
66cdfe64ce | ||
|
|
f6536573ae | ||
|
|
415c961937 | ||
|
|
ba6b25f3f1 | ||
|
|
a9002763e2 | ||
|
|
b75d064f20 | ||
|
|
a46fb9c8af | ||
|
|
6696636df8 | ||
|
|
37bc88dbad | ||
|
|
7a191cf78b | ||
|
|
733e99f094 | ||
|
|
9b25b6d03e | ||
|
|
05f848ee23 | ||
|
|
4da1b7d8fc | ||
|
|
4ff2bff974 | ||
|
|
47918aab1b | ||
|
|
e48672cf77 | ||
|
|
763abf43b9 | ||
|
|
649261c386 | ||
|
|
e02371d2bf | ||
|
|
4af20b8dc0 | ||
|
|
b5172802bb | ||
|
|
f64306be5e | ||
|
|
05f6d61cea | ||
|
|
0174051f3e | ||
|
|
136686f230 | ||
|
|
54981950b0 | ||
|
|
29a222eef4 | ||
|
|
76b21147e4 | ||
|
|
78bcf8db1b | ||
|
|
39c43e3b3d | ||
|
|
55ee99e811 | ||
|
|
31e7ecceda | ||
|
|
d666bddbc4 | ||
|
|
1cac71fae7 | ||
|
|
79ff95565d | ||
|
|
808328f5a4 | ||
|
|
ddbeadd3c8 | ||
|
|
501d69500d | ||
|
|
e07365b066 | ||
|
|
2baf5e84a5 | ||
|
|
024487bf10 |
+16
-1
@@ -9,9 +9,16 @@
|
|||||||
# App name displayed in the UI
|
# App name displayed in the UI
|
||||||
APP_NAME=Bulwark Webmail
|
APP_NAME=Bulwark Webmail
|
||||||
|
|
||||||
# URL of your JMAP-compatible mail server (required)
|
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
|
||||||
JMAP_SERVER_URL=https://your-jmap-server.com
|
JMAP_SERVER_URL=https://your-jmap-server.com
|
||||||
|
|
||||||
|
# Allow users to specify a custom JMAP server URL on the login form.
|
||||||
|
# When enabled, a "JMAP Server" field appears on the login page.
|
||||||
|
# Users can connect to any JMAP-compatible server.
|
||||||
|
# NOTE: External JMAP servers must include this domain in their CORS
|
||||||
|
# Access-Control-Allow-Origin header, or browser requests will be blocked.
|
||||||
|
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stalwart Mail Server Integration
|
# Stalwart Mail Server Integration
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -132,6 +139,14 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
|
|||||||
# URL for the company website link on the login page
|
# URL for the company website link on the login page
|
||||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Extension Directory / Marketplace
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# URL of the BulwarkMail extension directory for the admin marketplace.
|
||||||
|
# Set this to enable browsing and installing plugins/themes from the directory.
|
||||||
|
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Legacy Build-time Variables (still supported as fallback)
|
# Legacy Build-time Variables (still supported as fallback)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
+4
-1
@@ -46,4 +46,7 @@ next-env.d.ts
|
|||||||
/data/
|
/data/
|
||||||
|
|
||||||
# Local private data
|
# Local private data
|
||||||
/local-data/
|
/local-data/
|
||||||
|
|
||||||
|
# Sibling repos
|
||||||
|
/repos/
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Check for AI attribution in commit message
|
|
||||||
if grep -qi "co-authored-by.*claude\|co-authored-by.*anthropic\|claude code\|claude sonnet\|claude opus" "$1"; then
|
|
||||||
echo "❌ ERROR: Commit message contains AI attribution (Claude/Anthropic)"
|
|
||||||
echo " This violates project policy in CLAUDE.md"
|
|
||||||
echo " Remove 'Co-Authored-By: Claude' and similar references"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,5 +1,70 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.4.10 (2026-03-31)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Plugins**: Add plugin configuration UI with schema-driven admin config page, calendar event action slot, and Jitsi Meet plugin
|
||||||
|
- **Calendar**: Implement client-side recurrence expansion for calendar events
|
||||||
|
- **Calendar**: Add iCal subscription editing and batch event import
|
||||||
|
- **Calendar**: Add hover preview settings and functionality
|
||||||
|
- **Calendar**: Add virtual location input for calendar events (#121)
|
||||||
|
- **Email**: Add reply-to addresses support in email composer
|
||||||
|
- **Email**: Add mail layout settings and update email list components
|
||||||
|
- **Email**: Add auto-select reply identity feature with settings and localization
|
||||||
|
- **Email**: Enhance compose functionality with button integration and translations
|
||||||
|
- **Filters**: Preserve activation state when updating or creating Sieve scripts to avoid deactivating server-managed vacation scripts
|
||||||
|
- **Filters**: Skip server-managed vacation script in Sieve script handling
|
||||||
|
- **Settings**: Add support for custom JMAP server endpoints in login and settings
|
||||||
|
- **Settings**: Add folder expansion state management and settings navigation
|
||||||
|
- **UI**: Add options to hide account switcher and show account avatars on navigation rail
|
||||||
|
- **i18n**: Add JMAP server endpoint labels and hints in multiple languages
|
||||||
|
- **i18n**: Add missing translation keys to all non-English locales
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Security**: Patch critical auth bypass and credential leak vulnerabilities
|
||||||
|
- **Security**: Support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys and add diagnostic logging (#35)
|
||||||
|
- **Security**: Account isolation, auto-import signer certs, and no-key error handling (#35)
|
||||||
|
- **Calendar**: Fix JSCalendar 2.0 recurrenceRule single-object compatibility (#116)
|
||||||
|
- **Calendar**: Enhance calendar event handling to distinguish between events and tasks
|
||||||
|
- **Calendar**: Link existing events to target calendar during iCal import instead of skipping (#113)
|
||||||
|
- **Calendar**: Deduplicate UIDs during iCal import to prevent mass failures (#113)
|
||||||
|
- **Calendar**: Fix events disappearing after iCal import/subscription refresh
|
||||||
|
- **Calendar**: Enhance calendar event handling with full-day detection and layout adjustments
|
||||||
|
- **Calendar**: Use UTC timestamps for timed event rendering
|
||||||
|
- **Calendar**: Work around Stalwart not returning Task objects via CalendarEvent/query
|
||||||
|
- **Email**: Enhance email loading and deduplication logic in email store (#119)
|
||||||
|
- **Email**: Ensure draft editing function is called correctly in EmailViewer component (#60)
|
||||||
|
- **Email**: Match hover action background to selected row state
|
||||||
|
- **Email**: Align tag counts with mailbox folder counts in sidebar
|
||||||
|
- **Auth**: Handle 2FA/TOTP session expiry with basic auth (#117)
|
||||||
|
- **Mailbox**: Improve mailbox tree logic and enhance mailbox handling with logging (#118)
|
||||||
|
- **UI**: Improve dark mode handling for media elements and background images
|
||||||
|
- **UI**: Adjust account list spacing and remove push connection indicator
|
||||||
|
- **UI**: Fix nested button in theme card
|
||||||
|
|
||||||
|
## 1.4.9 (2026-03-27)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Admin**: Add Stalwart admin authentication, sidebar access, and a reorganized dashboard with dedicated policy sections
|
||||||
|
- **Plugins**: Add plugin/theme admin dashboard, harness tooling, forced enable or disable controls, managed policy enforcement, and a resizable detail sidebar
|
||||||
|
- **Filters**: Add vacation responder management with Sieve generation and parsing, UI integration, and improved sync preservation
|
||||||
|
- **Email**: Add plain text only composer mode, optional conversation threading disable, configurable hover action placement, and OAuth app password support
|
||||||
|
- **UI**: Add drag-and-drop customization for sidebar apps
|
||||||
|
- **Files**: Use dynamic server-configured maximum upload sizes
|
||||||
|
- **i18n**: Add Russian locale support and complete missing translation strings for recent task features
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Calendar**: Improve date parsing and event normalization, prevent calendar page re-render loops, ensure unique ICal subscription IDs, and create all-day events with correct JSCalendar midnight handling
|
||||||
|
- **Email**: Respect the configured mark-as-read delay in EmailViewer and fetch full email content when needed while editing drafts (#60, #95)
|
||||||
|
- **Auth**: Improve network error handling, add JMAP rate limiting handling, and enhance settings retrieval and persistence diagnostics (#100, #104)
|
||||||
|
- **UI**: Improve mobile layout behavior on contacts and calendar pages (#103)
|
||||||
|
- **Themes**: Repair theme ZIP bundle handling and enforce admin theme locks correctly
|
||||||
|
- **Code Quality**: Resolve outstanding ESLint warnings across the codebase
|
||||||
|
|
||||||
## 1.4.8 (2026-03-23)
|
## 1.4.8 (2026-03-23)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+1
-1
@@ -195,7 +195,7 @@ webmail/
|
|||||||
- **Never commit sensitive data** (API keys, passwords, etc.)
|
- **Never commit sensitive data** (API keys, passwords, etc.)
|
||||||
- **Sanitize user input** and email content
|
- **Sanitize user input** and email content
|
||||||
- **Block external content** by default for privacy
|
- **Block external content** by default for privacy
|
||||||
- Report security vulnerabilities privately
|
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ RUN apk upgrade --no-cache && \
|
|||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
RUN mkdir -p /app/data/settings /app/data/admin && chown -R nextjs:nodejs /app/data
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -102,6 +102,7 @@ Built with Next.js and the JMAP protocol.
|
|||||||
- **Batch operations** — multi-select with checkboxes, archive, delete, move, tag
|
- **Batch operations** — multi-select with checkboxes, archive, delete, move, tag
|
||||||
- **Archive modes** — archive directly or organize archived mail by year or month
|
- **Archive modes** — archive directly or organize archived mail by year or month
|
||||||
- **Print** emails directly from the viewer
|
- **Print** emails directly from the viewer
|
||||||
|
- **Answered/forwarded status icons** in email list and thread views
|
||||||
- **Color tags/labels** and star/unstar
|
- **Color tags/labels** and star/unstar
|
||||||
- **Virtual scrolling** for large mailboxes
|
- **Virtual scrolling** for large mailboxes
|
||||||
- **Quick reply** from the viewer
|
- **Quick reply** from the viewer
|
||||||
@@ -120,7 +121,9 @@ Built with Next.js and the JMAP protocol.
|
|||||||
- **Participant scheduling** — iTIP invitations, organizer/attendee UI, RSVP
|
- **Participant scheduling** — iTIP invitations, organizer/attendee UI, RSVP
|
||||||
- **Inline calendar invitations** in email viewer — auto-detect `.ics`, RSVP, import
|
- **Inline calendar invitations** in email viewer — auto-detect `.ics`, RSVP, import
|
||||||
- **iCalendar import** with preview and bulk create
|
- **iCalendar import** with preview and bulk create
|
||||||
- **Notifications** with configurable sound and alert persistence
|
- **Task management** — create, edit, and track tasks with due dates, priority, and completion status
|
||||||
|
- **Week numbers** in mini-calendar sidebar
|
||||||
|
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
|
||||||
- **Real-time sync** via JMAP push
|
- **Real-time sync** via JMAP push
|
||||||
|
|
||||||
### Contacts
|
### Contacts
|
||||||
@@ -156,7 +159,7 @@ Built with Next.js and the JMAP protocol.
|
|||||||
- **HTML sanitization** via DOMPurify with XSS prevention
|
- **HTML sanitization** via DOMPurify with XSS prevention
|
||||||
- **S/MIME** — manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures
|
- **S/MIME** — manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures
|
||||||
- **SPF/DKIM/DMARC** status indicators
|
- **SPF/DKIM/DMARC** status indicators
|
||||||
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode
|
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode and non-interactive SSO for embedded/iframe deployments
|
||||||
- **TOTP two-factor authentication**
|
- **TOTP two-factor authentication**
|
||||||
- **Account security panel** — manage passwords and 2FA via Stalwart admin API
|
- **Account security panel** — manage passwords and 2FA via Stalwart admin API
|
||||||
- **"Remember me"** — AES-256-GCM encrypted httpOnly cookie (opt-in)
|
- **"Remember me"** — AES-256-GCM encrypted httpOnly cookie (opt-in)
|
||||||
@@ -171,6 +174,7 @@ Built with Next.js and the JMAP protocol.
|
|||||||
- **Responsive** — desktop sidebar + mobile bottom tab bar with tablet support
|
- **Responsive** — desktop sidebar + mobile bottom tab bar with tablet support
|
||||||
- **Keyboard shortcuts** — full navigation without a mouse
|
- **Keyboard shortcuts** — full navigation without a mouse
|
||||||
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
||||||
|
- **Interactive guided tour** — onboarding walkthrough for new users
|
||||||
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
||||||
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
||||||
- **Sidebar apps** — pin custom tools to the navigation rail and open them inline or in a new tab
|
- **Sidebar apps** — pin custom tools to the navigation rail and open them inline or in a new tab
|
||||||
@@ -195,6 +199,7 @@ Automatic browser detection with persistent preference.
|
|||||||
### Operations
|
### Operations
|
||||||
|
|
||||||
- **Automatic update check** — server logs when a newer release is available
|
- **Automatic update check** — server logs when a newer release is available
|
||||||
|
- **Demo mode** — try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes — no mail server required
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -320,10 +325,6 @@ Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day exp
|
|||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
See [ROADMAP.md](ROADMAP.md) for planned features and current status.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[GNU AGPL v3](LICENSE)
|
[GNU AGPL v3](LICENSE)
|
||||||
|
|||||||
+127
-19
@@ -38,6 +38,8 @@ 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 { useTaskStore } from "@/stores/task-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
@@ -65,10 +67,11 @@ export default function CalendarPage() {
|
|||||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||||
refreshAllSubscriptions,
|
refreshAllSubscriptions, icalSubscriptions,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview } = useSettingsStore();
|
||||||
const taskStore = useTaskStore();
|
const taskStore = useTaskStore();
|
||||||
|
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
||||||
|
|
||||||
@@ -80,6 +83,7 @@ export default function CalendarPage() {
|
|||||||
const [showEventModal, setShowEventModal] = useState(false);
|
const [showEventModal, setShowEventModal] = useState(false);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||||
|
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||||
@@ -90,6 +94,9 @@ export default function CalendarPage() {
|
|||||||
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
|
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
|
||||||
const [showTaskModal, setShowTaskModal] = useState(false);
|
const [showTaskModal, setShowTaskModal] = useState(false);
|
||||||
const [editTask, setEditTask] = useState<import("@/lib/jmap/types").CalendarTask | null>(null);
|
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
|
||||||
@@ -181,9 +188,9 @@ export default function CalendarPage() {
|
|||||||
// Fetch tasks when tasks view is active or when tasks are shown on calendar grid
|
// Fetch tasks when tasks view is active or when tasks are shown on calendar grid
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (client && enableCalendarTasks && (normalizedViewMode === "tasks" || showTasksOnCalendar)) {
|
if (client && enableCalendarTasks && (normalizedViewMode === "tasks" || showTasksOnCalendar)) {
|
||||||
taskStore.fetchTasks(client);
|
fetchTasksFn(client);
|
||||||
}
|
}
|
||||||
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar]);
|
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (client && calendars.length > 0 && dateRange) {
|
if (client && calendars.length > 0 && dateRange) {
|
||||||
@@ -230,6 +237,8 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const handleTouchEnd = useCallback((e: ReactTouchEvent) => {
|
const handleTouchEnd = useCallback((e: ReactTouchEvent) => {
|
||||||
if (!touchStartRef.current || !isMobile) return;
|
if (!touchStartRef.current || !isMobile) return;
|
||||||
|
// Week view has its own horizontal scroll, skip swipe navigation
|
||||||
|
if (normalizedViewMode === 'week') { touchStartRef.current = null; return; }
|
||||||
const touch = e.changedTouches[0];
|
const touch = e.changedTouches[0];
|
||||||
const dx = touch.clientX - touchStartRef.current.x;
|
const dx = touch.clientX - touchStartRef.current.x;
|
||||||
const dy = touch.clientY - touchStartRef.current.y;
|
const dy = touch.clientY - touchStartRef.current.y;
|
||||||
@@ -238,20 +247,34 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
// Only trigger swipe if horizontal movement is dominant and fast enough
|
// Only trigger swipe if horizontal movement is dominant and fast enough
|
||||||
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5 && elapsed < 400) {
|
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5 && elapsed < 400) {
|
||||||
if (dx > 0) navigatePrev();
|
if (dx > 0) {
|
||||||
else navigateNext();
|
setSwipeDirection('right');
|
||||||
|
navigatePrev();
|
||||||
|
} else {
|
||||||
|
setSwipeDirection('left');
|
||||||
|
navigateNext();
|
||||||
|
}
|
||||||
|
setSwipeKey(k => k + 1);
|
||||||
|
// Clear direction after animation completes
|
||||||
|
setTimeout(() => setSwipeDirection(null), 250);
|
||||||
}
|
}
|
||||||
}, [isMobile, navigatePrev, navigateNext]);
|
}, [isMobile, normalizedViewMode, navigatePrev, navigateNext]);
|
||||||
|
|
||||||
const handleSelectDate = useCallback((date: Date) => {
|
const handleSelectDate = useCallback((date: Date) => {
|
||||||
setSelectedDate(date);
|
setSelectedDate(date);
|
||||||
setMiniMonth(date);
|
setMiniMonth(date);
|
||||||
// On mobile month view, tapping a date switches to day view
|
// On mobile month view, tapping a date switches to day view
|
||||||
if (isMobile && normalizedViewMode === "month") {
|
if (isMobile && normalizedViewMode === "month") {
|
||||||
|
setMobileReturnToMonth(true);
|
||||||
setViewMode("day");
|
setViewMode("day");
|
||||||
}
|
}
|
||||||
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
|
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
|
||||||
|
|
||||||
|
const navigateBackToMonth = useCallback(() => {
|
||||||
|
setMobileReturnToMonth(false);
|
||||||
|
setViewMode("month");
|
||||||
|
}, [setViewMode]);
|
||||||
|
|
||||||
const handleMiniMonthChange = useCallback((date: Date) => {
|
const handleMiniMonthChange = useCallback((date: Date) => {
|
||||||
setMiniMonth(date);
|
setMiniMonth(date);
|
||||||
setSelectedDate(date);
|
setSelectedDate(date);
|
||||||
@@ -316,18 +339,28 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||||
if (isMobile) return;
|
if (isMobile) return;
|
||||||
|
if (calendarHoverPreview === 'off') return;
|
||||||
if (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;
|
||||||
}, [isMobile, 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(() => {
|
||||||
@@ -364,6 +397,24 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}, [client, fetchEvents]);
|
}, [client, fetchEvents]);
|
||||||
|
|
||||||
|
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
|
||||||
|
if (!event.start) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventDate = getEventStartDate({
|
||||||
|
start: event.start,
|
||||||
|
utcStart: event.utcStart ?? null,
|
||||||
|
showWithoutTime: event.showWithoutTime ?? false,
|
||||||
|
});
|
||||||
|
if (Number.isNaN(eventDate.getTime())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedDate(eventDate);
|
||||||
|
setMiniMonth(eventDate);
|
||||||
|
}, [setSelectedDate]);
|
||||||
|
|
||||||
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => {
|
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => {
|
||||||
if (!client) { toast.error(t("notifications.event_error")); return; }
|
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||||
try {
|
try {
|
||||||
@@ -380,6 +431,9 @@ export default function CalendarPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
|
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
|
||||||
|
if (data.start) {
|
||||||
|
focusCalendarOnEvent({ start: data.start });
|
||||||
|
}
|
||||||
toast.success(t("notifications.event_updated"));
|
toast.success(t("notifications.event_updated"));
|
||||||
} else {
|
} else {
|
||||||
const created = await createEvent(client, data, sendSchedulingMessages);
|
const created = await createEvent(client, data, sendSchedulingMessages);
|
||||||
@@ -387,6 +441,7 @@ export default function CalendarPage() {
|
|||||||
toast.error(t("notifications.event_error"));
|
toast.error(t("notifications.event_error"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
focusCalendarOnEvent(created);
|
||||||
if (sendSchedulingMessages) {
|
if (sendSchedulingMessages) {
|
||||||
toast.success(t("notifications.invitation_sent"));
|
toast.success(t("notifications.invitation_sent"));
|
||||||
} else {
|
} else {
|
||||||
@@ -398,7 +453,7 @@ export default function CalendarPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
toast.error(t("notifications.event_error"));
|
toast.error(t("notifications.event_error"));
|
||||||
}
|
}
|
||||||
}, [client, editEvent, createEvent, updateEvent, t]);
|
}, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, t]);
|
||||||
|
|
||||||
const handleDuplicateEvent = useCallback(async (data: Partial<CalendarEvent>) => {
|
const handleDuplicateEvent = useCallback(async (data: Partial<CalendarEvent>) => {
|
||||||
if (!client) { toast.error(t("notifications.event_error")); return; }
|
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||||
@@ -408,6 +463,7 @@ export default function CalendarPage() {
|
|||||||
toast.error(t("notifications.event_error"));
|
toast.error(t("notifications.event_error"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
focusCalendarOnEvent(created);
|
||||||
toast.success(t("notifications.event_duplicated"));
|
toast.success(t("notifications.event_duplicated"));
|
||||||
setEditEvent(created);
|
setEditEvent(created);
|
||||||
setDefaultModalDate(undefined);
|
setDefaultModalDate(undefined);
|
||||||
@@ -416,7 +472,7 @@ export default function CalendarPage() {
|
|||||||
setShowEventModal(false);
|
setShowEventModal(false);
|
||||||
setEditEvent(null);
|
setEditEvent(null);
|
||||||
}
|
}
|
||||||
}, [client, createEvent, t]);
|
}, [client, createEvent, focusCalendarOnEvent, t]);
|
||||||
|
|
||||||
const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => {
|
const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => {
|
||||||
if (!client) { toast.error(t("notifications.event_error")); return; }
|
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||||
@@ -612,7 +668,7 @@ export default function CalendarPage() {
|
|||||||
if (!detailEvent || !client) return;
|
if (!detailEvent || !client) return;
|
||||||
const start = parseISO(detailEvent.start);
|
const start = parseISO(detailEvent.start);
|
||||||
const newStart = addDays(start, 1);
|
const newStart = addDays(start, 1);
|
||||||
const data: Partial<CalendarEvent> = {
|
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
|
||||||
title: detailEvent.title,
|
title: detailEvent.title,
|
||||||
description: detailEvent.description,
|
description: detailEvent.description,
|
||||||
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||||
@@ -623,7 +679,7 @@ export default function CalendarPage() {
|
|||||||
status: "confirmed",
|
status: "confirmed",
|
||||||
freeBusyStatus: detailEvent.freeBusyStatus,
|
freeBusyStatus: detailEvent.freeBusyStatus,
|
||||||
privacy: detailEvent.privacy,
|
privacy: detailEvent.privacy,
|
||||||
};
|
});
|
||||||
if (detailEvent.locations) data.locations = structuredClone(detailEvent.locations);
|
if (detailEvent.locations) data.locations = structuredClone(detailEvent.locations);
|
||||||
if (detailEvent.recurrenceRules) data.recurrenceRules = structuredClone(detailEvent.recurrenceRules);
|
if (detailEvent.recurrenceRules) data.recurrenceRules = structuredClone(detailEvent.recurrenceRules);
|
||||||
if (detailEvent.alerts) data.alerts = structuredClone(detailEvent.alerts);
|
if (detailEvent.alerts) data.alerts = structuredClone(detailEvent.alerts);
|
||||||
@@ -685,7 +741,7 @@ export default function CalendarPage() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKey);
|
window.addEventListener("keydown", handleKey);
|
||||||
return () => window.removeEventListener("keydown", handleKey);
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent]);
|
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]);
|
||||||
|
|
||||||
const visibleEvents = useMemo(() =>
|
const visibleEvents = useMemo(() =>
|
||||||
events.filter((e) => {
|
events.filter((e) => {
|
||||||
@@ -696,6 +752,35 @@ export default function CalendarPage() {
|
|||||||
[events, selectedCalendarIds]
|
[events, selectedCalendarIds]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hiddenEvents = events.filter((event) => {
|
||||||
|
if (!event.start || !event.calendarIds) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !Object.keys(event.calendarIds).some((calendarId) => selectedCalendarIds.includes(calendarId));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hiddenEvents.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug.log('Calendar visibility summary', {
|
||||||
|
totalEvents: events.length,
|
||||||
|
visibleEvents: visibleEvents.length,
|
||||||
|
hiddenEvents: hiddenEvents.length,
|
||||||
|
selectedCalendarIds,
|
||||||
|
hiddenSamples: hiddenEvents.slice(0, 5).map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
originalId: event.originalId,
|
||||||
|
title: event.title,
|
||||||
|
calendarIds: event.calendarIds,
|
||||||
|
originalCalendarIds: event.originalCalendarIds,
|
||||||
|
accountId: event.accountId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}, [events, selectedCalendarIds, visibleEvents]);
|
||||||
|
|
||||||
if (!isAuthenticated || !supportsCalendar) return null;
|
if (!isAuthenticated || !supportsCalendar) return null;
|
||||||
|
|
||||||
const renderView = () => {
|
const renderView = () => {
|
||||||
@@ -804,7 +889,7 @@ export default function CalendarPage() {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
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">
|
||||||
@@ -864,6 +949,7 @@ export default function CalendarPage() {
|
|||||||
updateCalendar(client, calendarId, { color });
|
updateCalendar(client, calendarId, { color });
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
|
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||||
client={client}
|
client={client}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -887,11 +973,12 @@ export default function CalendarPage() {
|
|||||||
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}
|
||||||
@@ -904,7 +991,16 @@ export default function CalendarPage() {
|
|||||||
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 && (
|
||||||
@@ -1020,6 +1116,18 @@ export default function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingSubscription && client && (() => {
|
||||||
|
const sub = icalSubscriptions.find(s => s.id === editingSubscription);
|
||||||
|
if (!sub) return null;
|
||||||
|
return (
|
||||||
|
<ICalSubscriptionModal
|
||||||
|
client={client}
|
||||||
|
editSubscription={sub}
|
||||||
|
onClose={() => setEditingSubscription(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<RecurrenceScopeDialog
|
<RecurrenceScopeDialog
|
||||||
isOpen={!!pendingScopeAction}
|
isOpen={!!pendingScopeAction}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function ContactsPage() {
|
|||||||
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
|
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
|
||||||
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
||||||
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
||||||
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
|
const selectedGroupMembers = useMemo(() => selectedGroupId ? getGroupMembers(selectedGroupId) : [], [selectedGroupId, getGroupMembers]);
|
||||||
|
|
||||||
// Collect all unique keywords across contacts
|
// Collect all unique keywords across contacts
|
||||||
const allKeywords = useMemo(() => {
|
const allKeywords = useMemo(() => {
|
||||||
@@ -307,7 +307,7 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectGroup = (id: string) => {
|
const _handleSelectGroup = (id: string) => {
|
||||||
setSelectedGroupId(id);
|
setSelectedGroupId(id);
|
||||||
setActiveCategory({ groupId: id });
|
setActiveCategory({ groupId: id });
|
||||||
setView("group-detail");
|
setView("group-detail");
|
||||||
@@ -601,7 +601,7 @@ export default function ContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||||
{inlineApp && (
|
{inlineApp && (
|
||||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
||||||
)}
|
)}
|
||||||
@@ -701,7 +701,7 @@ export default function ContactsPage() {
|
|||||||
className="touch-manipulation"
|
className="touch-manipulation"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
{t("back_to_mail")}
|
{t("back_to_contacts")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+23
-19
@@ -11,7 +11,7 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
|||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
@@ -45,7 +45,7 @@ export default function FilesPage() {
|
|||||||
navigateByPath,
|
navigateByPath,
|
||||||
refresh,
|
refresh,
|
||||||
createDirectory,
|
createDirectory,
|
||||||
uploadFile,
|
uploadFile: _uploadFile,
|
||||||
uploadFiles,
|
uploadFiles,
|
||||||
uploadFolder,
|
uploadFolder,
|
||||||
deleteResource,
|
deleteResource,
|
||||||
@@ -156,39 +156,43 @@ export default function FilesPage() {
|
|||||||
}
|
}
|
||||||
}, [createDirectory, t]);
|
}, [createDirectory, t]);
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
const maxSizeUpload = client?.getMaxSizeUpload() || 0;
|
||||||
|
|
||||||
const handleUploadFiles = useCallback(async (files: File[]) => {
|
const handleUploadFiles = useCallback(async (files: File[]) => {
|
||||||
const oversized = files.filter(f => f.size > MAX_FILE_SIZE);
|
if (maxSizeUpload > 0) {
|
||||||
const valid = files.filter(f => f.size <= MAX_FILE_SIZE);
|
const oversized = files.filter(f => f.size > maxSizeUpload);
|
||||||
if (oversized.length > 0) {
|
files = files.filter(f => f.size <= maxSizeUpload);
|
||||||
toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" }));
|
if (oversized.length > 0) {
|
||||||
|
toast.error(t("file_too_large", { name: oversized[0].name, max: formatFileSize(maxSizeUpload) }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (valid.length === 0) return;
|
if (files.length === 0) return;
|
||||||
try {
|
try {
|
||||||
await uploadFiles(valid);
|
await uploadFiles(files);
|
||||||
toast.success(t("upload_success", { count: valid.length }));
|
toast.success(t("upload_success", { count: files.length }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to upload files:", err);
|
console.error("Failed to upload files:", err);
|
||||||
toast.error(t("upload_error"));
|
toast.error(t("upload_error"));
|
||||||
}
|
}
|
||||||
}, [uploadFiles, t]);
|
}, [uploadFiles, t, maxSizeUpload]);
|
||||||
|
|
||||||
const handleUploadFolder = useCallback(async (files: File[]) => {
|
const handleUploadFolder = useCallback(async (files: File[]) => {
|
||||||
const oversized = files.filter(f => f.size > MAX_FILE_SIZE);
|
if (maxSizeUpload > 0) {
|
||||||
const valid = files.filter(f => f.size <= MAX_FILE_SIZE);
|
const oversized = files.filter(f => f.size > maxSizeUpload);
|
||||||
if (oversized.length > 0) {
|
files = files.filter(f => f.size <= maxSizeUpload);
|
||||||
toast.error(t("file_too_large", { name: oversized[0].name, max: "500 MB" }));
|
if (oversized.length > 0) {
|
||||||
|
toast.error(t("file_too_large", { name: oversized[0].name, max: formatFileSize(maxSizeUpload) }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (valid.length === 0) return;
|
if (files.length === 0) return;
|
||||||
try {
|
try {
|
||||||
await uploadFolder(valid);
|
await uploadFolder(files);
|
||||||
toast.success(t("upload_success", { count: valid.length }));
|
toast.success(t("upload_success", { count: files.length }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to upload folder:", err);
|
console.error("Failed to upload folder:", err);
|
||||||
toast.error(t("upload_error"));
|
toast.error(t("upload_error"));
|
||||||
}
|
}
|
||||||
}, [uploadFolder, t]);
|
}, [uploadFolder, t, maxSizeUpload]);
|
||||||
|
|
||||||
const handleDelete = useCallback(async (name: string) => {
|
const handleDelete = useCallback(async (name: string) => {
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
|
||||||
|
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
|
||||||
import { TourProvider } from "@/components/tour/tour-provider";
|
import { TourProvider } from "@/components/tour/tour-provider";
|
||||||
import { locales } from "@/i18n/routing";
|
import { locales } from "@/i18n/routing";
|
||||||
|
|
||||||
@@ -28,11 +29,13 @@ export default async function LocaleLayout({
|
|||||||
<IntlProvider locale={locale} messages={messages}>
|
<IntlProvider locale={locale} messages={messages}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<CalendarAlertProvider>
|
<CalendarAlertProvider>
|
||||||
<EmbeddedBridgeProvider>
|
<RateLimitToastProvider>
|
||||||
<TourProvider>
|
<EmbeddedBridgeProvider>
|
||||||
{children}
|
<TourProvider>
|
||||||
</TourProvider>
|
{children}
|
||||||
</EmbeddedBridgeProvider>
|
</TourProvider>
|
||||||
|
</EmbeddedBridgeProvider>
|
||||||
|
</RateLimitToastProvider>
|
||||||
</CalendarAlertProvider>
|
</CalendarAlertProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
|
|||||||
+53
-13
@@ -11,12 +11,12 @@ import { useThemeStore } from "@/stores/theme-store";
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react";
|
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react";
|
||||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||||
|
|
||||||
const APP_VERSION = "1.4.7";
|
const APP_VERSION = "1.4.10";
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||||
@@ -32,13 +32,14 @@ export default function LoginPage() {
|
|||||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||||
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode } = 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);
|
||||||
@@ -73,6 +74,12 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [appName, serverUrl]);
|
}, [appName, serverUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverUrl && !jmapEndpoint) {
|
||||||
|
setJmapEndpoint(serverUrl);
|
||||||
|
}
|
||||||
|
}, [serverUrl, jmapEndpoint]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
if (sessionStorage.getItem('session_expired') === 'true') {
|
if (sessionStorage.getItem('session_expired') === 'true') {
|
||||||
@@ -91,6 +98,14 @@ export default function LoginPage() {
|
|||||||
prevError.current = error;
|
prevError.current = error;
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
|
// Auto-show and focus TOTP field when server requires it
|
||||||
|
useEffect(() => {
|
||||||
|
if (error === 'totp_required') {
|
||||||
|
setShowTotpField(true);
|
||||||
|
setTimeout(() => totpInputRef.current?.focus(), 100);
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverUrl) return;
|
if (!serverUrl) return;
|
||||||
const saved = localStorage.getItem("webmail_usernames");
|
const saved = localStorage.getItem("webmail_usernames");
|
||||||
@@ -264,7 +279,7 @@ export default function LoginPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!serverUrl && !demoMode) {
|
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">
|
||||||
@@ -362,7 +377,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");
|
||||||
}
|
}
|
||||||
@@ -382,8 +397,9 @@ export default function LoginPage() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
|
||||||
const success = await login(
|
const success = await login(
|
||||||
serverUrl,
|
effectiveServerUrl,
|
||||||
formData.username,
|
formData.username,
|
||||||
formData.password,
|
formData.password,
|
||||||
totpCode || undefined,
|
totpCode || undefined,
|
||||||
@@ -733,9 +749,9 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : oauthDiscoveryDone ? (
|
) : oauthDiscoveryDone ? (
|
||||||
<div className="p-3.5 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-2">
|
<div className="p-3.5 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2">
|
||||||
<AlertCircle className="w-4 h-4 text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-400">
|
<p className="text-sm text-warning">
|
||||||
{t("error.oauth_discovery_failed")}
|
{t("error.oauth_discovery_failed")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -749,6 +765,27 @@ export default function LoginPage() {
|
|||||||
/* Login Form */
|
/* Login Form */
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<fieldset disabled={isLoading} className="space-y-4">
|
<fieldset disabled={isLoading} className="space-y-4">
|
||||||
|
{/* JMAP Endpoint field (when custom endpoints are allowed) */}
|
||||||
|
{allowCustomJmapEndpoint && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
|
||||||
|
{t("jmap_endpoint_label")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="jmap-endpoint"
|
||||||
|
type="url"
|
||||||
|
value={jmapEndpoint}
|
||||||
|
onChange={(e) => setJmapEndpoint(e.target.value)}
|
||||||
|
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
|
||||||
|
placeholder={t("jmap_endpoint_placeholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
{t("jmap_endpoint_cors_hint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Username field */}
|
{/* Username field */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
||||||
@@ -861,7 +898,10 @@ export default function LoginPage() {
|
|||||||
maxLength={6}
|
maxLength={6}
|
||||||
value={totpCode}
|
value={totpCode}
|
||||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||||
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest"
|
className={cn(
|
||||||
|
"h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest",
|
||||||
|
error === 'totp_required' && "border-primary ring-2 ring-primary/30"
|
||||||
|
)}
|
||||||
placeholder={t("totp_placeholder")}
|
placeholder={t("totp_placeholder")}
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
aria-label={t("totp_label")}
|
aria-label={t("totp_label")}
|
||||||
@@ -941,9 +981,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>
|
||||||
|
|||||||
+109
-37
@@ -30,6 +30,7 @@ import {
|
|||||||
ComposerErrorFallback,
|
ComposerErrorFallback,
|
||||||
} from "@/components/error";
|
} from "@/components/error";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
||||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||||
@@ -41,15 +42,19 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
||||||
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square } from "lucide-react";
|
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
|
import { usePluginStore } from "@/stores/plugin-store";
|
||||||
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { appName } = useConfig();
|
const { appName } = useConfig();
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const [showComposer, setShowComposer] = useState(false);
|
const [showComposer, setShowComposer] = useState(false);
|
||||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||||
const [composerDraftText, setComposerDraftText] = useState("");
|
const [composerDraftText, setComposerDraftText] = useState("");
|
||||||
@@ -66,11 +71,28 @@ export default function Home() {
|
|||||||
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
|
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
|
||||||
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
||||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||||
|
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
|
||||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
|
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
|
||||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost } = useAuthStore();
|
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRateLimited || !rateLimitUntil) {
|
||||||
|
setRateLimitSecondsLeft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCountdown = () => {
|
||||||
|
const seconds = Math.max(1, Math.ceil((rateLimitUntil - Date.now()) / 1000));
|
||||||
|
setRateLimitSecondsLeft(seconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCountdown();
|
||||||
|
const timer = setInterval(updateCountdown, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [isRateLimited, rateLimitUntil]);
|
||||||
|
|
||||||
// Mobile/tablet responsive hooks
|
// Mobile/tablet responsive hooks
|
||||||
const { isMobile, isTablet } = useDeviceDetection();
|
const { isMobile, isTablet } = useDeviceDetection();
|
||||||
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
|
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
|
||||||
@@ -96,6 +118,7 @@ export default function Home() {
|
|||||||
markAsRead,
|
markAsRead,
|
||||||
toggleStar,
|
toggleStar,
|
||||||
moveToMailbox,
|
moveToMailbox,
|
||||||
|
moveThreadToMailbox,
|
||||||
searchEmails,
|
searchEmails,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
@@ -266,6 +289,13 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
}, [checkAuth]);
|
}, [checkAuth]);
|
||||||
|
|
||||||
|
// Initialize plugins on mount (re-activates enabled plugins after refresh)
|
||||||
|
// Also syncs server-managed plugins and themes to the client
|
||||||
|
useEffect(() => {
|
||||||
|
usePluginStore.getState().initializePlugins();
|
||||||
|
useThemeStore.getState().syncServerThemes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Hydrate persisted column widths from localStorage
|
// Hydrate persisted column widths from localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -489,9 +519,24 @@ export default function Home() {
|
|||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditDraft = (email?: Email) => {
|
const handleEditDraft = async (email?: Email) => {
|
||||||
const draft = email || selectedEmail;
|
if (!client) return;
|
||||||
|
const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string'
|
||||||
|
? email
|
||||||
|
: selectedEmail;
|
||||||
|
let draft = draftCandidate;
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
|
|
||||||
|
// The email list only fetches limited properties (no bodyValues/htmlBody/bcc).
|
||||||
|
// Fetch the full email so the composer gets all draft content.
|
||||||
|
if (!draft.bodyValues) {
|
||||||
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
|
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||||
|
const fullDraft = await client.getEmail(draft.id, accountId);
|
||||||
|
if (!fullDraft) return;
|
||||||
|
draft = fullDraft;
|
||||||
|
}
|
||||||
|
|
||||||
const bodyText = draft.bodyValues
|
const bodyText = draft.bodyValues
|
||||||
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
|
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
|
||||||
: '';
|
: '';
|
||||||
@@ -565,8 +610,8 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchive = async () => {
|
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !emailToArchive) return;
|
||||||
|
|
||||||
// Find archive mailbox
|
// Find archive mailbox
|
||||||
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
||||||
@@ -576,10 +621,10 @@ export default function Home() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (archiveMode === 'single') {
|
if (archiveMode === 'single') {
|
||||||
await moveToMailbox(client, selectedEmail.id, archiveMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id);
|
||||||
} else {
|
} else {
|
||||||
// Determine year/month from the email's received date
|
// Determine year/month from the email's received date
|
||||||
const emailDate = new Date(selectedEmail.receivedAt);
|
const emailDate = new Date(emailToArchive.receivedAt);
|
||||||
const year = emailDate.getFullYear().toString();
|
const year = emailDate.getFullYear().toString();
|
||||||
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
|
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
|
||||||
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
||||||
@@ -594,7 +639,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (archiveMode === 'year') {
|
if (archiveMode === 'year') {
|
||||||
await moveToMailbox(client, selectedEmail.id, yearMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
||||||
} else {
|
} else {
|
||||||
// archiveMode === 'month' — find or create month subfolder under year
|
// archiveMode === 'month' — find or create month subfolder under year
|
||||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||||
@@ -605,9 +650,16 @@ export default function Home() {
|
|||||||
monthMailbox = await client.createMailbox(month, yearId);
|
monthMailbox = await client.createMailbox(month, yearId);
|
||||||
await fetchMailboxes(client);
|
await fetchMailboxes(client);
|
||||||
}
|
}
|
||||||
await moveToMailbox(client, selectedEmail.id, monthMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (conversationThread?.threadId === emailToArchive.threadId) {
|
||||||
|
setConversationThread(null);
|
||||||
|
setConversationEmails([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchMailboxes(client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to archive email:", error);
|
console.error("Failed to archive email:", error);
|
||||||
}
|
}
|
||||||
@@ -920,6 +972,10 @@ export default function Home() {
|
|||||||
|
|
||||||
// Get current mailbox name for mobile header
|
// Get current mailbox name for mobile header
|
||||||
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||||
|
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||||
|
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
|
||||||
|
|
||||||
// Handle email selection with mobile view switching
|
// Handle email selection with mobile view switching
|
||||||
const handleEmailSelect = async (email: { id: string }) => {
|
const handleEmailSelect = async (email: { id: string }) => {
|
||||||
@@ -970,6 +1026,9 @@ export default function Home() {
|
|||||||
setConversationEmails([]);
|
setConversationEmails([]);
|
||||||
}
|
}
|
||||||
selectEmail(null);
|
selectEmail(null);
|
||||||
|
if (isTablet) {
|
||||||
|
setTabletListVisible(true);
|
||||||
|
}
|
||||||
setActiveView("list");
|
setActiveView("list");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1050,6 +1109,13 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<DragDropProvider>
|
<DragDropProvider>
|
||||||
<div className="flex flex-col h-dvh bg-background overflow-hidden">
|
<div className="flex flex-col h-dvh bg-background overflow-hidden">
|
||||||
|
{isRateLimited && rateLimitSecondsLeft !== null && (
|
||||||
|
<div className="flex items-center justify-center gap-2 bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-300 text-sm py-1.5 px-4 flex-shrink-0">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
<span>{tCommon('rate_limited_title')}</span>
|
||||||
|
<span className="text-amber-700/80 dark:text-amber-300/80">{tCommon('rate_limited_detail', { seconds: rateLimitSecondsLeft })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{connectionLost && (
|
{connectionLost && (
|
||||||
<div className="flex items-center justify-center gap-2 bg-destructive/10 border-b border-destructive/30 text-destructive text-sm py-1.5 px-4 flex-shrink-0">
|
<div className="flex items-center justify-center gap-2 bg-destructive/10 border-b border-destructive/30 text-destructive text-sm py-1.5 px-4 flex-shrink-0">
|
||||||
<RotateCcw className="h-3.5 w-3.5 animate-spin" />
|
<RotateCcw className="h-3.5 w-3.5 animate-spin" />
|
||||||
@@ -1145,10 +1211,9 @@ export default function Home() {
|
|||||||
// Tablet/Desktop: fixed width with collapse animation
|
// Tablet/Desktop: fixed width with collapse animation
|
||||||
"md:flex-shrink-0 md:shadow-sm",
|
"md:flex-shrink-0 md:shadow-sm",
|
||||||
!isResizing && "transition-all duration-200 ease-out",
|
!isResizing && "transition-all duration-200 ease-out",
|
||||||
// Tablet: collapse when email selected
|
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
||||||
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
|
||||||
)}
|
)}
|
||||||
style={!isMobile && !(isTablet && !tabletListVisible) ? { width: emailListWidth } : undefined}
|
style={!isMobile && !shouldCollapseListPane ? { width: emailListWidth } : undefined}
|
||||||
>
|
>
|
||||||
{/* Mobile Header for List View */}
|
{/* Mobile Header for List View */}
|
||||||
<MobileHeader
|
<MobileHeader
|
||||||
@@ -1407,8 +1472,7 @@ export default function Home() {
|
|||||||
await handleDelete();
|
await handleDelete();
|
||||||
}}
|
}}
|
||||||
onArchive={async (email) => {
|
onArchive={async (email) => {
|
||||||
selectEmail(email);
|
await handleArchive(email);
|
||||||
await handleArchive();
|
|
||||||
}}
|
}}
|
||||||
onSetColorTag={(emailId, color) => {
|
onSetColorTag={(emailId, color) => {
|
||||||
handleSetColorTag(emailId, color);
|
handleSetColorTag(emailId, color);
|
||||||
@@ -1434,24 +1498,27 @@ export default function Home() {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Compose Button (mobile) */}
|
{/* Floating Compose Button */}
|
||||||
{isMobile && (
|
<Button
|
||||||
<Button
|
onClick={() => {
|
||||||
onClick={() => {
|
setComposerMode('compose');
|
||||||
setComposerMode('compose');
|
setShowComposer(true);
|
||||||
setShowComposer(true);
|
if (isMobile) setActiveView('viewer');
|
||||||
setActiveView('viewer');
|
}}
|
||||||
}}
|
className={cn(
|
||||||
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
|
"absolute z-40 rounded-full shadow-lg",
|
||||||
aria-label={t('sidebar.compose')}
|
isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12"
|
||||||
>
|
)}
|
||||||
<PenSquare className="h-6 w-6" />
|
aria-label={t('sidebar.compose')}
|
||||||
</Button>
|
title={t('sidebar.compose_hint')}
|
||||||
)}
|
data-tour="compose-button"
|
||||||
|
>
|
||||||
|
<PenSquare className={isMobile ? "h-6 w-6" : "h-5 w-5"} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email list resize handle (desktop only) */}
|
{/* Email list resize handle (desktop only) */}
|
||||||
{!isMobile && !isTablet && (
|
{!isMobile && !isTablet && !isFocusedMailLayout && (
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||||
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||||
@@ -1463,12 +1530,13 @@ export default function Home() {
|
|||||||
{/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */}
|
{/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col h-full bg-background",
|
"flex flex-col h-full bg-background flex-1 min-w-0",
|
||||||
// Mobile: full screen overlay when active
|
// Mobile: full screen overlay when active
|
||||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||||
// Tablet/Desktop: flex grow, min-w-0 allows truncation of long subjects
|
// Tablet/Desktop: relative
|
||||||
"md:flex-1 md:min-w-0 md:relative"
|
"md:relative",
|
||||||
|
shouldHideViewerPane && "md:hidden"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Inline Composer - shown in viewer pane */}
|
{/* Inline Composer - shown in viewer pane */}
|
||||||
@@ -1484,8 +1552,10 @@ export default function Home() {
|
|||||||
mode={pendingDraft?.mode ?? composerMode}
|
mode={pendingDraft?.mode ?? composerMode}
|
||||||
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
|
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
|
||||||
from: selectedEmail.from,
|
from: selectedEmail.from,
|
||||||
|
replyToAddresses: selectedEmail.replyTo,
|
||||||
to: selectedEmail.to,
|
to: selectedEmail.to,
|
||||||
cc: selectedEmail.cc,
|
cc: selectedEmail.cc,
|
||||||
|
bcc: selectedEmail.bcc,
|
||||||
subject: selectedEmail.subject,
|
subject: selectedEmail.subject,
|
||||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||||
@@ -1595,14 +1665,15 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
onDownloadAttachment={handleDownloadAttachment}
|
onDownloadAttachment={handleDownloadAttachment}
|
||||||
onQuickReply={handleQuickReply}
|
onQuickReply={handleQuickReply}
|
||||||
onBack={() => {
|
onBack={handleMobileBack}
|
||||||
setTabletListVisible(true);
|
|
||||||
selectEmail(null);
|
|
||||||
}}
|
|
||||||
onNavigateNext={handleNavigateNext}
|
onNavigateNext={handleNavigateNext}
|
||||||
onNavigatePrev={handleNavigatePrev}
|
onNavigatePrev={handleNavigatePrev}
|
||||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||||
onEditDraft={handleEditDraft}
|
onEditDraft={handleEditDraft}
|
||||||
|
onCompose={() => {
|
||||||
|
setComposerMode('compose');
|
||||||
|
setShowComposer(true);
|
||||||
|
}}
|
||||||
currentUserEmail={client?.getUsername()}
|
currentUserEmail={client?.getUsername()}
|
||||||
currentUserName={client?.getUsername()?.split("@")[0]}
|
currentUserName={client?.getUsername()?.split("@")[0]}
|
||||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||||
@@ -1656,6 +1727,7 @@ export default function Home() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
<TotpReauthDialog />
|
||||||
</div>
|
</div>
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
Bell,
|
Bell,
|
||||||
|
Puzzle,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -46,6 +47,8 @@ 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 { NotificationSettings } from '@/components/settings/notification-settings';
|
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 { 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';
|
||||||
@@ -55,9 +58,10 @@ import { InlineAppView } from '@/components/layout/inline-app-view';
|
|||||||
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
||||||
import { ResizeHandle } from '@/components/layout/resize-handle';
|
import { ResizeHandle } from '@/components/layout/resize-handle';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'advanced';
|
type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'themes' | 'plugins' | 'advanced';
|
||||||
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
|
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
|
||||||
|
|
||||||
interface TabDef {
|
interface TabDef {
|
||||||
@@ -65,6 +69,7 @@ interface TabDef {
|
|||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
group: TabGroup;
|
group: TabGroup;
|
||||||
|
experimental?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabIcons: Record<Tab, LucideIcon> = {
|
const tabIcons: Record<Tab, LucideIcon> = {
|
||||||
@@ -84,6 +89,8 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
|||||||
keywords: Tags,
|
keywords: Tags,
|
||||||
files: HardDrive,
|
files: HardDrive,
|
||||||
sidebar_apps: PanelLeftClose,
|
sidebar_apps: PanelLeftClose,
|
||||||
|
themes: Palette,
|
||||||
|
plugins: Puzzle,
|
||||||
advanced: Wrench,
|
advanced: Wrench,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,6 +105,7 @@ export default function SettingsPage() {
|
|||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
const { stalwartFeaturesEnabled } = useConfig();
|
const { stalwartFeaturesEnabled } = useConfig();
|
||||||
|
const { isFeatureEnabled } = usePolicyStore();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>(() => {
|
const [activeTab, setActiveTab] = useState<Tab>(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('settings-active-tab');
|
const saved = localStorage.getItem('settings-active-tab');
|
||||||
@@ -122,6 +130,19 @@ export default function SettingsPage() {
|
|||||||
});
|
});
|
||||||
}, [checkAuth]);
|
}, [checkAuth]);
|
||||||
|
|
||||||
|
// Listen for tab change events from child components
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const tab = (e as CustomEvent).detail as Tab;
|
||||||
|
if (tab) {
|
||||||
|
setActiveTab(tab);
|
||||||
|
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('settings-tab-change', handler);
|
||||||
|
return () => window.removeEventListener('settings-tab-change', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||||
@@ -145,16 +166,18 @@ export default function SettingsPage() {
|
|||||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
|
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
|
||||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
|
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
|
||||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
|
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
|
||||||
{ id: 'encryption', label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' },
|
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' as TabGroup }] : []),
|
||||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
|
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
|
||||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []),
|
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []),
|
||||||
{ id: 'templates', label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' },
|
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' as TabGroup }] : []),
|
||||||
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
|
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
|
||||||
{ id: 'keywords', label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' },
|
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' as TabGroup }] : []),
|
||||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||||
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
|
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
|
||||||
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||||
{ id: 'sidebar_apps', label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' },
|
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||||
|
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'system' as TabGroup, experimental: true }] : []),
|
||||||
|
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'system' as TabGroup, experimental: true }] : []),
|
||||||
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
|
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -195,6 +218,8 @@ export default function SettingsPage() {
|
|||||||
{activeTab === 'keywords' && <KeywordSettings />}
|
{activeTab === 'keywords' && <KeywordSettings />}
|
||||||
{activeTab === 'files' && <FilesSettingsComponent />}
|
{activeTab === 'files' && <FilesSettingsComponent />}
|
||||||
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||||
|
{activeTab === 'themes' && <ThemesSettings />}
|
||||||
|
{activeTab === 'plugins' && <PluginsSettings />}
|
||||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
{activeTab === 'advanced' && <AdvancedSettings />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -279,6 +304,11 @@ export default function SettingsPage() {
|
|||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
{tab.experimental && (
|
||||||
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning">
|
||||||
|
Experimental
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -385,6 +415,11 @@ export default function SettingsPage() {
|
|||||||
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||||
)} />
|
)} />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
{tab.experimental && (
|
||||||
|
<span className="ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning shrink-0">
|
||||||
|
Experimental
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Save, Loader2, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ConfigEntry {
|
||||||
|
value: unknown;
|
||||||
|
source: 'admin' | 'env' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminAuthPage() {
|
||||||
|
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||||
|
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { fetchConfig(); }, []);
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('/api/admin/config');
|
||||||
|
if (res.ok) setConfig(await res.json());
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(key: string, value: unknown) {
|
||||||
|
setEdits(prev => ({ ...prev, [key]: value }));
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentValue(key: string): unknown {
|
||||||
|
if (key in edits) return edits[key];
|
||||||
|
return config[key]?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (Object.keys(edits).length === 0) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(edits),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Authentication settings saved.' });
|
||||||
|
setEdits({});
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevert(key: string) {
|
||||||
|
const res = await fetch('/api/admin/config', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setEdits(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
|
await fetchConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEdits = Object.keys(edits).length > 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
|
||||||
|
</div>
|
||||||
|
{hasEdits && (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
Save changes
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OAuth */}
|
||||||
|
<Section title="OAuth / OpenID Connect">
|
||||||
|
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" />
|
||||||
|
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* SSO */}
|
||||||
|
<Section title="Single Sign-On">
|
||||||
|
<Toggle label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Session & Security */}
|
||||||
|
<Section title="Session & Security">
|
||||||
|
<Select label="Cookie SameSite" configKey="cookieSameSite" value={currentValue('cookieSameSite') as string} source={config.cookieSameSite?.source} options={['lax', 'strict', 'none']} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<Text label="Allowed Frame Ancestors" configKey="allowedFrameAncestors" value={currentValue('allowedFrameAncestors') as string} source={config.allowedFrameAncestors?.source} onChange={handleChange} onRevert={handleRevert} placeholder="'none' or https://..." />
|
||||||
|
<Text label="Parent Origin" description="For embedded mode communication" configKey="parentOrigin" value={currentValue('parentOrigin') as string} source={config.parentOrigin?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceBadge({ source }: { source?: string }) {
|
||||||
|
if (!source || source === 'default') return null;
|
||||||
|
return (
|
||||||
|
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||||
|
{source}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Text({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text' }: {
|
||||||
|
label: string; description?: string; configKey: string; value: string; source?: string;
|
||||||
|
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder}
|
||||||
|
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />
|
||||||
|
{source === 'admin' && (
|
||||||
|
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({ label, description, configKey, value, source, onChange, onRevert }: {
|
||||||
|
label: string; description?: string; configKey: string; value: boolean; source?: string;
|
||||||
|
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => onChange(configKey, !value)}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||||
|
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
{source === 'admin' && (
|
||||||
|
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Select({ label, configKey, value, source, options, onChange, onRevert }: {
|
||||||
|
label: string; configKey: string; value: string; source?: string; options: string[];
|
||||||
|
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)}
|
||||||
|
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
|
{options.map(o => <option key={o} value={o}>{o}</option>)}
|
||||||
|
</select>
|
||||||
|
{source === 'admin' && (
|
||||||
|
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ConfigEntry {
|
||||||
|
value: unknown;
|
||||||
|
source: 'admin' | 'env' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_FIELDS = [
|
||||||
|
{ key: 'faviconUrl', label: 'Favicon', accept: '.svg,.png,.ico,.webp' },
|
||||||
|
{ key: 'appLogoLightUrl', label: 'App Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEXT_FIELDS = [
|
||||||
|
{ key: 'loginCompanyName', label: 'Company Name' },
|
||||||
|
{ key: 'loginImprintUrl', label: 'Imprint URL' },
|
||||||
|
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
|
||||||
|
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminBrandingPage() {
|
||||||
|
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||||
|
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('/api/admin/config');
|
||||||
|
if (res.ok) setConfig(await res.json());
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(key: string, value: string) {
|
||||||
|
setEdits(prev => ({ ...prev, [key]: value }));
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentValue(key: string): string {
|
||||||
|
if (key in edits) return edits[key] as string;
|
||||||
|
return (config[key]?.value as string) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (Object.keys(edits).length === 0) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(edits),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' });
|
||||||
|
setEdits({});
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(slot: string, file: File) {
|
||||||
|
setUploading(slot);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('slot', slot);
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/branding', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
|
||||||
|
// Remove any pending URL edit for this slot since upload sets it
|
||||||
|
setEdits(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[slot];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Update config to reflect the uploaded URL
|
||||||
|
setConfig(prev => ({
|
||||||
|
...prev,
|
||||||
|
[slot]: { value: data.url, source: 'admin' },
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||||
|
}
|
||||||
|
setUploading(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteUpload(slot: string) {
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/branding', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slot }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Uploaded file removed. Reverted to default.' });
|
||||||
|
setEdits(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[slot];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to remove' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevert(key: string) {
|
||||||
|
const res = await fetch('/api/admin/config', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setEdits(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await fetchConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUploadedFile = (key: string): boolean => {
|
||||||
|
const val = currentValue(key);
|
||||||
|
return val.startsWith('/api/admin/branding/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasEdits = Object.keys(edits).length > 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Branding</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
|
||||||
|
</div>
|
||||||
|
{hasEdits && (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
Save changes
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Images & Logos</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{IMAGE_FIELDS.map(field => (
|
||||||
|
<div key={field.key} className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
|
||||||
|
{config[field.key]?.source === 'admin' && (
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||||
|
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={currentValue(field.key)}
|
||||||
|
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||||
|
placeholder="Enter URL or upload a file"
|
||||||
|
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||||
|
type="file"
|
||||||
|
accept={field.accept}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleUpload(field.key, file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||||
|
disabled={uploading === field.key}
|
||||||
|
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||||
|
title="Upload file"
|
||||||
|
>
|
||||||
|
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
{isUploadedFile(field.key) && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUpload(field.key)}
|
||||||
|
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
title="Remove uploaded file"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
|
||||||
|
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Preview */}
|
||||||
|
{currentValue(field.key) && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||||
|
<img
|
||||||
|
src={currentValue(field.key)}
|
||||||
|
alt={field.label}
|
||||||
|
className="max-h-6 max-w-[200px] object-contain"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{TEXT_FIELDS.map(field => (
|
||||||
|
<div key={field.key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
|
||||||
|
{config[field.key]?.source === 'admin' && (
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={currentValue(field.key)}
|
||||||
|
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||||
|
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
|
||||||
|
className="h-8 w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
{config[field.key]?.source === 'admin' && (
|
||||||
|
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Lock } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ChangePasswordPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setError('New password must be at least 8 characters.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('New passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('/api/admin/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setSuccess(true);
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setTimeout(() => router.push('/admin'), 2000);
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
setError(data.error || 'Failed to change password.');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Change Password</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Update your admin password.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">Current Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={e => setCurrentPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500">{error}</p>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<p className="text-sm text-green-600">Password changed. Redirecting...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Changing...' : 'Change Password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Settings,
|
||||||
|
Palette,
|
||||||
|
Shield,
|
||||||
|
Scale,
|
||||||
|
ScrollText,
|
||||||
|
LogOut,
|
||||||
|
KeyRound,
|
||||||
|
Puzzle,
|
||||||
|
SwatchBook,
|
||||||
|
Mail,
|
||||||
|
Calendar,
|
||||||
|
BookUser,
|
||||||
|
HardDrive,
|
||||||
|
ArrowLeft,
|
||||||
|
Store,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useConfig } from '@/hooks/use-config';
|
||||||
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
const NAV_GROUPS = [
|
||||||
|
{
|
||||||
|
label: 'Overview',
|
||||||
|
items: [
|
||||||
|
{ href: '/admin', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Configuration',
|
||||||
|
items: [
|
||||||
|
{ href: '/admin/settings', label: 'Settings', icon: Settings },
|
||||||
|
{ href: '/admin/branding', label: 'Branding', icon: Palette },
|
||||||
|
{ href: '/admin/auth', label: 'Authentication', icon: Shield },
|
||||||
|
{ href: '/admin/policy', label: 'Policy', icon: Scale },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Extensions',
|
||||||
|
items: [
|
||||||
|
{ href: '/admin/plugins', label: 'Plugins', icon: Puzzle },
|
||||||
|
{ href: '/admin/themes', label: 'Themes', icon: SwatchBook },
|
||||||
|
{ href: '/admin/marketplace', label: 'Marketplace', icon: Store },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'System',
|
||||||
|
items: [
|
||||||
|
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||||
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
|
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
const logoUrl = resolvedTheme === 'dark'
|
||||||
|
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||||
|
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pathname !== '/admin/login') {
|
||||||
|
checkAuth();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
function getJmapHeaders(): Record<string, string> {
|
||||||
|
const client = useAuthStore.getState().client;
|
||||||
|
if (!client) return {};
|
||||||
|
return {
|
||||||
|
'Authorization': client.getAuthHeader(),
|
||||||
|
'X-JMAP-Server-URL': client.getServerUrl(),
|
||||||
|
'X-JMAP-Username': client.getUsername(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const jmapHeaders = getJmapHeaders();
|
||||||
|
const res = await fetch('/api/admin/auth', { headers: jmapHeaders });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const stalwartAdmin = data.stalwartAdmin === true;
|
||||||
|
setIsStalwartAdmin(stalwartAdmin);
|
||||||
|
|
||||||
|
// If neither password-based admin nor Stalwart admin, redirect away
|
||||||
|
if (!data.enabled && !stalwartAdmin) {
|
||||||
|
router.replace('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.authenticated) {
|
||||||
|
setAuthenticated(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If Stalwart admin but not yet authenticated, auto-login
|
||||||
|
if (stalwartAdmin) {
|
||||||
|
const loginRes = await fetch('/api/admin/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
||||||
|
body: JSON.stringify({ stalwartAuth: true }),
|
||||||
|
});
|
||||||
|
if (loginRes.ok) {
|
||||||
|
setAuthenticated(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace('/admin/login');
|
||||||
|
} catch {
|
||||||
|
router.replace('/admin/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await fetch('/api/admin/auth', { method: 'DELETE' });
|
||||||
|
router.replace('/admin/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't gate the login page
|
||||||
|
if (pathname === '/admin/login') {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authenticated === null) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="animate-pulse text-muted-foreground text-sm">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex bg-background">
|
||||||
|
{/* Slim webmail nav rail */}
|
||||||
|
<nav className="w-14 bg-secondary flex flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
|
||||||
|
{logoUrl ? (
|
||||||
|
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
||||||
|
) : (
|
||||||
|
<div className="w-7 h-7 mb-2" />
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title="Mail"
|
||||||
|
>
|
||||||
|
<Mail className="w-[18px] h-[18px]" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/calendar"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title="Calendar"
|
||||||
|
>
|
||||||
|
<Calendar className="w-[18px] h-[18px]" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/contacts"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title="Contacts"
|
||||||
|
>
|
||||||
|
<BookUser className="w-[18px] h-[18px]" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/files"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title="Files"
|
||||||
|
>
|
||||||
|
<HardDrive className="w-[18px] h-[18px]" />
|
||||||
|
</a>
|
||||||
|
<div className="mt-auto flex flex-col items-center gap-2">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
|
||||||
|
<Shield className="w-[18px] h-[18px]" />
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/settings"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<Settings className="w-[18px] h-[18px]" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Admin Sidebar */}
|
||||||
|
<aside className="w-60 border-r border-border bg-secondary flex flex-col sticky top-0 h-screen">
|
||||||
|
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||||
|
{logoUrl ? (
|
||||||
|
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||||
|
) : (
|
||||||
|
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||||
|
)}
|
||||||
|
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
|
<div className="px-2 space-y-0.5">
|
||||||
|
{NAV_GROUPS.map((group, groupIndex) => (
|
||||||
|
<div key={group.label}>
|
||||||
|
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
||||||
|
<div className="px-3 pt-2.5 pb-1">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{group.items.map(({ href, label, icon: Icon }) => {
|
||||||
|
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
|
active
|
||||||
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
|
: 'hover:bg-muted text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn(
|
||||||
|
'w-4 h-4 shrink-0',
|
||||||
|
active ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||||
|
)} />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
|
||||||
|
{!isStalwartAdmin && (
|
||||||
|
<Link
|
||||||
|
href="/admin/change-password"
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
|
pathname === '/admin/change-password'
|
||||||
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
|
: 'hover:bg-muted text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<KeyRound className={cn(
|
||||||
|
'w-4 h-4 shrink-0',
|
||||||
|
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||||
|
)} />
|
||||||
|
Change Password
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Shield } from 'lucide-react';
|
||||||
|
import { useConfig } from '@/hooks/use-config';
|
||||||
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
|
||||||
|
export default function AdminLoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl;
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || 'Login failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/admin');
|
||||||
|
} catch {
|
||||||
|
setError('Network error. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="flex flex-col items-center mb-8">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
|
||||||
|
{logoUrl ? (
|
||||||
|
<img src={logoUrl} alt="" className="w-8 h-8 object-contain" />
|
||||||
|
) : (
|
||||||
|
<Shield className="w-6 h-6 text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring"
|
||||||
|
placeholder="Enter admin password"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !password}
|
||||||
|
className="w-full h-10 rounded-md bg-primary text-primary-foreground font-medium text-sm hover:bg-primary/90 disabled:opacity-50 disabled:pointer-events-none transition-all duration-200 shadow-sm"
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
|
||||||
|
export default function AdminLogsPage() {
|
||||||
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionFilter, setActionFilter] = useState('');
|
||||||
|
const limit = 50;
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
|
if (actionFilter) params.set('action', actionFilter);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/audit?${params}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setEntries(data.entries || []);
|
||||||
|
setTotal(data.total || 0);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [page, actionFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchLogs}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
|
||||||
|
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">All actions</option>
|
||||||
|
<option value="admin.login">Login</option>
|
||||||
|
<option value="admin.logout">Logout</option>
|
||||||
|
<option value="admin.login_failed">Login Failed</option>
|
||||||
|
<option value="admin.login_blocked">Login Blocked</option>
|
||||||
|
<option value="admin.change-password">Password Change</option>
|
||||||
|
<option value="config.update">Config Update</option>
|
||||||
|
<option value="config.revert">Config Revert</option>
|
||||||
|
<option value="policy.update">Policy Update</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-muted/30">
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Time</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Action</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-muted-foreground">IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{loading && entries.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">Loading...</td>
|
||||||
|
</tr>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">No entries found</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
entries.map((entry, i) => (
|
||||||
|
<tr key={i} className="hover:bg-muted/20">
|
||||||
|
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{new Date(entry.ts).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{entry.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-xs text-foreground max-w-xs truncate">
|
||||||
|
{formatDetail(entry.detail)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-xs text-muted-foreground font-mono">
|
||||||
|
{entry.ip}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetail(detail: Record<string, unknown>): string {
|
||||||
|
if (!detail || Object.keys(detail).length === 0) return '—';
|
||||||
|
if (detail.reason) return String(detail.reason);
|
||||||
|
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
||||||
|
if (detail.changes && Array.isArray(detail.changes)) {
|
||||||
|
return detail.changes.map((c: Record<string, unknown>) => `${c.key}`).join(', ');
|
||||||
|
}
|
||||||
|
if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`;
|
||||||
|
return JSON.stringify(detail).slice(0, 100);
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Extension {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
type: 'plugin' | 'theme';
|
||||||
|
pluginType: string | null;
|
||||||
|
description: string;
|
||||||
|
permissions: string[];
|
||||||
|
tags: string[];
|
||||||
|
totalDownloads: number;
|
||||||
|
featured: boolean;
|
||||||
|
minAppVersion: string | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
installed: boolean;
|
||||||
|
author: {
|
||||||
|
displayName: string;
|
||||||
|
githubLogin: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
data: Extension[];
|
||||||
|
meta: {
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type TypeFilter = 'all' | 'plugin' | 'theme';
|
||||||
|
|
||||||
|
export default function AdminMarketplacePage() {
|
||||||
|
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [perPage] = useState(12);
|
||||||
|
const [installing, setInstalling] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchExtensions = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query) params.set('q', query);
|
||||||
|
if (typeFilter !== 'all') params.set('type', typeFilter);
|
||||||
|
params.set('page', String(page));
|
||||||
|
params.set('perPage', String(perPage));
|
||||||
|
params.set('sort', 'newest');
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/marketplace?${params}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
setError(data.error || 'Failed to connect to extension directory');
|
||||||
|
setExtensions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: SearchResult = await res.json();
|
||||||
|
setExtensions(data.data || []);
|
||||||
|
setTotal(data.meta?.total || 0);
|
||||||
|
} catch {
|
||||||
|
setError('Failed to connect to extension directory. Make sure it is running.');
|
||||||
|
setExtensions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [query, typeFilter, page, perPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchExtensions();
|
||||||
|
}, [fetchExtensions]);
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setQuery(searchInput);
|
||||||
|
setPage(1);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
|
async function handleInstall(ext: Extension) {
|
||||||
|
setInstalling(ext.slug);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/marketplace', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: ext.slug,
|
||||||
|
version: ext.latestVersion || '1.0.0',
|
||||||
|
type: ext.type,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||||
|
setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` });
|
||||||
|
// Mark as installed in the UI
|
||||||
|
setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e));
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Installation failed — network error' });
|
||||||
|
} finally {
|
||||||
|
setInstalling(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / perPage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Marketplace</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Browse and install plugins and themes from the BulwarkMail extension directory
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search extensions..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5">
|
||||||
|
{(['all', 'plugin', 'theme'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => { setTypeFilter(t); setPage(1); }}
|
||||||
|
className={`h-8 px-3 rounded text-sm font-medium transition-colors ${
|
||||||
|
typeFilter === t
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === 'all' ? 'All' : t === 'plugin' ? 'Plugins' : 'Themes'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && (
|
||||||
|
<div className="border border-border rounded-lg p-12 text-center">
|
||||||
|
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">{error}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Start the extension directory server on the configured port
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchExtensions}
|
||||||
|
className="mt-4 inline-flex items-center gap-2 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && !error && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">Searching extensions...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!loading && !error && extensions.length === 0 && (
|
||||||
|
<div className="border border-border rounded-lg p-12 text-center">
|
||||||
|
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">No extensions found</p>
|
||||||
|
{query && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Try a different search term
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Extension Grid */}
|
||||||
|
{!loading && !error && extensions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{total} extension{total !== 1 ? 's' : ''} found
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{extensions.map((ext) => (
|
||||||
|
<ExtensionCard
|
||||||
|
key={ext.slug}
|
||||||
|
extension={ext}
|
||||||
|
installing={installing === ext.slug}
|
||||||
|
onInstall={() => handleInstall(ext)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExtensionCard({
|
||||||
|
extension,
|
||||||
|
installing,
|
||||||
|
onInstall,
|
||||||
|
}: {
|
||||||
|
extension: Extension;
|
||||||
|
installing: boolean;
|
||||||
|
onInstall: () => void;
|
||||||
|
}) {
|
||||||
|
const isPlugin = extension.type === 'plugin';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0">
|
||||||
|
{isPlugin ? (
|
||||||
|
<Puzzle className="w-5 h-5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<SwatchBook className="w-5 h-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm font-medium text-foreground truncate">{extension.name}</span>
|
||||||
|
{extension.featured && (
|
||||||
|
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||||
|
isPlugin
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
|
||||||
|
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
|
||||||
|
}`}>
|
||||||
|
{isPlugin ? (extension.pluginType || 'plugin') : 'theme'}
|
||||||
|
</span>
|
||||||
|
{extension.author && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
by {extension.author.displayName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-xs text-muted-foreground mt-3 line-clamp-2">
|
||||||
|
{extension.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{extension.tags && extension.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-3">
|
||||||
|
{extension.tags.slice(0, 3).map(tag => (
|
||||||
|
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
{extension.totalDownloads.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{extension.permissions && extension.permissions.length > 0 && (
|
||||||
|
<span title={extension.permissions.join(', ')}>
|
||||||
|
{extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{extension.installed ? (
|
||||||
|
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
Installed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onInstall}
|
||||||
|
disabled={installing}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{installing ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||||
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
|
||||||
|
interface AdminStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
|
lastLogin: string | null;
|
||||||
|
passwordChangedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigData {
|
||||||
|
appName?: string;
|
||||||
|
jmapServerUrl?: string;
|
||||||
|
settingsSyncEnabled?: boolean;
|
||||||
|
stalwartFeaturesEnabled?: boolean;
|
||||||
|
oauthEnabled?: boolean;
|
||||||
|
devMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboardPage() {
|
||||||
|
const [status, setStatus] = useState<AdminStatus | null>(null);
|
||||||
|
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
|
||||||
|
const [config, setConfig] = useState<ConfigData | null>(null);
|
||||||
|
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null);
|
||||||
|
const [warnings, setWarnings] = useState<string[]>([]);
|
||||||
|
const [pluginCount, setPluginCount] = useState(0);
|
||||||
|
const [themeCount, setThemeCount] = useState(0);
|
||||||
|
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
||||||
|
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDashboardData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchDashboardData() {
|
||||||
|
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
|
||||||
|
fetch('/api/admin/auth'),
|
||||||
|
fetch('/api/admin/audit?limit=10'),
|
||||||
|
fetch('/api/config'),
|
||||||
|
fetch('/api/admin/config'),
|
||||||
|
fetch('/api/admin/plugins').catch(() => null),
|
||||||
|
fetch('/api/admin/themes').catch(() => null),
|
||||||
|
fetch('/api/admin/policy').catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (statusRes.ok) setStatus(await statusRes.json());
|
||||||
|
if (auditRes.ok) {
|
||||||
|
const data = await auditRes.json();
|
||||||
|
setRecentActivity(data.entries || []);
|
||||||
|
}
|
||||||
|
let configData: ConfigData | null = null;
|
||||||
|
if (configRes.ok) {
|
||||||
|
configData = await configRes.json();
|
||||||
|
setConfig(configData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pluginRes?.ok) {
|
||||||
|
const plugins = await pluginRes.json();
|
||||||
|
setPluginCount(Array.isArray(plugins) ? plugins.length : 0);
|
||||||
|
}
|
||||||
|
if (themeRes?.ok) {
|
||||||
|
const themes = await themeRes.json();
|
||||||
|
setThemeCount(Array.isArray(themes) ? themes.length : 0);
|
||||||
|
}
|
||||||
|
if (policyRes?.ok) {
|
||||||
|
const policy = await policyRes.json();
|
||||||
|
const restrictionCount = policy.restrictions ? Object.keys(policy.restrictions).length : 0;
|
||||||
|
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
|
||||||
|
setPolicyRuleCount(restrictionCount + disabledGates);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configData?.jmapServerUrl) {
|
||||||
|
try {
|
||||||
|
const jmapRes = await fetch('/api/config');
|
||||||
|
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
||||||
|
} catch {
|
||||||
|
setJmapHealth('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const w: string[] = [];
|
||||||
|
if (adminConfigRes.ok) {
|
||||||
|
const sources = await adminConfigRes.json();
|
||||||
|
setConfigSources(sources);
|
||||||
|
const sessionSecret = sources?.sessionSecret;
|
||||||
|
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') {
|
||||||
|
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
|
||||||
|
}
|
||||||
|
const adminPassword = sources?.adminPassword;
|
||||||
|
if (adminPassword?.value && adminPassword.source === 'env') {
|
||||||
|
w.push('ADMIN_PASSWORD is still set in environment variables. Remove it now that the hash is stored securely.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setWarnings(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jmapUrl = config?.jmapServerUrl || '—';
|
||||||
|
const jmapHostname = jmapUrl !== '—' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '—';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-8">
|
||||||
|
{/* Warnings */}
|
||||||
|
{warnings.map((msg, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm text-warning">{msg}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{status && !status.lastLogin && (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-warning">First login detected</p>
|
||||||
|
<p className="text-sm text-warning/80 mt-0.5">
|
||||||
|
Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Server Info */}
|
||||||
|
<SettingsSection title="Server" description="Application and connection details">
|
||||||
|
<SettingItem label="Application">
|
||||||
|
<span className="text-sm text-foreground">{config?.appName || '—'}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="JMAP Server" description={jmapUrl !== '—' ? jmapUrl : undefined}>
|
||||||
|
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="JMAP Connection">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${
|
||||||
|
jmapHealth === 'ok' ? 'text-green-600 dark:text-green-400' : jmapHealth === 'error' ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground'
|
||||||
|
}`}>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${
|
||||||
|
jmapHealth === 'ok' ? 'bg-green-500' : jmapHealth === 'error' ? 'bg-red-500' : 'bg-muted-foreground/40'
|
||||||
|
}`} />
|
||||||
|
{jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Last Login">
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
||||||
|
</span>
|
||||||
|
</SettingItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<SettingsSection title="Features" description="Enabled integrations and modules">
|
||||||
|
<SettingItem label="Admin Panel" description="Administrative access to server configuration">
|
||||||
|
<ToggleSwitch checked={!!status?.enabled} onChange={() => {}} disabled />
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Settings Sync" description="Synchronize user settings across devices">
|
||||||
|
<ToggleSwitch checked={!!config?.settingsSyncEnabled} onChange={() => {}} disabled />
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="OAuth" description="OAuth authentication provider">
|
||||||
|
<ToggleSwitch checked={!!config?.oauthEnabled} onChange={() => {}} disabled />
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Stalwart Integration" description="Stalwart mail server features">
|
||||||
|
<ToggleSwitch checked={config?.stalwartFeaturesEnabled !== false} onChange={() => {}} disabled />
|
||||||
|
</SettingItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Extensions */}
|
||||||
|
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
|
||||||
|
<SettingItem label="Plugins">
|
||||||
|
<span className="text-sm text-foreground">{pluginCount}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Themes">
|
||||||
|
<span className="text-sm text-foreground">{themeCount}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Policy Rules">
|
||||||
|
<span className="text-sm text-foreground">{policyRuleCount}</span>
|
||||||
|
</SettingItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<SettingsSection title="Recent Activity" description="Latest administrative actions">
|
||||||
|
{recentActivity.length === 0 ? (
|
||||||
|
<div className="py-4 text-sm text-muted-foreground">
|
||||||
|
No activity recorded yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
recentActivity.map((entry, i) => (
|
||||||
|
<SettingItem
|
||||||
|
key={i}
|
||||||
|
label={entry.action}
|
||||||
|
description={formatDetail(entry.detail) || undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>{entry.ip}</span>
|
||||||
|
<span>{new Date(entry.ts).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</SettingItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetail(detail: Record<string, unknown>): string {
|
||||||
|
if (!detail || Object.keys(detail).length === 0) return '';
|
||||||
|
if (detail.key) return `${detail.key}: ${detail.old} → ${detail.new}`;
|
||||||
|
if (detail.reason) return String(detail.reason);
|
||||||
|
if (detail.changes && Array.isArray(detail.changes)) return `${detail.changes.length} setting(s) changed`;
|
||||||
|
return JSON.stringify(detail).slice(0, 80);
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface ConfigField {
|
||||||
|
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: unknown;
|
||||||
|
placeholder?: string;
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginConfig {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
type: string;
|
||||||
|
permissions: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
configSchema?: Record<string, ConfigField>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PluginConfigPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const pluginId = params.id as string;
|
||||||
|
const [plugin, setPlugin] = useState<PluginInfo | null>(null);
|
||||||
|
const [config, setConfig] = useState<PluginConfig>({});
|
||||||
|
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pluginId]);
|
||||||
|
|
||||||
|
// Initialize form values from config + schema defaults when data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (!plugin?.configSchema) return;
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||||
|
const stored = config[key];
|
||||||
|
if (stored !== undefined && stored !== null) {
|
||||||
|
initial[key] = String(stored);
|
||||||
|
} else if (field.default !== undefined) {
|
||||||
|
initial[key] = String(field.default);
|
||||||
|
} else {
|
||||||
|
initial[key] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFormValues(initial);
|
||||||
|
}, [plugin, config]);
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [pluginsRes, configRes] = await Promise.all([
|
||||||
|
fetch('/api/admin/plugins'),
|
||||||
|
fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (pluginsRes.ok) {
|
||||||
|
const plugins: PluginInfo[] = await pluginsRes.json();
|
||||||
|
setPlugin(plugins.find(p => p.id === pluginId) || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configRes.ok) {
|
||||||
|
setConfig(await configRes.json());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveAll() {
|
||||||
|
if (!plugin?.configSchema) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||||
|
if (field.required && !formValues[key]?.trim()) {
|
||||||
|
setMessage({ type: 'error', text: `"${field.label}" is required` });
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save each changed field
|
||||||
|
let hasError = false;
|
||||||
|
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||||
|
const newVal = formValues[key] ?? '';
|
||||||
|
const oldVal = config[key] !== undefined ? String(config[key]) : '';
|
||||||
|
|
||||||
|
// Skip unchanged fields (and skip secret fields that show as empty when they have a stored value)
|
||||||
|
if (newVal === oldVal) continue;
|
||||||
|
if (field.type === 'secret' && !newVal && config[key]) continue;
|
||||||
|
|
||||||
|
// Convert types
|
||||||
|
let value: unknown = newVal;
|
||||||
|
if (field.type === 'boolean') value = newVal === 'true';
|
||||||
|
else if (field.type === 'number') value = Number(newVal);
|
||||||
|
|
||||||
|
// Delete if clearing a non-required field
|
||||||
|
if (!newVal && !field.required) {
|
||||||
|
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setConfig(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
|
} else {
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key, value }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setConfig(prev => ({ ...prev, [key]: value }));
|
||||||
|
} else {
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage(hasError
|
||||||
|
? { type: 'error', text: 'Some settings failed to save' }
|
||||||
|
: { type: 'success', text: 'Configuration saved' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save configuration' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Back to Plugins
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-destructive">Plugin not found: {pluginId}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = plugin.configSchema;
|
||||||
|
const hasSchema = schema && Object.keys(schema).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Puzzle className="w-5 h-5" />
|
||||||
|
{plugin.name} Configuration
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
v{plugin.version} by {plugin.author}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSchema ? (
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Settings</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-5">
|
||||||
|
{Object.entries(schema).map(([key, field]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="text-sm font-medium text-foreground block mb-1">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
||||||
|
</label>
|
||||||
|
{field.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === 'boolean' ? (
|
||||||
|
<select
|
||||||
|
value={formValues[key] ?? String(field.default ?? 'false')}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="true">Enabled</option>
|
||||||
|
<option value="false">Disabled</option>
|
||||||
|
</select>
|
||||||
|
) : field.type === 'select' && field.options ? (
|
||||||
|
<select
|
||||||
|
value={formValues[key] ?? ''}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">— Select —</option>
|
||||||
|
{field.options.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : field.type === 'secret' ? (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={revealSecrets[key] ? 'text' : 'password'}
|
||||||
|
value={formValues[key] ?? ''}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
|
||||||
|
className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRevealSecrets(prev => ({ ...prev, [key]: !prev[key] }))}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={revealSecrets[key] ? 'Hide' : 'Show'}
|
||||||
|
>
|
||||||
|
{revealSecrets[key] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={field.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={formValues[key] ?? ''}
|
||||||
|
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
placeholder={field.placeholder || ''}
|
||||||
|
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSaveAll}
|
||||||
|
disabled={saving}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||||
|
Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border border-border rounded-lg p-8 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
||||||
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
|
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
|
|
||||||
|
interface PluginEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
description: string;
|
||||||
|
type: string;
|
||||||
|
enabled: boolean;
|
||||||
|
forceEnabled?: boolean;
|
||||||
|
permissions: string[];
|
||||||
|
installedAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPluginsPage() {
|
||||||
|
const [plugins, setPlugins] = useState<PluginEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||||
|
const [policyDirty, setPolicyDirty] = useState(false);
|
||||||
|
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPlugins(); fetchPolicy(); }, []);
|
||||||
|
|
||||||
|
async function fetchPolicy() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/policy');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPolicy(data);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePluginsEnabled() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, pluginsEnabled: !prev.features.pluginsEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePluginsUploadEnabled() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, pluginsUploadEnabled: !prev.features.pluginsUploadEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSavePolicy() {
|
||||||
|
setSavingPolicy(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/policy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(policy),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Plugin policy saved. Users will see changes on next login.' });
|
||||||
|
setPolicyDirty(false);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save policy' });
|
||||||
|
} finally {
|
||||||
|
setSavingPolicy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlugins() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/plugins');
|
||||||
|
if (res.ok) setPlugins(await res.json());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/plugins', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||||
|
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
|
||||||
|
await fetchPlugins();
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Upload failed' });
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePlugin(id: string, enabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
const res = await fetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, enabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setPlugins(prev => prev.map(p => p.id === id ? { ...p, enabled } : p));
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
// If force-enabling, also ensure the plugin is enabled
|
||||||
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p));
|
||||||
|
// Also update policy
|
||||||
|
setPolicy(prev => {
|
||||||
|
const current = prev.forceEnabledPlugins || [];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
forceEnabledPlugins: forceEnabled
|
||||||
|
? [...current.filter(pid => pid !== id), id]
|
||||||
|
: current.filter(pid => pid !== id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceEnableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const disabled = plugins.filter(p => !p.enabled);
|
||||||
|
if (disabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All plugins are already enabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const p of disabled) {
|
||||||
|
const res = await fetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: p.id, enabled: true }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
setPlugins(prev => prev.map(p => failed === 0 ? { ...p, enabled: true } : p));
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'success', text: `All ${disabled.length} plugin(s) enabled` });
|
||||||
|
} else {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'error', text: `${failed} plugin(s) failed to enable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceDisableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const enabled = plugins.filter(p => p.enabled);
|
||||||
|
if (enabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All plugins are already disabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const p of enabled) {
|
||||||
|
const res = await fetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: p.id, enabled: false }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'success', text: `All ${enabled.length} plugin(s) disabled` });
|
||||||
|
} else {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'error', text: `${failed} plugin(s) failed to disable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePlugin(id: string, name: string) {
|
||||||
|
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
setMessage(null);
|
||||||
|
const res = await fetch('/api/admin/plugins', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setPlugins(prev => prev.filter(p => p.id !== id));
|
||||||
|
setMessage({ type: 'success', text: `Plugin "${name}" removed` });
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Delete failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
||||||
|
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{policyDirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleSavePolicy}
|
||||||
|
disabled={savingPolicy}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
Save Policy
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<label className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 cursor-pointer transition-all shadow-sm">
|
||||||
|
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||||
|
Upload Plugin
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
onChange={handleUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plugin Policy */}
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Plugin Policy</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">Plugins Enabled</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={togglePluginsEnabled}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||||
|
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">User Plugin Uploads</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={togglePluginsUploadEnabled}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsUploadEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||||
|
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsUploadEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Force enable / disable all */}
|
||||||
|
{plugins.length > 0 && (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={forceEnableAll}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Power className="w-3.5 h-3.5" />
|
||||||
|
Enable All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={forceDisableAll}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<PowerOff className="w-3.5 h-3.5" />
|
||||||
|
Disable All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deployed Plugins */}
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Deployed Plugins</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded plugins for all users</p>
|
||||||
|
</div>
|
||||||
|
{plugins.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<Package className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">No plugins installed</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Upload a plugin ZIP file to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{plugins.map(plugin => (
|
||||||
|
<div key={plugin.id} className="px-4 py-4 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">{plugin.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
|
||||||
|
{plugin.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
{plugin.forceEnabled && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
|
||||||
|
<Lock className="w-3 h-3" /> Forced
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{plugin.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">{plugin.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
by {plugin.author} · {plugin.type} · installed {new Date(plugin.installedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
{plugin.permissions.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<AlertTriangle className="w-3 h-3 text-warning" />
|
||||||
|
<span className="text-xs text-warning">
|
||||||
|
Permissions: {plugin.permissions.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/admin/plugins/${plugin.id}`}
|
||||||
|
title="Configure"
|
||||||
|
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
||||||
|
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
|
||||||
|
className={`p-2 rounded-md transition-colors ${plugin.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
{plugin.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => togglePlugin(plugin.id, !plugin.enabled)}
|
||||||
|
title={plugin.enabled ? 'Disable' : 'Enable'}
|
||||||
|
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Power className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deletePlugin(plugin.id, plugin.name)}
|
||||||
|
title="Remove"
|
||||||
|
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Save, Loader2, Lock } from 'lucide-react';
|
||||||
|
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
||||||
|
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
|
|
||||||
|
// Feature gates managed on their own admin pages (excluded from this list)
|
||||||
|
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
||||||
|
|
||||||
|
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||||
|
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
||||||
|
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
|
||||||
|
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
||||||
|
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
||||||
|
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
||||||
|
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
||||||
|
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
|
||||||
|
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||||
|
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||||
|
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESTRICTABLE_SETTINGS = [
|
||||||
|
{ key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] },
|
||||||
|
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
||||||
|
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
||||||
|
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||||
|
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||||
|
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||||
|
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
|
||||||
|
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||||
|
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
||||||
|
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
||||||
|
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
|
||||||
|
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
|
||||||
|
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
|
||||||
|
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
||||||
|
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
||||||
|
{ key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' },
|
||||||
|
{ key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminPolicyPage() {
|
||||||
|
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPolicy(); }, []);
|
||||||
|
|
||||||
|
async function fetchPolicy() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/policy');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPolicy(data);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFeature(key: keyof FeatureGates) {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, [key]: !prev.features[key] },
|
||||||
|
}));
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLocked(settingKey: string) {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const existing = prev.restrictions[settingKey] || {};
|
||||||
|
const newRestrictions = { ...prev.restrictions };
|
||||||
|
if (existing.locked) {
|
||||||
|
delete newRestrictions[settingKey];
|
||||||
|
} else {
|
||||||
|
newRestrictions[settingKey] = { ...existing, locked: true };
|
||||||
|
}
|
||||||
|
return { ...prev, restrictions: newRestrictions };
|
||||||
|
});
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHidden(settingKey: string) {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const existing = prev.restrictions[settingKey] || {};
|
||||||
|
const newRestrictions = { ...prev.restrictions };
|
||||||
|
newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden };
|
||||||
|
if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) {
|
||||||
|
delete newRestrictions[settingKey];
|
||||||
|
}
|
||||||
|
return { ...prev, restrictions: newRestrictions };
|
||||||
|
});
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/policy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(policy),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Policy saved. Users will see changes on next login.' });
|
||||||
|
setDirty(false);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
|
||||||
|
</div>
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
Save policy
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feature Gates */}
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Feature Gates</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[])
|
||||||
|
.filter(key => !EXCLUDED_FEATURE_GATES.includes(key))
|
||||||
|
.map(key => {
|
||||||
|
const meta = FEATURE_GATE_LABELS[key];
|
||||||
|
if (!meta) return null;
|
||||||
|
const { label, description } = meta;
|
||||||
|
const enabled = policy.features[key];
|
||||||
|
return (
|
||||||
|
<div key={key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => toggleFeature(key)}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||||
|
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Setting Restrictions */}
|
||||||
|
{categories.map(category => (
|
||||||
|
<div key={category} className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">{category}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
|
||||||
|
const restriction = policy.restrictions[setting.key] || {};
|
||||||
|
return (
|
||||||
|
<div key={setting.key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<span className="text-sm text-foreground">{setting.label}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
|
||||||
|
className="rounded border-input" />
|
||||||
|
<Lock className="w-3 h-3" /> Lock
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input type="checkbox" checked={!!restriction.hidden} onChange={() => toggleHidden(setting.key)}
|
||||||
|
className="rounded border-input" />
|
||||||
|
Hide
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ConfigEntry {
|
||||||
|
value: unknown;
|
||||||
|
source: 'admin' | 'env' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminSettingsPage() {
|
||||||
|
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||||
|
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('/api/admin/config');
|
||||||
|
if (res.ok) {
|
||||||
|
setConfig(await res.json());
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(key: string, value: unknown) {
|
||||||
|
setEdits(prev => ({ ...prev, [key]: value }));
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentValue(key: string): unknown {
|
||||||
|
if (key in edits) return edits[key];
|
||||||
|
return config[key]?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (Object.keys(edits).length === 0) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(edits),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Settings saved. Changes take effect on next page load.' });
|
||||||
|
setEdits({});
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevert(key: string) {
|
||||||
|
const res = await fetch('/api/admin/config', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setEdits(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await fetchConfig();
|
||||||
|
setMessage({ type: 'success', text: `${key} reverted to default` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEdits = Object.keys(edits).length > 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">General server configuration</p>
|
||||||
|
</div>
|
||||||
|
{hasEdits && (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
Save changes
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* General */}
|
||||||
|
<SettingsSection title="General">
|
||||||
|
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
||||||
|
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
{!!currentValue('allowCustomJmapEndpoint') && (
|
||||||
|
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
||||||
|
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||||
|
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
|
||||||
|
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Logging */}
|
||||||
|
<SettingsSection title="Logging">
|
||||||
|
<SelectSetting label="Log Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<SelectSetting label="Log Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Settings Sync */}
|
||||||
|
<SettingsSection title="Settings Sync">
|
||||||
|
<ToggleSetting label="Settings Sync Enabled" description="Requires SESSION_SECRET to be set" configKey="settingsSyncEnabled" value={currentValue('settingsSyncEnabled') as boolean} source={config.settingsSyncEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceBadge({ source }: { source?: string }) {
|
||||||
|
if (!source || source === 'default') return null;
|
||||||
|
return (
|
||||||
|
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||||
|
{source}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextSetting({ label, configKey, value, source, onChange, onRevert, placeholder }: {
|
||||||
|
label: string; configKey: string; value: string; source?: string;
|
||||||
|
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<label className="text-sm text-foreground whitespace-nowrap">{label}</label>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(configKey, e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
{source === 'admin' && (
|
||||||
|
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleSetting({ label, description, configKey, value, source, onChange, onRevert }: {
|
||||||
|
label: string; description?: string; configKey: string; value: boolean; source?: string;
|
||||||
|
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(configKey, !value)}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
{source === 'admin' && (
|
||||||
|
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSetting({ label, configKey, value, source, options, onChange, onRevert }: {
|
||||||
|
label: string; configKey: string; value: string; source?: string; options: string[];
|
||||||
|
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(configKey, e.target.value)}
|
||||||
|
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||||
|
</select>
|
||||||
|
{source === 'admin' && (
|
||||||
|
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
||||||
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
|
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||||
|
|
||||||
|
const BUILTIN_THEME_OPTIONS = [
|
||||||
|
{ id: 'builtin-nord', name: 'Nord' },
|
||||||
|
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
|
||||||
|
{ id: 'builtin-solarized', name: 'Solarized' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ThemeEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
description: string;
|
||||||
|
variants: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
forceEnabled?: boolean;
|
||||||
|
installedAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminThemesPage() {
|
||||||
|
const [themes, setThemes] = useState<ThemeEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||||
|
const [policyDirty, setPolicyDirty] = useState(false);
|
||||||
|
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { fetchThemes(); fetchPolicy(); }, []);
|
||||||
|
|
||||||
|
async function fetchPolicy() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/policy');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPolicy({
|
||||||
|
...data,
|
||||||
|
themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleThemesEnabled() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, themesEnabled: !prev.features.themesEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUserThemeUploads() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, userThemesEnabled: !prev.features.userThemesEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBuiltinTheme(themeId: string) {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const disabled = prev.themePolicy?.disabledBuiltinThemes || [];
|
||||||
|
const isDisabled = disabled.includes(themeId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
themePolicy: {
|
||||||
|
...DEFAULT_THEME_POLICY,
|
||||||
|
...prev.themePolicy,
|
||||||
|
disabledBuiltinThemes: isDisabled
|
||||||
|
? disabled.filter((id: string) => id !== themeId)
|
||||||
|
: [...disabled, themeId],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAdminTheme(themeId: string) {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const disabled = prev.themePolicy?.disabledThemes || [];
|
||||||
|
const isDisabled = disabled.includes(themeId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
themePolicy: {
|
||||||
|
...DEFAULT_THEME_POLICY,
|
||||||
|
...prev.themePolicy,
|
||||||
|
disabledThemes: isDisabled
|
||||||
|
? disabled.filter((id: string) => id !== themeId)
|
||||||
|
: [...disabled, themeId],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDefaultTheme(themeId: string | null) {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
themePolicy: {
|
||||||
|
...DEFAULT_THEME_POLICY,
|
||||||
|
...prev.themePolicy,
|
||||||
|
defaultThemeId: themeId,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSavePolicy() {
|
||||||
|
setSavingPolicy(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/policy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(policy),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Theme policy saved. Users will see changes on next login.' });
|
||||||
|
setPolicyDirty(false);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save policy' });
|
||||||
|
} finally {
|
||||||
|
setSavingPolicy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchThemes() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/themes');
|
||||||
|
if (res.ok) setThemes(await res.json());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/themes', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||||
|
setMessage({ type: 'success', text: `Theme "${data.theme.name}" installed${warnings}` });
|
||||||
|
await fetchThemes();
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Upload failed' });
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTheme(id: string, enabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
const res = await fetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, enabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setThemes(prev => prev.map(t => t.id === id ? { ...t, enabled } : t));
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setThemes(prev => prev.map(t => t.id === id ? { ...t, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : t));
|
||||||
|
setPolicy(prev => {
|
||||||
|
const current = prev.forceEnabledThemes || [];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
forceEnabledThemes: forceEnabled
|
||||||
|
? [...current.filter(tid => tid !== id), id]
|
||||||
|
: current.filter(tid => tid !== id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceEnableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const disabled = themes.filter(t => !t.enabled);
|
||||||
|
if (disabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All themes are already enabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const t of disabled) {
|
||||||
|
const res = await fetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: t.id, enabled: true }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'success', text: `All ${disabled.length} theme(s) enabled` });
|
||||||
|
} else {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'error', text: `${failed} theme(s) failed to enable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceDisableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const enabled = themes.filter(t => t.enabled);
|
||||||
|
if (enabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All themes are already disabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const t of enabled) {
|
||||||
|
const res = await fetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: t.id, enabled: false }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'success', text: `All ${enabled.length} theme(s) disabled` });
|
||||||
|
} else {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'error', text: `${failed} theme(s) failed to disable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTheme(id: string, name: string) {
|
||||||
|
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
setMessage(null);
|
||||||
|
const res = await fetch('/api/admin/themes', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setThemes(prev => prev.filter(t => t.id !== id));
|
||||||
|
setMessage({ type: 'success', text: `Theme "${name}" removed` });
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Delete failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const themesEnabled = policy.features.themesEnabled ?? true;
|
||||||
|
const userThemesEnabled = policy.features.userThemesEnabled ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Themes</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{policyDirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleSavePolicy}
|
||||||
|
disabled={savingPolicy}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
Save Policy
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<label className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 cursor-pointer transition-all shadow-sm">
|
||||||
|
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||||
|
Upload Theme
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
onChange={handleUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Theme Policy */}
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Theme Policy</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Control theme availability and defaults for users</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{/* Master toggle */}
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">Themes Enabled</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={toggleThemesEnabled}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||||
|
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User uploads toggle */}
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">User Theme Uploads</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={toggleUserThemeUploads}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||||
|
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Force enable / disable all */}
|
||||||
|
{themes.length > 0 && (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={forceEnableAll}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Power className="w-3.5 h-3.5" />
|
||||||
|
Enable All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={forceDisableAll}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<PowerOff className="w-3.5 h-3.5" />
|
||||||
|
Disable All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Default Theme */}
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">Default Theme</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={policy.themePolicy?.defaultThemeId || ''}
|
||||||
|
onChange={(e) => setDefaultTheme(e.target.value || null)}
|
||||||
|
className="h-8 px-2 rounded-md border border-input bg-background text-sm text-foreground"
|
||||||
|
>
|
||||||
|
<option value="">System Default</option>
|
||||||
|
<optgroup label="Built-in">
|
||||||
|
{BUILTIN_THEME_OPTIONS
|
||||||
|
.filter(t => !(policy.themePolicy?.disabledBuiltinThemes || []).includes(t.id))
|
||||||
|
.map(t => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
{themes.length > 0 && (
|
||||||
|
<optgroup label="Admin-deployed">
|
||||||
|
{themes
|
||||||
|
.filter(t => !(policy.themePolicy?.disabledThemes || []).includes(t.id))
|
||||||
|
.map(t => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Built-in themes */}
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Built-in Themes</span>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{BUILTIN_THEME_OPTIONS.map(theme => {
|
||||||
|
const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id);
|
||||||
|
return (
|
||||||
|
<div key={theme.id} className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-sm text-foreground">{theme.name}</span>
|
||||||
|
<button onClick={() => toggleBuiltinTheme(theme.id)}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||||
|
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin-deployed themes */}
|
||||||
|
{themes.length > 0 && (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Admin-deployed Themes</span>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{themes.map(theme => {
|
||||||
|
const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id);
|
||||||
|
return (
|
||||||
|
<div key={theme.id} className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-sm text-foreground">{theme.name}</span>
|
||||||
|
<button onClick={() => toggleAdminTheme(theme.id)}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||||
|
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deployed Themes */}
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Palette className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Deployed Themes</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded themes available to all users</p>
|
||||||
|
</div>
|
||||||
|
{themes.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<Palette className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">No themes installed</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Upload a theme ZIP file to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{themes.map(theme => (
|
||||||
|
<div key={theme.id} className="px-4 py-4 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">{theme.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">v{theme.version}</span>
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
|
||||||
|
{theme.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
{theme.forceEnabled && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
|
||||||
|
<Lock className="w-3 h-3" /> Forced
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{theme.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">{theme.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
by {theme.author} · {theme.variants.join(', ')} · installed {new Date(theme.installedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleForceEnabled(theme.id, !theme.forceEnabled)}
|
||||||
|
title={theme.forceEnabled ? 'Remove force-enable (users can deactivate)' : 'Force enable (users cannot deactivate)'}
|
||||||
|
className={`p-2 rounded-md transition-colors ${theme.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
{theme.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleTheme(theme.id, !theme.enabled)}
|
||||||
|
title={theme.enabled ? 'Disable' : 'Enable'}
|
||||||
|
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Power className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteTheme(theme.id, theme.name)}
|
||||||
|
title="Remove"
|
||||||
|
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
import { readAuditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/audit — Get paginated audit log entries (admin-protected)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
|
||||||
|
const limit = Math.min(200, Math.max(1, parseInt(request.nextUrl.searchParams.get('limit') || '50', 10)));
|
||||||
|
const action = request.nextUrl.searchParams.get('action') || undefined;
|
||||||
|
|
||||||
|
const { entries, total } = await readAuditLog(page, limit, action);
|
||||||
|
|
||||||
|
return NextResponse.json({ entries, total, page, limit }, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Audit log read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { initAdminPassword, verifyAdminPassword, updateLastLogin, isAdminEnabled, getAdminMeta } from '@/lib/admin/password';
|
||||||
|
import { setAdminSessionCookie, clearAdminSessionCookie, requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { checkRateLimit } from '@/lib/admin/rate-limit';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user is a Stalwart admin by probing an admin-only endpoint.
|
||||||
|
*/
|
||||||
|
async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) return false;
|
||||||
|
|
||||||
|
// Probe admin-only endpoint: listing principals requires admin privileges
|
||||||
|
const response = await fetch(`${creds.apiUrl}/api/principal?limit=1`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Authorization': creds.authHeader },
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAdmin = response.ok;
|
||||||
|
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin });
|
||||||
|
return isAdmin;
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('Stalwart admin check error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/auth — Login
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Stalwart-based admin authentication
|
||||||
|
if (body.stalwartAuth === true) {
|
||||||
|
const isStalwartAdmin = await checkStalwartAdmin(request);
|
||||||
|
if (!isStalwartAdmin) {
|
||||||
|
await auditLog('admin.login_failed', { method: 'stalwart' }, ip);
|
||||||
|
return NextResponse.json({ error: 'Not a Stalwart admin' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setAdminSessionCookie();
|
||||||
|
await auditLog('admin.login', { method: 'stalwart' }, ip);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password-based admin authentication
|
||||||
|
await initAdminPassword();
|
||||||
|
if (!isAdminEnabled()) {
|
||||||
|
return NextResponse.json({ error: 'Admin dashboard is not configured' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit check
|
||||||
|
const limit = checkRateLimit(ip);
|
||||||
|
if (!limit.allowed) {
|
||||||
|
const retryAfter = Math.ceil(limit.retryAfterMs / 1000);
|
||||||
|
await auditLog('admin.login_blocked', { reason: 'rate_limit' }, ip);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many login attempts. Try again later.' },
|
||||||
|
{ status: 429, headers: { 'Retry-After': String(retryAfter) } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password } = body;
|
||||||
|
|
||||||
|
if (!password || typeof password !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Password is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await verifyAdminPassword(password);
|
||||||
|
if (!valid) {
|
||||||
|
await auditLog('admin.login_failed', {}, ip);
|
||||||
|
logger.warn('Admin login failed', { ip });
|
||||||
|
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setAdminSessionCookie();
|
||||||
|
await updateLastLogin();
|
||||||
|
await auditLog('admin.login', {}, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin login error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/auth — Check session status
|
||||||
|
* Also checks if the user is a Stalwart admin (admin panel enabled even without password).
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
await initAdminPassword();
|
||||||
|
const adminEnabled = isAdminEnabled();
|
||||||
|
const isStalwartAdmin = await checkStalwartAdmin(request);
|
||||||
|
|
||||||
|
// If neither password-based admin nor Stalwart admin, admin is disabled
|
||||||
|
if (!adminEnabled && !isStalwartAdmin) {
|
||||||
|
return NextResponse.json({ enabled: false, authenticated: false, stalwartAdmin: false }, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) {
|
||||||
|
return NextResponse.json({
|
||||||
|
enabled: adminEnabled,
|
||||||
|
authenticated: false,
|
||||||
|
stalwartAdmin: isStalwartAdmin,
|
||||||
|
}, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = getAdminMeta();
|
||||||
|
return NextResponse.json({
|
||||||
|
enabled: adminEnabled,
|
||||||
|
authenticated: true,
|
||||||
|
stalwartAdmin: isStalwartAdmin,
|
||||||
|
lastLogin: meta?.lastLogin,
|
||||||
|
passwordChangedAt: meta?.passwordChangedAt,
|
||||||
|
}, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin status error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/auth — Logout
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
await clearAdminSessionCookie();
|
||||||
|
await auditLog('admin.logout', {}, ip);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin logout error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
|
||||||
|
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/branding/[filename] — Serve uploaded branding images
|
||||||
|
*
|
||||||
|
* This endpoint is public (no admin auth) so browsers can load images.
|
||||||
|
* Only files in the branding directory are served; directory traversal is prevented.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ filename: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { filename } = await params;
|
||||||
|
|
||||||
|
// Sanitize: only allow basename, no path separators
|
||||||
|
const safe = path.basename(filename);
|
||||||
|
if (safe !== filename || filename.includes('..')) {
|
||||||
|
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(safe).toLowerCase();
|
||||||
|
const contentType = MIME_TYPES[ext];
|
||||||
|
if (!contentType) {
|
||||||
|
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(BRANDING_DIR, safe);
|
||||||
|
|
||||||
|
// Ensure resolved path is still within BRANDING_DIR
|
||||||
|
const resolved = path.resolve(filePath);
|
||||||
|
if (!resolved.startsWith(path.resolve(BRANDING_DIR))) {
|
||||||
|
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStat = await stat(resolved).catch(() => null);
|
||||||
|
if (!fileStat || !fileStat.isFile()) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await readFile(resolved);
|
||||||
|
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||||
|
'Content-Length': String(buffer.length),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
|
||||||
|
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||||
|
const ALLOWED_MIME_TYPES = new Set([
|
||||||
|
'image/svg+xml',
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/webp',
|
||||||
|
'image/x-icon',
|
||||||
|
'image/vnd.microsoft.icon',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Slots that correspond to branding config keys */
|
||||||
|
const VALID_SLOTS = new Set([
|
||||||
|
'faviconUrl',
|
||||||
|
'appLogoLightUrl',
|
||||||
|
'appLogoDarkUrl',
|
||||||
|
'loginLogoLightUrl',
|
||||||
|
'loginLogoDarkUrl',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
// Strip directory traversal, keep only safe chars
|
||||||
|
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/branding — Upload a branding image file
|
||||||
|
*
|
||||||
|
* Expects multipart/form-data with:
|
||||||
|
* - file: the image file
|
||||||
|
* - slot: which branding field this is for (e.g. "faviconUrl")
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
const slot = formData.get('slot') as string | null;
|
||||||
|
|
||||||
|
if (!file || !slot) {
|
||||||
|
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_SLOTS.has(slot)) {
|
||||||
|
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unsupported file type: ${file.type}. Allowed: SVG, PNG, JPEG, WebP, ICO` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine extension from mime type
|
||||||
|
const extMap: Record<string, string> = {
|
||||||
|
'image/svg+xml': '.svg',
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
'image/x-icon': '.ico',
|
||||||
|
'image/vnd.microsoft.icon': '.ico',
|
||||||
|
};
|
||||||
|
const ext = extMap[file.type] || '.png';
|
||||||
|
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||||
|
const filePath = path.join(BRANDING_DIR, safeName);
|
||||||
|
|
||||||
|
// Ensure branding directory exists
|
||||||
|
if (!existsSync(BRANDING_DIR)) {
|
||||||
|
await mkdir(BRANDING_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write file to disk
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
await writeFile(filePath, buffer);
|
||||||
|
|
||||||
|
// Update config to point to the served URL
|
||||||
|
const servedUrl = `/api/admin/branding/${safeName}`;
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||||
|
|
||||||
|
await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ url: servedUrl, filename: safeName });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Branding upload error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/branding — Remove an uploaded branding file
|
||||||
|
*
|
||||||
|
* Expects JSON body: { slot: string }
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { slot } = await request.json();
|
||||||
|
|
||||||
|
if (!slot || !VALID_SLOTS.has(slot)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and remove matching files for this slot
|
||||||
|
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
||||||
|
let removed = false;
|
||||||
|
for (const ext of possibleExts) {
|
||||||
|
const filePath = path.join(BRANDING_DIR, `${slot}${ext}`);
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
await unlink(filePath);
|
||||||
|
removed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the config override so it falls back to default/env
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
await configManager.removeAdminOverride(slot);
|
||||||
|
|
||||||
|
await auditLog('branding_delete', { slot, fileRemoved: removed }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Branding delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { changeAdminPassword } from '@/lib/admin/password';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/change-password — Change admin password
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { currentPassword, newPassword } = await request.json();
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword || typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Both current and new password are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
return NextResponse.json({ error: 'New password must be at least 8 characters' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await changeAdminPassword(currentPassword, newPassword);
|
||||||
|
if (!success) {
|
||||||
|
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog('admin.change-password', {}, ip);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin change password error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/config — Get full config with sources (admin-protected)
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const config = configManager.getAllWithSources();
|
||||||
|
|
||||||
|
return NextResponse.json(config, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin config read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/admin/config — Update config overrides (admin-protected)
|
||||||
|
*/
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const updates = await request.json();
|
||||||
|
|
||||||
|
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
||||||
|
return NextResponse.json({ error: 'Request body must be an object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate keys
|
||||||
|
const validKeys = Object.keys(CONFIG_ENV_MAP);
|
||||||
|
const invalidKeys = Object.keys(updates).filter(k => !validKeys.includes(k));
|
||||||
|
if (invalidKeys.length > 0) {
|
||||||
|
return NextResponse.json({ error: `Unknown config keys: ${invalidKeys.join(', ')}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get old values for audit
|
||||||
|
const oldValues: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(updates)) {
|
||||||
|
oldValues[key] = configManager.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
await configManager.setAdminConfig(updates);
|
||||||
|
await auditLog('config.update', { changes: Object.keys(updates).map(k => ({ key: k, old: oldValues[k], new: updates[k] })) }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin config update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/config — Remove admin override for a key (revert to env/default)
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { key } = await request.json();
|
||||||
|
|
||||||
|
if (!key || typeof key !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Key is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CONFIG_ENV_MAP[key]) {
|
||||||
|
return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldValue = configManager.get(key);
|
||||||
|
await configManager.removeAdminOverride(key);
|
||||||
|
await auditLog('config.revert', { key, oldValue }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin config revert error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import {
|
||||||
|
savePlugin,
|
||||||
|
saveTheme,
|
||||||
|
getPluginRegistry,
|
||||||
|
getThemeRegistry,
|
||||||
|
type ServerPlugin,
|
||||||
|
type ServerTheme,
|
||||||
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||||
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
|
|
||||||
|
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/marketplace — Search/browse the extension directory
|
||||||
|
* Proxies to the extension directory API
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { searchParams } = request.nextUrl;
|
||||||
|
const url = new URL('/api/v1/extensions', DIRECTORY_URL);
|
||||||
|
|
||||||
|
// Forward all search params
|
||||||
|
for (const [key, value] of searchParams.entries()) {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch from extension directory' },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Enrich with install status
|
||||||
|
const [pluginRegistry, themeRegistry] = await Promise.all([
|
||||||
|
getPluginRegistry(),
|
||||||
|
getThemeRegistry(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
|
||||||
|
const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
|
||||||
|
|
||||||
|
if (data.data) {
|
||||||
|
data.data = data.data.map((ext: Record<string, unknown>) => ({
|
||||||
|
...ext,
|
||||||
|
installed: ext.type === 'theme'
|
||||||
|
? installedThemes.has(ext.slug as string)
|
||||||
|
: installedPlugins.has(ext.slug as string),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Marketplace search error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Failed to connect to extension directory' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/marketplace — Install an extension from the directory
|
||||||
|
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { slug, version, type } = await request.json();
|
||||||
|
|
||||||
|
if (!slug || !version || !type) {
|
||||||
|
return NextResponse.json({ error: 'Missing slug, version, or type' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type !== 'plugin' && type !== 'theme') {
|
||||||
|
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the bundle from the directory
|
||||||
|
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL);
|
||||||
|
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bundleRes.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Failed to download bundle: ${bundleRes.status}` },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await bundleRes.arrayBuffer();
|
||||||
|
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
|
||||||
|
|
||||||
|
if (buffer.byteLength > maxSize) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the ZIP
|
||||||
|
let zip: JSZip;
|
||||||
|
try {
|
||||||
|
zip = await JSZip.loadAsync(buffer);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid ZIP file from directory' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find root directory
|
||||||
|
const entries = Object.keys(zip.files);
|
||||||
|
const topDirs = new Set(entries.map(e => e.split('/')[0]));
|
||||||
|
let root = '';
|
||||||
|
if (topDirs.size === 1) {
|
||||||
|
const dir = [...topDirs][0];
|
||||||
|
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
|
||||||
|
root = dir + '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read manifest
|
||||||
|
const manifestFile = zip.file(root + 'manifest.json');
|
||||||
|
if (!manifestFile) {
|
||||||
|
return NextResponse.json({ error: 'Bundle missing manifest.json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(await manifestFile.async('string'));
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid manifest.json in bundle' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
if (type === 'theme') {
|
||||||
|
// Read theme.css
|
||||||
|
const cssFile = zip.file(root + 'theme.css');
|
||||||
|
if (!cssFile) {
|
||||||
|
return NextResponse.json({ error: 'Theme bundle missing theme.css' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let css = await cssFile.async('string');
|
||||||
|
|
||||||
|
// Validate and sanitize CSS
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const safety = validateThemeCSSSafety(css);
|
||||||
|
if (!safety.valid) {
|
||||||
|
const sanitized = sanitizeThemeCSS(css);
|
||||||
|
css = sanitized.css;
|
||||||
|
warnings.push(...sanitized.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme: ServerTheme = {
|
||||||
|
id: (manifest.id as string) || slug,
|
||||||
|
name: (manifest.name as string) || slug,
|
||||||
|
version: (manifest.version as string) || version,
|
||||||
|
author: (manifest.author as string) || 'Unknown',
|
||||||
|
description: (manifest.description as string) || '',
|
||||||
|
variants: (manifest.variants as string[]) || ['light', 'dark'],
|
||||||
|
enabled: true,
|
||||||
|
installedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveTheme(theme, css);
|
||||||
|
await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, theme, warnings });
|
||||||
|
} else {
|
||||||
|
// Plugin installation
|
||||||
|
// Read entrypoint JS
|
||||||
|
const entrypoint = (manifest.entrypoint as string) || 'index.js';
|
||||||
|
const jsFile = zip.file(root + entrypoint);
|
||||||
|
if (!jsFile) {
|
||||||
|
return NextResponse.json({ error: `Bundle missing entrypoint: ${entrypoint}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await jsFile.async('string');
|
||||||
|
|
||||||
|
// Validate permissions
|
||||||
|
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
|
||||||
|
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
||||||
|
const unknownPerms = permissions.filter(p => !validPerms.has(p));
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
if (unknownPerms.length > 0) {
|
||||||
|
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin: ServerPlugin = {
|
||||||
|
id: (manifest.id as string) || slug,
|
||||||
|
name: (manifest.name as string) || slug,
|
||||||
|
version: (manifest.version as string) || version,
|
||||||
|
author: (manifest.author as string) || 'Unknown',
|
||||||
|
description: (manifest.description as string) || '',
|
||||||
|
type: (manifest.type as string) || 'hook',
|
||||||
|
permissions,
|
||||||
|
entrypoint,
|
||||||
|
enabled: true,
|
||||||
|
installedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await savePlugin(plugin, code);
|
||||||
|
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, plugin, warnings });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Installation failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/plugins/[id]/bundle — Serve plugin JS bundle
|
||||||
|
*
|
||||||
|
* Public endpoint so the client-side plugin loader can fetch bundles.
|
||||||
|
* Only serves plugins that exist in the registry and are enabled.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Validate ID format to prevent path traversal
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await getPlugin(id);
|
||||||
|
if (!plugin) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin.enabled) {
|
||||||
|
return NextResponse.json({ error: 'Plugin is disabled' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await getPluginBundle(id);
|
||||||
|
if (!code) {
|
||||||
|
return NextResponse.json({ error: 'Bundle not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(code, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/javascript; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||||
|
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
|
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
||||||
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/plugins/[id]/config — Read all config for a plugin
|
||||||
|
*
|
||||||
|
* Returns the full config object for admin-configured plugin settings.
|
||||||
|
* This endpoint is accessible from the client-side plugin API.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await getPlugin(id);
|
||||||
|
if (!plugin) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getPluginConfig(id);
|
||||||
|
return NextResponse.json(config, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/admin/plugins/[id]/config — Set a config key
|
||||||
|
*
|
||||||
|
* Body: { key: string, value: unknown }
|
||||||
|
* Requires admin authentication (checked via admin session).
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await getPlugin(id);
|
||||||
|
if (!plugin) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { key?: string; value?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.key || typeof body.key !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'key is required and must be a string' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate key format (alphanumeric, hyphens, underscores, dots)
|
||||||
|
if (!/^[a-zA-Z0-9._-]+$/.test(body.key)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setPluginConfig(id, body.key, body.value);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/plugins/[id]/config — Delete a config key
|
||||||
|
*
|
||||||
|
* Body: { key: string }
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { key?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.key || typeof body.key !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'key is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await deletePluginConfigKey(id, body.key);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import {
|
||||||
|
getPluginRegistry,
|
||||||
|
savePlugin,
|
||||||
|
deletePlugin as removePlugin,
|
||||||
|
type ServerPlugin,
|
||||||
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
|
||||||
|
// Server-side extraction using the same validation logic
|
||||||
|
// ZIP parsing needs to happen on the server for admin-uploaded plugins
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { MAX_PLUGIN_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||||
|
|
||||||
|
const SUSPICIOUS_JS_PATTERNS = [
|
||||||
|
{ pattern: /\beval\s*\(/g, label: 'eval()' },
|
||||||
|
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
|
||||||
|
{ pattern: /document\.cookie/g, label: 'document.cookie' },
|
||||||
|
{ pattern: /document\.write/g, label: 'document.write' },
|
||||||
|
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/plugins — List all admin-managed plugins
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const registry = await getPluginRegistry();
|
||||||
|
return NextResponse.json(registry.plugins, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Plugin list error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/plugins — Upload and install a plugin ZIP
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_PLUGIN_SIZE) {
|
||||||
|
return NextResponse.json({ error: 'Plugin ZIP exceeds 5 MB size limit' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and validate ZIP
|
||||||
|
let zip: JSZip;
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
zip = await JSZip.loadAsync(buffer);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find root
|
||||||
|
const entries = Object.keys(zip.files);
|
||||||
|
const topDirs = new Set(entries.map(e => e.split('/')[0]));
|
||||||
|
let root = '';
|
||||||
|
if (topDirs.size === 1) {
|
||||||
|
const dir = [...topDirs][0];
|
||||||
|
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
|
||||||
|
root = dir + '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read manifest
|
||||||
|
const manifestFile = zip.file(root + 'manifest.json');
|
||||||
|
if (!manifestFile) {
|
||||||
|
return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(await manifestFile.async('string'));
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate manifest
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
|
||||||
|
if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
|
||||||
|
if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
|
||||||
|
if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
|
||||||
|
if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') errors.push('Missing or invalid "entrypoint"');
|
||||||
|
|
||||||
|
const validTypes = ['ui-extension', 'sidebar-app', 'hook'];
|
||||||
|
if (!validTypes.includes(manifest.type as string)) {
|
||||||
|
errors.push(`Invalid type. Must be one of: ${validTypes.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
|
||||||
|
errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.permissions && Array.isArray(manifest.permissions)) {
|
||||||
|
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
||||||
|
const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p));
|
||||||
|
if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file extensions
|
||||||
|
for (const [filePath, entry] of Object.entries(zip.files)) {
|
||||||
|
if (entry.dir) continue;
|
||||||
|
const ext = filePath.lastIndexOf('.') >= 0 ? filePath.slice(filePath.lastIndexOf('.')).toLowerCase() : '';
|
||||||
|
if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) {
|
||||||
|
errors.push(`Disallowed file type: ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read entrypoint code
|
||||||
|
const entryFile = zip.file(root + (manifest.entrypoint as string));
|
||||||
|
if (!entryFile) {
|
||||||
|
return NextResponse.json({ error: `Missing entrypoint: ${manifest.entrypoint}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
const code = await entryFile.async('string');
|
||||||
|
|
||||||
|
// Security warnings (logged but not blocking for admin)
|
||||||
|
const warnings: string[] = [];
|
||||||
|
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||||
|
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const plugin: ServerPlugin = {
|
||||||
|
id: manifest.id as string,
|
||||||
|
name: manifest.name as string,
|
||||||
|
version: manifest.version as string,
|
||||||
|
author: manifest.author as string,
|
||||||
|
description: (manifest.description as string) || '',
|
||||||
|
type: manifest.type as string,
|
||||||
|
permissions: (manifest.permissions as string[]) || [],
|
||||||
|
entrypoint: manifest.entrypoint as string,
|
||||||
|
enabled: true,
|
||||||
|
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||||
|
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||||
|
: {}),
|
||||||
|
installedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await savePlugin(plugin, code);
|
||||||
|
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, warnings }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ plugin, warnings });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/admin/plugins — Update plugin metadata (enable/disable)
|
||||||
|
* Body: { id: string, enabled: boolean }
|
||||||
|
*/
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { id, enabled, forceEnabled } = await request.json();
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') {
|
||||||
|
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
|
||||||
|
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
||||||
|
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
||||||
|
|
||||||
|
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
|
||||||
|
const updated = await updatePluginMeta(id, updates);
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog('plugin.update', { id, ...updates }, ip);
|
||||||
|
return NextResponse.json({ plugin: updated });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Plugin update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/plugins — Remove a plugin
|
||||||
|
* Body: { id: string }
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { id } = await request.json();
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = await removePlugin(id);
|
||||||
|
if (!removed) {
|
||||||
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog('plugin.delete', { id }, ip);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Plugin delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/policy — Get settings policy (NOT admin-protected — users read this)
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const policy = configManager.getPolicy();
|
||||||
|
return NextResponse.json(policy, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Policy read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/admin/policy — Update settings policy (admin-protected)
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const policy = await request.json() as SettingsPolicy;
|
||||||
|
|
||||||
|
if (!policy || typeof policy !== 'object') {
|
||||||
|
return NextResponse.json({ error: 'Invalid policy object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (policy.restrictions && typeof policy.restrictions !== 'object') {
|
||||||
|
return NextResponse.json({ error: 'restrictions must be an object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (policy.features && typeof policy.features !== 'object') {
|
||||||
|
return NextResponse.json({ error: 'features must be an object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (policy.themePolicy && typeof policy.themePolicy !== 'object') {
|
||||||
|
return NextResponse.json({ error: 'themePolicy must be an object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await configManager.setPolicy(policy);
|
||||||
|
await auditLog('policy.update', { restrictionCount: Object.keys(policy.restrictions || {}).length }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Policy update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/stalwart-check
|
||||||
|
* Check if the currently logged-in user is a Stalwart admin.
|
||||||
|
* Probes the admin-only principal-list endpoint — if the user can access it, they're an admin.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const creds = await getStalwartCredentials(request);
|
||||||
|
if (!creds) {
|
||||||
|
return NextResponse.json({ isStalwartAdmin: false }, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe an admin-only endpoint: listing principals requires admin privileges.
|
||||||
|
// Use limit=1 to minimize payload.
|
||||||
|
const url = `${creds.apiUrl}/api/principal?limit=1`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Authorization': creds.authHeader },
|
||||||
|
});
|
||||||
|
|
||||||
|
const isStalwartAdmin = response.ok;
|
||||||
|
logger.info('Stalwart admin check', { username: creds.username, status: response.status, isStalwartAdmin });
|
||||||
|
|
||||||
|
return NextResponse.json({ isStalwartAdmin }, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Stalwart admin check error', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ isStalwartAdmin: false }, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/themes/[id]/css — Serve theme CSS to clients
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Validate ID format
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid theme ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify theme exists and is enabled
|
||||||
|
const registry = await getThemeRegistry();
|
||||||
|
const theme = registry.themes.find(t => t.id === id);
|
||||||
|
if (!theme) {
|
||||||
|
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!theme.enabled) {
|
||||||
|
return NextResponse.json({ error: 'Theme is disabled' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const css = await getThemeCSS(id);
|
||||||
|
if (!css) {
|
||||||
|
return NextResponse.json({ error: 'Theme CSS not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(css, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Theme CSS serve error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import {
|
||||||
|
getThemeRegistry,
|
||||||
|
saveTheme,
|
||||||
|
deleteTheme as removeTheme,
|
||||||
|
type ServerTheme,
|
||||||
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||||
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/themes — List all admin-managed themes
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const registry = await getThemeRegistry();
|
||||||
|
return NextResponse.json(registry.themes, {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Theme list error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/themes — Upload and install a theme ZIP
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_THEME_SIZE) {
|
||||||
|
return NextResponse.json({ error: 'Theme ZIP exceeds 1 MB size limit' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and validate ZIP
|
||||||
|
let zip: JSZip;
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
zip = await JSZip.loadAsync(buffer);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find root
|
||||||
|
const entries = Object.keys(zip.files);
|
||||||
|
const topDirs = new Set(entries.map(e => e.split('/')[0]));
|
||||||
|
let root = '';
|
||||||
|
if (topDirs.size === 1) {
|
||||||
|
const dir = [...topDirs][0];
|
||||||
|
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
|
||||||
|
root = dir + '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read manifest
|
||||||
|
const manifestFile = zip.file(root + 'manifest.json');
|
||||||
|
if (!manifestFile) {
|
||||||
|
return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(await manifestFile.async('string'));
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate manifest
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
|
||||||
|
if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
|
||||||
|
if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
|
||||||
|
if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
|
||||||
|
|
||||||
|
if (manifest.type !== 'theme') {
|
||||||
|
errors.push(`Expected type "theme", got "${manifest.type}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
|
||||||
|
errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) {
|
||||||
|
errors.push('Missing or empty "variants" array');
|
||||||
|
} else {
|
||||||
|
const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark');
|
||||||
|
if (!valid) errors.push('Variants must be "light" or "dark"');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read theme.css
|
||||||
|
const cssFile = zip.file(root + 'theme.css');
|
||||||
|
if (!cssFile) {
|
||||||
|
return NextResponse.json({ error: 'Missing theme.css' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let css = await cssFile.async('string');
|
||||||
|
|
||||||
|
// Validate and sanitize CSS
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const safety = validateThemeCSSSafety(css);
|
||||||
|
if (!safety.valid) {
|
||||||
|
const sanitized = sanitizeThemeCSS(css);
|
||||||
|
css = sanitized.css;
|
||||||
|
warnings.push(...sanitized.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const theme: ServerTheme = {
|
||||||
|
id: manifest.id as string,
|
||||||
|
name: manifest.name as string,
|
||||||
|
version: manifest.version as string,
|
||||||
|
author: manifest.author as string,
|
||||||
|
description: (manifest.description as string) || '',
|
||||||
|
variants: manifest.variants as string[],
|
||||||
|
enabled: true,
|
||||||
|
installedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveTheme(theme, css);
|
||||||
|
await auditLog('theme.install', { id: theme.id, name: theme.name, version: theme.version, warnings }, ip);
|
||||||
|
|
||||||
|
return NextResponse.json({ theme, warnings });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Theme install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/admin/themes — Update theme metadata (enable/disable)
|
||||||
|
* Body: { id: string, enabled: boolean }
|
||||||
|
*/
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { id, enabled, forceEnabled } = await request.json();
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing theme id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') {
|
||||||
|
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
|
||||||
|
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
||||||
|
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
||||||
|
|
||||||
|
const { updateThemeMeta } = await import('@/lib/admin/plugin-registry');
|
||||||
|
const updated = await updateThemeMeta(id, updates);
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog('theme.update', { id, ...updates }, ip);
|
||||||
|
return NextResponse.json({ theme: updated });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Theme update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/themes — Remove a theme
|
||||||
|
* Body: { id: string }
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth();
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const { id } = await request.json();
|
||||||
|
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing theme id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = await removeTheme(id);
|
||||||
|
if (!removed) {
|
||||||
|
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog('theme.delete', { id }, ip);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Theme delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,45 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
|
||||||
|
const { serverUrl, username } = credentials;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ serverUrl, username },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Session read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT — retrieve full credentials (including password) for session restoration.
|
||||||
|
* Protected by Sec-Fetch-Site to ensure only same-origin browser requests succeed.
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Block non-browser and cross-origin requests
|
||||||
|
const secFetchSite = request.headers.get('sec-fetch-site');
|
||||||
|
if (secFetchSite !== 'same-origin') {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = getSlot(request);
|
||||||
|
const cookieName = sessionCookieName(slot);
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(cookieName)?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = decryptSession(token);
|
||||||
|
if (!credentials) {
|
||||||
|
cookieStore.delete(cookieName);
|
||||||
|
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(credentials, {
|
return NextResponse.json(credentials, {
|
||||||
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
|
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
|
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||||
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||||
|
*
|
||||||
|
* This allows 2FA users who log in with basic auth + TOTP to upgrade
|
||||||
|
* to token-based auth, avoiding session expiry when the TOTP rotates.
|
||||||
|
*
|
||||||
|
* Tries three strategies:
|
||||||
|
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
|
||||||
|
* 2. ROPC grant without client_id
|
||||||
|
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function tryTokenRequest(
|
||||||
|
tokenEndpoint: string,
|
||||||
|
params: URLSearchParams,
|
||||||
|
extraHeaders?: Record<string, string>,
|
||||||
|
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
|
||||||
|
const response = await fetch(tokenEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await response.json();
|
||||||
|
if (!tokens.access_token) {
|
||||||
|
return { ok: false, status: 502, error: 'Response missing access_token' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, tokens };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||||
|
// 1. Try OAuth discovery
|
||||||
|
const metadata = await discoverOAuth(serverUrl);
|
||||||
|
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||||
|
|
||||||
|
// 2. Try common Stalwart token endpoint paths directly
|
||||||
|
const candidates = [
|
||||||
|
`${serverUrl}/auth/token`,
|
||||||
|
`${serverUrl}/api/oauth/token`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const url of candidates) {
|
||||||
|
try {
|
||||||
|
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
|
||||||
|
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
|
||||||
|
if (probe.status !== 404 && probe.status !== 405) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network error — endpoint not reachable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { serverUrl, username, password, slot: bodySlot } = await request.json();
|
||||||
|
|
||||||
|
if (!serverUrl || !username || !password) {
|
||||||
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||||
|
|
||||||
|
// Use the server-side JMAP_SERVER_URL if set (may differ from the
|
||||||
|
// public URL the browser uses, e.g. inside Docker).
|
||||||
|
const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl;
|
||||||
|
|
||||||
|
const tokenEndpoint = await findTokenEndpoint(internalServerUrl);
|
||||||
|
if (!tokenEndpoint) {
|
||||||
|
// Also try with the client-provided URL in case the internal one differs
|
||||||
|
const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null;
|
||||||
|
if (!clientEndpoint) {
|
||||||
|
logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl });
|
||||||
|
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||||
|
}
|
||||||
|
return await attemptAllStrategies(clientEndpoint, username, password, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await attemptAllStrategies(tokenEndpoint, username, password, slot);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attemptAllStrategies(
|
||||||
|
tokenEndpoint: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
slot: number,
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||||
|
|
||||||
|
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
|
||||||
|
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
|
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||||
|
|
||||||
|
// Strategy 1: ROPC with client_id (if configured)
|
||||||
|
if (clientId) {
|
||||||
|
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
|
||||||
|
if (clientSecret) params.set('client_secret', clientSecret);
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: ROPC without client_id
|
||||||
|
{
|
||||||
|
const params = new URLSearchParams({ grant_type: 'password', username, password });
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
|
||||||
|
{
|
||||||
|
const params = new URLSearchParams({ grant_type: 'password' });
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 4: client_credentials with Basic Auth (last resort)
|
||||||
|
{
|
||||||
|
const params = new URLSearchParams({ grant_type: 'client_credentials' });
|
||||||
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||||
|
return await storeAndRespond(result.tokens, slot);
|
||||||
|
}
|
||||||
|
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('TOTP token exchange: all strategies failed', { attempts });
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'token_exchange_failed',
|
||||||
|
detail: 'All token exchange strategies failed',
|
||||||
|
attempts,
|
||||||
|
}, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeAndRespond(
|
||||||
|
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||||
|
slot: number,
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
expires_in: tokens.expires_in || 3600,
|
||||||
|
has_refresh_token: !!tokens.refresh_token,
|
||||||
|
});
|
||||||
|
}
|
||||||
+39
-27
@@ -1,43 +1,55 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime configuration endpoint
|
* Runtime configuration endpoint
|
||||||
*
|
*
|
||||||
* This endpoint serves configuration values that can be set at runtime
|
* This endpoint serves configuration values that can be set at runtime
|
||||||
* via environment variables, enabling post-build configuration for
|
* via environment variables or admin dashboard overrides, enabling
|
||||||
* Docker deployments.
|
* post-build configuration for Docker deployments.
|
||||||
*
|
*
|
||||||
* Priority order:
|
* Priority order:
|
||||||
* 1. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
* 1. Admin dashboard overrides (data/admin/config.json)
|
||||||
* 2. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
* 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
||||||
* 3. Default values
|
* 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
||||||
|
* 4. Default values
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
logger.debug('Config requested');
|
logger.debug('Config requested');
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
|
||||||
|
const appName = configManager.get<string>('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||||
|
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
|
||||||
|
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||||
|
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
|
||||||
|
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
|
||||||
|
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
appName: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail',
|
appName,
|
||||||
jmapServerUrl: process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '',
|
jmapServerUrl,
|
||||||
oauthEnabled: process.env.OAUTH_ENABLED === 'true',
|
oauthEnabled,
|
||||||
oauthOnly: process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true',
|
oauthOnly,
|
||||||
oauthClientId: process.env.OAUTH_CLIENT_ID || '',
|
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||||
oauthIssuerUrl: process.env.OAUTH_ISSUER_URL || '',
|
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||||
rememberMeEnabled: !!process.env.SESSION_SECRET,
|
rememberMeEnabled: !!process.env.SESSION_SECRET,
|
||||||
settingsSyncEnabled: process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET,
|
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && !!process.env.SESSION_SECRET,
|
||||||
stalwartFeaturesEnabled: process.env.STALWART_FEATURES !== 'false',
|
stalwartFeaturesEnabled,
|
||||||
devMode: process.env.DEV_MOCK_JMAP === 'true',
|
devMode: configManager.get<boolean>('devMode', false),
|
||||||
faviconUrl: process.env.FAVICON_URL || '/branding/Bulwark_Favicon.svg',
|
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||||
appLogoLightUrl: process.env.APP_LOGO_LIGHT_URL || '',
|
appLogoLightUrl: configManager.get<string>('appLogoLightUrl', ''),
|
||||||
appLogoDarkUrl: process.env.APP_LOGO_DARK_URL || '',
|
appLogoDarkUrl: configManager.get<string>('appLogoDarkUrl', ''),
|
||||||
loginLogoLightUrl: process.env.LOGIN_LOGO_LIGHT_URL || '/branding/Bulwark_Logo_Color.svg',
|
loginLogoLightUrl: configManager.get<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||||
loginLogoDarkUrl: process.env.LOGIN_LOGO_DARK_URL || '/branding/Bulwark_Logo_White.svg',
|
loginLogoDarkUrl: configManager.get<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||||
loginCompanyName: process.env.LOGIN_COMPANY_NAME || '',
|
loginCompanyName: configManager.get<string>('loginCompanyName', ''),
|
||||||
loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '',
|
loginImprintUrl: configManager.get<string>('loginImprintUrl', ''),
|
||||||
loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '',
|
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
||||||
loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '',
|
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||||
demoMode: process.env.DEMO_MODE === 'true',
|
demoMode: configManager.get<boolean>('demoMode', false),
|
||||||
autoSsoEnabled: process.env.AUTO_SSO_ENABLED === 'true',
|
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||||
embeddedMode: !!process.env.ALLOWED_FRAME_ANCESTORS && process.env.ALLOWED_FRAME_ANCESTORS !== "'none'",
|
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||||
parentOrigin: process.env.NEXT_PUBLIC_PARENT_ORIGIN || '',
|
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||||
|
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ function localDateTime(dayOffset: number, hour: number, minute: number): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a JMAP duration like "PT1H30M", "P1D", "PT45M" into milliseconds. */
|
/** Parse a JMAP duration like "PT1H30M", "P1D", "PT45M" into milliseconds. */
|
||||||
function parseDurationMs(dur: string): number {
|
function _parseDurationMs(dur: string): number {
|
||||||
let ms = 0;
|
let ms = 0;
|
||||||
const dayMatch = dur.match(/(\d+)D/);
|
const dayMatch = dur.match(/(\d+)D/);
|
||||||
const hourMatch = dur.match(/(\d+)H/);
|
const hourMatch = dur.match(/(\d+)H/);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
-16
@@ -4,6 +4,47 @@ import { logger } from '@/lib/logger';
|
|||||||
import { decryptSession } from '@/lib/auth/crypto';
|
import { decryptSession } from '@/lib/auth/crypto';
|
||||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
|
function classifyError(error: unknown): { message: string; status: number } {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
|
switch (code) {
|
||||||
|
case 'EACCES':
|
||||||
|
case 'EPERM':
|
||||||
|
return {
|
||||||
|
message: 'Write permission denied on settings data directory. Check filesystem permissions for the SETTINGS_DATA_DIR (or data/settings/).',
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
case 'EROFS':
|
||||||
|
return {
|
||||||
|
message: 'Filesystem is read-only. Settings cannot be saved. Ensure the data directory is on a writable volume.',
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
case 'ENOSPC':
|
||||||
|
return {
|
||||||
|
message: 'No disk space available to save settings.',
|
||||||
|
status: 507,
|
||||||
|
};
|
||||||
|
case 'ENOENT':
|
||||||
|
return {
|
||||||
|
message: 'Settings data directory does not exist and could not be created. Check SETTINGS_DATA_DIR configuration.',
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
if (msg.includes('SESSION_SECRET')) {
|
||||||
|
return {
|
||||||
|
message: 'Server configuration error: SESSION_SECRET is not set.',
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message: `Internal server error: ${msg}`,
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isEnabled(): boolean {
|
function isEnabled(): boolean {
|
||||||
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET;
|
return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET;
|
||||||
@@ -12,16 +53,14 @@ function isEnabled(): boolean {
|
|||||||
/**
|
/**
|
||||||
* Verify identity against session cookies across all account slots.
|
* Verify identity against session cookies across all account slots.
|
||||||
* With multi-account, the requesting account may be on any slot (0-4).
|
* With multi-account, the requesting account may be on any slot (0-4).
|
||||||
* Returns true if any slot matches OR if no session cookies exist at all.
|
* Returns true only if a matching session cookie is found.
|
||||||
*/
|
*/
|
||||||
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
async function verifyIdentity(username: string, serverUrl: string): Promise<boolean> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
let hasAnyCookie = false;
|
|
||||||
|
|
||||||
for (let slot = 0; slot <= 4; slot++) {
|
for (let slot = 0; slot <= 4; slot++) {
|
||||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||||
if (!token) continue;
|
if (!token) continue;
|
||||||
hasAnyCookie = true;
|
|
||||||
|
|
||||||
const session = decryptSession(token);
|
const session = decryptSession(token);
|
||||||
if (session && session.username === username && session.serverUrl === serverUrl) {
|
if (session && session.username === username && session.serverUrl === serverUrl) {
|
||||||
@@ -29,10 +68,7 @@ async function verifyIdentity(username: string, serverUrl: string): Promise<bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No cookies at all → can't verify, allow (same-origin protection applies)
|
// No matching session found (or no cookies at all) → reject
|
||||||
if (!hasAnyCookie) return true;
|
|
||||||
|
|
||||||
// Cookies exist but none matched → identity mismatch
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,15 +89,13 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = await loadUserSettings(username, serverUrl);
|
const settings = await loadUserSettings(username, serverUrl);
|
||||||
if (!settings) {
|
return NextResponse.json({ settings: settings || null });
|
||||||
return NextResponse.json({ error: 'No settings found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
return NextResponse.json({ settings });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,13 +118,41 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveUserSettings(username, serverUrl, settings);
|
// Enforce admin policy — strip locked settings so users can't override them
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const policy = configManager.getPolicy();
|
||||||
|
const filteredSettings = { ...settings };
|
||||||
|
for (const key of Object.keys(filteredSettings)) {
|
||||||
|
const restriction = policy.restrictions[key];
|
||||||
|
if (!restriction) continue;
|
||||||
|
if (restriction.locked) {
|
||||||
|
delete filteredSettings[key];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = filteredSettings[key];
|
||||||
|
if (restriction.allowedValues && restriction.allowedValues.length > 0) {
|
||||||
|
if (!restriction.allowedValues.includes(value)) {
|
||||||
|
delete filteredSettings[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (restriction.min !== undefined && value < restriction.min) {
|
||||||
|
delete filteredSettings[key];
|
||||||
|
}
|
||||||
|
if (restriction.max !== undefined && value > restriction.max) {
|
||||||
|
delete filteredSettings[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveUserSettings(username, serverUrl, filteredSettings);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +174,10 @@ export async function DELETE(request: NextRequest) {
|
|||||||
await deleteUserSettings(username, serverUrl);
|
await deleteUserSettings(username, serverUrl);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Settings delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
logger.error('Settings delete error', { error: message, code });
|
||||||
|
const classified = classifyError(error);
|
||||||
|
return NextResponse.json({ error: classified.message }, { status: classified.status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+130
-47
@@ -23,6 +23,39 @@
|
|||||||
--color-popover: #ffffff;
|
--color-popover: #ffffff;
|
||||||
--color-popover-foreground: #0f172a;
|
--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;
|
||||||
--list-item-height: 48px;
|
--list-item-height: 48px;
|
||||||
@@ -54,6 +87,39 @@
|
|||||||
--color-destructive-foreground: #fafafa;
|
--color-destructive-foreground: #fafafa;
|
||||||
--color-popover: #1c1c1c;
|
--color-popover: #1c1c1c;
|
||||||
--color-popover-foreground: #fafafa;
|
--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 {
|
||||||
@@ -74,6 +140,27 @@
|
|||||||
--color-destructive-foreground: var(--color-destructive-foreground);
|
--color-destructive-foreground: var(--color-destructive-foreground);
|
||||||
--color-popover: var(--color-popover);
|
--color-popover: var(--color-popover);
|
||||||
--color-popover-foreground: var(--color-popover-foreground);
|
--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);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -163,21 +250,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.email-content blockquote {
|
.email-content blockquote {
|
||||||
border-left: 3px solid #d1d5db;
|
border-left: 3px solid var(--color-border);
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
color: #4b5563;
|
color: var(--color-muted-foreground);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .email-content blockquote {
|
|
||||||
border-left-color: #4b5563;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-content pre {
|
.email-content pre {
|
||||||
background-color: #f5f5f7;
|
background-color: var(--color-muted);
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
|
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
|
||||||
@@ -186,23 +268,13 @@ body {
|
|||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .email-content pre {
|
|
||||||
background-color: #1f2937;
|
|
||||||
border-color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-content code {
|
.email-content code {
|
||||||
background-color: #f3f4f6;
|
background-color: var(--color-muted);
|
||||||
padding: 0.125rem 0.375rem;
|
padding: 0.125rem 0.375rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
|
font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #dc2626;
|
color: var(--color-destructive);
|
||||||
}
|
|
||||||
|
|
||||||
.dark .email-content code {
|
|
||||||
background-color: #374151;
|
|
||||||
color: #f87171;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-content h1,
|
.email-content h1,
|
||||||
@@ -282,26 +354,15 @@ body {
|
|||||||
.email-content table[border="1"] th,
|
.email-content table[border="1"] th,
|
||||||
.email-content table[border="1"] td {
|
.email-content table[border="1"] td {
|
||||||
padding: 0.625rem;
|
padding: 0.625rem;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--color-border);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-content table.data-table th,
|
.email-content table.data-table th,
|
||||||
.email-content table[border="1"] th {
|
.email-content table[border="1"] th {
|
||||||
background-color: #f9fafb;
|
background-color: var(--color-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #374151;
|
color: var(--color-foreground);
|
||||||
}
|
|
||||||
|
|
||||||
.dark .email-content table.data-table th,
|
|
||||||
.dark .email-content table[border="1"] th {
|
|
||||||
background-color: #1f2937;
|
|
||||||
color: #d1d5db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .email-content table.data-table td,
|
|
||||||
.dark .email-content table[border="1"] td {
|
|
||||||
border-color: #374151;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reset styles for layout tables (commonly used in HTML emails) */
|
/* Reset styles for layout tables (commonly used in HTML emails) */
|
||||||
@@ -323,28 +384,19 @@ body {
|
|||||||
|
|
||||||
.email-content hr {
|
.email-content hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid var(--color-border);
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .email-content hr {
|
|
||||||
border-top-color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Email thread quoted text */
|
/* Email thread quoted text */
|
||||||
.email-content .quoted-text {
|
.email-content .quoted-text {
|
||||||
border-left: 3px solid #d1d5db;
|
border-left: 3px solid var(--color-border);
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
color: #4b5563;
|
color: var(--color-muted-foreground);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .email-content .quoted-text {
|
|
||||||
border-left-color: #4b5563;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toast animations */
|
/* Toast animations */
|
||||||
@keyframes toast-enter {
|
@keyframes toast-enter {
|
||||||
from {
|
from {
|
||||||
@@ -477,6 +529,37 @@ body {
|
|||||||
animation: slide-in-from-left 0.3s ease-out;
|
animation: slide-in-from-left 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Calendar swipe animations (subtler slide for view transitions) */
|
||||||
|
@keyframes cal-slide-in-right {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cal-slide-in-left {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-30%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-right {
|
||||||
|
animation: cal-slide-in-right 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-left {
|
||||||
|
animation: cal-slide-in-left 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reduced motion: respect user OS preference */
|
/* Reduced motion: respect user OS preference */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
|
|||||||
+9
-2
@@ -8,12 +8,19 @@ export default function NotFound() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
window.location.href = "/login";
|
// 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]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return null;
|
// 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 (
|
return (
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { useMemo, useRef, useEffect, useCallback } 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, startOfDay } 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";
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export function CalendarAgendaView({
|
|||||||
|
|
||||||
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[] = [];
|
||||||
@@ -144,9 +144,9 @@ export function CalendarAgendaView({
|
|||||||
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;
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { useTranslations, useFormatter } from "next-intl";
|
|||||||
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
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, CalendarTask } 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";
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
@@ -66,7 +66,7 @@ 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 */ }
|
||||||
});
|
});
|
||||||
@@ -127,10 +127,10 @@ export function CalendarDayView({
|
|||||||
return format(new Date(2000, 0, 1, h), "HH:mm");
|
return format(new Date(2000, 0, 1, h), "HH:mm");
|
||||||
};
|
};
|
||||||
|
|
||||||
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
|
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents, selectedDate), [timedEvents, selectedDate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
<div className="flex min-h-0 flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
||||||
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
|
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
|
||||||
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
||||||
{isMobile
|
{isMobile
|
||||||
@@ -182,7 +182,7 @@ export function CalendarDayView({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 w-3.5 h-3.5 rounded-full border flex items-center justify-center",
|
"flex-shrink-0 w-3.5 h-3.5 rounded-full border flex items-center justify-center",
|
||||||
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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -200,7 +200,7 @@ export function CalendarDayView({
|
|||||||
</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) => (
|
||||||
@@ -241,11 +241,9 @@ export function CalendarDayView({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const start = parseISO(ev.start);
|
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
|
||||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
|
||||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||||
const calId = getPrimaryCalendarId(ev);
|
const calId = getPrimaryCalendarId(ev);
|
||||||
@@ -288,8 +286,8 @@ export function CalendarDayView({
|
|||||||
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-red-500 -ml-1" />
|
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ml-1" />
|
||||||
<div className="flex-1 h-px bg-red-500" />
|
<div className="flex-1 h-px bg-destructive" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -121,13 +121,14 @@ export function CalendarMonthView({
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(json);
|
const data = JSON.parse(json);
|
||||||
const originalStart = parseISO(data.originalStart);
|
const originalStart = parseISO(data.originalStart);
|
||||||
|
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
|
||||||
|
const isAllDay = event?.showWithoutTime;
|
||||||
const newStart = new Date(day);
|
const newStart = new Date(day);
|
||||||
newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0);
|
newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0);
|
||||||
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
|
const newStartISO = isAllDay ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
|
||||||
if (newStartISO === data.originalStart) return;
|
if (newStartISO === data.originalStart) return;
|
||||||
const client = useAuthStore.getState().client;
|
const client = useAuthStore.getState().client;
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
|
|
||||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
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, ListTodo, Plus, RefreshCw, Share2, Trash2 } from "lucide-react";
|
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2 } from "lucide-react";
|
||||||
import { cn, formatDateTime } from "@/lib/utils";
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
@@ -18,6 +18,7 @@ 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;
|
||||||
|
onEditSubscription?: (subscriptionId: string) => void;
|
||||||
client?: IJMAPClient | null;
|
client?: IJMAPClient | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export function CalendarSidebarPanel({
|
|||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
onSubscribe,
|
onSubscribe,
|
||||||
|
onEditSubscription,
|
||||||
client,
|
client,
|
||||||
}: CalendarSidebarPanelProps) {
|
}: CalendarSidebarPanelProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
@@ -173,6 +175,16 @@ export function CalendarSidebarPanel({
|
|||||||
ref={contextMenuRef}
|
ref={contextMenuRef}
|
||||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setContextMenuCalId(null);
|
||||||
|
onEditSubscription?.(sub.id);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
{tSub('edit')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRefreshSubscription(sub.id)}
|
onClick={() => handleRefreshSubscription(sub.id)}
|
||||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
||||||
|
|||||||
@@ -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, ListTodo } 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";
|
||||||
@@ -40,6 +40,7 @@ export function CalendarToolbar({
|
|||||||
onSubscribe,
|
onSubscribe,
|
||||||
isMobile,
|
isMobile,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
|
onNavigateBack,
|
||||||
calendars,
|
calendars,
|
||||||
selectedCalendarIds,
|
selectedCalendarIds,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
@@ -97,8 +98,7 @@ export function CalendarToolbar({
|
|||||||
|
|
||||||
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;
|
||||||
@@ -111,109 +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 && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="relative" ref={dropdownRef}>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
<button
|
||||||
<ChevronLeft className="w-4 h-4" />
|
onClick={() => setShowCalendarDropdown((v) => !v)}
|
||||||
</Button>
|
className={cn(
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
|
"p-1.5 rounded-md border border-border transition-colors touch-manipulation",
|
||||||
<ChevronRight className="w-4 h-4" />
|
showCalendarDropdown ? "bg-muted" : "hover:bg-muted"
|
||||||
</Button>
|
)}
|
||||||
<span className="text-base font-semibold ml-2 select-none">
|
aria-label={t("my_calendars")}
|
||||||
{getDateLabel()}
|
>
|
||||||
</span>
|
<CalendarDays className="w-4 h-4" />
|
||||||
</div>
|
</button>
|
||||||
)}
|
{showCalendarDropdown && (
|
||||||
|
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
|
||||||
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||||
<div className="relative" ref={dropdownRef}>
|
{t("my_calendars")}
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowCalendarDropdown((v) => !v)}
|
|
||||||
aria-label={t("my_calendars")}
|
|
||||||
className="touch-manipulation"
|
|
||||||
>
|
|
||||||
<CalendarDays className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
{showCalendarDropdown && (
|
|
||||||
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
|
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
|
||||||
{t("my_calendars")}
|
|
||||||
</h3>
|
|
||||||
<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 (
|
||||||
@@ -239,44 +206,76 @@ export function CalendarToolbar({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const shared = calendars.filter(c => c.isShared);
|
||||||
|
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
|
||||||
|
for (const c of shared) {
|
||||||
|
const key = c.accountId || c.accountName || c.id;
|
||||||
|
if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] });
|
||||||
|
groups.get(key)!.cals.push(c);
|
||||||
|
}
|
||||||
|
return Array.from(groups.values()).map((group) => (
|
||||||
|
<div key={group.accountName} className="mt-2">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
|
||||||
|
{group.accountName}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{group.cals.map((cal) => {
|
||||||
|
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||||
|
const color = cal.color || "#3b82f6";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cal.id}
|
||||||
|
onClick={() => onToggleVisibility(cal.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
|
||||||
|
"hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
|
||||||
|
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
|
||||||
|
)}
|
||||||
|
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
||||||
|
/>
|
||||||
|
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
||||||
|
{cal.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
));
|
)}
|
||||||
})()}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isMobile && (
|
{/* ── DESKTOP TOOLBAR ── */}
|
||||||
<div className="relative" ref={viewDropdownRef}>
|
{!isMobile && (
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
variant="outline"
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
|
||||||
size="sm"
|
<ChevronLeft className="w-4 h-4" />
|
||||||
onClick={() => setShowViewDropdown((v) => !v)}
|
|
||||||
className="touch-manipulation capitalize text-xs"
|
|
||||||
>
|
|
||||||
{t(`views.${viewMode}`)}
|
|
||||||
<ChevronLeft className="w-3 h-3 ml-1 rotate-[-90deg]" />
|
|
||||||
</Button>
|
</Button>
|
||||||
{showViewDropdown && (
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
|
||||||
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-1 min-w-[120px]">
|
<ChevronRight className="w-4 h-4" />
|
||||||
{views.map((v) => (
|
</Button>
|
||||||
<button
|
<span className="text-base font-semibold ml-2 select-none">
|
||||||
key={v}
|
{getDateLabel()}
|
||||||
onClick={() => { onViewModeChange(v); setShowViewDropdown(false); }}
|
</span>
|
||||||
className={cn(
|
|
||||||
"flex items-center w-full px-3 py-2 rounded-md text-sm transition-colors touch-manipulation",
|
|
||||||
v === viewMode ? "bg-primary text-primary-foreground" : "hover:bg-muted text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t(`views.${v}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
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, CalendarTask } 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";
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
@@ -53,16 +53,13 @@ export function CalendarWeekView({
|
|||||||
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>();
|
||||||
@@ -80,7 +77,7 @@ export function CalendarWeekView({
|
|||||||
const cursor = new Date(startDay);
|
const cursor = new Date(startDay);
|
||||||
while (cursor <= endDay) {
|
while (cursor <= endDay) {
|
||||||
const key = format(cursor, "yyyy-MM-dd");
|
const key = format(cursor, "yyyy-MM-dd");
|
||||||
if (!ev.showWithoutTime) {
|
if (!ev.showWithoutTime && !isTimedEventFullDayOnDate(ev, cursor)) {
|
||||||
const arr = timed.get(key) || [];
|
const arr = timed.get(key) || [];
|
||||||
arr.push(ev);
|
arr.push(ev);
|
||||||
timed.set(key, arr);
|
timed.set(key, arr);
|
||||||
@@ -92,10 +89,18 @@ 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);
|
||||||
@@ -137,6 +142,17 @@ export function CalendarWeekView({
|
|||||||
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(() => {
|
||||||
@@ -175,20 +191,28 @@ 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 + taskRowCount) * 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 + taskRowCount) * 24 + 4) }}
|
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||||
>
|
>
|
||||||
{weekDays.map((day) => (
|
{weekDays.map((day) => (
|
||||||
@@ -252,7 +276,7 @@ export function CalendarWeekView({
|
|||||||
>
|
>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"w-2.5 h-2.5 rounded-full border flex-shrink-0 flex items-center justify-center",
|
"w-2.5 h-2.5 rounded-full border flex-shrink-0 flex items-center justify-center",
|
||||||
isCompleted ? "bg-green-500 border-green-500" : "border-current"
|
isCompleted ? "bg-success border-success" : "border-current"
|
||||||
)}>
|
)}>
|
||||||
{isCompleted && <Check className="h-2 w-2 text-white" />}
|
{isCompleted && <Check className="h-2 w-2 text-white" />}
|
||||||
</span>
|
</span>
|
||||||
@@ -271,8 +295,8 @@ export function CalendarWeekView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex border-b border-border" role="row">
|
<div className="flex border-b border-border" role="row">
|
||||||
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "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);
|
||||||
@@ -305,9 +329,9 @@ export function CalendarWeekView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||||
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")}>
|
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
|
||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
@@ -323,12 +347,12 @@ export function CalendarWeekView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn("flex-1 border-l border-border relative", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
|
<div className="flex-1 border-l border-border relative grid grid-cols-7">
|
||||||
{weekDays.map((day) => {
|
{weekDays.map((day) => {
|
||||||
const key = format(day, "yyyy-MM-dd");
|
const key = format(day, "yyyy-MM-dd");
|
||||||
const dayEvents = timedEvents.get(key) || [];
|
const dayEvents = timedEvents.get(key) || [];
|
||||||
const todayCol = isToday(day);
|
const todayCol = isToday(day);
|
||||||
const layouted = layoutOverlappingEvents(dayEvents);
|
const layouted = layoutOverlappingEvents(dayEvents, day);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -355,11 +379,9 @@ export function CalendarWeekView({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const start = parseISO(ev.start);
|
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
|
||||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
|
||||||
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||||
const calId = getPrimaryCalendarId(ev);
|
const calId = getPrimaryCalendarId(ev);
|
||||||
@@ -402,8 +424,8 @@ export function CalendarWeekView({
|
|||||||
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-2 h-2 rounded-full bg-red-500 -ml-1" />
|
<div className="w-2 h-2 rounded-full bg-destructive -ml-1" />
|
||||||
<div className="flex-1 h-px bg-red-500" />
|
<div className="flex-1 h-px bg-destructive" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -480,5 +502,6 @@ export function CalendarWeekView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { useCallback, useState, type CSSProperties, type DragEvent } from "react
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Users } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
|
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface EventCardProps {
|
interface EventCardProps {
|
||||||
@@ -68,14 +69,14 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
const startDate = parseISO(event.start);
|
const startDate = getEventStartDate(event);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
const 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}` : ""}`;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { format, parseISO } from "date-fns";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
|
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
getUserParticipantId,
|
getUserParticipantId,
|
||||||
@@ -138,9 +139,9 @@ export function EventDetailPopover({
|
|||||||
const [isSavingNote, setIsSavingNote] = useState(false);
|
const [isSavingNote, setIsSavingNote] = useState(false);
|
||||||
|
|
||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
const startDate = parseISO(event.start);
|
const startDate = getEventStartDate(event);
|
||||||
const durationMinutes = parseDuration(event.duration);
|
const durationMinutes = parseDuration(event.duration);
|
||||||
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
const endDate = getEventEndDate(event);
|
||||||
|
|
||||||
const locationName = useMemo(() => {
|
const locationName = useMemo(() => {
|
||||||
if (!event.locations) return null;
|
if (!event.locations) return null;
|
||||||
@@ -297,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>
|
||||||
)}
|
)}
|
||||||
@@ -507,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" />}
|
||||||
@@ -520,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" />}
|
||||||
@@ -533,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" />}
|
||||||
@@ -548,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
|
||||||
@@ -611,9 +612,9 @@ function ParticipantStatusBadge({
|
|||||||
t: ReturnType<typeof useTranslations>;
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
accepted: "text-green-600 dark:text-green-400",
|
accepted: "text-success",
|
||||||
declined: "text-red-600 dark:text-red-400",
|
declined: "text-destructive",
|
||||||
tentative: "text-amber-600 dark:text-amber-400",
|
tentative: "text-warning",
|
||||||
"needs-action": "text-muted-foreground",
|
"needs-action": "text-muted-foreground",
|
||||||
};
|
};
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Vid
|
|||||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { buildAllDayDuration, getEventDisplayEndDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { ParticipantInput } from "./participant-input";
|
import { ParticipantInput } from "./participant-input";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
getStatusCounts,
|
getStatusCounts,
|
||||||
buildParticipantMap,
|
buildParticipantMap,
|
||||||
} from "@/lib/calendar-participants";
|
} from "@/lib/calendar-participants";
|
||||||
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
export interface PendingEventPreview {
|
export interface PendingEventPreview {
|
||||||
@@ -159,7 +160,7 @@ export function EventModal({
|
|||||||
}, [event, existingParticipants]);
|
}, [event, existingParticipants]);
|
||||||
|
|
||||||
const getInitialStart = (): Date => {
|
const getInitialStart = (): Date => {
|
||||||
if (event?.start) return parseISO(event.start);
|
if (event?.start) return getEventStartDate(event);
|
||||||
if (defaultDate) {
|
if (defaultDate) {
|
||||||
const d = new Date(defaultDate);
|
const d = new Date(defaultDate);
|
||||||
if (defaultEndDate) return d;
|
if (defaultEndDate) return d;
|
||||||
@@ -177,9 +178,7 @@ export function EventModal({
|
|||||||
if (event.showWithoutTime) {
|
if (event.showWithoutTime) {
|
||||||
return getEventDisplayEndDate(event);
|
return getEventDisplayEndDate(event);
|
||||||
}
|
}
|
||||||
const s = parseISO(event.start);
|
return getEventEndDate(event);
|
||||||
const dur = parseDuration(event.duration);
|
|
||||||
return new Date(s.getTime() + dur * 60000);
|
|
||||||
}
|
}
|
||||||
if (defaultEndDate) return new Date(defaultEndDate);
|
if (defaultEndDate) return new Date(defaultEndDate);
|
||||||
return addHours(getInitialStart(), 1);
|
return addHours(getInitialStart(), 1);
|
||||||
@@ -190,6 +189,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()));
|
||||||
@@ -265,7 +267,7 @@ export function EventModal({
|
|||||||
? `${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) {
|
||||||
@@ -315,6 +317,20 @@ export function EventModal({
|
|||||||
data.locations = null;
|
data.locations = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (virtualLocation.trim()) {
|
||||||
|
data.virtualLocations = {
|
||||||
|
vl1: {
|
||||||
|
"@type": "VirtualLocation",
|
||||||
|
name: null,
|
||||||
|
description: null,
|
||||||
|
uri: virtualLocation.trim(),
|
||||||
|
features: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (event && event.virtualLocations && Object.keys(event.virtualLocations).length > 0) {
|
||||||
|
data.virtualLocations = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (recurrence !== "none") {
|
if (recurrence !== "none") {
|
||||||
data.recurrenceRules = [{
|
data.recurrenceRules = [{
|
||||||
"@type": "RecurrenceRule",
|
"@type": "RecurrenceRule",
|
||||||
@@ -374,7 +390,7 @@ export function EventModal({
|
|||||||
} 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;
|
||||||
@@ -384,7 +400,7 @@ export function EventModal({
|
|||||||
|
|
||||||
const handleDuplicate = useCallback(() => {
|
const handleDuplicate = useCallback(() => {
|
||||||
if (!event || !onDuplicate) return;
|
if (!event || !onDuplicate) return;
|
||||||
const start = parseISO(event.start);
|
const start = getEventStartDate(event);
|
||||||
const newStart = addDays(start, 1);
|
const newStart = addDays(start, 1);
|
||||||
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
|
const newUid = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
@@ -393,7 +409,7 @@ export function EventModal({
|
|||||||
uid: newUid,
|
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,
|
||||||
@@ -403,6 +419,7 @@ export function EventModal({
|
|||||||
privacy: event.privacy,
|
privacy: event.privacy,
|
||||||
};
|
};
|
||||||
if (event.locations) data.locations = structuredClone(event.locations);
|
if (event.locations) data.locations = structuredClone(event.locations);
|
||||||
|
if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations);
|
||||||
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||||
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||||
if (event.participants) data.participants = structuredClone(event.participants);
|
if (event.participants) data.participants = structuredClone(event.participants);
|
||||||
@@ -456,9 +473,9 @@ export function EventModal({
|
|||||||
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
|
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
|
||||||
|
|
||||||
if (isAttendeeMode && event) {
|
if (isAttendeeMode && event) {
|
||||||
const startD = parseISO(event.start);
|
const startD = getEventStartDate(event);
|
||||||
const durMin = parseDuration(event.duration);
|
const durMin = parseDuration(event.duration);
|
||||||
const endD = new Date(startD.getTime() + durMin * 60000);
|
const endD = getEventEndDate(event);
|
||||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
|
||||||
const participants = getParticipantList(event);
|
const participants = getParticipantList(event);
|
||||||
|
|
||||||
@@ -530,8 +547,8 @@ export function EventModal({
|
|||||||
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
||||||
onClick={() => handleRsvp("accepted")}
|
onClick={() => handleRsvp("accepted")}
|
||||||
className={userCurrentStatus === "accepted"
|
className={userCurrentStatus === "accepted"
|
||||||
? "bg-green-600 hover:bg-green-700 text-white dark:bg-green-500 dark:hover:bg-green-600"
|
? "bg-success hover:bg-success/80 text-success-foreground"
|
||||||
: "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"}
|
: "text-success border-success/30 hover:bg-success/10"}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
|
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
|
||||||
{t("participants.accepted")}
|
{t("participants.accepted")}
|
||||||
@@ -541,8 +558,8 @@ export function EventModal({
|
|||||||
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
||||||
onClick={() => handleRsvp("tentative")}
|
onClick={() => handleRsvp("tentative")}
|
||||||
className={userCurrentStatus === "tentative"
|
className={userCurrentStatus === "tentative"
|
||||||
? "bg-amber-600 hover:bg-amber-700 text-white dark:bg-amber-500 dark:hover:bg-amber-600"
|
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
||||||
: "border border-amber-500 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950"}
|
: "border border-warning/30 text-warning hover:bg-warning/10"}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
|
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
|
||||||
{t("participants.tentative")}
|
{t("participants.tentative")}
|
||||||
@@ -552,8 +569,8 @@ export function EventModal({
|
|||||||
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
||||||
onClick={() => handleRsvp("declined")}
|
onClick={() => handleRsvp("declined")}
|
||||||
className={userCurrentStatus === "declined"
|
className={userCurrentStatus === "declined"
|
||||||
? "bg-red-600 hover:bg-red-700 text-white dark:bg-red-500 dark:hover:bg-red-600"
|
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
||||||
: "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"}
|
: "text-destructive hover:bg-destructive/10"}
|
||||||
>
|
>
|
||||||
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
|
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
|
||||||
{t("participants.declined")}
|
{t("participants.declined")}
|
||||||
@@ -567,9 +584,9 @@ export function EventModal({
|
|||||||
|
|
||||||
// View mode: read-only display of event details with Edit button
|
// View mode: read-only display of event details with Edit button
|
||||||
if (mode === "view" && event) {
|
if (mode === "view" && event) {
|
||||||
const startD = parseISO(event.start);
|
const startD = getEventStartDate(event);
|
||||||
const durMin = parseDuration(event.duration);
|
const durMin = parseDuration(event.duration);
|
||||||
const endD = new Date(startD.getTime() + durMin * 60000);
|
const endD = getEventEndDate(event);
|
||||||
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
|
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
|
||||||
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
||||||
const viewParticipants = getParticipantList(event);
|
const viewParticipants = getParticipantList(event);
|
||||||
@@ -701,8 +718,8 @@ export function EventModal({
|
|||||||
{onDelete && (
|
{onDelete && (
|
||||||
showDeleteConfirm ? (
|
showDeleteConfirm ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">{t("form.delete_confirm")}</span>
|
<span className="text-sm text-destructive">{t("form.delete_confirm")}</span>
|
||||||
<Button variant="outline" size="sm" onClick={() => { onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700">
|
<Button variant="outline" size="sm" onClick={() => { onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-destructive border-destructive/30">
|
||||||
{t("events.delete")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||||
@@ -710,7 +727,7 @@ export function EventModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-red-600 dark:text-red-400">
|
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-destructive">
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
{t("events.delete")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -780,6 +797,39 @@ export function EventModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-1 block">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Video className="w-4 h-4" />
|
||||||
|
{t("form.meeting_link")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={virtualLocation}
|
||||||
|
onChange={(e) => setVirtualLocation(e.target.value)}
|
||||||
|
placeholder="https://meet.example.com/..."
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
<PluginSlot
|
||||||
|
name="calendar-event-actions"
|
||||||
|
className="mt-2 flex flex-wrap gap-2"
|
||||||
|
extraProps={{
|
||||||
|
eventData: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
start: startDate + 'T' + startTime,
|
||||||
|
end: endDate + 'T' + endTime,
|
||||||
|
isAllDay: allDay,
|
||||||
|
location,
|
||||||
|
virtualLocation,
|
||||||
|
calendarId,
|
||||||
|
},
|
||||||
|
setVirtualLocation,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1 block">
|
<label className="text-sm font-medium mb-1 block">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
@@ -997,9 +1047,9 @@ function StatusBadge({ status, isOrganizer, t }: {
|
|||||||
return <span className="text-xs text-primary">{t("participants.organizer")}</span>;
|
return <span className="text-xs text-primary">{t("participants.organizer")}</span>;
|
||||||
}
|
}
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
accepted: "text-green-600 dark:text-green-400",
|
accepted: "text-success",
|
||||||
declined: "text-red-600 dark:text-red-400",
|
declined: "text-destructive",
|
||||||
tentative: "text-amber-600 dark:text-amber-400",
|
tentative: "text-warning",
|
||||||
"needs-action": "text-muted-foreground",
|
"needs-action": "text-muted-foreground",
|
||||||
};
|
};
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 { IJMAPClient } from '@/lib/jmap/client-interface';
|
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";
|
||||||
@@ -17,7 +18,7 @@ interface ICalImportModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
||||||
|
|
||||||
type ImportStep = "select" | "preview" | "importing";
|
type ImportStep = "select" | "preview" | "importing";
|
||||||
@@ -195,7 +196,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
const formatEventDate = (event: Partial<CalendarEvent>): string => {
|
const formatEventDate = (event: Partial<CalendarEvent>): string => {
|
||||||
if (!event.start) return "";
|
if (!event.start) return "";
|
||||||
try {
|
try {
|
||||||
const date = parseISO(event.start);
|
const date = getEventStartDate(event as CalendarEvent);
|
||||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||||
return event.showWithoutTime
|
return event.showWithoutTime
|
||||||
? format(date, "MMM d, yyyy")
|
? format(date, "MMM d, yyyy")
|
||||||
@@ -349,7 +350,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-md px-3 py-2">
|
<div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,24 +5,28 @@ 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 { IJMAPClient } from '@/lib/jmap/client-interface';
|
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: IJMAPClient;
|
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>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
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";
|
||||||
@@ -54,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]);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useMemo, useCallback, useState } 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, Plus } 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";
|
||||||
@@ -204,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")}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { X, Trash2, CalendarDays, Bell, Flag } from "lucide-react";
|
import { X, Trash2, CalendarDays, Bell, Flag } from "lucide-react";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types";
|
import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types";
|
||||||
|
|
||||||
interface TaskModalProps {
|
interface TaskModalProps {
|
||||||
@@ -43,7 +43,7 @@ export function TaskModal({
|
|||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
onClose,
|
onClose,
|
||||||
isMobile,
|
isMobile: _isMobile,
|
||||||
}: TaskModalProps) {
|
}: TaskModalProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const isEdit = !!task;
|
const isEdit = !!task;
|
||||||
@@ -97,7 +97,7 @@ export function TaskModal({
|
|||||||
due = `${dueDate}T${dueTime}:00`;
|
due = `${dueDate}T${dueTime}:00`;
|
||||||
showWithoutTime = false;
|
showWithoutTime = false;
|
||||||
} else {
|
} else {
|
||||||
due = `${dueDate}T00:00:00`;
|
due = dueDate;
|
||||||
showWithoutTime = true;
|
showWithoutTime = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe('ContactForm', () => {
|
|||||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
const inputs = screen.getAllByRole('textbox');
|
const _inputs = screen.getAllByRole('textbox');
|
||||||
const givenNameInput = screen.getByPlaceholderText('given_name');
|
const givenNameInput = screen.getByPlaceholderText('given_name');
|
||||||
fireEvent.change(givenNameInput, { target: { value: 'Jane' } });
|
fireEvent.change(givenNameInput, { target: { value: 'Jane' } });
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe('ContactListItem', () => {
|
|||||||
it('applies selected styling', () => {
|
it('applies selected styling', () => {
|
||||||
const { container } = render(<ContactListItem contact={contact} {...baseProps} isSelected={true} />);
|
const { container } = render(<ContactListItem contact={contact} {...baseProps} isSelected={true} />);
|
||||||
const div = container.firstElementChild;
|
const div = container.firstElementChild;
|
||||||
expect(div?.className).toContain('bg-blue-200');
|
expect(div?.className).toContain('bg-selection');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows email as display name when no name exists', () => {
|
it('shows email as display name when no name exists', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { ContactList } from '../contact-list';
|
import { ContactList } from '../contact-list';
|
||||||
import type { ContactCard } from '@/lib/jmap/types';
|
import type { ContactCard } from '@/lib/jmap/types';
|
||||||
@@ -22,7 +22,7 @@ const bob = makeContact({
|
|||||||
emails: { e0: { address: 'bob@example.com' } },
|
emails: { e0: { address: 'bob@example.com' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const group = makeContact({
|
const _group = makeContact({
|
||||||
id: '3',
|
id: '3',
|
||||||
kind: 'group',
|
kind: 'group',
|
||||||
name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true },
|
name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Link, Users, Briefcase, Heart, Languages, MessageCircle, User, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react";
|
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ export function ContactImportDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isDupe && (
|
{isDupe && (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-400 flex-shrink-0">
|
<span className="text-xs px-1.5 py-0.5 rounded bg-warning/15 text-warning flex-shrink-0">
|
||||||
{t("import.duplicate")}
|
{t("import.duplicate")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
|||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
|
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-blue-200 dark:bg-blue-900/50 shadow-sm"
|
? "bg-selection shadow-sm"
|
||||||
: "bg-background hover:bg-muted hover:shadow-sm",
|
: "bg-background hover:bg-muted hover:shadow-sm",
|
||||||
isChecked && !isSelected && "ring-2 ring-primary/20 bg-blue-100 dark:bg-blue-900/30",
|
isChecked && !isSelected && "ring-2 ring-primary/20 bg-selection/60",
|
||||||
)}
|
)}
|
||||||
style={{ gap: 'var(--density-item-gap)', paddingInline: '16px', paddingBlock: 'var(--density-item-py)' }}
|
style={{ gap: 'var(--density-item-gap)', paddingInline: '16px', paddingBlock: 'var(--density-item-py)' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ describe('EmailListItem tag badge', () => {
|
|||||||
useSettingsStore.setState({
|
useSettingsStore.setState({
|
||||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||||
showPreview: false,
|
showPreview: false,
|
||||||
|
mailLayout: 'split',
|
||||||
});
|
});
|
||||||
useEmailStore.setState({
|
useEmailStore.setState({
|
||||||
selectedEmailIds: new Set<string>(),
|
selectedEmailIds: new Set<string>(),
|
||||||
@@ -104,4 +105,17 @@ describe('EmailListItem tag badge', () => {
|
|||||||
render(<EmailListItem email={email} />);
|
render(<EmailListItem email={email} />);
|
||||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders inline preview text in focused mail layout', () => {
|
||||||
|
useSettingsStore.setState({
|
||||||
|
showPreview: true,
|
||||||
|
mailLayout: 'focus',
|
||||||
|
});
|
||||||
|
const email = makeEmail({ preview: 'Inline preview content' });
|
||||||
|
const { container } = render(<EmailListItem email={email} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('p')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -171,11 +171,11 @@ function getParticipationLabel(
|
|||||||
function getParticipationTone(status: string | null): string {
|
function getParticipationTone(status: string | null): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'accepted':
|
case 'accepted':
|
||||||
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
|
return 'bg-success/15 text-success';
|
||||||
case 'tentative':
|
case 'tentative':
|
||||||
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
|
return 'bg-warning/15 text-warning';
|
||||||
case 'declined':
|
case 'declined':
|
||||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
|
return 'bg-destructive/15 text-destructive';
|
||||||
default:
|
default:
|
||||||
return 'bg-muted text-muted-foreground';
|
return 'bg-muted text-muted-foreground';
|
||||||
}
|
}
|
||||||
@@ -731,9 +731,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
|
|
||||||
if (state === 'error') {
|
if (state === 'error') {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-red-200 dark:border-red-900/50 bg-red-50/50 dark:bg-red-950/20 px-4 py-3 flex items-center gap-2.5">
|
<div className="rounded-lg border border-destructive/20 bg-destructive/10 px-4 py-3 flex items-center gap-2.5">
|
||||||
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
|
<AlertCircle className="w-4 h-4 text-destructive flex-shrink-0" />
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">{t('parse_error')}</span>
|
<span className="text-sm text-destructive">{t('parse_error')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -744,7 +744,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b border-border">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{isCancellation ? (
|
{isCancellation ? (
|
||||||
<CalendarX className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
|
<CalendarX className="w-4 h-4 text-destructive flex-shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<Calendar className="w-4 h-4 text-primary flex-shrink-0" />
|
<Calendar className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
@@ -808,19 +808,19 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
<span className={cn(
|
<span className={cn(
|
||||||
"rounded-full px-2 py-0.5 text-[11px] font-medium",
|
"rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||||
parsedEvent.status === 'cancelled'
|
parsedEvent.status === 'cancelled'
|
||||||
? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
? "bg-destructive/15 text-destructive"
|
||||||
: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
: "bg-warning/15 text-warning"
|
||||||
)}>
|
)}>
|
||||||
{t(`event_status_${parsedEvent.status}`)}
|
{t(`event_status_${parsedEvent.status}`)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{existingEvent && (
|
{existingEvent && (
|
||||||
<span className="rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
<span className="rounded-full bg-success/15 px-2 py-0.5 text-[11px] font-medium text-success">
|
||||||
{t('already_in_calendar')}
|
{t('already_in_calendar')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{userIsOrganizer && (
|
{userIsOrganizer && (
|
||||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
<span className="rounded-full bg-info/15 px-2 py-0.5 text-[11px] font-medium text-info">
|
||||||
{t('organizer_role')}
|
{t('organizer_role')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -833,7 +833,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{actionFeedback && (
|
{actionFeedback && (
|
||||||
<span className="rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
<span className="rounded-full bg-success/15 px-2 py-0.5 text-[11px] font-medium text-success">
|
||||||
{actionFeedback}
|
{actionFeedback}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -877,8 +877,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
<div className={cn(
|
<div className={cn(
|
||||||
'flex items-start gap-1.5 text-xs rounded-md px-3 py-2',
|
'flex items-start gap-1.5 text-xs rounded-md px-3 py-2',
|
||||||
trustAssessment.level === 'warning'
|
trustAssessment.level === 'warning'
|
||||||
? 'bg-red-50 dark:bg-red-950/20 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-900/50'
|
? 'bg-destructive/10 text-destructive border border-destructive/20'
|
||||||
: 'bg-amber-50 dark:bg-amber-950/20 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-900/50'
|
: 'bg-warning/10 text-warning border border-warning/20'
|
||||||
)}>
|
)}>
|
||||||
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||||
<span>{trustMessage}</span>
|
<span>{trustMessage}</span>
|
||||||
@@ -887,7 +887,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
|
|
||||||
{/* Action error */}
|
{/* Action error */}
|
||||||
{actionError && (
|
{actionError && (
|
||||||
<div className="flex items-start gap-1.5 text-xs text-red-600 dark:text-red-400 rounded-md px-3 py-2 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900/50">
|
<div className="flex items-start gap-1.5 text-xs text-destructive rounded-md px-3 py-2 bg-destructive/10 border border-destructive/20">
|
||||||
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||||
<span>{actionError}</span>
|
<span>{actionError}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -905,8 +905,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
|
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
|
||||||
currentRsvp === 'accepted'
|
currentRsvp === 'accepted'
|
||||||
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800"
|
? "bg-success/15 text-success border-success/20"
|
||||||
: "text-muted-foreground hover:text-green-700 dark:hover:text-green-400 border-border hover:border-green-300 dark:hover:border-green-800 hover:bg-green-50 dark:hover:bg-green-950/20"
|
: "text-muted-foreground hover:text-success border-border hover:border-success/30 hover:bg-success/10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Check className="w-3.5 h-3.5" />
|
<Check className="w-3.5 h-3.5" />
|
||||||
@@ -919,8 +919,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
|
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
|
||||||
currentRsvp === 'tentative'
|
currentRsvp === 'tentative'
|
||||||
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 border-amber-200 dark:border-amber-800"
|
? "bg-warning/15 text-warning border-warning/20"
|
||||||
: "text-muted-foreground hover:text-amber-700 dark:hover:text-amber-400 border-border hover:border-amber-300 dark:hover:border-amber-800 hover:bg-amber-50 dark:hover:bg-amber-950/20"
|
: "text-muted-foreground hover:text-warning border-border hover:border-warning/30 hover:bg-warning/10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HelpCircle className="w-3.5 h-3.5" />
|
<HelpCircle className="w-3.5 h-3.5" />
|
||||||
@@ -933,8 +933,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
|
"inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-md transition-colors min-h-[36px] disabled:opacity-50 border",
|
||||||
currentRsvp === 'declined'
|
currentRsvp === 'declined'
|
||||||
? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800"
|
? "bg-destructive/15 text-destructive border-destructive/20"
|
||||||
: "text-muted-foreground hover:text-red-700 dark:hover:text-red-400 border-border hover:border-red-300 dark:hover:border-red-800 hover:bg-red-50 dark:hover:bg-red-950/20"
|
: "text-muted-foreground hover:text-destructive border-border hover:border-destructive/30 hover:bg-destructive/10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|||||||
import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder";
|
import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder";
|
||||||
import type { MimeAttachment } from "@/lib/smime/mime-builder";
|
import type { MimeAttachment } from "@/lib/smime/mime-builder";
|
||||||
import { smimeSign } from "@/lib/smime/smime-sign";
|
import { smimeSign } from "@/lib/smime/smime-sign";
|
||||||
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
|
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { useTemplateStore } from "@/stores/template-store";
|
import { useTemplateStore } from "@/stores/template-store";
|
||||||
@@ -28,6 +29,7 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
|||||||
import { TemplateForm } from "@/components/templates/template-form";
|
import { TemplateForm } from "@/components/templates/template-form";
|
||||||
import type { EmailTemplate } from "@/lib/template-types";
|
import type { EmailTemplate } from "@/lib/template-types";
|
||||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||||
|
import { findReplyIdentityId } from "@/lib/reply-identity";
|
||||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||||
|
|
||||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||||
@@ -75,8 +77,10 @@ interface EmailComposerProps {
|
|||||||
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||||
replyTo?: {
|
replyTo?: {
|
||||||
from?: { email?: string; name?: string }[];
|
from?: { email?: string; name?: string }[];
|
||||||
|
replyToAddresses?: { email?: string; name?: string }[];
|
||||||
to?: { email?: string; name?: string }[];
|
to?: { email?: string; name?: string }[];
|
||||||
cc?: { email?: string; name?: string }[];
|
cc?: { email?: string; name?: string }[];
|
||||||
|
bcc?: { email?: string; name?: string }[];
|
||||||
subject?: string;
|
subject?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
htmlBody?: string;
|
htmlBody?: string;
|
||||||
@@ -98,17 +102,21 @@ export function EmailComposer({
|
|||||||
const t = useTranslations('email_composer');
|
const t = useTranslations('email_composer');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
|
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
|
||||||
|
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||||
|
|
||||||
// Initialize with reply/forward data if provided
|
// Initialize with reply/forward data if provided
|
||||||
const getInitialTo = () => {
|
const getInitialTo = () => {
|
||||||
if (!replyTo) return "";
|
if (!replyTo) return "";
|
||||||
|
// RFC 5322: use Reply-To header if present, otherwise fall back to From
|
||||||
|
const replyTarget = replyTo.replyToAddresses?.length
|
||||||
|
? replyTo.replyToAddresses.filter(r => r.email).map(r => r.email).join(", ")
|
||||||
|
: replyTo.from?.[0]?.email || "";
|
||||||
if (mode === 'reply') {
|
if (mode === 'reply') {
|
||||||
const email = replyTo.from?.[0]?.email || "";
|
return replyTarget ? replyTarget + ', ' : "";
|
||||||
return email ? email + ', ' : "";
|
|
||||||
} else if (mode === 'replyAll') {
|
} else if (mode === 'replyAll') {
|
||||||
const from = replyTo.from?.[0]?.email || "";
|
|
||||||
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
|
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
|
||||||
const combined = [from, originalTo].filter(Boolean).join(", ");
|
const combined = [replyTarget, originalTo].filter(Boolean).join(", ");
|
||||||
return combined ? combined + ', ' : "";
|
return combined ? combined + ', ' : "";
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
@@ -133,6 +141,26 @@ export function EmailComposer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getInitialBody = () => {
|
const getInitialBody = () => {
|
||||||
|
if (plainTextMode) {
|
||||||
|
// Plain text mode: produce plain text body with no HTML
|
||||||
|
const prefix = initialDraftText || "";
|
||||||
|
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||||
|
|
||||||
|
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||||
|
const from = replyTo.from?.[0];
|
||||||
|
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||||
|
|
||||||
|
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
|
||||||
|
const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n');
|
||||||
|
|
||||||
|
if (mode === 'forward') {
|
||||||
|
return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||||
|
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||||
|
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
|
||||||
|
}
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
|
||||||
const prefix = initialDraftText ? `<p>${initialDraftText.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>` : "";
|
const prefix = initialDraftText ? `<p>${initialDraftText.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>` : "";
|
||||||
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||||
|
|
||||||
@@ -204,6 +232,31 @@ export function EmailComposer({
|
|||||||
const currentIdentity = selectedIdentityId
|
const currentIdentity = selectedIdentityId
|
||||||
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
||||||
: primaryIdentity;
|
: primaryIdentity;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoSelectReplyIdentity) return;
|
||||||
|
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
||||||
|
if (mode !== 'reply' && mode !== 'replyAll') return;
|
||||||
|
|
||||||
|
const matchedIdentityId = findReplyIdentityId(identities, {
|
||||||
|
to: replyTo?.to,
|
||||||
|
cc: replyTo?.cc,
|
||||||
|
bcc: replyTo?.bcc,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchedIdentityId) {
|
||||||
|
setSelectedIdentityId(matchedIdentityId);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
autoSelectReplyIdentity,
|
||||||
|
identities,
|
||||||
|
initialData?.selectedIdentityId,
|
||||||
|
mode,
|
||||||
|
replyTo?.bcc,
|
||||||
|
replyTo?.cc,
|
||||||
|
replyTo?.to,
|
||||||
|
selectedIdentityId,
|
||||||
|
]);
|
||||||
|
|
||||||
const composerSignatureHtml = currentIdentity?.htmlSignature
|
const composerSignatureHtml = currentIdentity?.htmlSignature
|
||||||
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
||||||
: currentIdentity?.textSignature
|
: currentIdentity?.textSignature
|
||||||
@@ -368,12 +421,14 @@ export function EmailComposer({
|
|||||||
? substitutePlaceholders(template.body, filledValues)
|
? substitutePlaceholders(template.body, filledValues)
|
||||||
: template.body;
|
: template.body;
|
||||||
|
|
||||||
// Convert template plain text body to HTML for the rich text editor
|
// In plain text mode, use template body as-is; otherwise convert to HTML
|
||||||
const htmlBody = `<p>${filledBody.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>`;
|
const bodyContent = plainTextMode
|
||||||
|
? filledBody
|
||||||
|
: `<p>${filledBody.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>`;
|
||||||
|
|
||||||
if (mode === 'compose') {
|
if (mode === 'compose') {
|
||||||
setSubject(filledSubject);
|
setSubject(filledSubject);
|
||||||
setBody(htmlBody);
|
setBody(bodyContent);
|
||||||
if (template.defaultRecipients?.to?.length) {
|
if (template.defaultRecipients?.to?.length) {
|
||||||
setTo(template.defaultRecipients.to.join(', ') + ', ');
|
setTo(template.defaultRecipients.to.join(', ') + ', ');
|
||||||
}
|
}
|
||||||
@@ -386,7 +441,7 @@ export function EmailComposer({
|
|||||||
setShowBcc(true);
|
setShowBcc(true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setBody((prev) => htmlBody + prev);
|
setBody((prev) => bodyContent + (plainTextMode ? '\n' : '') + prev);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (template.identityId) {
|
if (template.identityId) {
|
||||||
@@ -394,7 +449,7 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setShowTemplatePicker(false);
|
setShowTemplatePicker(false);
|
||||||
}, [mode]);
|
}, [mode, plainTextMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleTemplateKey = (e: KeyboardEvent) => {
|
const handleTemplateKey = (e: KeyboardEvent) => {
|
||||||
@@ -529,7 +584,7 @@ export function EmailComposer({
|
|||||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
|
|
||||||
if (!toAddresses.length && !subject && !htmlToPlainText(body).trim()) {
|
if (!toAddresses.length && !subject && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,7 +620,7 @@ export function EmailComposer({
|
|||||||
const savedDraftId = await client.createDraft(
|
const savedDraftId = await client.createDraft(
|
||||||
toAddresses,
|
toAddresses,
|
||||||
subject || t('no_subject'),
|
subject || t('no_subject'),
|
||||||
htmlToPlainText(body),
|
plainTextMode ? body : htmlToPlainText(body),
|
||||||
ccAddresses,
|
ccAddresses,
|
||||||
bccAddresses,
|
bccAddresses,
|
||||||
currentIdentity?.id,
|
currentIdentity?.id,
|
||||||
@@ -629,7 +684,7 @@ export function EmailComposer({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
|
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
const bodyPlainText = htmlToPlainText(body).trim();
|
const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim();
|
||||||
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
|
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
|
||||||
const canSend = toAddresses.length > 0 && !!subject && hasContent;
|
const canSend = toAddresses.length > 0 && !!subject && hasContent;
|
||||||
|
|
||||||
@@ -679,8 +734,8 @@ export function EmailComposer({
|
|||||||
: currentIdentity.email
|
: currentIdentity.email
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Body is already HTML from the rich text editor.
|
// Body is already HTML from the rich text editor (or plain text in plain text mode).
|
||||||
// Build HTML signature block
|
// Build HTML signature block (used only in rich text mode)
|
||||||
const buildSignatureHtml = (): string => {
|
const buildSignatureHtml = (): string => {
|
||||||
if (currentIdentity?.htmlSignature) {
|
if (currentIdentity?.htmlSignature) {
|
||||||
return `<br><br>-- <br>${sanitizeEmailHtml(currentIdentity.htmlSignature)}`;
|
return `<br><br>-- <br>${sanitizeEmailHtml(currentIdentity.htmlSignature)}`;
|
||||||
@@ -691,13 +746,14 @@ export function EmailComposer({
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const signatureHtml = buildSignatureHtml();
|
// In plain text mode, send text/plain only (no HTML body)
|
||||||
|
const finalBody = plainTextMode
|
||||||
|
? appendPlainTextSignature(body, currentIdentity)
|
||||||
|
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
||||||
|
|
||||||
// Build final HTML body: editor content + signature
|
const finalHtmlBody = plainTextMode
|
||||||
const finalHtmlBody = `<div>${body}</div>${signatureHtml}`;
|
? undefined
|
||||||
|
: `<div>${body}</div>${buildSignatureHtml()}`;
|
||||||
// Generate plain text version from the HTML body for multipart/alternative
|
|
||||||
const finalBody = appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||||
@@ -1100,24 +1156,47 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body - Rich Text Editor */}
|
{/* Body */}
|
||||||
<RichTextEditor
|
{plainTextMode ? (
|
||||||
content={body}
|
<textarea
|
||||||
onChange={(html) => {
|
value={body}
|
||||||
setBody(html);
|
onChange={(e) => {
|
||||||
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
setBody(e.target.value);
|
||||||
}}
|
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
||||||
onImageUpload={handleImageUpload}
|
}}
|
||||||
placeholder={t('body_placeholder')}
|
placeholder={t('body_placeholder')}
|
||||||
hasError={validationErrors.body}
|
className={cn(
|
||||||
/>
|
"w-full min-h-[300px] px-4 py-3 text-sm text-foreground bg-transparent resize-y focus:outline-none font-mono",
|
||||||
|
validationErrors.body && "ring-2 ring-red-500 dark:ring-red-400 rounded"
|
||||||
|
)}
|
||||||
|
style={{ height: 'calc(100vh - 350px)' }}
|
||||||
|
aria-invalid={validationErrors.body || undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RichTextEditor
|
||||||
|
content={body}
|
||||||
|
onChange={(html) => {
|
||||||
|
setBody(html);
|
||||||
|
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
||||||
|
}}
|
||||||
|
onImageUpload={handleImageUpload}
|
||||||
|
placeholder={t('body_placeholder')}
|
||||||
|
hasError={validationErrors.body}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{composerSignatureHtml && (
|
{plainTextMode ? (
|
||||||
|
getPlainTextSignature(currentIdentity) ? (
|
||||||
|
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
|
||||||
|
{'-- \n'}{getPlainTextSignature(currentIdentity)}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
) : composerSignatureHtml ? (
|
||||||
<div
|
<div
|
||||||
className="px-4 pb-3 text-sm leading-6 text-foreground break-words [&_a]:text-primary [&_a]:underline-offset-2 [&_a:hover]:underline"
|
className="px-4 pb-3 text-sm leading-6 text-foreground break-words [&_a]:text-primary [&_a]:underline-offset-2 [&_a:hover]:underline"
|
||||||
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }}
|
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments */}
|
||||||
@@ -1237,6 +1316,7 @@ export function EmailComposer({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<PluginSlot name="composer-toolbar" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Discard + Send (desktop) */}
|
{/* Right side - Discard + Send (desktop) */}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ContextMenuSubMenu,
|
ContextMenuSubMenu,
|
||||||
ContextMenuHeader,
|
ContextMenuHeader,
|
||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import {
|
import {
|
||||||
Reply,
|
Reply,
|
||||||
ReplyAll,
|
ReplyAll,
|
||||||
@@ -131,7 +132,7 @@ export function EmailContextMenu({
|
|||||||
onEditDraft,
|
onEditDraft,
|
||||||
}: EmailContextMenuProps) {
|
}: EmailContextMenuProps) {
|
||||||
const t = useTranslations("context_menu");
|
const t = useTranslations("context_menu");
|
||||||
const tColor = useTranslations("email_viewer.color_tag");
|
const _tColor = useTranslations("email_viewer.color_tag");
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
@@ -366,6 +367,8 @@ export function EmailContextMenu({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PluginSlot name="context-menu-email" />
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useTranslations } from "next-intl";
|
|||||||
|
|
||||||
interface EmailHoverActionsProps {
|
interface EmailHoverActionsProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
|
backgroundClassName?: string;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
@@ -54,8 +55,16 @@ const ACTION_CONFIG: Record<HoverAction, {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CORNER_CLASSES = {
|
||||||
|
'top-right': 'top-1 right-1',
|
||||||
|
'top-left': 'top-1 left-1',
|
||||||
|
'bottom-right': 'bottom-1 right-1',
|
||||||
|
'bottom-left': 'bottom-1 left-1',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export function EmailHoverActions({
|
export function EmailHoverActions({
|
||||||
email,
|
email,
|
||||||
|
backgroundClassName = "bg-muted",
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -64,10 +73,13 @@ export function EmailHoverActions({
|
|||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
}: EmailHoverActionsProps) {
|
}: EmailHoverActionsProps) {
|
||||||
const hoverActions = useSettingsStore((state) => state.hoverActions);
|
const hoverActions = useSettingsStore((state) => state.hoverActions);
|
||||||
|
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
|
||||||
|
const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner);
|
||||||
const t = useTranslations("settings.email_behavior.hover_actions");
|
const t = useTranslations("settings.email_behavior.hover_actions");
|
||||||
|
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
|
const hoverBackgroundClassName = backgroundClassName;
|
||||||
|
|
||||||
if (hoverActions.length === 0) return null;
|
if (hoverActions.length === 0) return null;
|
||||||
|
|
||||||
@@ -96,42 +108,68 @@ export function EmailHoverActions({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const actionButtons = hoverActions.map((actionId) => {
|
||||||
|
const config = ACTION_CONFIG[actionId];
|
||||||
|
if (!config) return null;
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
const DisplayIcon = actionId === "markRead"
|
||||||
|
? (isUnread ? MailOpen : Mail)
|
||||||
|
: actionId === "star" && isStarred
|
||||||
|
? Star
|
||||||
|
: Icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={actionId}
|
||||||
|
onClick={(e) => handleAction(e, actionId)}
|
||||||
|
title={t(config.titleKey)}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
|
||||||
|
config.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DisplayIcon
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4",
|
||||||
|
actionId === "star" && isStarred && "fill-amber-400 text-amber-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hoverActionsMode === 'floating') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute z-10 hidden group-hover:flex items-center",
|
||||||
|
CORNER_CLASSES[hoverActionsCorner],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center gap-0.5 rounded-lg px-1.5 py-0.5 shadow-md border border-border",
|
||||||
|
hoverBackgroundClassName,
|
||||||
|
)}>
|
||||||
|
{actionButtons}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-full bg-gradient-to-r from-transparent to-muted" />
|
<div
|
||||||
<div className="flex items-center gap-0.5 h-full bg-muted pr-3 pl-0.5">
|
className={cn("w-8 h-full", hoverBackgroundClassName)}
|
||||||
{hoverActions.map((actionId) => {
|
style={{
|
||||||
const config = ACTION_CONFIG[actionId];
|
WebkitMaskImage: "linear-gradient(to right, transparent, black)",
|
||||||
if (!config) return null;
|
maskImage: "linear-gradient(to right, transparent, black)",
|
||||||
const Icon = config.icon;
|
}}
|
||||||
|
/>
|
||||||
const DisplayIcon = actionId === "markRead"
|
<div className={cn("flex items-center gap-0.5 h-full pr-3 pl-0.5", hoverBackgroundClassName)}>
|
||||||
? (isUnread ? MailOpen : Mail)
|
{actionButtons}
|
||||||
: actionId === "star" && isStarred
|
|
||||||
? Star
|
|
||||||
: Icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={actionId}
|
|
||||||
onClick={(e) => handleAction(e, actionId)}
|
|
||||||
title={t(config.titleKey)}
|
|
||||||
className={cn(
|
|
||||||
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
|
|
||||||
config.className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DisplayIcon
|
|
||||||
className={cn(
|
|
||||||
"w-4 h-4",
|
|
||||||
actionId === "star" && isStarred && "fill-amber-400 text-amber-400",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { formatDate } from "@/lib/utils";
|
|||||||
import { Email } from "@/lib/jmap/types";
|
import { Email } from "@/lib/jmap/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Paperclip, Star, Circle, CheckSquare, Square, Tag, Reply, Forward } from "lucide-react";
|
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
@@ -35,6 +35,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
|
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const { identities } = useAuthStore();
|
const { identities } = useAuthStore();
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
@@ -44,6 +45,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
const isAnswered = email.keywords?.$answered;
|
const isAnswered = email.keywords?.$answered;
|
||||||
const isForwarded = email.keywords?.$forwarded;
|
const isForwarded = email.keywords?.$forwarded;
|
||||||
const sender = email.from?.[0];
|
const sender = email.from?.[0];
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color tag using keyword definitions from settings
|
// Resolve color tag using keyword definitions from settings
|
||||||
const colorTagId = getEmailColorTag(email.keywords);
|
const colorTagId = getEmailColorTag(email.keywords);
|
||||||
@@ -87,15 +90,15 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
// Apply color tag as background, with selected and unread states
|
// Apply color tag as background, with selected and unread states
|
||||||
colorTag ? colorTag : (
|
colorTag ? colorTag : (
|
||||||
selected
|
selected
|
||||||
? "bg-blue-200 dark:bg-blue-900/50"
|
? "bg-selection"
|
||||||
: "bg-background"
|
: "bg-background"
|
||||||
),
|
),
|
||||||
selected && !colorTag && "shadow-sm",
|
selected && !colorTag && "shadow-sm",
|
||||||
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
||||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||||
isUnread && !selected && !colorTag && "bg-amber-50 dark:bg-amber-900/20",
|
isUnread && !selected && !colorTag && "bg-warning/10",
|
||||||
// Add visual feedback for checked state
|
// Add visual feedback for checked state
|
||||||
isChecked && "ring-2 ring-primary/20 bg-blue-100 dark:bg-blue-900/30",
|
isChecked && "ring-2 ring-primary/20 bg-selection/60",
|
||||||
// Drag state visual feedback
|
// Drag state visual feedback
|
||||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
||||||
// Long press visual feedback
|
// Long press visual feedback
|
||||||
@@ -114,15 +117,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start px-4" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
|
<div
|
||||||
|
className={cn('px-4', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||||
|
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
|
>
|
||||||
{/* Checkbox - only visible when in selection mode */}
|
{/* Checkbox - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleCheckboxClick}
|
onClick={handleCheckboxClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||||
|
!isFocusedMailLayout && 'mt-2',
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
"active:scale-95",
|
"active:scale-95",
|
||||||
"animate-in fade-in zoom-in-95 duration-150",
|
"animate-in fade-in zoom-in-95 duration-150",
|
||||||
@@ -140,12 +147,12 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
{/* Unread indicator */}
|
{/* Unread indicator */}
|
||||||
{isUnread && (
|
{isUnread && (
|
||||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
{density !== 'extra-compact' && (
|
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
@@ -156,85 +163,131 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* First Line: Sender and Date */}
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"truncate text-sm",
|
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||||
isUnread
|
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
? "font-bold text-foreground"
|
)}>
|
||||||
: "font-medium text-muted-foreground"
|
{sender?.name || sender?.email || 'Unknown'}
|
||||||
)}>
|
</span>
|
||||||
{sender?.name || sender?.email || "Unknown"}
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||||
</span>
|
<span className={cn(
|
||||||
<div className="flex items-center gap-1.5">
|
'shrink-0 truncate',
|
||||||
{isStarred && (
|
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
)}>
|
||||||
)}
|
{email.subject || t('no_subject')}
|
||||||
{isImportant && (
|
|
||||||
<span className="px-1.5 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded font-medium">
|
|
||||||
Important
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
{inlinePreview && (
|
||||||
<EmailIdentityBadge email={email} identities={identities} compact={true} />
|
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
{isAnswered && !isForwarded && (
|
)}
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
</div>
|
||||||
)}
|
</div>
|
||||||
{isForwarded && !isAnswered && (
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||||
)}
|
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
|
||||||
|
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
|
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{isAnswered && isForwarded && (
|
{isAnswered && isForwarded && (
|
||||||
<>
|
<>
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{email.hasAttachment && (
|
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
|
||||||
)}
|
<span className={cn(
|
||||||
|
'text-xs tabular-nums',
|
||||||
|
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
) : (
|
||||||
{keywordDef && (
|
<>
|
||||||
<span className={cn(
|
{/* First Line: Sender and Date */}
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
)}>
|
<span className={cn(
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
"truncate text-sm",
|
||||||
{keywordDef.label}
|
isUnread
|
||||||
</span>
|
? "font-bold text-foreground"
|
||||||
)}
|
: "font-medium text-muted-foreground"
|
||||||
<span className={cn(
|
)}>
|
||||||
"text-xs tabular-nums",
|
{sender?.name || sender?.email || "Unknown"}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isStarred && (
|
||||||
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
|
)}
|
||||||
|
{isImportant && (
|
||||||
|
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
|
||||||
|
Important
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<EmailIdentityBadge email={email} identities={identities} compact={true} />
|
||||||
|
{isAnswered && !isForwarded && (
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isForwarded && !isAnswered && (
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isAnswered && isForwarded && (
|
||||||
|
<>
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{email.hasAttachment && (
|
||||||
|
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{keywordDef && (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||||
|
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
||||||
|
)}>
|
||||||
|
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
||||||
|
{keywordDef.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs tabular-nums",
|
||||||
|
isUnread
|
||||||
|
? "text-foreground font-semibold"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Second Line: Subject */}
|
||||||
|
<div className={cn(
|
||||||
|
"mb-1 line-clamp-1 text-sm",
|
||||||
isUnread
|
isUnread
|
||||||
? "text-foreground font-semibold"
|
? "font-semibold text-foreground"
|
||||||
: "text-muted-foreground"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{formatDate(email.receivedAt)}
|
{email.subject || t('no_subject')}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Second Line: Subject */}
|
{/* Third Line: Preview (controlled by showPreview setting) */}
|
||||||
<div className={cn(
|
{showPreview && density !== 'extra-compact' && (
|
||||||
"mb-1 line-clamp-1 text-sm",
|
<p className={cn(
|
||||||
isUnread
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
? "font-semibold text-foreground"
|
isUnread
|
||||||
: "font-normal text-foreground/90"
|
? "text-muted-foreground"
|
||||||
)}>
|
: "text-muted-foreground/80"
|
||||||
{email.subject || t('no_subject')}
|
)}>
|
||||||
</div>
|
{email.preview || "No preview available"}
|
||||||
|
</p>
|
||||||
{/* Third Line: Preview (controlled by showPreview setting) */}
|
)}
|
||||||
{showPreview && density !== 'extra-compact' && (
|
</>
|
||||||
<p className={cn(
|
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
|
||||||
isUnread
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-muted-foreground/80"
|
|
||||||
)}>
|
|
||||||
{email.preview || "No preview available"}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,6 +295,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
{/* Hover Quick Actions */}
|
{/* Hover Quick Actions */}
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={email}
|
email={email}
|
||||||
|
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar}
|
onToggleStar={onToggleStar}
|
||||||
onMarkAsRead={onMarkAsRead}
|
onMarkAsRead={onMarkAsRead}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function EmailList({
|
|||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const {
|
const {
|
||||||
selectedEmailIds,
|
selectedEmailIds,
|
||||||
selectAllEmails,
|
selectAllEmails: _selectAllEmails,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
batchMarkAsRead,
|
batchMarkAsRead,
|
||||||
batchDelete,
|
batchDelete,
|
||||||
@@ -89,10 +89,12 @@ export function EmailList({
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
|
const disableThreading = useSettingsStore((state) => state.disableThreading);
|
||||||
|
|
||||||
const threadGroups = useMemo(() => {
|
const threadGroups = useMemo(() => {
|
||||||
const groups = groupEmailsByThread(emails);
|
const groups = groupEmailsByThread(emails, disableThreading);
|
||||||
return sortThreadGroups(groups);
|
return sortThreadGroups(groups);
|
||||||
}, [emails]);
|
}, [emails, disableThreading]);
|
||||||
|
|
||||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
@@ -101,11 +103,16 @@ export function EmailList({
|
|||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
const estimateSize = useCallback(() => {
|
const estimateSize = useCallback(() => {
|
||||||
|
if (isFocusedMailLayout) {
|
||||||
|
return { 'extra-compact': 32, compact: 40, regular: 46, comfortable: 54 }[density];
|
||||||
|
}
|
||||||
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
|
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
|
||||||
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
||||||
}, [density, showPreview]);
|
}, [density, isFocusedMailLayout, showPreview]);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: threadGroups.length,
|
count: threadGroups.length,
|
||||||
@@ -214,15 +221,26 @@ export function EmailList({
|
|||||||
}
|
}
|
||||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
||||||
|
|
||||||
// Range-based load more: trigger when last visible item is near the end
|
// Range-based load more: trigger when last visible item is near the end.
|
||||||
|
// Debounce to prevent rapid cascade when thread grouping reduces item
|
||||||
|
// count below the viewport size (e.g. 2400 emails → fewer thread groups).
|
||||||
const virtualItems = virtualizer.getVirtualItems();
|
const virtualItems = virtualizer.getVirtualItems();
|
||||||
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
|
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
|
||||||
|
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lastVirtualItemIndex === undefined) return;
|
if (lastVirtualItemIndex === undefined) return;
|
||||||
if (lastVirtualItemIndex >= threadGroups.length - 5) {
|
if (lastVirtualItemIndex >= threadGroups.length - 5) {
|
||||||
handleLoadMore();
|
// Clear any pending timer so we don't stack calls
|
||||||
|
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
|
||||||
|
loadMoreTimerRef.current = setTimeout(() => {
|
||||||
|
handleLoadMore();
|
||||||
|
loadMoreTimerRef.current = null;
|
||||||
|
}, 150);
|
||||||
}
|
}
|
||||||
|
return () => {
|
||||||
|
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
|
||||||
|
};
|
||||||
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
|
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
|
||||||
|
|
||||||
// Scroll to the thread group containing the selected email
|
// Scroll to the thread group containing the selected email
|
||||||
@@ -242,7 +260,7 @@ export function EmailList({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
virtualizer.measure();
|
virtualizer.measure();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [density, showPreview]);
|
}, [density, isFocusedMailLayout, showPreview]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col min-h-0", className)}>
|
<div className={cn("flex flex-col min-h-0", className)}>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
StickyNote,
|
StickyNote,
|
||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
|
PanelRightOpen,
|
||||||
Send,
|
Send,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
Inbox,
|
Inbox,
|
||||||
@@ -67,6 +68,7 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
|
PenSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||||
@@ -95,6 +97,9 @@ import type { SmimeStatus } from "@/lib/smime/types";
|
|||||||
import { parseTnef, isTnefAttachment } from "@/lib/tnef";
|
import { parseTnef, isTnefAttachment } from "@/lib/tnef";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import type { TnefAttachment } from "@/lib/tnef";
|
import type { TnefAttachment } from "@/lib/tnef";
|
||||||
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
|
import { usePluginStore } from "@/stores/plugin-store";
|
||||||
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
|
|
||||||
interface EmailViewerProps {
|
interface EmailViewerProps {
|
||||||
email: Email | null;
|
email: Email | null;
|
||||||
@@ -117,6 +122,7 @@ interface EmailViewerProps {
|
|||||||
onNavigatePrev?: () => void;
|
onNavigatePrev?: () => void;
|
||||||
onShowShortcuts?: () => void;
|
onShowShortcuts?: () => void;
|
||||||
onEditDraft?: () => void;
|
onEditDraft?: () => void;
|
||||||
|
onCompose?: () => void;
|
||||||
currentUserEmail?: string;
|
currentUserEmail?: string;
|
||||||
currentUserName?: string;
|
currentUserName?: string;
|
||||||
currentMailboxRole?: string;
|
currentMailboxRole?: string;
|
||||||
@@ -200,7 +206,7 @@ const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to format recipients with contextual display
|
// Helper function to format recipients with contextual display
|
||||||
const formatRecipients = (
|
const _formatRecipients = (
|
||||||
recipients: Array<{ name?: string; email: string }> | undefined,
|
recipients: Array<{ name?: string; email: string }> | undefined,
|
||||||
currentUserEmail: string | undefined,
|
currentUserEmail: string | undefined,
|
||||||
t: (key: string, params?: Record<string, string | number>) => string
|
t: (key: string, params?: Record<string, string | number>) => string
|
||||||
@@ -861,6 +867,7 @@ export function EmailViewer({
|
|||||||
onNavigatePrev,
|
onNavigatePrev,
|
||||||
onShowShortcuts,
|
onShowShortcuts,
|
||||||
onEditDraft,
|
onEditDraft,
|
||||||
|
onCompose,
|
||||||
currentUserEmail,
|
currentUserEmail,
|
||||||
currentUserName,
|
currentUserName,
|
||||||
currentMailboxRole,
|
currentMailboxRole,
|
||||||
@@ -883,8 +890,10 @@ export function EmailViewer({
|
|||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
// Detect if current mailbox is Junk folder
|
// Detect if current mailbox is Junk folder
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
@@ -902,7 +911,7 @@ export function EmailViewer({
|
|||||||
// Tablet list visibility
|
// Tablet list visibility
|
||||||
const { isTablet, isMobile } = useDeviceDetection();
|
const { isTablet, isMobile } = useDeviceDetection();
|
||||||
const { tabletListVisible } = useUIStore();
|
const { tabletListVisible } = useUIStore();
|
||||||
const { identities, client, isDemoMode } = useAuthStore();
|
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const { startTour } = useTour();
|
const { startTour } = useTour();
|
||||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||||
@@ -947,11 +956,18 @@ export function EmailViewer({
|
|||||||
const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState<PostalMimeAttachment[]>([]);
|
const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState<PostalMimeAttachment[]>([]);
|
||||||
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
|
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
|
||||||
|
|
||||||
|
// Plugin detail sidebar state
|
||||||
|
const detailSlots = usePluginStore(s => s.slots['email-detail-sidebar']);
|
||||||
|
const hasDetailSidebar = detailSlots && detailSlots.length > 0;
|
||||||
|
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false);
|
||||||
|
const [detailSidebarWidth, setDetailSidebarWidth] = useState(280);
|
||||||
|
const detailSidebarWidthRef = useRef(280);
|
||||||
|
|
||||||
// Ensure S/MIME key records are loaded from IndexedDB
|
// Ensure S/MIME key records are loaded from IndexedDB
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
smimeStore.load();
|
smimeStore.load(activeAccountId ?? undefined);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [activeAccountId]);
|
||||||
|
|
||||||
// Build mailbox tree for move-to dropdown
|
// Build mailbox tree for move-to dropdown
|
||||||
const moveTargetIds = useMemo(() => new Set(
|
const moveTargetIds = useMemo(() => new Set(
|
||||||
@@ -1137,10 +1153,30 @@ export function EmailViewer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Mark as read when email is viewed
|
// Mark as read when email is viewed, respecting the delay setting
|
||||||
if (email && !email.keywords?.$seen && onMarkAsRead) {
|
if (!email || email.keywords?.$seen || !onMarkAsRead) {
|
||||||
onMarkAsRead(email.id, true);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
|
||||||
|
|
||||||
|
// Never auto-mark
|
||||||
|
if (markAsReadDelay === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instant mark
|
||||||
|
if (markAsReadDelay === 0) {
|
||||||
|
onMarkAsRead(email.id, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delayed mark
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
onMarkAsRead(email.id, true);
|
||||||
|
}, markAsReadDelay);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- email?.id changes when email changes, which is the intended trigger
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- email?.id changes when email changes, which is the intended trigger
|
||||||
}, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
|
}, [email?.id, email?.keywords?.$seen, onMarkAsRead]);
|
||||||
|
|
||||||
@@ -1509,8 +1545,25 @@ export function EmailViewer({
|
|||||||
|
|
||||||
if (detection.type === 'enveloped-data') {
|
if (detection.type === 'enveloped-data') {
|
||||||
// Encrypted message
|
// Encrypted message
|
||||||
const { keyRecords, unlockedDecryptionKeys } = smimeStore;
|
const { keyRecords, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys } = smimeStore;
|
||||||
smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) });
|
smimeDebug('[S/MIME] decrypt attempt:', {
|
||||||
|
keyRecordCount: keyRecords.length,
|
||||||
|
unlockedKeyCount: unlockedDecryptionKeys.size,
|
||||||
|
legacyKeyCount: unlockedLegacyDecryptionKeys.size,
|
||||||
|
keyRecordIds: keyRecords.map(k => k.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Short-circuit: no keys imported at all
|
||||||
|
if (keyRecords.length === 0) {
|
||||||
|
smimeDebug('[S/MIME] no key records available, skipping decrypt');
|
||||||
|
setSmimeStatus({
|
||||||
|
isSigned: false,
|
||||||
|
isEncrypted: true,
|
||||||
|
decryptionError: 'no-key',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
|
let result: Awaited<ReturnType<typeof smimeDecrypt>> | null = null;
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
@@ -1521,6 +1574,7 @@ export function EmailViewer({
|
|||||||
cmsBytes: candidate.normalized,
|
cmsBytes: candidate.normalized,
|
||||||
keyRecords,
|
keyRecords,
|
||||||
unlockedKeys: unlockedDecryptionKeys,
|
unlockedKeys: unlockedDecryptionKeys,
|
||||||
|
legacyUnlockedKeys: unlockedLegacyDecryptionKeys,
|
||||||
});
|
});
|
||||||
smimeDebug('[S/MIME] decrypt success with candidate:', {
|
smimeDebug('[S/MIME] decrypt success with candidate:', {
|
||||||
source: candidate.source,
|
source: candidate.source,
|
||||||
@@ -1602,8 +1656,15 @@ export function EmailViewer({
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
try {
|
try {
|
||||||
await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email');
|
await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email');
|
||||||
} catch { /* ignore import errors */ }
|
smimeDebug('[S/MIME] auto-imported signer cert:', { email: verifyResult.status.signerCert.email, fingerprint: verifyResult.status.signerCert.fingerprint });
|
||||||
|
} catch (importErr) {
|
||||||
|
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
|
||||||
}
|
}
|
||||||
|
} else if (verifyResult.status.signatureValid && verifyResult.status.signerCert) {
|
||||||
|
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: verifyResult.status.signerCert.email });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
smimeError('[S/MIME] nested signature verify failed:', {
|
smimeError('[S/MIME] nested signature verify failed:', {
|
||||||
@@ -1641,10 +1702,12 @@ export function EmailViewer({
|
|||||||
decryptionError: 'locked',
|
decryptionError: 'locked',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const errMsg = err instanceof Error ? err.message : 'Decryption failed';
|
||||||
|
const isNoKeyError = errMsg.includes('No imported S/MIME key matches');
|
||||||
setSmimeStatus({
|
setSmimeStatus({
|
||||||
isSigned: false,
|
isSigned: false,
|
||||||
isEncrypted: true,
|
isEncrypted: true,
|
||||||
decryptionError: err instanceof Error ? err.message : 'Decryption failed',
|
decryptionError: isNoKeyError ? 'no-key' : errMsg,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1706,8 +1769,15 @@ export function EmailViewer({
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
try {
|
try {
|
||||||
await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email');
|
await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email');
|
||||||
} catch { /* ignore import errors */ }
|
smimeDebug('[S/MIME] auto-imported signer cert:', { email: result.status.signerCert.email, fingerprint: result.status.signerCert.fingerprint });
|
||||||
|
} catch (importErr) {
|
||||||
|
smimeError('[S/MIME] auto-import signer cert failed:', importErr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint });
|
||||||
}
|
}
|
||||||
|
} else if (result.status.signatureValid && result.status.signerCert) {
|
||||||
|
smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: result.status.signerCert.email });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -1740,6 +1810,8 @@ export function EmailViewer({
|
|||||||
smimeStore.autoImportSignerCerts,
|
smimeStore.autoImportSignerCerts,
|
||||||
smimeStore.keyRecords,
|
smimeStore.keyRecords,
|
||||||
smimeStore.unlockedDecryptionKeys,
|
smimeStore.unlockedDecryptionKeys,
|
||||||
|
smimeStore.unlockedLegacyDecryptionKeys,
|
||||||
|
smimeStore,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// TNEF (winmail.dat) detection and processing
|
// TNEF (winmail.dat) detection and processing
|
||||||
@@ -1917,7 +1989,7 @@ export function EmailViewer({
|
|||||||
if (decryptedCidAttachments.length > 0) {
|
if (decryptedCidAttachments.length > 0) {
|
||||||
const urls: Record<string, string> = {};
|
const urls: Record<string, string> = {};
|
||||||
|
|
||||||
decryptedCidAttachments.forEach((att, index) => {
|
decryptedCidAttachments.forEach((att) => {
|
||||||
const bytes = getAttachmentContentBytes(att);
|
const bytes = getAttachmentContentBytes(att);
|
||||||
if (!bytes) return;
|
if (!bytes) return;
|
||||||
const cidValue = att.contentId!.replace(/^<|>$/g, '');
|
const cidValue = att.contentId!.replace(/^<|>$/g, '');
|
||||||
@@ -1974,7 +2046,7 @@ export function EmailViewer({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
objectUrls.forEach(url => URL.revokeObjectURL(url));
|
objectUrls.forEach(url => URL.revokeObjectURL(url));
|
||||||
};
|
};
|
||||||
}, [client, email?.id, smimeDecryptedAttachments]);
|
}, [client, email?.id, smimeDecryptedAttachments, email?.attachments]);
|
||||||
|
|
||||||
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
|
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
|
||||||
if (smimeDecryptedAttachments.length > 0) {
|
if (smimeDecryptedAttachments.length > 0) {
|
||||||
@@ -2494,14 +2566,21 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// If email has native dark mode, let it handle its own theming
|
// If email has native dark mode, let it handle its own theming
|
||||||
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
|
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
|
||||||
|
// Re-invert leaf media elements so they appear normal.
|
||||||
|
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid
|
||||||
|
// double re-inverting images nested inside those containers.
|
||||||
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
|
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
|
||||||
html { background: #1a1a1a; }
|
html { background: #1a1a1a; }
|
||||||
body { filter: invert(1) hue-rotate(180deg); }
|
body { filter: invert(1) hue-rotate(180deg); }
|
||||||
img, video, picture, svg, canvas, object, embed,
|
img, video, svg, canvas, object, embed, input[type="image"] {
|
||||||
[style*="background-image"], [style*="background:"],
|
filter: invert(1) hue-rotate(180deg);
|
||||||
[background], [bgcolor],
|
}
|
||||||
td[background], table[background],
|
[style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
img[src], input[type="image"] {
|
[style*="background:"]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
|
[background]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
|
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
|
td[background]:not(:has(img, video, svg, canvas, object, embed)),
|
||||||
|
table[background]:not(:has(img, video, svg, canvas, object, embed)) {
|
||||||
filter: invert(1) hue-rotate(180deg);
|
filter: invert(1) hue-rotate(180deg);
|
||||||
}
|
}
|
||||||
` : '';
|
` : '';
|
||||||
@@ -2540,11 +2619,35 @@ export function EmailViewer({
|
|||||||
a.setAttribute('target', '_blank');
|
a.setAttribute('target', '_blank');
|
||||||
a.setAttribute('rel', 'noopener noreferrer');
|
a.setAttribute('rel', 'noopener noreferrer');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dark mode: re-invert elements with stylesheet-defined background images
|
||||||
|
// (CSS attribute selectors only catch inline styles, not <style> block rules)
|
||||||
|
if (isDark && !emailHasNativeDarkMode) {
|
||||||
|
const win = doc.defaultView;
|
||||||
|
if (win) {
|
||||||
|
doc.body.querySelectorAll('*').forEach(el => {
|
||||||
|
const htmlEl = el as HTMLElement;
|
||||||
|
// Skip elements already handled by CSS attribute selectors
|
||||||
|
if (htmlEl.style.backgroundImage || htmlEl.style.background ||
|
||||||
|
htmlEl.hasAttribute('background') || htmlEl.hasAttribute('bgcolor')) return;
|
||||||
|
// Skip leaf media elements (already re-inverted by CSS)
|
||||||
|
const tag = htmlEl.tagName;
|
||||||
|
if (['IMG', 'VIDEO', 'SVG', 'CANVAS', 'OBJECT', 'EMBED'].includes(tag)) return;
|
||||||
|
const computed = win.getComputedStyle(htmlEl);
|
||||||
|
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
|
||||||
|
// Only re-invert if this container doesn't have media children
|
||||||
|
if (!htmlEl.querySelector('img, video, svg, canvas, object, embed')) {
|
||||||
|
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-origin restrictions - iframe will still display content
|
// Cross-origin restrictions - iframe will still display content
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isDark, emailHasNativeDarkMode]);
|
||||||
|
|
||||||
// Export email as .eml file
|
// Export email as .eml file
|
||||||
const handleExportEmail = async () => {
|
const handleExportEmail = async () => {
|
||||||
@@ -2743,6 +2846,12 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
||||||
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
||||||
|
{onCompose && (
|
||||||
|
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
|
||||||
|
<PenSquare className="w-4 h-4 mr-2" />
|
||||||
|
{t('compose')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2758,7 +2867,7 @@ export function EmailViewer({
|
|||||||
<>
|
<>
|
||||||
{/* Left: Reply actions */}
|
{/* Left: Reply actions */}
|
||||||
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
|
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
|
||||||
{showBackButton && isTablet && !tabletListVisible && onBack && (
|
{showBackButton && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -2773,7 +2882,7 @@ export function EmailViewer({
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onEditDraft}
|
onClick={() => onEditDraft()}
|
||||||
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||||
title={t('tooltips.edit_draft')}
|
title={t('tooltips.edit_draft')}
|
||||||
>
|
>
|
||||||
@@ -2819,6 +2928,7 @@ export function EmailViewer({
|
|||||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
|
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
</>)}
|
</>)}
|
||||||
|
<PluginSlot name="toolbar-actions" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
{/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
||||||
@@ -3491,7 +3601,7 @@ export function EmailViewer({
|
|||||||
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||||
{/* Back button (for below-subject mode on tablet) */}
|
{/* Back button (for below-subject mode on tablet) */}
|
||||||
{toolbarPosition === 'below-subject' && isTablet && !tabletListVisible && onBack && (
|
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -3529,7 +3639,7 @@ export function EmailViewer({
|
|||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
{isImportant && (
|
{isImportant && (
|
||||||
<span className="px-1.5 lg:px-2 py-0.5 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
|
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
|
||||||
{t('important')}
|
{t('important')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -4360,9 +4470,9 @@ export function EmailViewer({
|
|||||||
|
|
||||||
{/* Draft Banner */}
|
{/* Draft Banner */}
|
||||||
{isDraft && (
|
{isDraft && (
|
||||||
<div className="border-b border-border bg-amber-50 dark:bg-amber-950/30">
|
<div className="border-b border-border bg-warning/10">
|
||||||
<div className="max-w-4xl mx-auto px-6 py-2.5 flex items-center justify-between">
|
<div className="max-w-4xl mx-auto px-6 py-2.5 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-400">
|
<div className="flex items-center gap-2 text-warning">
|
||||||
<File className="w-4 h-4" />
|
<File className="w-4 h-4" />
|
||||||
<span className="text-sm font-medium">{t('draft_banner')}</span>
|
<span className="text-sm font-medium">{t('draft_banner')}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -4370,7 +4480,7 @@ export function EmailViewer({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onEditDraft}
|
onClick={() => onEditDraft()}
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
<EditIcon className="w-3.5 h-3.5" />
|
<EditIcon className="w-3.5 h-3.5" />
|
||||||
@@ -4443,6 +4553,8 @@ export function EmailViewer({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
|
<PluginSlot name="email-banner" extraProps={{ email }} />
|
||||||
|
|
||||||
{/* Email Body */}
|
{/* Email Body */}
|
||||||
<div className="email-content-wrapper overflow-x-auto">
|
<div className="email-content-wrapper overflow-x-auto">
|
||||||
{effectiveEmailContent.isHtml ? (
|
{effectiveEmailContent.isHtml ? (
|
||||||
@@ -4470,6 +4582,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PluginSlot name="email-footer" />
|
||||||
|
|
||||||
{/* Quick Reply Section - hidden for drafts */}
|
{/* Quick Reply Section - hidden for drafts */}
|
||||||
{!isDraft && (<div className={cn(
|
{!isDraft && (<div className={cn(
|
||||||
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
|
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
|
||||||
@@ -4619,13 +4733,13 @@ export function EmailViewer({
|
|||||||
|
|
||||||
{/* Mobile bottom action bar */}
|
{/* Mobile bottom action bar */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<nav className="fixed bottom-0 left-0 right-0 z-[50] bg-background border-t border-border sm:hidden">
|
<nav className="fixed bottom-0 left-0 right-0 z-[50] bg-background border-t border-border sm:hidden overflow-hidden">
|
||||||
<div className="flex items-center justify-around">
|
<div className="flex items-center overflow-x-auto mobile-scroll-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={onNavigatePrev}
|
onClick={onNavigatePrev}
|
||||||
disabled={!onNavigatePrev}
|
disabled={!onNavigatePrev}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150",
|
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
|
||||||
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
||||||
)}
|
)}
|
||||||
aria-label={t('tooltips.previous')}
|
aria-label={t('tooltips.previous')}
|
||||||
@@ -4635,8 +4749,8 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
{isDraft && onEditDraft ? (
|
{isDraft && onEditDraft ? (
|
||||||
<button
|
<button
|
||||||
onClick={onEditDraft}
|
onClick={() => onEditDraft()}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-primary active:text-primary/80 transition-colors duration-150"
|
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
|
||||||
aria-label={t('tooltips.edit_draft')}
|
aria-label={t('tooltips.edit_draft')}
|
||||||
>
|
>
|
||||||
<EditIcon className="w-5 h-5" />
|
<EditIcon className="w-5 h-5" />
|
||||||
@@ -4646,7 +4760,7 @@ export function EmailViewer({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => onReply?.()}
|
onClick={() => onReply?.()}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||||
aria-label={t('tooltips.reply')}
|
aria-label={t('tooltips.reply')}
|
||||||
>
|
>
|
||||||
<Reply className="w-5 h-5" />
|
<Reply className="w-5 h-5" />
|
||||||
@@ -4654,7 +4768,7 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onReplyAll}
|
onClick={onReplyAll}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||||
aria-label={t('tooltips.reply_all')}
|
aria-label={t('tooltips.reply_all')}
|
||||||
>
|
>
|
||||||
<ReplyAll className="w-5 h-5" />
|
<ReplyAll className="w-5 h-5" />
|
||||||
@@ -4662,7 +4776,7 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onForward}
|
onClick={onForward}
|
||||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||||
aria-label={t('tooltips.forward')}
|
aria-label={t('tooltips.forward')}
|
||||||
>
|
>
|
||||||
<Forward className="w-5 h-5" />
|
<Forward className="w-5 h-5" />
|
||||||
@@ -4673,7 +4787,7 @@ export function EmailViewer({
|
|||||||
onClick={onNavigateNext}
|
onClick={onNavigateNext}
|
||||||
disabled={!onNavigateNext}
|
disabled={!onNavigateNext}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150",
|
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
|
||||||
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
||||||
)}
|
)}
|
||||||
aria-label={t('tooltips.next')}
|
aria-label={t('tooltips.next')}
|
||||||
@@ -4685,6 +4799,58 @@ export function EmailViewer({
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Plugin Detail Sidebar - resizable, collapsible */}
|
||||||
|
{hasDetailSidebar && !isMobile && (
|
||||||
|
<>
|
||||||
|
{/* Collapse toggle when sidebar is collapsed */}
|
||||||
|
{detailSidebarCollapsed && (
|
||||||
|
<div className="flex flex-col items-center border-l border-border bg-background">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setDetailSidebarCollapsed(false)}
|
||||||
|
className="h-8 w-8 m-1"
|
||||||
|
aria-label="Expand panel"
|
||||||
|
>
|
||||||
|
<PanelRightOpen className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!detailSidebarCollapsed && (
|
||||||
|
<>
|
||||||
|
<ResizeHandle
|
||||||
|
onResizeStart={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
|
||||||
|
onResize={(delta) => {
|
||||||
|
const newWidth = Math.max(200, Math.min(500, detailSidebarWidthRef.current - delta));
|
||||||
|
setDetailSidebarWidth(newWidth);
|
||||||
|
}}
|
||||||
|
onResizeEnd={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
|
||||||
|
onDoubleClick={() => setDetailSidebarWidth(280)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="flex flex-col h-full border-l border-border bg-background overflow-hidden"
|
||||||
|
style={{ width: detailSidebarWidth, minWidth: detailSidebarWidth }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end px-1 py-1 border-b border-border shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setDetailSidebarCollapsed(true)}
|
||||||
|
className="h-7 w-7"
|
||||||
|
aria-label="Collapse panel"
|
||||||
|
>
|
||||||
|
<PanelRightClose className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<PluginSlot name="email-detail-sidebar" extraProps={{ email }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Contact Detail Sidebar - desktop only */}
|
{/* Contact Detail Sidebar - desktop only */}
|
||||||
{contactSidebarEmail && !isMobileDevice && (
|
{contactSidebarEmail && !isMobileDevice && (
|
||||||
<ContactSidebarPanel
|
<ContactSidebarPanel
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
|||||||
text: t('unlock_key_desc'),
|
text: t('unlock_key_desc'),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
});
|
});
|
||||||
|
} else if (status.decryptionError === 'no-key') {
|
||||||
|
items.push({
|
||||||
|
icon: <Lock className="w-4 h-4" />,
|
||||||
|
text: t('status_encrypted_no_key'),
|
||||||
|
variant: 'warning',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <ShieldX className="w-4 h-4" />,
|
icon: <ShieldX className="w-4 h-4" />,
|
||||||
@@ -83,10 +89,10 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
|||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
const variantStyles = {
|
const variantStyles = {
|
||||||
success: 'bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800',
|
success: 'bg-success/10 text-success border-success/30',
|
||||||
warning: 'bg-yellow-50 dark:bg-yellow-950/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
|
warning: 'bg-warning/10 text-warning border-warning/30',
|
||||||
error: 'bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800',
|
error: 'bg-destructive/10 text-destructive border-destructive/30',
|
||||||
info: 'bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800',
|
info: 'bg-info/10 text-info border-info/30',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ function EmailCard({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
objectUrls.forEach(url => URL.revokeObjectURL(url));
|
objectUrls.forEach(url => URL.revokeObjectURL(url));
|
||||||
};
|
};
|
||||||
}, [client, email?.id]);
|
}, [client, email?.id, email?.attachments]);
|
||||||
|
|
||||||
// Sanitize and prepare email HTML content
|
// Sanitize and prepare email HTML content
|
||||||
const emailContent = useMemo(() => {
|
const emailContent = useMemo(() => {
|
||||||
|
|||||||
@@ -85,9 +85,9 @@ export function ThreadEmailItem({
|
|||||||
"pl-12 pr-4",
|
"pl-12 pr-4",
|
||||||
"border-l-2 border-l-transparent",
|
"border-l-2 border-l-transparent",
|
||||||
selected
|
selected
|
||||||
? "bg-blue-200 dark:bg-blue-900/50 border-l-primary"
|
? "bg-selection border-l-primary"
|
||||||
: "hover:bg-muted/50",
|
: "hover:bg-muted/50",
|
||||||
isUnread && !selected && "bg-amber-50 dark:bg-amber-900/20",
|
isUnread && !selected && "bg-warning/10",
|
||||||
!isLast && "border-b border-border/30",
|
!isLast && "border-b border-border/30",
|
||||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
||||||
@@ -121,7 +121,7 @@ export function ThreadEmailItem({
|
|||||||
{/* Unread indicator */}
|
{/* Unread indicator */}
|
||||||
{isUnread && (
|
{isUnread && (
|
||||||
<div className="absolute left-7 top-1/2 -translate-y-1/2">
|
<div className="absolute left-7 top-1/2 -translate-y-1/2">
|
||||||
<Circle className="w-1.5 h-1.5 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
<Circle className="w-1.5 h-1.5 fill-unread text-unread" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||||
|
|
||||||
// Resolve color and keyword definition from keyword definitions if not passed directly
|
// Resolve color and keyword definition from keyword definitions if not passed directly
|
||||||
const tagId = getEmailColorTag(email.keywords);
|
const tagId = getEmailColorTag(email.keywords);
|
||||||
@@ -131,15 +134,19 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
|
<div
|
||||||
|
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||||
|
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
|
>
|
||||||
{/* Checkbox - only visible when in selection mode */}
|
{/* Checkbox - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleCheckboxClick}
|
onClick={handleCheckboxClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||||
|
!isFocusedMailLayout && 'mt-2',
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
"active:scale-95",
|
"active:scale-95",
|
||||||
"animate-in fade-in zoom-in-95 duration-150",
|
"animate-in fade-in zoom-in-95 duration-150",
|
||||||
@@ -156,11 +163,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
|
|
||||||
{isUnread && (
|
{isUnread && (
|
||||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{density !== 'extra-compact' && (
|
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
@@ -170,76 +177,121 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className={cn(
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
"truncate text-sm",
|
<span className={cn(
|
||||||
isUnread
|
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||||
? "font-bold text-foreground"
|
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
: "font-medium text-muted-foreground"
|
)}>
|
||||||
)}>
|
{sender?.name || sender?.email || 'Unknown'}
|
||||||
{sender?.name || sender?.email || "Unknown"}
|
</span>
|
||||||
</span>
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||||
<div className="flex items-center gap-1.5">
|
<span className={cn(
|
||||||
{isStarred && (
|
'shrink-0 truncate',
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
)}
|
)}>
|
||||||
{isAnswered && !isForwarded && (
|
{email.subject || '(no subject)'}
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
</span>
|
||||||
)}
|
{inlinePreview && (
|
||||||
{isForwarded && !isAnswered && (
|
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
)}
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
|
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||||
|
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
|
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{isAnswered && isForwarded && (
|
{isAnswered && isForwarded && (
|
||||||
<>
|
<>
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{email.hasAttachment && (
|
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
{resolvedKeywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || 'bg-gray-400')} />}
|
||||||
)}
|
<span className={cn(
|
||||||
|
'text-xs tabular-nums',
|
||||||
|
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
) : (
|
||||||
{resolvedKeywordDef && (
|
<>
|
||||||
<span className={cn(
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
|
<span className={cn(
|
||||||
)}>
|
"truncate text-sm",
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
|
isUnread
|
||||||
{resolvedKeywordDef.label}
|
? "font-bold text-foreground"
|
||||||
</span>
|
: "font-medium text-muted-foreground"
|
||||||
)}
|
)}>
|
||||||
<span className={cn(
|
{sender?.name || sender?.email || "Unknown"}
|
||||||
"text-xs tabular-nums",
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isStarred && (
|
||||||
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
|
)}
|
||||||
|
{isAnswered && !isForwarded && (
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isForwarded && !isAnswered && (
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isAnswered && isForwarded && (
|
||||||
|
<>
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{email.hasAttachment && (
|
||||||
|
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{resolvedKeywordDef && (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||||
|
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
|
||||||
|
)}>
|
||||||
|
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
|
||||||
|
{resolvedKeywordDef.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs tabular-nums",
|
||||||
|
isUnread
|
||||||
|
? "text-foreground font-semibold"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{formatDate(email.receivedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
"mb-1 line-clamp-1 text-sm",
|
||||||
isUnread
|
isUnread
|
||||||
? "text-foreground font-semibold"
|
? "font-semibold text-foreground"
|
||||||
: "text-muted-foreground"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{formatDate(email.receivedAt)}
|
{email.subject || "(no subject)"}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={cn(
|
{showPreview && density !== 'extra-compact' && (
|
||||||
"mb-1 line-clamp-1 text-sm",
|
<p className={cn(
|
||||||
isUnread
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
? "font-semibold text-foreground"
|
isUnread
|
||||||
: "font-normal text-foreground/90"
|
? "text-muted-foreground"
|
||||||
)}>
|
: "text-muted-foreground/80"
|
||||||
{email.subject || "(no subject)"}
|
)}>
|
||||||
</div>
|
{email.preview || "No preview available"}
|
||||||
|
</p>
|
||||||
{showPreview && density !== 'extra-compact' && (
|
)}
|
||||||
<p className={cn(
|
</>
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
|
||||||
isUnread
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-muted-foreground/80"
|
|
||||||
)}>
|
|
||||||
{email.preview || "No preview available"}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,6 +299,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{/* Hover Quick Actions */}
|
{/* Hover Quick Actions */}
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={email}
|
email={email}
|
||||||
|
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar}
|
onToggleStar={onToggleStar}
|
||||||
onMarkAsRead={onMarkAsRead}
|
onMarkAsRead={onMarkAsRead}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
@@ -280,8 +333,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const t = useTranslations('threads');
|
const t = useTranslations('threads');
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||||
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
||||||
|
|
||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||||
|
|
||||||
@@ -405,15 +461,19 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
)}
|
)}
|
||||||
onClick={handleHeaderClick}
|
onClick={handleHeaderClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
|
<div
|
||||||
|
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||||
|
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||||
|
>
|
||||||
{/* Checkbox for thread selection - only visible when in selection mode */}
|
{/* Checkbox for thread selection - only visible when in selection mode */}
|
||||||
{selectedEmailIds.size > 0 && (
|
{selectedEmailIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleThreadCheckboxClick}
|
onClick={handleThreadCheckboxClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||||
|
!isFocusedMailLayout && 'mt-2',
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
"active:scale-95",
|
"active:scale-95",
|
||||||
"animate-in fade-in zoom-in-95 duration-150",
|
"animate-in fade-in zoom-in-95 duration-150",
|
||||||
@@ -428,7 +488,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && !isFocusedMailLayout && (
|
||||||
<button
|
<button
|
||||||
data-expand-toggle
|
data-expand-toggle
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -456,11 +516,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
|
|
||||||
{hasUnread && (
|
{hasUnread && (
|
||||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{density !== 'extra-compact' && (
|
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={latestEmail.from?.[0]?.name}
|
name={latestEmail.from?.[0]?.name}
|
||||||
email={latestEmail.from?.[0]?.email}
|
email={latestEmail.from?.[0]?.email}
|
||||||
@@ -470,88 +530,143 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
{isFocusedMailLayout ? (
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className={cn(
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
"truncate text-sm",
|
<span className={cn(
|
||||||
hasUnread
|
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||||
? "font-bold text-foreground"
|
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||||
: "font-medium text-muted-foreground"
|
)}>
|
||||||
)}>
|
{participantNames.join(', ')}
|
||||||
{participantNames.join(", ")}
|
</span>
|
||||||
</span>
|
<span
|
||||||
<span
|
className={cn(
|
||||||
className={cn(
|
'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium',
|
||||||
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
hasUnread ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
|
||||||
hasUnread
|
)}
|
||||||
? "bg-primary text-primary-foreground"
|
title={t('messages_tooltip', { count: emailCount })}
|
||||||
: "bg-muted text-muted-foreground"
|
>
|
||||||
)}
|
<MessageSquare className="w-3 h-3" />
|
||||||
title={t('messages_tooltip', { count: emailCount })}
|
{emailCount}
|
||||||
>
|
</span>
|
||||||
<MessageSquare className="w-3 h-3" />
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||||
{emailCount}
|
<span className={cn(
|
||||||
</span>
|
'shrink-0 truncate',
|
||||||
<div className="flex items-center gap-1.5">
|
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||||
{hasStarred && (
|
)}>
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
{latestEmail.subject || '(no subject)'}
|
||||||
)}
|
</span>
|
||||||
{hasAnswered && !hasForwarded && (
|
{inlinePreview && (
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||||
)}
|
)}
|
||||||
{hasForwarded && !hasAnswered && (
|
</div>
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
</div>
|
||||||
)}
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
|
{hasStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||||
|
{hasAnswered && !hasForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
|
{hasForwarded && !hasAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{hasAnswered && hasForwarded && (
|
{hasAnswered && hasForwarded && (
|
||||||
<>
|
<>
|
||||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hasAttachment && (
|
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
|
||||||
)}
|
<span className={cn(
|
||||||
|
'text-xs tabular-nums',
|
||||||
|
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{formatDate(latestEmail.receivedAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
) : (
|
||||||
{keywordDef && (
|
<>
|
||||||
<span className={cn(
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
<span className={cn(
|
||||||
)}>
|
"truncate text-sm",
|
||||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
hasUnread
|
||||||
{keywordDef.label}
|
? "font-bold text-foreground"
|
||||||
</span>
|
: "font-medium text-muted-foreground"
|
||||||
)}
|
)}>
|
||||||
<span className={cn(
|
{participantNames.join(", ")}
|
||||||
"text-xs tabular-nums",
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
||||||
|
hasUnread
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
title={t('messages_tooltip', { count: emailCount })}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
{emailCount}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{hasStarred && (
|
||||||
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
|
)}
|
||||||
|
{hasAnswered && !hasForwarded && (
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{hasForwarded && !hasAnswered && (
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{hasAnswered && hasForwarded && (
|
||||||
|
<>
|
||||||
|
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hasAttachment && (
|
||||||
|
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{keywordDef && (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||||
|
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
||||||
|
)}>
|
||||||
|
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
||||||
|
{keywordDef.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs tabular-nums",
|
||||||
|
hasUnread
|
||||||
|
? "text-foreground font-semibold"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{formatDate(latestEmail.receivedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
"mb-1 line-clamp-1 text-sm",
|
||||||
hasUnread
|
hasUnread
|
||||||
? "text-foreground font-semibold"
|
? "font-semibold text-foreground"
|
||||||
: "text-muted-foreground"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{formatDate(latestEmail.receivedAt)}
|
{latestEmail.subject || "(no subject)"}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={cn(
|
{showPreview && density !== 'extra-compact' && (
|
||||||
"mb-1 line-clamp-1 text-sm",
|
<p className={cn(
|
||||||
hasUnread
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
? "font-semibold text-foreground"
|
hasUnread
|
||||||
: "font-normal text-foreground/90"
|
? "text-muted-foreground"
|
||||||
)}>
|
: "text-muted-foreground/80"
|
||||||
{latestEmail.subject || "(no subject)"}
|
)}>
|
||||||
</div>
|
{latestEmail.preview || "No preview available"}
|
||||||
|
</p>
|
||||||
{showPreview && density !== 'extra-compact' && (
|
)}
|
||||||
<p className={cn(
|
</>
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
|
||||||
hasUnread
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-muted-foreground/80"
|
|
||||||
)}>
|
|
||||||
{latestEmail.preview || "No preview available"}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -559,6 +674,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{/* Hover Quick Actions for thread header */}
|
{/* Hover Quick Actions for thread header */}
|
||||||
<EmailHoverActions
|
<EmailHoverActions
|
||||||
email={latestEmail}
|
email={latestEmail}
|
||||||
|
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")}
|
||||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||||
@@ -568,7 +684,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && !isMobile && (
|
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
||||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) {
|
|||||||
export function ComposerErrorFallback({ resetError, t }: FallbackProps) {
|
export function ComposerErrorFallback({ resetError, t }: FallbackProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-background border rounded-lg items-center justify-center p-8">
|
<div className="flex flex-col h-full bg-background border rounded-lg items-center justify-center p-8">
|
||||||
<AlertCircle className="w-10 h-10 text-amber-500 mb-3" />
|
<AlertCircle className="w-10 h-10 text-warning mb-3" />
|
||||||
<p className="text-sm text-muted-foreground text-center mb-4">
|
<p className="text-sm text-muted-foreground text-center mb-4">
|
||||||
{t("composer_error")}
|
{t("composer_error")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -181,8 +181,6 @@ function isPreviewable(name: string): boolean {
|
|||||||
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
|
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
|
||||||
|
|
||||||
function getFileIconByName(name: string, size: "sm" | "lg") {
|
function getFileIconByName(name: string, size: "sm" | "lg") {
|
||||||
const cls = size === "sm" ? "w-5 h-5" : "w-10 h-10";
|
const cls = size === "sm" ? "w-5 h-5" : "w-10 h-10";
|
||||||
if (isVectorFile(name)) return <PenTool className={`${cls} text-orange-500`} />;
|
if (isVectorFile(name)) return <PenTool className={`${cls} text-orange-500`} />;
|
||||||
@@ -455,13 +453,13 @@ export function FileBrowser({
|
|||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleNavigateUp = () => {
|
const handleNavigateUp = useCallback(() => {
|
||||||
if (currentPath === '/') return;
|
if (currentPath === '/') return;
|
||||||
const segments = currentPath.split('/').filter(Boolean);
|
const segments = currentPath.split('/').filter(Boolean);
|
||||||
segments.pop();
|
segments.pop();
|
||||||
const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/');
|
const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/');
|
||||||
onNavigate(parentPath, null);
|
onNavigate(parentPath, null);
|
||||||
};
|
}, [currentPath, onNavigate]);
|
||||||
|
|
||||||
const handleResourceClick = (resource: FileResource, e: React.MouseEvent) => {
|
const handleResourceClick = (resource: FileResource, e: React.MouseEvent) => {
|
||||||
if (resource.isDirectory) {
|
if (resource.isDirectory) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Upload, FolderPlus, FilePlus, FolderUp } from "lucide-react";
|
import { Upload, FolderPlus, FilePlus } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||||
|
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ function FolderTreeItem({
|
|||||||
}) {
|
}) {
|
||||||
const isExpanded = expandedIds.has(node.id);
|
const isExpanded = expandedIds.has(node.id);
|
||||||
const isSelected = currentPath === node.path;
|
const isSelected = currentPath === node.path;
|
||||||
const isLoading = loadingIds.has(node.id);
|
const _isLoading = loadingIds.has(node.id);
|
||||||
const children = childrenCache.get(node.id);
|
const children = childrenCache.get(node.id);
|
||||||
const hasChildren = children !== undefined && children.length > 0;
|
const hasChildren = children !== undefined && children.length > 0;
|
||||||
const indentPx = depth * 16;
|
const indentPx = depth * 16;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type {
|
|||||||
FilterActionType,
|
FilterActionType,
|
||||||
} from "@/lib/jmap/sieve-types";
|
} from "@/lib/jmap/sieve-types";
|
||||||
import type { Mailbox } from "@/lib/jmap/types";
|
import type { Mailbox } from "@/lib/jmap/types";
|
||||||
import { buildMailboxTree, flattenMailboxTree } from "@/lib/utils";
|
import { buildMailboxTree, flattenMailboxTree, type MailboxNode } from "@/lib/utils";
|
||||||
|
|
||||||
interface FilterRuleModalProps {
|
interface FilterRuleModalProps {
|
||||||
rule?: FilterRule;
|
rule?: FilterRule;
|
||||||
@@ -71,10 +71,19 @@ export function FilterRuleModal({
|
|||||||
|
|
||||||
const modalRef = useFocusTrap({ isActive: true, onEscape: onClose });
|
const modalRef = useFocusTrap({ isActive: true, onEscape: onClose });
|
||||||
|
|
||||||
const hierarchicalMailboxes = useMemo(
|
const { hierarchicalMailboxes, mailboxPathMap } = useMemo(() => {
|
||||||
() => flattenMailboxTree(buildMailboxTree(mailboxes.filter((mb) => !mb.isShared))),
|
const tree = buildMailboxTree(mailboxes.filter((mb) => !mb.isShared));
|
||||||
[mailboxes]
|
const pathMap = new Map<string, string>();
|
||||||
);
|
const buildPaths = (nodes: MailboxNode[], parentPath = "") => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name;
|
||||||
|
pathMap.set(node.id, fullPath);
|
||||||
|
if (node.children.length > 0) buildPaths(node.children, fullPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
buildPaths(tree);
|
||||||
|
return { hierarchicalMailboxes: flattenMailboxTree(tree), mailboxPathMap: pathMap };
|
||||||
|
}, [mailboxes]);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
const trimmedName = name.trim();
|
const trimmedName = name.trim();
|
||||||
@@ -143,7 +152,8 @@ export function FilterRuleModal({
|
|||||||
delete updated.value;
|
delete updated.value;
|
||||||
}
|
}
|
||||||
if (updates.type && ACTIONS_WITH_MAILBOX.has(updates.type) && !updated.value) {
|
if (updates.type && ACTIONS_WITH_MAILBOX.has(updates.type) && !updated.value) {
|
||||||
updated.value = mailboxes[0]?.name || "";
|
const firstMb = hierarchicalMailboxes[0];
|
||||||
|
updated.value = firstMb ? (mailboxPathMap.get(firstMb.id) || firstMb.name) : "";
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
})
|
})
|
||||||
@@ -289,7 +299,7 @@ export function FilterRuleModal({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeCondition(index)}
|
onClick={() => removeCondition(index)}
|
||||||
disabled={conditions.length <= 1}
|
disabled={conditions.length <= 1}
|
||||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||||
aria-label={t("delete_rule")}
|
aria-label={t("delete_rule")}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
@@ -338,7 +348,7 @@ export function FilterRuleModal({
|
|||||||
>
|
>
|
||||||
<option value="">{t("move_to_folder")}</option>
|
<option value="">{t("move_to_folder")}</option>
|
||||||
{hierarchicalMailboxes.map((mb) => (
|
{hierarchicalMailboxes.map((mb) => (
|
||||||
<option key={mb.id} value={mb.name}>
|
<option key={mb.id} value={mailboxPathMap.get(mb.id) || mb.name}>
|
||||||
{"\u00A0".repeat(mb.depth * 3)}{mb.name}
|
{"\u00A0".repeat(mb.depth * 3)}{mb.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@@ -377,7 +387,7 @@ export function FilterRuleModal({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeAction(index)}
|
onClick={() => removeAction(index)}
|
||||||
disabled={actions.length <= 1}
|
disabled={actions.length <= 1}
|
||||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||||
aria-label={t("delete_rule")}
|
aria-label={t("delete_rule")}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function SieveEditorModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 flex-1 overflow-hidden flex flex-col space-y-4">
|
<div className="px-6 py-4 flex-1 overflow-hidden flex flex-col space-y-4">
|
||||||
<div className="flex items-start gap-2 p-3 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-sm text-amber-700 dark:text-amber-400">
|
<div className="flex items-start gap-2 p-3 rounded-md bg-warning/10 border border-warning/20 text-sm text-warning">
|
||||||
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
<p>{t("warning")}</p>
|
<p>{t("warning")}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,8 +131,8 @@ export function SieveEditorModal({
|
|||||||
<div
|
<div
|
||||||
className={`flex items-start gap-2 p-3 rounded-md text-sm ${
|
className={`flex items-start gap-2 p-3 rounded-md text-sm ${
|
||||||
validationResult.isValid
|
validationResult.isValid
|
||||||
? "bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400"
|
? "bg-success/10 border border-success/20 text-success"
|
||||||
: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400"
|
: "bg-destructive/10 border border-destructive/20 text-destructive"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{validationResult.isValid ? (
|
{validationResult.isValid ? (
|
||||||
@@ -157,7 +157,7 @@ export function SieveEditorModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showSaveWarning && (
|
{showSaveWarning && (
|
||||||
<div className="flex items-start gap-2 p-3 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-sm text-amber-700 dark:text-amber-400">
|
<div className="flex items-start gap-2 p-3 rounded-md bg-warning/10 border border-warning/20 text-sm text-warning">
|
||||||
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
<p>{t("save_warning")}</p>
|
<p>{t("save_warning")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
|||||||
|
|
||||||
const client = useAuthStore((state) => state.client);
|
const client = useAuthStore((state) => state.client);
|
||||||
const identities = useIdentityStore((state) => state.identities);
|
const identities = useIdentityStore((state) => state.identities);
|
||||||
const preferredPrimaryId = useIdentityStore((state) => state.preferredPrimaryId);
|
const _preferredPrimaryId = useIdentityStore((state) => state.preferredPrimaryId);
|
||||||
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
|
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
|
||||||
const syncIdentities = useSyncIdentities();
|
const syncIdentities = useSyncIdentities();
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
|||||||
)} />
|
)} />
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-muted-foreground truncate">
|
<span className="text-[10px] text-muted-foreground truncate">
|
||||||
{new URL(account.serverUrl).hostname}
|
{(() => { try { return new URL(account.serverUrl).hostname; } catch { return account.serverUrl; } })()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
import { useState, useMemo, useRef, useCallback } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
||||||
import { Search, X } from 'lucide-react';
|
import { Search, X } from 'lucide-react';
|
||||||
|
|||||||
@@ -2,16 +2,25 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard, Plus } from "lucide-react";
|
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check } from "lucide-react";
|
||||||
import { AccountSwitcher } from "./account-switcher";
|
import { AccountSwitcher } from "./account-switcher";
|
||||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||||
|
import { useConfig } from "@/hooks/use-config";
|
||||||
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { usePathname, Link } from "@/i18n/navigation";
|
import { usePathname, Link } from "@/i18n/navigation";
|
||||||
|
import NextLink from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import { getInitials } from "@/lib/account-utils";
|
||||||
import { cn, formatFileSize } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -69,10 +78,10 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to
|
|||||||
|
|
||||||
const free = quota.total - quota.used;
|
const free = quota.total - quota.used;
|
||||||
const strokeColor = usagePercent > 90
|
const strokeColor = usagePercent > 90
|
||||||
? "stroke-red-500 dark:stroke-red-400"
|
? "stroke-destructive"
|
||||||
: usagePercent > 70
|
: usagePercent > 70
|
||||||
? "stroke-amber-500 dark:stroke-amber-400"
|
? "stroke-warning"
|
||||||
: "stroke-green-500 dark:stroke-green-400";
|
: "stroke-success";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -119,10 +128,10 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to
|
|||||||
className={cn(
|
className={cn(
|
||||||
"h-1.5 rounded-full transition-all",
|
"h-1.5 rounded-full transition-all",
|
||||||
usagePercent > 90
|
usagePercent > 90
|
||||||
? "bg-red-500 dark:bg-red-400"
|
? "bg-destructive"
|
||||||
: usagePercent > 70
|
: usagePercent > 70
|
||||||
? "bg-amber-500 dark:bg-amber-400"
|
? "bg-warning"
|
||||||
: "bg-green-500 dark:bg-green-400"
|
: "bg-success"
|
||||||
)}
|
)}
|
||||||
style={{ width: `${usagePercent}%` }}
|
style={{ width: `${usagePercent}%` }}
|
||||||
/>
|
/>
|
||||||
@@ -152,11 +161,86 @@ export function NavigationRail({
|
|||||||
}: NavigationRailProps) {
|
}: NavigationRailProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||||
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
const { supportsCalendar } = useCalendarStore();
|
const { supportsCalendar } = useCalendarStore();
|
||||||
const { mailboxes } = useEmailStore();
|
const { mailboxes } = useEmailStore();
|
||||||
const { supportsWebDAV } = useWebDAVStore();
|
const { supportsWebDAV } = useWebDAVStore();
|
||||||
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
||||||
|
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
|
||||||
|
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
||||||
|
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
||||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||||
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
|
|
||||||
|
// Account list for rail
|
||||||
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
|
const activeAccountId = useAccountStore((s) => s.activeAccountId);
|
||||||
|
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||||
|
const [logoutMenuOpen, setLogoutMenuOpen] = useState(false);
|
||||||
|
const logoutBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const logoutPopoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [logoutPopoverStyle, setLogoutPopoverStyle] = useState<React.CSSProperties>({});
|
||||||
|
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||||
|
|
||||||
|
const updateLogoutPosition = useCallback(() => {
|
||||||
|
if (!logoutBtnRef.current) return;
|
||||||
|
const rect = logoutBtnRef.current.getBoundingClientRect();
|
||||||
|
setLogoutPopoverStyle({
|
||||||
|
position: "fixed",
|
||||||
|
left: rect.right + 8,
|
||||||
|
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!logoutMenuOpen) return;
|
||||||
|
updateLogoutPosition();
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
logoutBtnRef.current?.contains(e.target as Node) ||
|
||||||
|
logoutPopoverRef.current?.contains(e.target as Node)
|
||||||
|
) return;
|
||||||
|
setLogoutMenuOpen(false);
|
||||||
|
};
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setLogoutMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
};
|
||||||
|
}, [logoutMenuOpen, updateLogoutPosition]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const { client } = useAuthStore.getState();
|
||||||
|
if (!client) return;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Authorization': client.getAuthHeader(),
|
||||||
|
'X-JMAP-Server-URL': client.getServerUrl(),
|
||||||
|
'X-JMAP-Username': client.getUsername(),
|
||||||
|
};
|
||||||
|
fetch('/api/admin/stalwart-check', { headers })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!cancelled && data.isStalwartAdmin) {
|
||||||
|
setIsStalwartAdmin(true);
|
||||||
|
// Pre-create admin session so /admin works even after full page navigation
|
||||||
|
fetch('/api/admin/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...headers },
|
||||||
|
body: JSON.stringify({ stalwartAuth: true }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
||||||
@@ -180,7 +264,7 @@ export function NavigationRail({
|
|||||||
if (orientation === "horizontal") {
|
if (orientation === "horizontal") {
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className={cn("flex items-center justify-around bg-background border-t border-border shrink-0", className)}
|
className={cn("flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden", className)}
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label={t("nav_label")}
|
aria-label={t("nav_label")}
|
||||||
>
|
>
|
||||||
@@ -193,7 +277,7 @@ export function NavigationRail({
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
|
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
|
||||||
"transition-colors duration-150",
|
"transition-colors duration-150",
|
||||||
isActive
|
isActive
|
||||||
? "text-primary"
|
? "text-primary"
|
||||||
@@ -218,7 +302,7 @@ export function NavigationRail({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Custom sidebar apps (per-app mobile visibility) */}
|
{/* Custom sidebar apps (per-app mobile visibility) */}
|
||||||
{sidebarApps.filter((app) => app.showOnMobile).map((app) => {
|
{visibleSidebarApps.filter((app) => app.showOnMobile).map((app) => {
|
||||||
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||||
const isActive = activeAppId === app.id;
|
const isActive = activeAppId === app.id;
|
||||||
return (
|
return (
|
||||||
@@ -234,7 +318,7 @@ export function NavigationRail({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
|
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
|
||||||
"transition-colors duration-150",
|
"transition-colors duration-150",
|
||||||
isActive
|
isActive
|
||||||
? "text-primary"
|
? "text-primary"
|
||||||
@@ -252,12 +336,27 @@ export function NavigationRail({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Admin (Stalwart admins) */}
|
||||||
|
{isStalwartAdmin && (
|
||||||
|
<NextLink
|
||||||
|
href="/admin"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
|
||||||
|
"transition-colors duration-150",
|
||||||
|
"text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Shield className="w-5 h-5" />
|
||||||
|
<span className="text-[10px] font-medium leading-tight">{t("admin") || "Admin"}</span>
|
||||||
|
</NextLink>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
|
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
|
||||||
"transition-colors duration-150",
|
"transition-colors duration-150",
|
||||||
isSettingsActive
|
isSettingsActive
|
||||||
? "text-primary"
|
? "text-primary"
|
||||||
@@ -287,6 +386,19 @@ export function NavigationRail({
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{(() => {
|
||||||
|
const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl);
|
||||||
|
return logoUrl ? (
|
||||||
|
<div className="flex items-center justify-center py-3 px-1">
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-8 h-8 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col",
|
"flex flex-col",
|
||||||
@@ -333,7 +445,7 @@ export function NavigationRail({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Custom sidebar apps */}
|
{/* Custom sidebar apps */}
|
||||||
{sidebarApps.length > 0 && (
|
{visibleSidebarApps.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-t",
|
"border-t",
|
||||||
@@ -342,7 +454,7 @@ export function NavigationRail({
|
|||||||
style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }}
|
style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sidebarApps.map((app) => {
|
{visibleSidebarApps.map((app) => {
|
||||||
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||||
const isActive = activeAppId === app.id;
|
const isActive = activeAppId === app.id;
|
||||||
return (
|
return (
|
||||||
@@ -397,8 +509,20 @@ export function NavigationRail({
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer: Settings + Help + Storage Quota + Sign Out + Push Status */}
|
<PluginSlot name="navigation-rail-bottom" />
|
||||||
|
|
||||||
|
{/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
|
||||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
||||||
|
{isStalwartAdmin && (
|
||||||
|
<NextLink
|
||||||
|
href="/admin"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title={t("admin") || "Admin"}
|
||||||
|
>
|
||||||
|
<Shield className="w-[18px] h-[18px]" />
|
||||||
|
</NextLink>
|
||||||
|
)}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||||
@@ -427,6 +551,22 @@ export function NavigationRail({
|
|||||||
<Keyboard className="w-[18px] h-[18px]" />
|
<Keyboard className="w-[18px] h-[18px]" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!onShowShortcuts && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowShortcutsModal(true)}
|
||||||
|
data-tour="nav-shortcuts"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
title={t("keyboard_shortcuts")}
|
||||||
|
>
|
||||||
|
<Keyboard className="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
<KeyboardShortcutsModal
|
||||||
|
isOpen={showShortcutsModal}
|
||||||
|
onClose={() => setShowShortcutsModal(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{quota && quota.total > 0 && (
|
{quota && quota.total > 0 && (
|
||||||
<div data-tour="storage-quota">
|
<div data-tour="storage-quota">
|
||||||
@@ -434,21 +574,85 @@ export function NavigationRail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPushConnected != null && (
|
{onLogout && showRailAccountList && accounts.length > 0 && (
|
||||||
<span
|
<>
|
||||||
className="relative group"
|
<div className="w-8 border-t my-1" style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }} />
|
||||||
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
|
|
||||||
>
|
{/* Account circles */}
|
||||||
<span
|
<div className="flex flex-col items-center gap-3">
|
||||||
className={cn(
|
{accounts.map((account) => {
|
||||||
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
|
const isActive = account.id === activeAccountId;
|
||||||
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
|
const initials = getInitials(account.displayName || account.label, account.email || account.username);
|
||||||
)}
|
return (
|
||||||
/>
|
<button
|
||||||
</span>
|
key={account.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isActive) switchAccount(account.id);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center justify-center w-8 h-8 rounded-full text-white text-[11px] font-medium transition-all flex-shrink-0",
|
||||||
|
isActive
|
||||||
|
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||||
|
: "opacity-70 hover:opacity-100"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: account.avatarColor }}
|
||||||
|
title={`${account.displayName || account.label} (${account.email || account.username})`}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
{isActive && (
|
||||||
|
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
|
||||||
|
<Check className="w-2 h-2 text-primary-foreground" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logout button with popover */}
|
||||||
|
<button
|
||||||
|
ref={logoutBtnRef}
|
||||||
|
onClick={() => setLogoutMenuOpen(!logoutMenuOpen)}
|
||||||
|
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
title={t("sign_out")}
|
||||||
|
aria-expanded={logoutMenuOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{logoutMenuOpen && createPortal(
|
||||||
|
<div
|
||||||
|
ref={logoutPopoverRef}
|
||||||
|
style={logoutPopoverStyle}
|
||||||
|
className="w-56 rounded-lg border border-border bg-background text-foreground shadow-lg z-50 overflow-hidden"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => { setLogoutMenuOpen(false); logout(); }}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-foreground hover:bg-muted transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
{t("sign_out")}
|
||||||
|
</button>
|
||||||
|
{accounts.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setLogoutMenuOpen(false); logoutAll(); }}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-destructive hover:bg-muted transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
{t("sign_out_all")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{onLogout && (
|
{onLogout && !showRailAccountList && (
|
||||||
<AccountSwitcher variant="rail" />
|
<AccountSwitcher variant="rail" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { X, Plus, Pencil, Trash2, GripVertical, ExternalLink, PanelRight } from 'lucide-react';
|
import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight } from 'lucide-react';
|
||||||
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|||||||
+136
-53
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Inbox,
|
Inbox,
|
||||||
@@ -11,7 +12,6 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
Archive,
|
Archive,
|
||||||
PenSquare,
|
|
||||||
ChevronsLeft,
|
ChevronsLeft,
|
||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -41,8 +41,6 @@ import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/s
|
|||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
|
||||||
import { AccountSwitcher } from "./account-switcher";
|
import { AccountSwitcher } from "./account-switcher";
|
||||||
import { useTour } from "@/components/tour/tour-provider";
|
import { useTour } from "@/components/tour/tour-provider";
|
||||||
|
|
||||||
@@ -310,7 +308,7 @@ function TagItem({
|
|||||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", palette?.dot || "bg-gray-400", !isCollapsed && "mr-2")} />
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", palette?.dot || "bg-gray-400", !isCollapsed && "mr-2")} />
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<>
|
<>
|
||||||
<span className="truncate">{kw.label}</span>
|
<span className="flex-1 truncate">{kw.label}</span>
|
||||||
<span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
|
<span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
@@ -429,11 +427,16 @@ export function Sidebar({
|
|||||||
onUnreadFilterClick,
|
onUnreadFilterClick,
|
||||||
className,
|
className,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
|
const router = useRouter();
|
||||||
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||||
const { primaryIdentity } = useAuthStore();
|
const { primaryIdentity: _primaryIdentity } = useAuthStore();
|
||||||
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
|
const [foldersExpanded, setFoldersExpanded] = useState(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('sidebarFoldersExpanded');
|
||||||
|
return stored !== null ? JSON.parse(stored) : true;
|
||||||
|
} catch { return true; }
|
||||||
|
});
|
||||||
const [tagsExpanded, setTagsExpanded] = useState(() => {
|
const [tagsExpanded, setTagsExpanded] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('sidebarTagsExpanded');
|
const stored = localStorage.getItem('sidebarTagsExpanded');
|
||||||
@@ -441,6 +444,7 @@ export function Sidebar({
|
|||||||
} catch { return true; }
|
} catch { return true; }
|
||||||
});
|
});
|
||||||
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||||
|
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher);
|
||||||
const tagCounts = useEmailStore(s => s.tagCounts);
|
const tagCounts = useEmailStore(s => s.tagCounts);
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
|
|
||||||
@@ -455,10 +459,17 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const tree = buildMailboxTree(mailboxes);
|
const tree = buildMailboxTree(mailboxes);
|
||||||
const defaultExpanded = tree
|
const collectExpandable = (nodes: MailboxNode[]): string[] => {
|
||||||
.filter(node => node.children.length > 0)
|
const ids: string[] = [];
|
||||||
.map(node => node.id);
|
for (const node of nodes) {
|
||||||
setExpandedFolders(new Set(defaultExpanded));
|
if (node.children.length > 0) {
|
||||||
|
ids.push(node.id);
|
||||||
|
ids.push(...collectExpandable(node.children));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
setExpandedFolders(new Set(collectExpandable(tree)));
|
||||||
}
|
}
|
||||||
}, [mailboxes]);
|
}, [mailboxes]);
|
||||||
|
|
||||||
@@ -532,17 +543,6 @@ export function Sidebar({
|
|||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{(() => {
|
|
||||||
const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl);
|
|
||||||
return logoUrl ? (
|
|
||||||
<img
|
|
||||||
src={logoUrl}
|
|
||||||
alt=""
|
|
||||||
className={cn("object-contain flex-shrink-0", isCollapsed ? "w-6 h-6" : "w-6 h-6")}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -553,7 +553,7 @@ export function Sidebar({
|
|||||||
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && !hideAccountSwitcher && (
|
||||||
<AccountSwitcher variant="expanded" className="flex-1" />
|
<AccountSwitcher variant="expanded" className="flex-1" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -567,24 +567,103 @@ export function Sidebar({
|
|||||||
{/* Mailbox List */}
|
{/* Mailbox List */}
|
||||||
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{mailboxes.length === 0 ? (
|
{/* Folders Section Header */}
|
||||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
<div
|
||||||
{!isCollapsed && t("loading_mailboxes")}
|
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
|
||||||
</div>
|
className={cn(
|
||||||
) : (
|
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 font-medium",
|
||||||
|
isCollapsed ? "justify-center px-1" : "px-2",
|
||||||
|
"text-foreground hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setFoldersExpanded((prev: boolean) => {
|
||||||
|
const next = !prev;
|
||||||
|
try { localStorage.setItem('sidebarFoldersExpanded', JSON.stringify(next)); } catch { /* */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"p-0.5 rounded mr-1 transition-all duration-200",
|
||||||
|
"hover:bg-muted active:bg-accent"
|
||||||
|
)}
|
||||||
|
title={foldersExpanded ? t('collapse_tooltip') : t('expand_tooltip')}
|
||||||
|
>
|
||||||
|
{foldersExpanded ? (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (isCollapsed) return;
|
||||||
|
setFoldersExpanded((prev: boolean) => {
|
||||||
|
const next = !prev;
|
||||||
|
try { localStorage.setItem('sidebarFoldersExpanded', JSON.stringify(next)); } catch { /* */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center px-1 rounded",
|
||||||
|
"transition-colors duration-150",
|
||||||
|
isCollapsed ? "justify-center" : "flex-1 text-left"
|
||||||
|
)}
|
||||||
|
style={{ paddingBlock: 'var(--density-sidebar-py)', ...(isCollapsed ? {} : { paddingLeft: '4px' }) }}
|
||||||
|
title={isCollapsed ? t("folders") : undefined}
|
||||||
|
>
|
||||||
|
<Folder className={cn(
|
||||||
|
"w-4 h-4 flex-shrink-0 transition-colors",
|
||||||
|
!isCollapsed && "mr-2",
|
||||||
|
foldersExpanded && "text-primary"
|
||||||
|
)} />
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span className="flex-1 truncate">{t("folders")}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ }
|
||||||
|
router.push('/settings');
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted active:bg-accent ml-auto mr-1"
|
||||||
|
title={t('settings')}
|
||||||
|
>
|
||||||
|
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Folder Items */}
|
||||||
|
{((foldersExpanded && !isCollapsed) || isCollapsed) && (
|
||||||
<>
|
<>
|
||||||
{mailboxTree.map((node) => (
|
{mailboxes.length === 0 ? (
|
||||||
<MailboxTreeItem
|
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||||
key={node.id}
|
{!isCollapsed && t("loading_mailboxes")}
|
||||||
node={node}
|
</div>
|
||||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
) : (
|
||||||
expandedFolders={expandedFolders}
|
<>
|
||||||
onMailboxSelect={onMailboxSelect}
|
{mailboxTree.map((node) => (
|
||||||
onToggleExpand={handleToggleExpand}
|
<MailboxTreeItem
|
||||||
isCollapsed={isCollapsed}
|
key={node.id}
|
||||||
onUnreadFilterClick={onUnreadFilterClick}
|
node={node}
|
||||||
/>
|
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||||
))}
|
expandedFolders={expandedFolders}
|
||||||
|
onMailboxSelect={onMailboxSelect}
|
||||||
|
onToggleExpand={handleToggleExpand}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onUnreadFilterClick={onUnreadFilterClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -649,6 +728,20 @@ export function Sidebar({
|
|||||||
<span className="flex-1 truncate">{t("tags")}</span>
|
<span className="flex-1 truncate">{t("tags")}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try { localStorage.setItem('settings-active-tab', 'keywords'); } catch { /* */ }
|
||||||
|
router.push('/settings');
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted active:bg-accent ml-auto mr-1"
|
||||||
|
title={t('settings')}
|
||||||
|
>
|
||||||
|
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||||
@@ -671,21 +764,11 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compose Button */}
|
|
||||||
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")} data-tour="compose-button">
|
|
||||||
<PenSquare className="w-5 h-5" />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button onClick={onCompose} className="w-full" title={t("compose_hint")} data-tour="compose-button">
|
|
||||||
<PenSquare className="w-4 h-4 mr-2" />
|
|
||||||
{t("compose")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface PluginErrorBoundaryProps {
|
||||||
|
pluginId: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginErrorBoundary extends React.Component<PluginErrorBoundaryProps, PluginErrorBoundaryState> {
|
||||||
|
constructor(props: PluginErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): PluginErrorBoundaryState {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||||
|
console.error(`[plugin:${this.props.pluginId}] Render error:`, error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): React.ReactNode {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return this.props.fallback ?? null;
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { SlotRegistration } from '@/lib/plugin-types';
|
||||||
|
import { PluginErrorBoundary } from './plugin-error-boundary';
|
||||||
|
|
||||||
|
interface PluginSlotRendererProps {
|
||||||
|
registration: SlotRegistration;
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
extraProps?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginSlotRenderer({ registration, fallback = null, extraProps }: PluginSlotRendererProps) {
|
||||||
|
const Component = registration.component;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PluginErrorBoundary pluginId={registration.pluginId} fallback={fallback}>
|
||||||
|
<Component {...(extraProps ?? {})} />
|
||||||
|
</PluginErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { SlotName } from '@/lib/plugin-types';
|
||||||
|
import { usePluginStore } from '@/stores/plugin-store';
|
||||||
|
import { PluginSlotRenderer } from './plugin-slot-renderer';
|
||||||
|
|
||||||
|
interface PluginSlotProps {
|
||||||
|
name: SlotName;
|
||||||
|
className?: string;
|
||||||
|
extraProps?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginSlot({ name, className, extraProps }: PluginSlotProps) {
|
||||||
|
const registrations = usePluginStore(s => s.slots[name]);
|
||||||
|
|
||||||
|
if (!registrations || registrations.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-plugin-slot={name}>
|
||||||
|
{registrations.map((reg, i) => (
|
||||||
|
<PluginSlotRenderer
|
||||||
|
key={`${reg.pluginId}-${i}`}
|
||||||
|
registration={reg}
|
||||||
|
extraProps={extraProps}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import itMessages from '@/locales/it/common.json';
|
|||||||
import deMessages from '@/locales/de/common.json';
|
import deMessages from '@/locales/de/common.json';
|
||||||
import nlMessages from '@/locales/nl/common.json';
|
import nlMessages from '@/locales/nl/common.json';
|
||||||
import ptMessages from '@/locales/pt/common.json';
|
import ptMessages from '@/locales/pt/common.json';
|
||||||
|
import ruMessages from '@/locales/ru/common.json';
|
||||||
|
|
||||||
// Pre-loaded translations (loaded at build time, not runtime)
|
// Pre-loaded translations (loaded at build time, not runtime)
|
||||||
const ALL_MESSAGES = {
|
const ALL_MESSAGES = {
|
||||||
@@ -22,6 +23,7 @@ const ALL_MESSAGES = {
|
|||||||
de: deMessages,
|
de: deMessages,
|
||||||
nl: nlMessages,
|
nl: nlMessages,
|
||||||
pt: ptMessages,
|
pt: ptMessages,
|
||||||
|
ru: ruMessages,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IntlProviderProps {
|
interface IntlProviderProps {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { toast } from '@/stores/toast-store';
|
||||||
|
|
||||||
|
type RateLimitBlockedDetail = {
|
||||||
|
retryAfterMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RateLimitToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onRateLimitBlocked = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<RateLimitBlockedDetail>).detail;
|
||||||
|
const seconds = Math.max(1, Math.ceil((detail?.retryAfterMs ?? 0) / 1000));
|
||||||
|
|
||||||
|
toast.warning(
|
||||||
|
tCommon('rate_limited_action_title'),
|
||||||
|
tCommon('rate_limited_action_detail', { seconds }),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('bulwark:rate-limit-blocked', onRateLimitBlocked);
|
||||||
|
return () => window.removeEventListener('bulwark:rate-limit-blocked', onRateLimitBlocked);
|
||||||
|
}, [tCommon]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2 } from 'lucide-react';
|
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||||
@@ -440,20 +440,74 @@ function EncryptionSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EmailClientSection() {
|
||||||
|
const t = useTranslations('settings.security');
|
||||||
|
const { client } = useAuthStore();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const jmapUsername = client?.getUsername() || '';
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(jmapUsername).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Monitor className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h4 className="text-sm font-medium text-foreground">{t('email_client.title')}</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('email_client.description')}</p>
|
||||||
|
<div className="p-3 bg-muted/70 dark:bg-muted/40 rounded-md space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">
|
||||||
|
{t('email_client.jmap_username_label')}
|
||||||
|
</label>
|
||||||
|
<div className="flex rounded-lg">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={jmapUsername}
|
||||||
|
className="py-2 px-3 block w-full bg-background border border-border border-e-transparent rounded-s-lg text-sm text-foreground focus:z-10 focus:border-ring focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="h-[38px] px-3 shrink-0 inline-flex items-center gap-1.5 rounded-e-lg border border-border bg-muted text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||||
|
{copied ? t('email_client.copied') : t('email_client.copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground pt-1">{t('email_client.password_instructions')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AccountSecuritySettings() {
|
export function AccountSecuritySettings() {
|
||||||
const t = useTranslations('settings.security');
|
const t = useTranslations('settings.security');
|
||||||
const { isStalwart, isProbing, probe, fetchAll } = useAccountSecurityStore();
|
const { isStalwart, isProbing, probe, fetchAll, fetchAuthInfo } = useAccountSecurityStore();
|
||||||
const { isAuthenticated } = useAuthStore();
|
const { isAuthenticated, authMode } = useAuthStore();
|
||||||
|
const isOAuth = authMode === 'oauth';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && isStalwart === null) {
|
if (isAuthenticated && isStalwart === null) {
|
||||||
probe().then((detected) => {
|
probe().then((detected) => {
|
||||||
if (detected) {
|
if (detected) {
|
||||||
fetchAll();
|
if (isOAuth) {
|
||||||
|
fetchAuthInfo();
|
||||||
|
} else {
|
||||||
|
fetchAll();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, isStalwart, probe, fetchAll]);
|
}, [isAuthenticated, isStalwart, probe, fetchAll, fetchAuthInfo, isOAuth]);
|
||||||
|
|
||||||
if (isProbing) {
|
if (isProbing) {
|
||||||
return (
|
return (
|
||||||
@@ -477,40 +531,44 @@ export function AccountSecuritySettings() {
|
|||||||
return (
|
return (
|
||||||
<SettingsSection title={t('title')} description={t('description')}>
|
<SettingsSection title={t('title')} description={t('description')}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Password Change */}
|
{!isOAuth && (
|
||||||
<PasswordChangeSection />
|
<>
|
||||||
|
<PasswordChangeSection />
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
<DisplayNameSection />
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h4 className="text-sm font-medium text-foreground">{t('totp.section_title')}</h4>
|
||||||
|
</div>
|
||||||
|
<TotpSection />
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border-t border-border" />
|
|
||||||
|
|
||||||
{/* Display Name */}
|
|
||||||
<DisplayNameSection />
|
|
||||||
|
|
||||||
<div className="border-t border-border" />
|
|
||||||
|
|
||||||
{/* Two-Factor Authentication */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Shield className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<h4 className="text-sm font-medium text-foreground">{t('totp.section_title')}</h4>
|
|
||||||
</div>
|
|
||||||
<TotpSection />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-border" />
|
|
||||||
|
|
||||||
{/* App Passwords */}
|
|
||||||
<AppPasswordsSection />
|
<AppPasswordsSection />
|
||||||
|
|
||||||
<div className="border-t border-border" />
|
{isOAuth && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
<EmailClientSection />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Encryption at Rest */}
|
{!isOAuth && (
|
||||||
<div>
|
<>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="border-t border-border" />
|
||||||
<Lock className="w-4 h-4 text-muted-foreground" />
|
<div>
|
||||||
<h4 className="text-sm font-medium text-foreground">{t('encryption.section_title')}</h4>
|
<div className="flex items-center gap-2 mb-3">
|
||||||
</div>
|
<Lock className="w-4 h-4 text-muted-foreground" />
|
||||||
<EncryptionSection />
|
<h4 className="text-sm font-medium text-foreground">{t('encryption.section_title')}</h4>
|
||||||
</div>
|
</div>
|
||||||
|
<EncryptionSection />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user