Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd72dec98f | ||
|
|
1331a3767c | ||
|
|
cb200330e7 | ||
|
|
178922323d | ||
|
|
91cf125a5d | ||
|
|
f4b7ef8117 | ||
|
|
d175fc2983 | ||
|
|
2f8bbdc636 | ||
|
|
b1573aada1 | ||
|
|
1cd6cde77c | ||
|
|
802a30508a | ||
|
|
70c1ddd48c | ||
|
|
2dc8537780 | ||
|
|
128d7d0401 | ||
|
|
d3d79be64c | ||
|
|
43475945bf | ||
|
|
09302684da | ||
|
|
e68fcb4aec | ||
|
|
ded13f02cc | ||
|
|
cfdee5e5c1 | ||
|
|
e7648eb1ac | ||
|
|
e7be3d1e0c | ||
|
|
9639a6bb75 | ||
|
|
904a62ce79 | ||
|
|
2903e56cf6 | ||
|
|
b5e0189938 | ||
|
|
a44bd7c3e6 | ||
|
|
0885d3c13e | ||
|
|
ef8eb1d73b | ||
|
|
9f67bc078a | ||
|
|
da411af6d3 | ||
|
|
265908b05b | ||
|
|
2a769c2b0a | ||
|
|
28054c81ea | ||
|
|
e7264f521c | ||
|
|
1b0ca8967e | ||
|
|
3e336d459c | ||
|
|
94f55afd1f | ||
|
|
7b058ed0ac | ||
|
|
853b0eb855 | ||
|
|
41a458d872 | ||
|
|
d1c5dba7d7 | ||
|
|
8c50abe221 | ||
|
|
07367a8a5d | ||
|
|
1a50788c91 | ||
|
|
0e06bfe273 | ||
|
|
f68e41d81a | ||
|
|
8b164c556e | ||
|
|
2e1f53c899 | ||
|
|
a6d2efaf74 | ||
|
|
01cd9644ed | ||
|
|
1521826d37 | ||
|
|
0d218d0d2a | ||
|
|
9777dd655c | ||
|
|
f970fd1822 | ||
|
|
5e096240b3 | ||
|
|
bc97a1ac10 | ||
|
|
4594fb2572 | ||
|
|
5319562c94 | ||
|
|
599fa66822 | ||
|
|
8041700668 | ||
|
|
bade68a8b8 | ||
|
|
4be7176802 | ||
|
|
8813533958 | ||
|
|
affa239d75 | ||
|
|
5d292fa43f | ||
|
|
878df6bb49 | ||
|
|
607a9584fd | ||
|
|
b8e2bfd793 | ||
|
|
4ad6d37877 | ||
|
|
d7c29b7bec | ||
|
|
b86bc541ab | ||
|
|
8c74e01a40 | ||
|
|
231a9017d2 | ||
|
|
9a5bb78b18 | ||
|
|
eecf16daa2 | ||
|
|
210150a02e | ||
|
|
e50691d6c4 | ||
|
|
089963b1b3 | ||
|
|
4af952613a | ||
|
|
0d9fa0285f | ||
|
|
683fe75864 | ||
|
|
e9c9be84ad | ||
|
|
7822a363dd | ||
|
|
1e535e96a2 | ||
|
|
32135ddb95 | ||
|
|
841513e510 | ||
|
|
5964b2e456 |
@@ -50,6 +50,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
GIT_COMMIT=${{ github.sha }}
|
||||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
GIT_COMMIT=${{ github.sha }}
|
||||||
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||||
|
|||||||
+101
@@ -1,5 +1,106 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.6.2 (2026-05-06)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Plugins**: Hot-reload and dev-folder loading for live plugin development
|
||||||
|
- **Plugins**: On-demand `src/` bundling via esbuild
|
||||||
|
- **Plugins**: New `http:fetch` permission and `httpOrigins` manifest field
|
||||||
|
- **Plugins**: `onBeforeEmailSend` hook with `fromEmail` exposed on `OutgoingEmail`
|
||||||
|
- **Plugins**: Project `EmailReadView` for the email-banner slot and expose auth results
|
||||||
|
- **Plugins**: Ingest icon, banner, and screenshots from the source repo
|
||||||
|
- **Plugins**: Restrict plugin and theme install/uninstall to the admin dashboard
|
||||||
|
- **Mail**: Multi-server JMAP support
|
||||||
|
- **Settings**: Fulltext search across the settings sidebar
|
||||||
|
- **Settings**: Sub-result rows with highlight in settings search
|
||||||
|
- **Settings**: Surface plugin settings as search sub-results
|
||||||
|
- **Settings**: Remove experimental tags from themes, plugins, and sender favicons
|
||||||
|
- **Viewer**: Redesigned external-mail banner above attachments
|
||||||
|
- **Calendar**: Calendar invitation banner expands on row click
|
||||||
|
- **Calendar**: Calendar invitation banner is now collapsible
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Admin**: Collapse admin panel into a single tabbed page
|
||||||
|
- **Plugins**: Inline plugin configure panel to avoid dev-mode hang
|
||||||
|
- **Plugins**: Resolve `PLUGIN_DEV_DIR` plugins in admin config route
|
||||||
|
- **Plugins**: Add missing body type assertion in `createPluginAPI` fetch options
|
||||||
|
- **Plugins**: Propagate `settingsSchema`
|
||||||
|
- **Settings**: Highlight plugin and theme cards in search results
|
||||||
|
- **Settings**: Open plugin card on first click of a setting sub-result
|
||||||
|
- **Settings**: Drop ghost sub-results from account and language search
|
||||||
|
- **Settings**: Improve search highlight styling
|
||||||
|
- **Viewer**: Show notification banners above attachments
|
||||||
|
- **Viewer**: Rework S/MIME banner to match calendar invitation
|
||||||
|
- **Viewer**: Close PDF preview on Escape before email viewer
|
||||||
|
- **Viewer**: Render PDF previews via `<object>` with `blob:` in object-src CSP (#253)
|
||||||
|
- **Calendar**: Align invitation icon with sender avatar column
|
||||||
|
- **Calendar**: Fix invitation picker clipping (#250)
|
||||||
|
- **Auth**: Read `activeAccountId` from authStore in account selectors
|
||||||
|
- **UI**: Adjust toast item border radius and progress bar styles
|
||||||
|
- **UI**: Remove fly-in animation from context menu submenus
|
||||||
|
- **i18n**: Add missing Czech flag icon
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
- Add missing translation keys across 15 locales
|
||||||
|
|
||||||
|
## 1.6.1 (2026-05-04)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Updates**: Update-available detection with non-dismissible notice and dev-reload refresh
|
||||||
|
- **Plugins**: New plugin hooks for compose, attachments, search, lifecycle, and routing
|
||||||
|
- **Sharing**: Share indicators for calendars and contacts, updated JMAP capabilities (#244)
|
||||||
|
- **Mail**: Auto-add recipients to trusted senders when replying
|
||||||
|
- **Identity**: Sanitize identity display name to prevent invalid `From` headers
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Mobile**: Synchronize mobile submenu view with browser history for better navigation
|
||||||
|
- **Viewer**: Update email viewer styles to improve overflow handling
|
||||||
|
- **Auth**: Ensure `cookieSlot` consistency during account updates in auth store
|
||||||
|
- **Auth**: Thread per-account cookie slot through OAuth flows
|
||||||
|
- **Calendar**: Square the colored left marker on calendar events
|
||||||
|
- **About**: Show git commit in About instead of "unknown"
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
- Update mailbox context menu translations across 12 locales
|
||||||
|
|
||||||
|
## 1.6.0 (2026-05-01)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Deployment**: Subpath deployment support via `NEXT_PUBLIC_BASE_PATH` environment variable
|
||||||
|
- **Mail**: Image attachment thumbnails and preview chips
|
||||||
|
- **Mobile**: Reworked mobile mail viewer toolbar
|
||||||
|
- **Mobile**: Mobile-friendly settings panel
|
||||||
|
- **Mobile**: Mobile-friendly admin panel
|
||||||
|
- **Mail**: Redesigned expanded details panel
|
||||||
|
- **Mailbox**: Show full path in mailbox context menu header with intelligent path shortening
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Viewer**: Respect per-email dark mode toggle when "always show in light mode" is on
|
||||||
|
- **Navigation**: Scroll apps list in navigation rail to prevent overflow
|
||||||
|
- **Context menu**: Clamp submenu inside viewport
|
||||||
|
- **Context menu**: Prevent context menu from clipping below viewport
|
||||||
|
- **Context menu**: Prevent jump and animation on open
|
||||||
|
- **Mail**: Stop silently destroying emails when trash mailbox isn't found (#195)
|
||||||
|
- **Mail**: Preserve list scroll position when tagging an email
|
||||||
|
- **Mail**: Render below-header overflow popup outside clipped row
|
||||||
|
- **Mail**: Collapse below-header attachments to single row with overflow pill
|
||||||
|
- **Push**: Fix push preview JMAP query
|
||||||
|
- **Tour**: Navigate tour to mailbox when starting from another page
|
||||||
|
- **i18n**: Add `useTranslations` for "selected emails" and "cancel" on email list batch operations
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
- Translate SPF/DKIM/DMARC tooltips
|
||||||
|
- Add missing keys across 14 locales
|
||||||
|
|
||||||
## 1.5.4 (2026-05-01)
|
## 1.5.4 (2026-05-01)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ COPY package.json package-lock.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# Optional: serve under a subpath like /webmail. Baked into emitted asset URLs
|
||||||
|
# at build time, so it cannot be changed without rebuilding.
|
||||||
|
ARG NEXT_PUBLIC_BASE_PATH=
|
||||||
|
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||||
|
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
|
||||||
|
# `git rev-parse` inside the build can't find it - CI must pass it in.
|
||||||
|
ARG GIT_COMMIT=unknown
|
||||||
|
ENV GIT_COMMIT=$GIT_COMMIT
|
||||||
RUN npx next build --webpack
|
RUN npx next build --webpack
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
[](https://grafana.external.bulwarkmail.org/)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -53,11 +54,9 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
> **Anonymous telemetry is on by default** since 1.5.3. Each instance sends a daily heartbeat (version, platform, bucketed account counts, feature toggles - no message data, no PII). Disable from **Admin → Telemetry**, by setting `BULWARK_TELEMETRY=off`, or by clearing the endpoint. Full schema: [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
|
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Bulwark is a full webmail suite – not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
||||||
|
|
||||||
- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
|
- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
|
||||||
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||||
@@ -219,6 +218,26 @@ LOG_LEVEL=info # error | warn | info | debug
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Subpath / reverse proxy mount</summary>
|
||||||
|
|
||||||
|
To serve the webmail at a subpath (e.g. `https://example.com/webmail`):
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_BASE_PATH=/webmail
|
||||||
|
NEXT_PUBLIC_LOCALE_PREFIX=always # avoids next-intl rewrite loops
|
||||||
|
```
|
||||||
|
|
||||||
|
Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** because Next.js bakes it into emitted asset URLs. To use it with the published Docker image, build your own image with the variable set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .
|
||||||
|
```
|
||||||
|
|
||||||
|
Then point your reverse proxy at the container without stripping the prefix - the app expects to receive requests under `/webmail/...` and serves all routes (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, etc.) accordingly.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function OAuthCallbackInner() {
|
|||||||
|
|
||||||
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
|
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
|
||||||
const serverUrl = sessionStorage.getItem("oauth_server_url");
|
const serverUrl = sessionStorage.getItem("oauth_server_url");
|
||||||
|
const serverId = sessionStorage.getItem("oauth_server_id") || undefined;
|
||||||
|
|
||||||
if (!codeVerifier || !serverUrl) {
|
if (!codeVerifier || !serverUrl) {
|
||||||
setError("missing_params");
|
setError("missing_params");
|
||||||
@@ -52,12 +53,13 @@ function OAuthCallbackInner() {
|
|||||||
const prefix = getPathPrefix(params.locale as string);
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||||
|
|
||||||
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri)
|
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri, serverId)
|
||||||
.then((success) => {
|
.then((success) => {
|
||||||
if (success) {
|
if (success) {
|
||||||
sessionStorage.removeItem("oauth_state");
|
sessionStorage.removeItem("oauth_state");
|
||||||
sessionStorage.removeItem("oauth_code_verifier");
|
sessionStorage.removeItem("oauth_code_verifier");
|
||||||
sessionStorage.removeItem("oauth_server_url");
|
sessionStorage.removeItem("oauth_server_url");
|
||||||
|
sessionStorage.removeItem("oauth_server_id");
|
||||||
sessionStorage.removeItem("oauth_add_account_mode");
|
sessionStorage.removeItem("oauth_add_account_mode");
|
||||||
let redirectTo = `${prefix}/${params.locale}`;
|
let redirectTo = `${prefix}/${params.locale}`;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+188
-33
@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
@@ -16,6 +17,15 @@ import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor,
|
|||||||
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";
|
||||||
|
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
||||||
|
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
|
||||||
|
|
||||||
|
function findServerByDomain(servers: PublicJmapServerEntry[], email: string | undefined): PublicJmapServerEntry | undefined {
|
||||||
|
if (!email || !email.includes("@")) return undefined;
|
||||||
|
const domain = email.split("@")[1]?.trim().toLowerCase();
|
||||||
|
if (!domain) return undefined;
|
||||||
|
return servers.find((s) => (s.domains ?? []).some((d) => d.toLowerCase() === domain));
|
||||||
|
}
|
||||||
|
|
||||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||||
@@ -28,7 +38,12 @@ const THEME_OPTIONS = [
|
|||||||
|
|
||||||
function VersionBadge() {
|
function VersionBadge() {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`;
|
const banner = useUpdateStore(useShallow(selectBanner));
|
||||||
|
const startPolling = useUpdateStore((s) => s.startPolling);
|
||||||
|
|
||||||
|
useEffect(() => { startPolling(); }, [startPolling]);
|
||||||
|
|
||||||
|
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}${banner?.latest ? `\nLatest: ${banner.latest}` : ""}`;
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
navigator.clipboard.writeText(versionInfo).then(() => {
|
navigator.clipboard.writeText(versionInfo).then(() => {
|
||||||
@@ -37,16 +52,49 @@ function VersionBadge() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isRed = banner?.variant === "red";
|
||||||
|
const triggerText = !banner
|
||||||
|
? `v${APP_VERSION}`
|
||||||
|
: banner.severity === "security"
|
||||||
|
? "Security update available"
|
||||||
|
: banner.severity === "deprecated"
|
||||||
|
? "Version no longer supported"
|
||||||
|
: "New version available";
|
||||||
|
|
||||||
|
const triggerColor = !banner
|
||||||
|
? "text-muted-foreground/40"
|
||||||
|
: isRed
|
||||||
|
? "text-red-600/80 dark:text-red-400/80 hover:text-red-600 dark:hover:text-red-400"
|
||||||
|
: "text-amber-600/80 dark:text-amber-400/80 hover:text-amber-600 dark:hover:text-amber-400";
|
||||||
|
|
||||||
|
const triggerClass = cn(
|
||||||
|
"peer text-center text-xs transition-colors",
|
||||||
|
triggerColor,
|
||||||
|
banner?.url ? "cursor-pointer underline-offset-2 hover:underline" : "cursor-default",
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigger = banner?.url ? (
|
||||||
|
<a href={banner.url} target="_blank" rel="noopener noreferrer" className={triggerClass}>
|
||||||
|
{triggerText}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<p className={triggerClass}>{triggerText}</p>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative inline-flex justify-center">
|
<div className="relative inline-flex justify-center">
|
||||||
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default">
|
{trigger}
|
||||||
v{APP_VERSION}
|
|
||||||
</p>
|
|
||||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
|
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
|
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
|
||||||
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
|
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
|
||||||
|
{banner?.latest && (
|
||||||
|
<p>Latest: <span className="font-medium">{banner.latest}</span></p>
|
||||||
|
)}
|
||||||
|
{banner?.advisory && (
|
||||||
|
<p className="text-red-500 dark:text-red-400">{banner.advisory}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
@@ -69,7 +117,7 @@ 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, allowCustomJmapEndpoint } = useConfig();
|
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -77,6 +125,18 @@ export default function LoginPage() {
|
|||||||
password: "",
|
password: "",
|
||||||
});
|
});
|
||||||
const [jmapEndpoint, setJmapEndpoint] = useState("");
|
const [jmapEndpoint, setJmapEndpoint] = useState("");
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
|
||||||
|
const [domainAutoLocked, setDomainAutoLocked] = useState(false);
|
||||||
|
|
||||||
|
const hasServerList = jmapServers.length > 0;
|
||||||
|
const selectedServer = hasServerList
|
||||||
|
? jmapServers.find((s) => s.id === selectedServerId) ?? jmapServers[0]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Effective values: per-server overrides win, then global config.
|
||||||
|
const serverUrl = selectedServer?.url || configuredServerUrl;
|
||||||
|
const effectiveOauthClientId = selectedServer?.oauth?.clientId || globalOauthClientId;
|
||||||
|
const effectiveOauthIssuerUrl = selectedServer?.oauth?.issuerUrl || globalOauthIssuerUrl;
|
||||||
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);
|
||||||
@@ -117,6 +177,27 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [serverUrl, jmapEndpoint]);
|
}, [serverUrl, jmapEndpoint]);
|
||||||
|
|
||||||
|
// Initialize selected server when the server list arrives. Picks the first
|
||||||
|
// entry; the auto-pick effect below may override based on the email domain.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasServerList) return;
|
||||||
|
if (selectedServerId && jmapServers.some((s) => s.id === selectedServerId)) return;
|
||||||
|
setSelectedServerId(jmapServers[0].id);
|
||||||
|
}, [hasServerList, jmapServers, selectedServerId]);
|
||||||
|
|
||||||
|
// Auto-pick by email domain. Locks the dropdown to the matched server until
|
||||||
|
// the user clears the email or types a domain we don't recognize.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jmapServerAutoPickByDomain || !hasServerList) return;
|
||||||
|
const match = findServerByDomain(jmapServers, formData.username);
|
||||||
|
if (match) {
|
||||||
|
if (selectedServerId !== match.id) setSelectedServerId(match.id);
|
||||||
|
setDomainAutoLocked(true);
|
||||||
|
} else {
|
||||||
|
setDomainAutoLocked(false);
|
||||||
|
}
|
||||||
|
}, [jmapServerAutoPickByDomain, hasServerList, jmapServers, formData.username, selectedServerId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
if (sessionStorage.getItem('session_expired') === 'true') {
|
if (sessionStorage.getItem('session_expired') === 'true') {
|
||||||
@@ -214,7 +295,9 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!oauthEnabled || !serverUrl) return;
|
if (!oauthEnabled || !serverUrl) return;
|
||||||
discoverOAuth(oauthIssuerUrl || serverUrl)
|
setOauthDiscoveryDone(false);
|
||||||
|
setOauthMetadata(null);
|
||||||
|
discoverOAuth(effectiveOauthIssuerUrl || serverUrl)
|
||||||
.then((metadata) => {
|
.then((metadata) => {
|
||||||
setOauthMetadata(metadata);
|
setOauthMetadata(metadata);
|
||||||
setOauthDiscoveryDone(true);
|
setOauthDiscoveryDone(true);
|
||||||
@@ -223,7 +306,7 @@ export default function LoginPage() {
|
|||||||
setOauthMetadata(null);
|
setOauthMetadata(null);
|
||||||
setOauthDiscoveryDone(true);
|
setOauthDiscoveryDone(true);
|
||||||
});
|
});
|
||||||
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
|
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]);
|
||||||
|
|
||||||
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
||||||
const ssoError = searchParams.get("sso_error");
|
const ssoError = searchParams.get("sso_error");
|
||||||
@@ -238,7 +321,11 @@ export default function LoginPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ redirect_uri: redirectUri, locale: params.locale }),
|
body: JSON.stringify({
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
locale: params.locale,
|
||||||
|
server_id: selectedServer?.id,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -264,7 +351,7 @@ export default function LoginPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
setOauthLoading(false);
|
setOauthLoading(false);
|
||||||
}
|
}
|
||||||
}, [params.locale]);
|
}, [params.locale, selectedServer?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
||||||
@@ -405,24 +492,45 @@ export default function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOAuthLogin = async () => {
|
const handleOAuthLogin = async () => {
|
||||||
if (!oauthMetadata || !oauthClientId) return;
|
if (!oauthMetadata || !effectiveOauthClientId) return;
|
||||||
setOauthLoading(true);
|
setOauthLoading(true);
|
||||||
|
|
||||||
const verifier = generateCodeVerifier();
|
const verifier = generateCodeVerifier();
|
||||||
const challenge = await generateCodeChallenge(verifier);
|
const challenge = await generateCodeChallenge(verifier);
|
||||||
const state = generateState();
|
const state = generateState();
|
||||||
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
|
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||||
|
|
||||||
|
// Resolve the JMAP URL to send to the callback. Server-list entries win
|
||||||
|
// over the custom-endpoint input, which wins over the global server URL.
|
||||||
|
const oauthServerUrl = selectedServer?.url
|
||||||
|
|| (allowCustomJmapEndpoint ? jmapEndpoint : configuredServerUrl);
|
||||||
|
|
||||||
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", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
|
sessionStorage.setItem("oauth_server_url", oauthServerUrl!);
|
||||||
|
if (selectedServer?.id) {
|
||||||
|
sessionStorage.setItem("oauth_server_id", selectedServer.id);
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem("oauth_server_id");
|
||||||
|
}
|
||||||
if (isAddAccountMode) {
|
if (isAddAccountMode) {
|
||||||
sessionStorage.setItem("oauth_add_account_mode", "true");
|
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the next-free cookie slot so loginWithOAuth (in stores/auth-store.ts)
|
||||||
|
// writes the refresh token to the correct per-account jmap_rt_<slot> cookie.
|
||||||
|
// loginWithOAuth reads this key but it was previously never written, so every
|
||||||
|
// OAuth account collapsed onto slot 0 and clobbered earlier accounts' refresh
|
||||||
|
// tokens. getNextCookieSlot() returns 0 when no accounts exist (correct for
|
||||||
|
// first sign-in) and the lowest unused slot otherwise (correct for "+ Add
|
||||||
|
// Account").
|
||||||
|
const nextSlot = useAccountStore.getState().getNextCookieSlot();
|
||||||
|
sessionStorage.setItem("oauth_cookie_slot", nextSlot.toString());
|
||||||
|
|
||||||
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
||||||
authUrl.searchParams.set("response_type", "code");
|
authUrl.searchParams.set("response_type", "code");
|
||||||
authUrl.searchParams.set("client_id", oauthClientId);
|
authUrl.searchParams.set("client_id", effectiveOauthClientId);
|
||||||
authUrl.searchParams.set("redirect_uri", redirectUri);
|
authUrl.searchParams.set("redirect_uri", redirectUri);
|
||||||
authUrl.searchParams.set("scope", OAUTH_SCOPES);
|
authUrl.searchParams.set("scope", OAUTH_SCOPES);
|
||||||
authUrl.searchParams.set("state", state);
|
authUrl.searchParams.set("state", state);
|
||||||
@@ -435,7 +543,10 @@ 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;
|
// Server-list entries always win - `allowCustomJmapEndpoint` is only honored
|
||||||
|
// when the admin hasn't configured a server list.
|
||||||
|
const effectiveServerUrl = selectedServer?.url
|
||||||
|
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
|
||||||
const success = await login(
|
const success = await login(
|
||||||
effectiveServerUrl,
|
effectiveServerUrl,
|
||||||
formData.username,
|
formData.username,
|
||||||
@@ -556,14 +667,18 @@ export default function LoginPage() {
|
|||||||
<div className="px-8 pb-10 pt-4">
|
<div className="px-8 pb-10 pt-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3",
|
"mb-5 p-3 rounded-xl border border-destructive/20 bg-destructive/5 flex items-start gap-3",
|
||||||
shakeError && "animate-shake"
|
shakeError && "animate-shake"
|
||||||
)}>
|
)}>
|
||||||
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
|
<div className="w-10 h-10 rounded-full bg-destructive/15 text-destructive flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 self-center">
|
||||||
|
<p className="text-sm text-destructive leading-relaxed">
|
||||||
{t(`error.${error}`) || t("error.generic")}
|
{t(`error.${error}`) || t("error.generic")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -703,38 +818,46 @@ export default function LoginPage() {
|
|||||||
{/* Session Expired Banner */}
|
{/* Session Expired Banner */}
|
||||||
{sessionExpired && (
|
{sessionExpired && (
|
||||||
<div
|
<div
|
||||||
className="mb-5 p-3.5 bg-blue-500/10 border border-blue-500/20 rounded-xl flex items-start gap-3"
|
className="mb-5 p-3 rounded-xl border border-info/20 bg-info/5 flex items-start gap-3"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<Info className="w-4.5 h-4.5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<p className="text-sm text-blue-700 dark:text-blue-300 flex-1 leading-relaxed">
|
<Info className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 self-center flex items-center gap-2">
|
||||||
|
<p className="text-sm text-info flex-1 leading-relaxed">
|
||||||
{t("session_expired")}
|
{t("session_expired")}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSessionExpired(false)}
|
onClick={() => setSessionExpired(false)}
|
||||||
className="p-0.5 rounded-md hover:bg-blue-500/10 transition-colors flex-shrink-0"
|
className="p-1 rounded-md text-info hover:bg-info/10 transition-colors flex-shrink-0"
|
||||||
aria-label={t("dismiss")}
|
aria-label={t("dismiss")}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3",
|
"mb-5 p-3 rounded-xl border border-destructive/20 bg-destructive/5 flex items-start gap-3",
|
||||||
shakeError && "animate-shake"
|
shakeError && "animate-shake"
|
||||||
)}>
|
)}>
|
||||||
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
|
<div className="w-10 h-10 rounded-full bg-destructive/15 text-destructive flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 self-center">
|
||||||
|
<p className="text-sm text-destructive leading-relaxed">
|
||||||
{error === 'invalid_credentials' && showTotpField && totpCode
|
{error === 'invalid_credentials' && showTotpField && totpCode
|
||||||
? t('error.totp_invalid')
|
? t('error.totp_invalid')
|
||||||
: t(`error.${error}`) || t("error.generic")}
|
: t(`error.${error}`) || t("error.generic")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dev Mode: One-click login */}
|
{/* Dev Mode: One-click login */}
|
||||||
@@ -785,12 +908,16 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : oauthDiscoveryDone ? (
|
) : oauthDiscoveryDone ? (
|
||||||
<div className="p-3.5 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2">
|
<div className="p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
|
||||||
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
|
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<p className="text-sm text-warning">
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 self-center">
|
||||||
|
<p className="text-sm text-warning leading-relaxed">
|
||||||
{t("error.oauth_discovery_failed")}
|
{t("error.oauth_discovery_failed")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||||
@@ -801,8 +928,32 @@ 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) */}
|
{/* Server picker (when admin has configured a server list) */}
|
||||||
{allowCustomJmapEndpoint && (
|
{hasServerList && jmapServers.length > 1 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="jmap-server-select" className="block text-sm font-medium text-foreground">
|
||||||
|
{t("jmap_server_label")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="jmap-server-select"
|
||||||
|
value={selectedServer?.id ?? ""}
|
||||||
|
onChange={(e) => setSelectedServerId(e.target.value)}
|
||||||
|
disabled={domainAutoLocked}
|
||||||
|
className="h-11 w-full px-3.5 bg-muted/40 border border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-sm text-foreground disabled:opacity-70 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{jmapServers.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{domainAutoLocked && (
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
{t("jmap_server_auto_picked")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* JMAP Endpoint field (only when no server list and custom endpoints are allowed) */}
|
||||||
|
{!hasServerList && allowCustomJmapEndpoint && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
|
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
|
||||||
{t("jmap_endpoint_label")}
|
{t("jmap_endpoint_label")}
|
||||||
@@ -1017,12 +1168,16 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
|
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
|
||||||
<div className="mt-2 p-3 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2">
|
<div className="mt-2 p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
|
||||||
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
|
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<p className="text-sm text-warning">
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 self-center">
|
||||||
|
<p className="text-sm text-warning leading-relaxed">
|
||||||
{t("error.oauth_discovery_failed")}
|
{t("error.oauth_discovery_failed")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+131
-12
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Sidebar } from "@/components/layout/sidebar";
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
import { EmailList } from "@/components/email/email-list";
|
import { EmailList } from "@/components/email/email-list";
|
||||||
@@ -58,6 +59,8 @@ 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 { usePluginStore } from "@/stores/plugin-store";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
|
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
||||||
|
import { emailToReadView } from "@/lib/plugin-projection";
|
||||||
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -115,6 +118,88 @@ export default function Home() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [isRateLimited, rateLimitUntil]);
|
}, [isRateLimited, rateLimitUntil]);
|
||||||
|
|
||||||
|
// Plugin hooks: window-level lifecycle + selection + service-worker messages.
|
||||||
|
// One effect because the listeners share a registration / cleanup window.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const onFocus = () => { appLifecycleHooks.onWindowFocus.emit(); };
|
||||||
|
const onBlur = () => { appLifecycleHooks.onWindowBlur.emit(); };
|
||||||
|
const onOnline = () => { appLifecycleHooks.onOnline.emit(); };
|
||||||
|
const onOffline = () => { appLifecycleHooks.onOffline.emit(); };
|
||||||
|
|
||||||
|
let selectionTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const onSelectionChange = () => {
|
||||||
|
if (selectionTimer) clearTimeout(selectionTimer);
|
||||||
|
selectionTimer = setTimeout(() => {
|
||||||
|
const sel = document.getSelection();
|
||||||
|
const text = sel?.toString() ?? '';
|
||||||
|
if (!text) return;
|
||||||
|
const anchorNode = sel?.anchorNode as Node | null;
|
||||||
|
const anchorEl = (anchorNode?.nodeType === Node.ELEMENT_NODE
|
||||||
|
? anchorNode as Element
|
||||||
|
: anchorNode?.parentElement) ?? null;
|
||||||
|
let source: 'email-body' | 'composer' | 'task-detail' | 'event-detail' | 'other' = 'other';
|
||||||
|
let emailId: string | undefined;
|
||||||
|
if (anchorEl) {
|
||||||
|
if (anchorEl.closest('[data-plugin-source="email-body"], iframe.email-body, .email-viewer-body')) {
|
||||||
|
source = 'email-body';
|
||||||
|
const idEl = anchorEl.closest('[data-email-id]') as HTMLElement | null;
|
||||||
|
emailId = idEl?.dataset.emailId;
|
||||||
|
} else if (anchorEl.closest('[data-plugin-source="composer"], .email-composer')) {
|
||||||
|
source = 'composer';
|
||||||
|
} else if (anchorEl.closest('[data-plugin-source="task-detail"]')) {
|
||||||
|
source = 'task-detail';
|
||||||
|
} else if (anchorEl.closest('[data-plugin-source="event-detail"]')) {
|
||||||
|
source = 'event-detail';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uiHooks.onTextSelectionChange.emit({ text, source, emailId });
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSwMessage = (e: MessageEvent) => {
|
||||||
|
const msg = e.data as { kind?: string; tag?: string; data?: unknown } | null;
|
||||||
|
if (msg && msg.kind === 'notificationclick' && typeof msg.tag === 'string') {
|
||||||
|
toastHooks.onNotificationClick.emit({ tag: msg.tag, data: msg.data });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('focus', onFocus);
|
||||||
|
window.addEventListener('blur', onBlur);
|
||||||
|
window.addEventListener('online', onOnline);
|
||||||
|
window.addEventListener('offline', onOffline);
|
||||||
|
document.addEventListener('selectionchange', onSelectionChange);
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
|
||||||
|
navigator.serviceWorker.addEventListener('message', onSwMessage);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('focus', onFocus);
|
||||||
|
window.removeEventListener('blur', onBlur);
|
||||||
|
window.removeEventListener('online', onOnline);
|
||||||
|
window.removeEventListener('offline', onOffline);
|
||||||
|
document.removeEventListener('selectionchange', onSelectionChange);
|
||||||
|
if (selectionTimer) clearTimeout(selectionTimer);
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
|
||||||
|
navigator.serviceWorker.removeEventListener('message', onSwMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Plugin hooks: route navigation. Tracks Next.js pathname transitions.
|
||||||
|
const pathname = usePathname();
|
||||||
|
const prevPathnameRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pathname) return;
|
||||||
|
const from = prevPathnameRef.current;
|
||||||
|
if (from === pathname) return;
|
||||||
|
if (from !== null) {
|
||||||
|
routerHooks.onRouteLeave.emit({ path: from });
|
||||||
|
routerHooks.onNavigate.emit({ path: pathname, from });
|
||||||
|
}
|
||||||
|
routerHooks.onRouteEnter.emit({ path: pathname });
|
||||||
|
prevPathnameRef.current = pathname;
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
// 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();
|
||||||
@@ -139,6 +224,7 @@ export default function Home() {
|
|||||||
deleteEmail,
|
deleteEmail,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
toggleStar,
|
toggleStar,
|
||||||
|
setEmailKeywordsLocal,
|
||||||
moveToMailbox,
|
moveToMailbox,
|
||||||
moveThreadToMailbox,
|
moveThreadToMailbox,
|
||||||
searchEmails,
|
searchEmails,
|
||||||
@@ -654,7 +740,7 @@ export default function Home() {
|
|||||||
|
|
||||||
// System-notification click handler. The push SW navigates the user back
|
// System-notification click handler. The push SW navigates the user back
|
||||||
// here with `?email=<id>` (specific email it built the toast from) or
|
// here with `?email=<id>` (specific email it built the toast from) or
|
||||||
// `?openLatestUnread=1` (generic "New mail" toast — happens when the
|
// `?openLatestUnread=1` (generic "New mail" toast - happens when the
|
||||||
// preview API failed). We resolve those params once after the inbox has
|
// preview API failed). We resolve those params once after the inbox has
|
||||||
// finished loading and open the right message, then strip the params so a
|
// finished loading and open the right message, then strip the params so a
|
||||||
// refresh doesn't re-open it.
|
// refresh doesn't re-open it.
|
||||||
@@ -834,7 +920,15 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReply = (draftText?: string) => {
|
const handleReply = async (draftText?: string) => {
|
||||||
|
if (selectedEmail) {
|
||||||
|
const ok = await emailHooks.onBeforeReply.intercept({
|
||||||
|
originalEmailId: selectedEmail.id,
|
||||||
|
originalEmail: emailToReadView(selectedEmail),
|
||||||
|
mode: 'reply' as const,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
setComposerDraftText(draftText || "");
|
setComposerDraftText(draftText || "");
|
||||||
setComposerMode('reply');
|
setComposerMode('reply');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
@@ -893,13 +987,29 @@ export default function Home() {
|
|||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReplyAll = () => {
|
const handleReplyAll = async () => {
|
||||||
|
if (selectedEmail) {
|
||||||
|
const ok = await emailHooks.onBeforeReplyAll.intercept({
|
||||||
|
originalEmailId: selectedEmail.id,
|
||||||
|
originalEmail: emailToReadView(selectedEmail),
|
||||||
|
mode: 'reply-all' as const,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
setComposerMode('replyAll');
|
setComposerMode('replyAll');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleForward = () => {
|
const handleForward = async () => {
|
||||||
|
if (selectedEmail) {
|
||||||
|
const ok = await emailHooks.onBeforeForward.intercept({
|
||||||
|
originalEmailId: selectedEmail.id,
|
||||||
|
originalEmail: emailToReadView(selectedEmail),
|
||||||
|
mode: 'forward' as const,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
setComposerMode('forward');
|
setComposerMode('forward');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
@@ -931,13 +1041,24 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Not in trash: always move to trash
|
// Not in trash: always move to trash
|
||||||
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
|
const trashMailbox =
|
||||||
|
mailboxes.find(m => m.role === 'trash' && !m.isShared) ??
|
||||||
|
mailboxes.find(m => {
|
||||||
|
if (m.isShared) return false;
|
||||||
|
const lower = m.name.toLowerCase();
|
||||||
|
return lower.includes('trash') || lower.includes('deleted');
|
||||||
|
});
|
||||||
if (trashMailbox) {
|
if (trashMailbox) {
|
||||||
try {
|
try {
|
||||||
await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
|
await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to move email to trash:", error);
|
console.error("Failed to move email to trash:", error);
|
||||||
|
const { toast } = await import('sonner');
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to move email to trash');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const { toast } = await import('sonner');
|
||||||
|
toast.error('Trash mailbox not found - cannot move email to trash');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1085,11 +1206,9 @@ export default function Home() {
|
|||||||
// Update email keywords via JMAP
|
// Update email keywords via JMAP
|
||||||
await client.updateEmailKeywords(emailId, keywords);
|
await client.updateEmailKeywords(emailId, keywords);
|
||||||
|
|
||||||
// Update local state
|
// Patch the email in place so the list keeps its scroll/pagination state
|
||||||
selectEmail(email.id === selectedEmail?.id ? { ...email, keywords } : selectedEmail);
|
// instead of being reset to the first page by a full refetch.
|
||||||
|
setEmailKeywordsLocal(emailId, keywords);
|
||||||
// Refresh emails list to show color in list
|
|
||||||
await fetchEmails(client, selectedMailbox);
|
|
||||||
|
|
||||||
// Refresh tag counts
|
// Refresh tag counts
|
||||||
fetchTagCounts(client);
|
fetchTagCounts(client);
|
||||||
@@ -1491,7 +1610,7 @@ export default function Home() {
|
|||||||
|
|
||||||
const originalEmailId = selectedEmail.id;
|
const originalEmailId = selectedEmail.id;
|
||||||
|
|
||||||
// RFC 5322 §3.6.4 threading — keep the conversation stitched together (#234).
|
// RFC 5322 §3.6.4 threading - keep the conversation stitched together (#234).
|
||||||
const threading = computeReplyThreadingHeaders({
|
const threading = computeReplyThreadingHeaders({
|
||||||
messageId: selectedEmail.messageId,
|
messageId: selectedEmail.messageId,
|
||||||
references: selectedEmail.references,
|
references: selectedEmail.references,
|
||||||
@@ -1555,7 +1674,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show the list stub immediately so subject/sender render without
|
// Show the list stub immediately so subject/sender render without
|
||||||
// waiting for the body fetch — avoids the loading flicker.
|
// waiting for the body fetch - avoids the loading flicker.
|
||||||
const listEmail = emails.find(e => e.id === email.id);
|
const listEmail = emails.find(e => e.id === email.id);
|
||||||
if (listEmail) {
|
if (listEmail) {
|
||||||
selectEmail(listEmail);
|
selectEmail(listEmail);
|
||||||
|
|||||||
+450
-32
@@ -1,14 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { useRouter } from '@/i18n/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations, useMessages } from 'next-intl';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
LogOut,
|
LogOut,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
Palette,
|
Palette,
|
||||||
|
Search,
|
||||||
User,
|
User,
|
||||||
Shield,
|
Shield,
|
||||||
UserPen,
|
UserPen,
|
||||||
@@ -31,9 +32,11 @@ import {
|
|||||||
Languages,
|
Languages,
|
||||||
Info,
|
Info,
|
||||||
Bug,
|
Bug,
|
||||||
|
X,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
||||||
import { LayoutSettings } from '@/components/settings/layout-settings';
|
import { LayoutSettings } from '@/components/settings/layout-settings';
|
||||||
import { LanguageSettings } from '@/components/settings/language-settings';
|
import { LanguageSettings } from '@/components/settings/language-settings';
|
||||||
@@ -62,6 +65,9 @@ import { ThemesSettings } from '@/components/settings/themes-settings';
|
|||||||
import { PluginsSettings } from '@/components/settings/plugins-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 { usePluginStore } from '@/stores/plugin-store';
|
||||||
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||||
import { NavigationRail } from '@/components/layout/navigation-rail';
|
import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||||
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||||
@@ -105,7 +111,6 @@ 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> = {
|
||||||
@@ -137,6 +142,175 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
|||||||
|
|
||||||
const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
|
const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
|
||||||
|
|
||||||
|
// Translation paths per tab. Tabs that share a namespace (email_behavior,
|
||||||
|
// appearance) explicitly list the subkeys they actually render so sub-results
|
||||||
|
// are attributed to the correct tab. Tabs with their own namespace just point
|
||||||
|
// at the namespace root.
|
||||||
|
const tabSearchPaths: Record<Tab, string[]> = {
|
||||||
|
account: [
|
||||||
|
'settings.account.name_label',
|
||||||
|
'settings.account.username_label',
|
||||||
|
'settings.account.account_type_label',
|
||||||
|
'settings.account.auth_method_label',
|
||||||
|
'settings.account.email',
|
||||||
|
'settings.account.server',
|
||||||
|
'settings.account.storage',
|
||||||
|
],
|
||||||
|
language: ['settings.appearance.language'],
|
||||||
|
notifications: ['settings.notifications'],
|
||||||
|
appearance: [
|
||||||
|
'settings.appearance.theme',
|
||||||
|
'settings.appearance.font_size',
|
||||||
|
'settings.appearance.list_density',
|
||||||
|
'settings.appearance.animations',
|
||||||
|
],
|
||||||
|
layout: [
|
||||||
|
'settings.appearance.toolbar_position',
|
||||||
|
'settings.appearance.toolbar_labels',
|
||||||
|
'settings.appearance.hide_account_switcher',
|
||||||
|
'settings.appearance.show_rail_account_list',
|
||||||
|
'settings.appearance.unified_mailbox',
|
||||||
|
'settings.appearance.colorful_sidebar_icons',
|
||||||
|
'settings.email_behavior.mail_layout',
|
||||||
|
],
|
||||||
|
reading: [
|
||||||
|
'settings.email_behavior.mark_read',
|
||||||
|
'settings.email_behavior.archive_mode',
|
||||||
|
'settings.email_behavior.delete_action',
|
||||||
|
'settings.email_behavior.attachment_click_action',
|
||||||
|
'settings.email_behavior.attachment_image_previews',
|
||||||
|
'settings.email_behavior.attachment_position',
|
||||||
|
'settings.email_behavior.disable_threading',
|
||||||
|
'settings.email_behavior.emails_per_page',
|
||||||
|
'settings.email_behavior.hide_inline_image_attachments',
|
||||||
|
'settings.email_behavior.hover_actions',
|
||||||
|
'settings.email_behavior.permanently_delete_junk',
|
||||||
|
'settings.email_behavior.show_preview',
|
||||||
|
'settings.email_behavior.plain_text_mode',
|
||||||
|
],
|
||||||
|
composing: [
|
||||||
|
'settings.email_behavior.attachment_reminder',
|
||||||
|
'settings.email_behavior.auto_select_reply_identity',
|
||||||
|
'settings.email_behavior.default_mail_program',
|
||||||
|
'settings.email_behavior.sub_address_delimiter',
|
||||||
|
],
|
||||||
|
identities: ['settings.identities'],
|
||||||
|
vacation: ['settings.vacation'],
|
||||||
|
filters: ['settings.filters'],
|
||||||
|
templates: ['settings.templates'],
|
||||||
|
folders: ['settings.folders'],
|
||||||
|
keywords: ['settings.keywords'],
|
||||||
|
security: ['settings.security'],
|
||||||
|
encryption: ['smime'],
|
||||||
|
content_senders: [
|
||||||
|
'settings.email_behavior.always_light_mode',
|
||||||
|
'settings.email_behavior.external_content',
|
||||||
|
'settings.email_behavior.trusted_senders',
|
||||||
|
],
|
||||||
|
calendar: ['calendar.settings', 'calendar.management'],
|
||||||
|
contacts: ['settings.contacts', 'contacts'],
|
||||||
|
files: ['settings.files'],
|
||||||
|
sidebar_apps: ['settings.sidebar_apps', 'sidebar_apps'],
|
||||||
|
about_data: ['settings.advanced'],
|
||||||
|
themes: [],
|
||||||
|
plugins: [],
|
||||||
|
debug: ['settings.advanced'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extra English keywords per tab so common search terms hit even when the
|
||||||
|
// translation doesn't contain the literal word.
|
||||||
|
const tabKeywords: Record<Tab, string> = {
|
||||||
|
account: 'profile email password user signin signout',
|
||||||
|
language: 'locale region timezone date time format',
|
||||||
|
notifications: 'sound alert push badge',
|
||||||
|
appearance: 'theme dark light font size accent color animation density',
|
||||||
|
layout: 'toolbar sidebar account switcher unified mailbox icons rail',
|
||||||
|
reading: 'mark read preview thread conversation archive delete attachment open',
|
||||||
|
composing: 'editor signature plain text reply forward draft compose',
|
||||||
|
identities: 'from address signature email',
|
||||||
|
vacation: 'auto reply away out of office holiday responder',
|
||||||
|
filters: 'sieve rules block junk forward',
|
||||||
|
templates: 'snippet quick reply',
|
||||||
|
folders: 'mailbox subscribe',
|
||||||
|
keywords: 'tags labels colors',
|
||||||
|
security: 'password 2fa two-factor passkey app password mfa',
|
||||||
|
encryption: 's/mime smime certificate pgp gpg',
|
||||||
|
content_senders: 'block sender remote images privacy tracking',
|
||||||
|
calendar: 'event schedule appointment meeting timezone',
|
||||||
|
contacts: 'address book contact',
|
||||||
|
files: 'attachments cloud drive storage upload',
|
||||||
|
sidebar_apps: 'apps webview iframe',
|
||||||
|
about_data: 'export import storage quota privacy backup',
|
||||||
|
themes: 'custom theme css skin appearance',
|
||||||
|
plugins: 'extensions addons',
|
||||||
|
debug: 'logs developer console diagnostic',
|
||||||
|
};
|
||||||
|
|
||||||
|
function flattenStrings(node: unknown, sink: string[]): void {
|
||||||
|
if (typeof node === 'string') {
|
||||||
|
sink.push(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const item of node) flattenStrings(item, sink);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node && typeof node === 'object') {
|
||||||
|
for (const value of Object.values(node)) flattenStrings(value, sink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubResult {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
// For plugin setting fields: the id of the plugin whose card needs to be
|
||||||
|
// expanded before the field becomes visible in the DOM.
|
||||||
|
pluginId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk a translation subtree and emit sub-results for renderable settings.
|
||||||
|
// Picks up:
|
||||||
|
// - bare string leaves (when a tab path points directly at a flat label)
|
||||||
|
// - objects with a `label` or `title` field (the standard pattern)
|
||||||
|
// - flat `*_label` string keys at any object level (e.g. `name_label`)
|
||||||
|
function collectSubResults(node: unknown, sink: SubResult[]): void {
|
||||||
|
if (typeof node === 'string') {
|
||||||
|
sink.push({ label: node });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
|
||||||
|
const obj = node as Record<string, unknown>;
|
||||||
|
const label = typeof obj.label === 'string' ? obj.label : (typeof obj.title === 'string' ? obj.title : undefined);
|
||||||
|
if (label) {
|
||||||
|
sink.push({
|
||||||
|
label,
|
||||||
|
description: typeof obj.description === 'string' ? obj.description : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (typeof value === 'string' && key !== 'label' && key !== 'title' && key.endsWith('_label')) {
|
||||||
|
sink.push({ label: value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const value of Object.values(obj)) {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
collectSubResults(value, sink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getByPath(obj: unknown, path: string): unknown {
|
||||||
|
let cur: unknown = obj;
|
||||||
|
for (const key of path.split('.')) {
|
||||||
|
if (cur && typeof cur === 'object' && key in (cur as Record<string, unknown>)) {
|
||||||
|
cur = (cur as Record<string, unknown>)[key];
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
|
||||||
// Map legacy tab IDs to current ones; runs once on read of localStorage.
|
// Map legacy tab IDs to current ones; runs once on read of localStorage.
|
||||||
const LEGACY_TAB_MAP: Record<string, Tab> = {
|
const LEGACY_TAB_MAP: Record<string, Tab> = {
|
||||||
email: 'reading',
|
email: 'reading',
|
||||||
@@ -170,8 +344,84 @@ export default function SettingsPage() {
|
|||||||
const { isFeatureEnabled } = usePolicyStore();
|
const { isFeatureEnabled } = usePolicyStore();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
|
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
|
||||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null);
|
||||||
const isDesktop = useIsDesktop();
|
const isDesktop = useIsDesktop();
|
||||||
|
|
||||||
|
const messages = useMessages() as Record<string, unknown>;
|
||||||
|
const installedPlugins = usePluginStore((s) => s.plugins);
|
||||||
|
const installedThemes = useThemeStore((s) => s.installedThemes);
|
||||||
|
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
|
||||||
|
|
||||||
|
// Build a per-tab haystack for fulltext search and a list of sub-results
|
||||||
|
// (individual settings) per tab. Sub-results come from translation entries
|
||||||
|
// that have a `label`/`title` field, plus dynamic content (installed
|
||||||
|
// plugins/themes/sidebar apps).
|
||||||
|
const { tabSearchHaystacks, tabSubResults } = useMemo(() => {
|
||||||
|
const haystacks: Partial<Record<Tab, string>> = {};
|
||||||
|
const subs: Partial<Record<Tab, SubResult[]>> = {};
|
||||||
|
const tabIds = Object.keys(tabSearchPaths) as Tab[];
|
||||||
|
for (const tabId of tabIds) {
|
||||||
|
const strings: string[] = [tabId.replace(/_/g, ' '), tabKeywords[tabId] ?? ''];
|
||||||
|
const list: SubResult[] = [];
|
||||||
|
for (const path of tabSearchPaths[tabId]) {
|
||||||
|
const node = getByPath(messages, path);
|
||||||
|
flattenStrings(node, strings);
|
||||||
|
collectSubResults(node, list);
|
||||||
|
}
|
||||||
|
// Dedupe sub-results by label
|
||||||
|
const seen = new Set<string>();
|
||||||
|
subs[tabId] = list.filter((r) => {
|
||||||
|
if (seen.has(r.label)) return false;
|
||||||
|
seen.add(r.label);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
haystacks[tabId] = strings.join(' ').toLowerCase();
|
||||||
|
}
|
||||||
|
if (installedPlugins.length) {
|
||||||
|
const haystackText = installedPlugins.map((p) => {
|
||||||
|
const fieldText = p.settingsSchema
|
||||||
|
? Object.values(p.settingsSchema)
|
||||||
|
.map((s) => `${s.label} ${s.description ?? ''}`)
|
||||||
|
.join(' ')
|
||||||
|
: '';
|
||||||
|
return `${p.name} ${p.description} ${p.author} ${fieldText}`;
|
||||||
|
}).join(' ');
|
||||||
|
haystacks.plugins = `${haystacks.plugins ?? ''} ${haystackText}`.toLowerCase();
|
||||||
|
const pluginSubs: SubResult[] = installedPlugins.flatMap((p) => {
|
||||||
|
const items: SubResult[] = [{ label: p.name, description: p.description }];
|
||||||
|
if (p.settingsSchema) {
|
||||||
|
for (const schema of Object.values(p.settingsSchema)) {
|
||||||
|
items.push({
|
||||||
|
label: schema.label,
|
||||||
|
description: schema.description,
|
||||||
|
pluginId: p.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
subs.plugins = [...(subs.plugins ?? []), ...pluginSubs];
|
||||||
|
}
|
||||||
|
if (installedThemes.length) {
|
||||||
|
const text = installedThemes.map((th) => `${th.name} ${th.description} ${th.author}`).join(' ');
|
||||||
|
haystacks.themes = `${haystacks.themes ?? ''} ${text}`.toLowerCase();
|
||||||
|
subs.themes = [
|
||||||
|
...(subs.themes ?? []),
|
||||||
|
...installedThemes.map((th) => ({ label: th.name, description: th.description })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (sidebarAppsList.length) {
|
||||||
|
const text = sidebarAppsList.map((a) => `${a.name} ${a.url}`).join(' ');
|
||||||
|
haystacks.sidebar_apps = `${haystacks.sidebar_apps ?? ''} ${text}`.toLowerCase();
|
||||||
|
subs.sidebar_apps = [
|
||||||
|
...(subs.sidebar_apps ?? []),
|
||||||
|
...sidebarAppsList.map((a) => ({ label: a.name, description: a.url })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return { tabSearchHaystacks: haystacks, tabSubResults: subs };
|
||||||
|
}, [messages, installedPlugins, installedThemes, sidebarAppsList]);
|
||||||
|
|
||||||
// Sidebar resize state
|
// Sidebar resize state
|
||||||
const [settingsSidebarWidth, setSettingsSidebarWidth] = useState(() => {
|
const [settingsSidebarWidth, setSettingsSidebarWidth] = useState(() => {
|
||||||
try { const v = localStorage.getItem('settings-sidebar-width'); return v ? Number(v) : 256; } catch { return 256; }
|
try { const v = localStorage.getItem('settings-sidebar-width'); return v ? Number(v) : 256; } catch { return 256; }
|
||||||
@@ -213,6 +463,88 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||||
|
|
||||||
|
// Sync the mobile submenu view with browser history so the system back
|
||||||
|
// button (or gesture) returns to the settings list before exiting /settings.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDesktop) return;
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!mobileShowContent) return;
|
||||||
|
|
||||||
|
window.history.pushState({ __settingsSubmenu: true }, '');
|
||||||
|
|
||||||
|
const handlePop = () => {
|
||||||
|
setMobileShowContent(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('popstate', handlePop);
|
||||||
|
return () => window.removeEventListener('popstate', handlePop);
|
||||||
|
}, [isDesktop, mobileShowContent]);
|
||||||
|
|
||||||
|
// After clicking a search sub-result, scroll the matching setting into view
|
||||||
|
// and add a temporary highlight class. Some tabs fetch data and render
|
||||||
|
// their SettingItems only after a loading state, so retry until the element
|
||||||
|
// shows up (or we give up after ~2s).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingHighlight) return;
|
||||||
|
if (pendingHighlight.tab !== activeTab) return;
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
// For plugin-setting sub-results, ask the plugins tab to expand the
|
||||||
|
// matching card so the field becomes part of the DOM. Dispatched here
|
||||||
|
// (not in the click handler) because PluginsSettings only mounts after
|
||||||
|
// the tab switches, and its listener registers in its own useEffect -
|
||||||
|
// child effects run before parent effects, so by the time we get here
|
||||||
|
// the listener is guaranteed to be in place.
|
||||||
|
if (pendingHighlight.pluginId) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('settings-plugin-expand', { detail: { pluginId: pendingHighlight.pluginId } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let cleanupTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let highlightedEl: HTMLElement | null = null;
|
||||||
|
|
||||||
|
const escaped = pendingHighlight.label.replace(/"/g, '\\"');
|
||||||
|
const selector = `[data-search-label="${escaped}"]`;
|
||||||
|
const deadline = Date.now() + 2000;
|
||||||
|
|
||||||
|
const tryHighlight = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const el = document.querySelector<HTMLElement>(selector);
|
||||||
|
if (!el) {
|
||||||
|
if (Date.now() < deadline) {
|
||||||
|
retryTimer = setTimeout(tryHighlight, 80);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
// Remove + reflow + add restarts the CSS animation if the class was
|
||||||
|
// already present (re-clicking the same sub-result).
|
||||||
|
el.classList.remove('settings-search-highlight');
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add('settings-search-highlight');
|
||||||
|
highlightedEl = el;
|
||||||
|
cleanupTimer = setTimeout(() => {
|
||||||
|
el.classList.remove('settings-search-highlight');
|
||||||
|
highlightedEl = null;
|
||||||
|
}, 1800);
|
||||||
|
};
|
||||||
|
|
||||||
|
// First attempt next frame so the freshly-mounted tab content is in DOM.
|
||||||
|
const raf = window.requestAnimationFrame(tryHighlight);
|
||||||
|
|
||||||
|
// Do NOT reset pendingHighlight here - that would retrigger this effect
|
||||||
|
// and the cleanup below would strip the class right after we added it.
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.cancelAnimationFrame(raf);
|
||||||
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
|
if (cleanupTimer) clearTimeout(cleanupTimer);
|
||||||
|
if (highlightedEl) highlightedEl.classList.remove('settings-search-highlight');
|
||||||
|
};
|
||||||
|
}, [pendingHighlight, activeTab]);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -255,8 +587,8 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
// Advanced
|
// Advanced
|
||||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
||||||
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup, experimental: true }] : []),
|
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup }] : []),
|
||||||
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup, experimental: true }] : []),
|
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []),
|
||||||
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -269,6 +601,30 @@ export default function SettingsPage() {
|
|||||||
}))
|
}))
|
||||||
.filter((g) => g.items.length > 0);
|
.filter((g) => g.items.length > 0);
|
||||||
|
|
||||||
|
const trimmedQuery = searchQuery.trim().toLowerCase();
|
||||||
|
const matchesQuery = (tab: TabDef) => {
|
||||||
|
if (!trimmedQuery) return true;
|
||||||
|
if (tab.label.toLowerCase().includes(trimmedQuery)) return true;
|
||||||
|
return tabSearchHaystacks[tab.id]?.includes(trimmedQuery) ?? false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const subResultsForTab = (tabId: Tab): SubResult[] => {
|
||||||
|
if (!trimmedQuery) return [];
|
||||||
|
const list = tabSubResults[tabId] ?? [];
|
||||||
|
return list
|
||||||
|
.filter((r) =>
|
||||||
|
r.label.toLowerCase().includes(trimmedQuery) ||
|
||||||
|
(r.description?.toLowerCase().includes(trimmedQuery) ?? false)
|
||||||
|
)
|
||||||
|
.slice(0, 6);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredGroupedTabs = trimmedQuery
|
||||||
|
? groupedTabs
|
||||||
|
.map((g) => ({ ...g, items: g.items.filter(matchesQuery) }))
|
||||||
|
.filter((g) => g.items.length > 0)
|
||||||
|
: groupedTabs;
|
||||||
|
|
||||||
// If active tab is not in the visible list (e.g., feature disabled), fall back.
|
// If active tab is not in the visible list (e.g., feature disabled), fall back.
|
||||||
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
|
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
|
||||||
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
|
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
|
||||||
@@ -281,6 +637,11 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubResultSelect = (tabId: Tab, sub: SubResult) => {
|
||||||
|
handleTabSelect(tabId);
|
||||||
|
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
|
||||||
|
};
|
||||||
|
|
||||||
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
||||||
|
|
||||||
const renderTabContent = () => (
|
const renderTabContent = () => (
|
||||||
@@ -321,7 +682,7 @@ export default function SettingsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setMobileShowContent(false)}
|
onClick={() => window.history.back()}
|
||||||
className="h-10 w-10"
|
className="h-10 w-10"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-5 h-5" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
@@ -330,10 +691,8 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<div className="bg-card border border-border rounded-lg p-4">
|
|
||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
@@ -365,8 +724,36 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="px-4 pt-3 pb-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t('search_placeholder')}
|
||||||
|
className="pl-9 pr-9 h-10"
|
||||||
|
aria-label={t('search_placeholder')}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:bg-muted"
|
||||||
|
aria-label={t('search_clear')}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
{groupedTabs.map((group, groupIndex) => (
|
{filteredGroupedTabs.length === 0 && (
|
||||||
|
<div className="px-5 py-6 text-sm text-muted-foreground text-center">
|
||||||
|
{t('search_no_results')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filteredGroupedTabs.map((group, groupIndex) => (
|
||||||
<div key={group.group}>
|
<div key={group.group}>
|
||||||
{groupIndex > 0 && <div className="mx-5 my-2 border-t border-border" />}
|
{groupIndex > 0 && <div className="mx-5 my-2 border-t border-border" />}
|
||||||
<div className="px-5 pt-3 pb-1.5">
|
<div className="px-5 pt-3 pb-1.5">
|
||||||
@@ -376,23 +763,29 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{group.items.map((tab) => {
|
{group.items.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
|
const subs = subResultsForTab(tab.id);
|
||||||
return (
|
return (
|
||||||
|
<div key={tab.id}>
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
|
||||||
onClick={() => handleTabSelect(tab.id)}
|
onClick={() => handleTabSelect(tab.id)}
|
||||||
className="w-full flex items-center justify-between px-5 py-3.5 text-sm text-foreground hover:bg-muted transition-colors duration-150"
|
className="w-full flex items-center justify-between px-5 py-3.5 text-sm text-foreground hover:bg-muted transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<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>
|
||||||
|
{subs.map((sub) => (
|
||||||
|
<button
|
||||||
|
key={`${tab.id}:${sub.label}`}
|
||||||
|
onClick={() => handleSubResultSelect(tab.id, sub)}
|
||||||
|
className="w-full flex items-center pl-12 pr-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-left"
|
||||||
|
>
|
||||||
|
<span className="truncate">{sub.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -463,8 +856,36 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
||||||
|
<div className="px-3 pt-1 pb-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t('search_placeholder')}
|
||||||
|
className="pl-8 pr-8 h-9 text-sm"
|
||||||
|
aria-label={t('search_placeholder')}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded-md text-muted-foreground hover:bg-muted"
|
||||||
|
aria-label={t('search_clear')}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="px-2 space-y-0.5">
|
<div className="px-2 space-y-0.5">
|
||||||
{groupedTabs.map((group, groupIndex) => (
|
{filteredGroupedTabs.length === 0 && (
|
||||||
|
<div className="px-3 py-6 text-sm text-muted-foreground text-center">
|
||||||
|
{t('search_no_results')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filteredGroupedTabs.map((group, groupIndex) => (
|
||||||
<div key={group.group}>
|
<div key={group.group}>
|
||||||
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
||||||
<div className="px-3 pt-2.5 pb-1">
|
<div className="px-3 pt-2.5 pb-1">
|
||||||
@@ -474,9 +895,10 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{group.items.map((tab) => {
|
{group.items.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
|
const subs = subResultsForTab(tab.id);
|
||||||
return (
|
return (
|
||||||
|
<div key={tab.id}>
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||||
@@ -490,12 +912,17 @@ export default function SettingsPage() {
|
|||||||
effectiveActiveTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
effectiveActiveTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||||
)} />
|
)} />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
{tab.experimental && (
|
|
||||||
<span className="ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning shrink-0">
|
|
||||||
Experimental
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
{subs.map((sub) => (
|
||||||
|
<button
|
||||||
|
key={`${tab.id}:${sub.label}`}
|
||||||
|
onClick={() => handleSubResultSelect(tab.id, sub)}
|
||||||
|
className="w-full text-left pl-9 pr-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<span className="truncate block">{sub.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -515,19 +942,10 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="max-w-3xl mx-auto p-8">
|
<div className="max-w-3xl mx-auto px-6 py-6">
|
||||||
<div className="mb-6">
|
|
||||||
<div className="flex items-center gap-2.5 mb-2">
|
|
||||||
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
|
|
||||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-card border border-border rounded-lg p-6">
|
|
||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: JmapServerEntry[];
|
||||||
|
source?: string;
|
||||||
|
onChange: (next: JmapServerEntry[]) => void;
|
||||||
|
onRevert: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowDraft {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
domains: string;
|
||||||
|
oauthClientId: string;
|
||||||
|
oauthIssuerUrl: string;
|
||||||
|
oauthClientSecret: string;
|
||||||
|
oauthExpanded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryToDraft(e: JmapServerEntry): RowDraft {
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
label: e.label,
|
||||||
|
url: e.url,
|
||||||
|
domains: (e.domains ?? []).join(', '),
|
||||||
|
oauthClientId: e.oauth?.clientId ?? '',
|
||||||
|
oauthIssuerUrl: e.oauth?.issuerUrl ?? '',
|
||||||
|
oauthClientSecret: e.oauth?.clientSecret ?? '',
|
||||||
|
oauthExpanded: !!(e.oauth && (e.oauth.clientId || e.oauth.issuerUrl || e.oauth.clientSecret)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftToEntry(d: RowDraft): JmapServerEntry | null {
|
||||||
|
const id = d.id.trim();
|
||||||
|
const url = d.url.trim().replace(/\/+$/, '');
|
||||||
|
if (!id || !url) return null;
|
||||||
|
const domains = d.domains
|
||||||
|
.split(/[,\s]+/)
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
const clientId = d.oauthClientId.trim();
|
||||||
|
const issuerUrl = d.oauthIssuerUrl.trim().replace(/\/+$/, '');
|
||||||
|
const clientSecret = d.oauthClientSecret;
|
||||||
|
const oauth = clientId || issuerUrl || clientSecret
|
||||||
|
? {
|
||||||
|
...(clientId ? { clientId } : {}),
|
||||||
|
...(issuerUrl ? { issuerUrl } : {}),
|
||||||
|
...(clientSecret ? { clientSecret } : {}),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: d.label.trim() || id,
|
||||||
|
url,
|
||||||
|
...(domains.length > 0 ? { domains } : {}),
|
||||||
|
...(oauth ? { oauth } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyDraft(): RowDraft {
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
label: '',
|
||||||
|
url: '',
|
||||||
|
domains: '',
|
||||||
|
oauthClientId: '',
|
||||||
|
oauthIssuerUrl: '',
|
||||||
|
oauthClientSecret: '',
|
||||||
|
oauthExpanded: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
|
||||||
|
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Re-sync from props when the underlying config value changes (e.g. revert,
|
||||||
|
// initial load). Skip when drafts already represent the same array to avoid
|
||||||
|
// clobbering in-progress edits.
|
||||||
|
setDrafts((prev) => {
|
||||||
|
if (prev.length === value.length) {
|
||||||
|
const same = prev.every((d, i) => {
|
||||||
|
const e = value[i];
|
||||||
|
return d.id === e.id && d.url === e.url && d.label === e.label;
|
||||||
|
});
|
||||||
|
if (same) return prev;
|
||||||
|
}
|
||||||
|
return value.map(entryToDraft);
|
||||||
|
});
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
function commit(next: RowDraft[]) {
|
||||||
|
setDrafts(next);
|
||||||
|
const entries: JmapServerEntry[] = [];
|
||||||
|
for (const d of next) {
|
||||||
|
const e = draftToEntry(d);
|
||||||
|
if (e) entries.push(e);
|
||||||
|
}
|
||||||
|
onChange(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(idx: number, patch: Partial<RowDraft>) {
|
||||||
|
commit(drafts.map((d, i) => (i === idx ? { ...d, ...patch } : d)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(idx: number) {
|
||||||
|
commit(drafts.filter((_, i) => i !== idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
setDrafts((prev) => [...prev, emptyDraft()]);
|
||||||
|
// Don't commit yet - new row needs id+url before it counts.
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const duplicateIdx = new Set<number>();
|
||||||
|
drafts.forEach((d, i) => {
|
||||||
|
const id = d.id.trim();
|
||||||
|
if (!id) return;
|
||||||
|
if (ids.has(id)) duplicateIdx.add(i);
|
||||||
|
ids.add(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">Servers</span>
|
||||||
|
{source && source !== 'default' && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Each entry appears as an option on the login dropdown. Leave the list empty to fall back to the single <code className="text-[11px]">JMAP Server URL</code> above.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{source === 'admin' && (
|
||||||
|
<button
|
||||||
|
onClick={onRevert}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
title="Revert to default"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={add}
|
||||||
|
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-xs text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Add server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{drafts.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground italic">No servers configured.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{drafts.map((d, i) => {
|
||||||
|
const isDuplicate = duplicateIdx.has(i);
|
||||||
|
return (
|
||||||
|
<div key={i} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label className="block text-[11px] font-medium text-muted-foreground mb-1">ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={d.id}
|
||||||
|
onChange={(e) => update(i, { id: e.target.value })}
|
||||||
|
placeholder="main"
|
||||||
|
className={`h-8 w-full rounded-md border bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${isDuplicate ? 'border-destructive' : 'border-input'}`}
|
||||||
|
/>
|
||||||
|
{isDuplicate && <p className="text-[10px] text-destructive mt-0.5">Duplicate id</p>}
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-4">
|
||||||
|
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Label</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={d.label}
|
||||||
|
onChange={(e) => update(i, { label: e.target.value })}
|
||||||
|
placeholder="Main server"
|
||||||
|
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-5">
|
||||||
|
<label className="block text-[11px] font-medium text-muted-foreground mb-1">JMAP URL</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={d.url}
|
||||||
|
onChange={(e) => update(i, { url: e.target.value })}
|
||||||
|
placeholder="https://mail.example.com"
|
||||||
|
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => remove(i)}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
title="Remove server"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
|
||||||
|
Email domains (comma-separated, used for auto-pick)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={d.domains}
|
||||||
|
onChange={(e) => update(i, { domains: e.target.value })}
|
||||||
|
placeholder="example.com, example.org"
|
||||||
|
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => update(i, { oauthExpanded: !d.oauthExpanded })}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{d.oauthExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||||
|
Per-server OAuth (optional, overrides global)
|
||||||
|
</button>
|
||||||
|
{d.oauthExpanded && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pl-4 border-l border-border">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={d.oauthClientId}
|
||||||
|
onChange={(e) => update(i, { oauthClientId: e.target.value })}
|
||||||
|
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Issuer URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={d.oauthIssuerUrl}
|
||||||
|
onChange={(e) => update(i, { oauthIssuerUrl: e.target.value })}
|
||||||
|
placeholder="https://auth.example.com"
|
||||||
|
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client Secret</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={d.oauthClientSecret}
|
||||||
|
onChange={(e) => update(i, { oauthClientSecret: e.target.value })}
|
||||||
|
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface ConfigEntry {
|
||||||
|
value: unknown;
|
||||||
|
source: 'admin' | 'env' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthTab() {
|
||||||
|
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||||
|
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { fetchConfig(); }, []);
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await apiFetch('/api/admin/config');
|
||||||
|
if (res.ok) setConfig(await res.json());
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(key: string, value: unknown) {
|
||||||
|
setEdits(prev => ({ ...prev, [key]: value }));
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentValue(key: string): unknown {
|
||||||
|
if (key in edits) return edits[key];
|
||||||
|
return config[key]?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (Object.keys(edits).length === 0) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/admin/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(edits),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Authentication settings saved.' });
|
||||||
|
setEdits({});
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevert(key: string) {
|
||||||
|
const res = await apiFetch('/api/admin/config', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setEdits(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
|
await fetchConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [setupRunning, setSetupRunning] = useState(false);
|
||||||
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
|
const [setupOrigin, setSetupOrigin] = useState('');
|
||||||
|
const [setupIssuer, setSetupIssuer] = useState('');
|
||||||
|
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
|
||||||
|
|
||||||
|
function openSetupDialog() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const origin = window.location.origin;
|
||||||
|
const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
|
||||||
|
setSetupOrigin(origin);
|
||||||
|
setSetupIssuer(jmapUrl || origin);
|
||||||
|
setSetupOauthOnly(currentValue('oauthOnly') === true);
|
||||||
|
setSetupOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAutoSetup() {
|
||||||
|
setSetupRunning(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/oauth/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
origin: setupOrigin.trim().replace(/\/+$/, ''),
|
||||||
|
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
|
||||||
|
oauthOnly: setupOauthOnly,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
|
||||||
|
});
|
||||||
|
setEdits({});
|
||||||
|
setSetupOpen(false);
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
|
||||||
|
setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
|
||||||
|
} finally {
|
||||||
|
setSetupRunning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
|
||||||
|
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
|
||||||
|
|
||||||
|
const hasEdits = Object.keys(edits).length > 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
|
||||||
|
Requires your Stalwart account to have admin permissions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openSetupDialog}
|
||||||
|
disabled={setupRunning}
|
||||||
|
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
{setupRunning ? 'Configuring…' : 'Set up automagically'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{setupOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="oauth-setup-title"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
|
||||||
|
<div className="px-5 py-4 border-b border-border">
|
||||||
|
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
|
||||||
|
Webmail origin
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setup-origin"
|
||||||
|
type="url"
|
||||||
|
value={setupOrigin}
|
||||||
|
onChange={(e) => setSetupOrigin(e.target.value)}
|
||||||
|
disabled={setupRunning}
|
||||||
|
placeholder="https://webmail.example.com"
|
||||||
|
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback</code>) on Stalwart.
|
||||||
|
</p>
|
||||||
|
{!setupOriginValid && setupOrigin.length > 0 && (
|
||||||
|
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
|
||||||
|
Stalwart issuer URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setup-issuer"
|
||||||
|
type="url"
|
||||||
|
value={setupIssuer}
|
||||||
|
onChange={(e) => setSetupIssuer(e.target.value)}
|
||||||
|
disabled={setupRunning}
|
||||||
|
placeholder="https://mail.example.com"
|
||||||
|
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
|
||||||
|
</p>
|
||||||
|
{!setupIssuerValid && setupIssuer.length > 0 && (
|
||||||
|
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={setupOauthOnly}
|
||||||
|
onChange={(e) => setSetupOauthOnly(e.target.checked)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-input"
|
||||||
|
disabled={setupRunning}
|
||||||
|
/>
|
||||||
|
Also enable “OAuth only” (hide password login)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setSetupOpen(false)}
|
||||||
|
disabled={setupRunning}
|
||||||
|
className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAutoSetup}
|
||||||
|
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
{setupRunning ? 'Configuring…' : 'Configure'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm: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 w-full sm:w-auto">
|
||||||
|
<input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder}
|
||||||
|
className="h-8 w-full sm: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="shrink-0 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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm: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 shrink-0">
|
||||||
|
<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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<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,297 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface ConfigEntry {
|
||||||
|
value: unknown;
|
||||||
|
source: 'admin' | 'env' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_FIELDS = [
|
||||||
|
{ key: 'faviconUrl', label: 'Favicon', accept: '.svg,.png,.ico,.webp' },
|
||||||
|
{ key: 'appLogoLightUrl', label: 'App Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEXT_FIELDS = [
|
||||||
|
{ key: 'loginCompanyName', label: 'Company Name' },
|
||||||
|
{ key: 'loginImprintUrl', label: 'Imprint URL' },
|
||||||
|
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
|
||||||
|
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BrandingTab() {
|
||||||
|
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||||
|
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await apiFetch('/api/admin/config');
|
||||||
|
if (res.ok) setConfig(await res.json());
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(key: string, value: string) {
|
||||||
|
setEdits(prev => ({ ...prev, [key]: value }));
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentValue(key: string): string {
|
||||||
|
if (key in edits) return edits[key] as string;
|
||||||
|
return (config[key]?.value as string) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (Object.keys(edits).length === 0) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/admin/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(edits),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' });
|
||||||
|
setEdits({});
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(slot: string, file: File) {
|
||||||
|
setUploading(slot);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('slot', slot);
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/admin/branding', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
|
||||||
|
setEdits(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[slot];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setConfig(prev => ({
|
||||||
|
...prev,
|
||||||
|
[slot]: { value: data.url, source: 'admin' },
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||||
|
}
|
||||||
|
setUploading(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteUpload(slot: string) {
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/admin/branding', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slot }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Uploaded file removed. Reverted to default.' });
|
||||||
|
setEdits(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[slot];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to remove' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevert(key: string) {
|
||||||
|
const res = await apiFetch('/api/admin/config', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setEdits(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await fetchConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUploadedFile = (key: string): boolean => {
|
||||||
|
const val = currentValue(key);
|
||||||
|
return val.startsWith('/api/admin/branding/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasEdits = Object.keys(edits).length > 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<label className="text-sm text-foreground">{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 w-full sm:w-auto">
|
||||||
|
<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-full sm:w-64 min-w-0 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>
|
||||||
|
{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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<label className="text-sm text-foreground">{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 w-full sm:w-auto">
|
||||||
|
<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-full sm:w-72 min-w-0 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,224 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||||
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface AdminStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
|
lastLogin: string | null;
|
||||||
|
passwordChangedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigData {
|
||||||
|
appName?: string;
|
||||||
|
jmapServerUrl?: string;
|
||||||
|
settingsSyncEnabled?: boolean;
|
||||||
|
stalwartFeaturesEnabled?: boolean;
|
||||||
|
oauthEnabled?: boolean;
|
||||||
|
devMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardTab() {
|
||||||
|
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 [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
|
||||||
|
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDashboardData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchDashboardData() {
|
||||||
|
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes, telemetryRes] = await Promise.all([
|
||||||
|
apiFetch('/api/admin/auth'),
|
||||||
|
apiFetch('/api/admin/audit?limit=10'),
|
||||||
|
apiFetch('/api/config'),
|
||||||
|
apiFetch('/api/admin/config'),
|
||||||
|
apiFetch('/api/admin/plugins').catch(() => null),
|
||||||
|
apiFetch('/api/admin/themes').catch(() => null),
|
||||||
|
apiFetch('/api/admin/policy').catch(() => null),
|
||||||
|
apiFetch('/api/admin/telemetry').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 (telemetryRes?.ok) {
|
||||||
|
const telemetry = await telemetryRes.json();
|
||||||
|
if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') {
|
||||||
|
setAccountCounts(telemetry.accountCounts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configData?.jmapServerUrl) {
|
||||||
|
try {
|
||||||
|
const jmapRes = await apiFetch('/api/config');
|
||||||
|
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
||||||
|
} catch {
|
||||||
|
setJmapHealth('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const w: string[] = [];
|
||||||
|
if (adminConfigRes.ok) {
|
||||||
|
const sources = await adminConfigRes.json();
|
||||||
|
setConfigSources(sources);
|
||||||
|
const sessionSecret = sources?.sessionSecret;
|
||||||
|
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') {
|
||||||
|
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
|
||||||
|
}
|
||||||
|
const adminPassword = sources?.adminPassword;
|
||||||
|
if (adminPassword?.value && adminPassword.source === 'env') {
|
||||||
|
w.push('ADMIN_PASSWORD is still set in environment variables. Remove it now that the hash is stored securely.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setWarnings(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jmapUrl = config?.jmapServerUrl || '-';
|
||||||
|
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-8">
|
||||||
|
{warnings.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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days">
|
||||||
|
<SettingItem label="Total accounts" description="Distinct identities seen in the retention window">
|
||||||
|
<span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Active in last 7 days" description="Identities with a login in the past week">
|
||||||
|
<span className="text-sm text-foreground">{accountCounts?.active7d ?? '-'}</span>
|
||||||
|
</SettingItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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,174 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import type { AuditEntry } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
export function LogsTab() {
|
||||||
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionFilter, setActionFilter] = useState('');
|
||||||
|
const limit = 50;
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
|
if (actionFilter) params.set('action', actionFilter);
|
||||||
|
|
||||||
|
const res = await apiFetch(`/api/admin/audit?${params}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setEntries(data.entries || []);
|
||||||
|
setTotal(data.total || 0);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [page, actionFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
|
||||||
|
className="h-8 w-full sm:w-auto 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>
|
||||||
|
|
||||||
|
<div className="sm:hidden space-y-2">
|
||||||
|
{loading && entries.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">Loading...</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">No entries found</div>
|
||||||
|
) : (
|
||||||
|
entries.map((entry, i) => (
|
||||||
|
<div key={i} className="rounded-lg border border-border p-3 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground truncate">
|
||||||
|
{entry.action}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||||
|
{new Date(entry.ts).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-foreground break-words">
|
||||||
|
{formatDetail(entry.detail)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground font-mono">
|
||||||
|
{entry.ip}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden sm:block border border-border rounded-lg overflow-x-auto">
|
||||||
|
<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 whitespace-nowrap">Time</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">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>
|
||||||
|
|
||||||
|
{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,372 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface Extension {
|
||||||
|
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;
|
||||||
|
iconUrl: string | null;
|
||||||
|
bannerUrl: string | null;
|
||||||
|
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 function MarketplaceTab() {
|
||||||
|
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [perPage] = useState(12);
|
||||||
|
const [installing, setInstalling] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchExtensions = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query) params.set('q', query);
|
||||||
|
if (typeFilter !== 'all') params.set('type', typeFilter);
|
||||||
|
params.set('page', String(page));
|
||||||
|
params.set('perPage', String(perPage));
|
||||||
|
params.set('sort', 'newest');
|
||||||
|
|
||||||
|
const res = await apiFetch(`/api/admin/marketplace?${params}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
setError(data.error || 'Failed to connect to extension directory');
|
||||||
|
setExtensions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: SearchResult = await res.json();
|
||||||
|
setExtensions(data.data || []);
|
||||||
|
setTotal(data.meta?.total || 0);
|
||||||
|
} catch {
|
||||||
|
setError('Failed to connect to extension directory. Make sure it is running.');
|
||||||
|
setExtensions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [query, typeFilter, page, perPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchExtensions();
|
||||||
|
}, [fetchExtensions]);
|
||||||
|
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setQuery(searchInput);
|
||||||
|
setPage(1);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
|
async function handleInstall(ext: Extension) {
|
||||||
|
setInstalling(ext.slug);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/marketplace', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: ext.slug,
|
||||||
|
version: ext.latestVersion || '1.0.0',
|
||||||
|
type: ext.type,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||||
|
setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` });
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
|
<div className="relative flex-1 min-w-0">
|
||||||
|
<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 self-start sm:self-auto">
|
||||||
|
{(['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 && (
|
||||||
|
<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 && !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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!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>
|
||||||
|
|
||||||
|
{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';
|
||||||
|
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||||
|
{extension.bannerUrl && (
|
||||||
|
<Link href={previewHref} className="block focus:outline-none">
|
||||||
|
<img
|
||||||
|
src={extension.bannerUrl}
|
||||||
|
alt=""
|
||||||
|
className="block h-24 w-full object-cover border-b border-border"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
|
{extension.iconUrl ? (
|
||||||
|
<img
|
||||||
|
src={extension.iconUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-10 h-10 object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : 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 group-hover:underline">
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground mt-3 line-clamp-2">
|
||||||
|
{extension.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground">
|
||||||
|
<Eye className="w-3 h-3" />
|
||||||
|
Preview
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="px-4 pb-4 -mt-1">
|
||||||
|
{extension.installed ? (
|
||||||
|
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
Installed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||||
|
disabled={installing}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{installing ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Quick install
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface ConfigField {
|
||||||
|
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: unknown;
|
||||||
|
placeholder?: string;
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginConfig {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
type: string;
|
||||||
|
permissions: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
configSchema?: Record<string, ConfigField>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pluginId: string;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
||||||
|
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(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function fetchData() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [pluginsRes, configRes] = await Promise.all([
|
||||||
|
apiFetch('/api/admin/plugins'),
|
||||||
|
apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [pluginId]);
|
||||||
|
|
||||||
|
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 handleSaveAll() {
|
||||||
|
if (!plugin?.configSchema) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let hasError = false;
|
||||||
|
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
||||||
|
const newVal = formValues[key] ?? '';
|
||||||
|
const oldVal = config[key] !== undefined ? String(config[key]) : '';
|
||||||
|
|
||||||
|
if (newVal === oldVal) continue;
|
||||||
|
if (field.type === 'secret' && !newVal && config[key]) continue;
|
||||||
|
|
||||||
|
let value: unknown = newVal;
|
||||||
|
if (field.type === 'boolean') value = newVal === 'true';
|
||||||
|
else if (field.type === 'number') value = Number(newVal);
|
||||||
|
|
||||||
|
if (!newVal && !field.required) {
|
||||||
|
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setConfig(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
|
} else {
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key, value }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setConfig(prev => ({ ...prev, [key]: value }));
|
||||||
|
} else {
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage(hasError
|
||||||
|
? { type: 'error', text: 'Some settings failed to save' }
|
||||||
|
: { type: 'success', text: 'Configuration saved' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save configuration' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
<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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Back to Plugins"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<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,459 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
||||||
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
|
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { PluginConfigPanel } from './plugin-config-panel';
|
||||||
|
|
||||||
|
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 function PluginsTab() {
|
||||||
|
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);
|
||||||
|
const [configuringId, setConfiguringId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPlugins(); fetchPolicy(); }, []);
|
||||||
|
|
||||||
|
async function fetchPolicy() {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/policy');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPolicy(data);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePluginsEnabled() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, pluginsEnabled: !prev.features.pluginsEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePluginsUploadEnabled() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, pluginsUploadEnabled: !prev.features.pluginsUploadEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRequirePluginApproval() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSavePolicy() {
|
||||||
|
setSavingPolicy(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(policy),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Plugin policy saved. Users will see changes on next login.' });
|
||||||
|
setPolicyDirty(false);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save policy' });
|
||||||
|
} finally {
|
||||||
|
setSavingPolicy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlugins() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/plugins');
|
||||||
|
if (res.ok) setPlugins(await res.json());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||||
|
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
|
||||||
|
await fetchPlugins();
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Upload failed' });
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePlugin(id: string, enabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, enabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setPlugins(prev => prev.map(p => p.id === id ? { ...p, enabled } : p));
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p));
|
||||||
|
setPolicy(prev => {
|
||||||
|
const current = prev.forceEnabledPlugins || [];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
forceEnabledPlugins: forceEnabled
|
||||||
|
? [...current.filter(pid => pid !== id), id]
|
||||||
|
: current.filter(pid => pid !== id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceEnableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const disabled = plugins.filter(p => !p.enabled);
|
||||||
|
if (disabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All plugins are already enabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const p of disabled) {
|
||||||
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: p.id, enabled: true }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
setPlugins(prev => prev.map(p => failed === 0 ? { ...p, enabled: true } : p));
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'success', text: `All ${disabled.length} plugin(s) enabled` });
|
||||||
|
} else {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'error', text: `${failed} plugin(s) failed to enable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceDisableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const enabled = plugins.filter(p => p.enabled);
|
||||||
|
if (enabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All plugins are already disabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const p of enabled) {
|
||||||
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: p.id, enabled: false }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'success', text: `All ${enabled.length} plugin(s) disabled` });
|
||||||
|
} else {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'error', text: `${failed} plugin(s) failed to disable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePlugin(id: string, name: string) {
|
||||||
|
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
setMessage(null);
|
||||||
|
const res = await apiFetch('/api/admin/plugins', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setPlugins(prev => prev.filter(p => p.id !== id));
|
||||||
|
setMessage({ type: 'success', text: `Plugin "${name}" removed` });
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Delete failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuringId) {
|
||||||
|
return <PluginConfigPanel pluginId={configuringId} onBack={() => { setConfiguringId(null); fetchPlugins(); }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
||||||
|
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
||||||
|
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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 flex-wrap 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm: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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">User Plugin Uploads</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={togglePluginsUploadEnabled}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsUploadEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||||
|
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsUploadEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">Require Admin Approval</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={toggleRequirePluginApproval}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||||
|
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plugins.length > 0 && (
|
||||||
|
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm: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>
|
||||||
|
|
||||||
|
<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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfiguringId(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" />
|
||||||
|
</button>
|
||||||
|
<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';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
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' },
|
||||||
|
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
||||||
|
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
||||||
|
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
|
||||||
|
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||||
|
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||||
|
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||||
|
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESTRICTABLE_SETTINGS = [
|
||||||
|
{ key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] },
|
||||||
|
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
||||||
|
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
||||||
|
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||||
|
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||||
|
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||||
|
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
|
||||||
|
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||||
|
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
||||||
|
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
||||||
|
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
|
||||||
|
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
|
||||||
|
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
|
||||||
|
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
||||||
|
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
||||||
|
{ key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' },
|
||||||
|
{ key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PolicyTab() {
|
||||||
|
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPolicy(); }, []);
|
||||||
|
|
||||||
|
async function fetchPolicy() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/policy');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPolicy(data);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFeature(key: keyof FeatureGates) {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, [key]: !prev.features[key] },
|
||||||
|
}));
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLocked(settingKey: string) {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const existing = prev.restrictions[settingKey] || {};
|
||||||
|
const newRestrictions = { ...prev.restrictions };
|
||||||
|
if (existing.locked) {
|
||||||
|
delete newRestrictions[settingKey];
|
||||||
|
} else {
|
||||||
|
newRestrictions[settingKey] = { ...existing, locked: true };
|
||||||
|
}
|
||||||
|
return { ...prev, restrictions: newRestrictions };
|
||||||
|
});
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHidden(settingKey: string) {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const existing = prev.restrictions[settingKey] || {};
|
||||||
|
const newRestrictions = { ...prev.restrictions };
|
||||||
|
newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden };
|
||||||
|
if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) {
|
||||||
|
delete newRestrictions[settingKey];
|
||||||
|
}
|
||||||
|
return { ...prev, restrictions: newRestrictions };
|
||||||
|
});
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(policy),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Policy saved. Users will see changes on next login.' });
|
||||||
|
setDirty(false);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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={`shrink-0 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>
|
||||||
|
|
||||||
|
{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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<span className="text-sm text-foreground">{setting.label}</span>
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<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,272 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { JmapServersSection } from './_jmap-servers-section';
|
||||||
|
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
|
interface ConfigEntry {
|
||||||
|
value: unknown;
|
||||||
|
source: 'admin' | 'env' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsTab() {
|
||||||
|
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||||
|
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await apiFetch('/api/admin/config');
|
||||||
|
if (res.ok) {
|
||||||
|
setConfig(await res.json());
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(key: string, value: unknown) {
|
||||||
|
setEdits(prev => ({ ...prev, [key]: value }));
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentValue(key: string): unknown {
|
||||||
|
if (key in edits) return edits[key];
|
||||||
|
return config[key]?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (Object.keys(edits).length === 0) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/admin/config', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(edits),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Settings saved. Changes take effect on next page load.' });
|
||||||
|
setEdits({});
|
||||||
|
await fetchConfig();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevert(key: string) {
|
||||||
|
const res = await apiFetch('/api/admin/config', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setEdits(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await fetchConfig();
|
||||||
|
setMessage({ type: 'success', text: `${key} reverted to default` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEdits = Object.keys(edits).length > 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SettingsSection title="General">
|
||||||
|
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
||||||
|
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
{!!currentValue('allowCustomJmapEndpoint') && (
|
||||||
|
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
||||||
|
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||||
|
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection title="JMAP Servers (multi-server)">
|
||||||
|
<ToggleSetting
|
||||||
|
label="Auto-pick server by email domain"
|
||||||
|
description="When users type their email, automatically select the matching server from the list below."
|
||||||
|
configKey="jmapServerAutoPickByDomain"
|
||||||
|
value={currentValue('jmapServerAutoPickByDomain') as boolean}
|
||||||
|
source={config.jmapServerAutoPickByDomain?.source}
|
||||||
|
onChange={handleChange}
|
||||||
|
onRevert={handleRevert}
|
||||||
|
/>
|
||||||
|
<JmapServersSection
|
||||||
|
value={(currentValue('jmapServers') as JmapServerEntry[]) ?? []}
|
||||||
|
source={config.jmapServers?.source}
|
||||||
|
onChange={(next) => handleChange('jmapServers', next)}
|
||||||
|
onRevert={() => handleRevert('jmapServers')}
|
||||||
|
/>
|
||||||
|
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
|
||||||
|
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
||||||
|
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||||
|
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<label className="text-sm text-foreground">{label}</label>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(configKey, e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="h-8 w-full sm: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="shrink-0 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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm: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 shrink-0">
|
||||||
|
<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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<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,250 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface TelemetryStatus {
|
||||||
|
consent: 'pending' | 'on' | 'off';
|
||||||
|
consentSource: 'env' | 'file';
|
||||||
|
endpoint: string;
|
||||||
|
defaultEndpoint: string;
|
||||||
|
consentedAt: string | null;
|
||||||
|
lastSentAt: string | null;
|
||||||
|
nextScheduledAt: string | null;
|
||||||
|
payloadPreview: Record<string, unknown>;
|
||||||
|
accountCounts: { total: number; active7d: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso: string | null): string {
|
||||||
|
if (!iso) return 'never';
|
||||||
|
const d = Date.now() - new Date(iso).getTime();
|
||||||
|
if (d < 0) return new Date(iso).toLocaleString();
|
||||||
|
const m = Math.floor(d / 60000);
|
||||||
|
if (m < 1) return 'just now';
|
||||||
|
if (m < 60) return `${m} min ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 48) return `${h} hours ago`;
|
||||||
|
const days = Math.floor(h / 24);
|
||||||
|
return `${days} days ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TelemetryTab() {
|
||||||
|
const [status, setStatus] = useState<TelemetryStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [endpointDraft, setEndpointDraft] = useState('');
|
||||||
|
const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/telemetry');
|
||||||
|
if (!r.ok) throw new Error('failed to load');
|
||||||
|
const data = (await r.json()) as TelemetryStatus;
|
||||||
|
setStatus(data);
|
||||||
|
setEndpointDraft(data.endpoint);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { void refresh(); }, []);
|
||||||
|
|
||||||
|
async function setConsent(consent: 'on' | 'off'): Promise<void> {
|
||||||
|
setBusy('consent');
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/telemetry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'set-consent', consent }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||||
|
alert(j.error ?? 'failed');
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEndpoint(): Promise<void> {
|
||||||
|
setBusy('endpoint');
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/telemetry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||||
|
alert(j.error ?? 'failed');
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNow(): Promise<void> {
|
||||||
|
setBusy('send');
|
||||||
|
setSendResult(null);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/telemetry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'send-now' }),
|
||||||
|
});
|
||||||
|
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string };
|
||||||
|
setSendResult({
|
||||||
|
ok: !!j.ok,
|
||||||
|
msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`,
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !status) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envOverridden = status.consentSource === 'env';
|
||||||
|
const isOn = status.consent === 'on';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
|
||||||
|
running, on what platforms, and which features they use. <strong>Enabled by default</strong>;
|
||||||
|
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
|
||||||
|
<a
|
||||||
|
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Full schema and policy <ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium">Status</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
|
||||||
|
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
|
||||||
|
{status.consent === 'off' && 'Heartbeats are off.'}
|
||||||
|
{envOverridden && (
|
||||||
|
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'consent' || envOverridden || isOn}
|
||||||
|
onClick={() => void setConsent('on')}
|
||||||
|
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'consent' || envOverridden || status.consent === 'off'}
|
||||||
|
onClick={() => void setConsent('off')}
|
||||||
|
className="px-3 py-1.5 rounded-md border hover:bg-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-2 gap-2 text-sm pt-2 border-t">
|
||||||
|
<dt className="text-muted-foreground">Last sent</dt>
|
||||||
|
<dd>{timeAgo(status.lastSentAt)}</dd>
|
||||||
|
<dt className="text-muted-foreground">Next scheduled</dt>
|
||||||
|
<dd>{timeAgo(status.nextScheduledAt)}</dd>
|
||||||
|
<dt className="text-muted-foreground">Consented at</dt>
|
||||||
|
<dd>{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-2">
|
||||||
|
<div className="font-medium">Account activity</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Unique accounts that have logged in over the last 90 days. Identities are stored as a
|
||||||
|
per-instance HMAC, never as plaintext usernames. These are the numbers reported in the
|
||||||
|
heartbeat as bucketed ranges.
|
||||||
|
</p>
|
||||||
|
<dl className="grid grid-cols-2 gap-2 text-sm pt-1">
|
||||||
|
<dt className="text-muted-foreground">Total (90d)</dt>
|
||||||
|
<dd className="font-mono">{status.accountCounts?.total ?? 0}</dd>
|
||||||
|
<dt className="text-muted-foreground">Active (7d)</dt>
|
||||||
|
<dd className="font-mono">{status.accountCounts?.active7d ?? 0}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="font-medium">Endpoint</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Where heartbeats are sent. Defaults to the project's collector. Point at your own collector
|
||||||
|
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={endpointDraft}
|
||||||
|
onChange={(e) => setEndpointDraft(e.target.value)}
|
||||||
|
placeholder={status.defaultEndpoint}
|
||||||
|
className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
|
||||||
|
onClick={() => void saveEndpoint()}
|
||||||
|
className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" /> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium">Payload preview</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Exactly what the next heartbeat would send from this install, right now.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === 'send' || !isOn}
|
||||||
|
onClick={() => void sendNow()}
|
||||||
|
className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" /> Send now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{sendResult && (
|
||||||
|
<div
|
||||||
|
className={`text-sm flex items-center gap-2 ${
|
||||||
|
sendResult.ok ? 'text-emerald-600' : 'text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sendResult.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||||
|
{sendResult.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
|
||||||
|
{JSON.stringify(status.payloadPreview, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
||||||
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
|
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
const BUILTIN_THEME_OPTIONS = [
|
||||||
|
{ id: 'builtin-nord', name: 'Nord' },
|
||||||
|
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
|
||||||
|
{ id: 'builtin-solarized', name: 'Solarized' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ThemeEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
description: string;
|
||||||
|
variants: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
forceEnabled?: boolean;
|
||||||
|
installedAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemesTab() {
|
||||||
|
const [themes, setThemes] = useState<ThemeEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||||
|
const [policyDirty, setPolicyDirty] = useState(false);
|
||||||
|
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { fetchThemes(); fetchPolicy(); }, []);
|
||||||
|
|
||||||
|
async function fetchPolicy() {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/policy');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPolicy({
|
||||||
|
...data,
|
||||||
|
themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleThemesEnabled() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, themesEnabled: !prev.features.themesEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUserThemeUploads() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, userThemesEnabled: !prev.features.userThemesEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBuiltinTheme(themeId: string) {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const disabled = prev.themePolicy?.disabledBuiltinThemes || [];
|
||||||
|
const isDisabled = disabled.includes(themeId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
themePolicy: {
|
||||||
|
...DEFAULT_THEME_POLICY,
|
||||||
|
...prev.themePolicy,
|
||||||
|
disabledBuiltinThemes: isDisabled
|
||||||
|
? disabled.filter((id: string) => id !== themeId)
|
||||||
|
: [...disabled, themeId],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAdminTheme(themeId: string) {
|
||||||
|
setPolicy(prev => {
|
||||||
|
const disabled = prev.themePolicy?.disabledThemes || [];
|
||||||
|
const isDisabled = disabled.includes(themeId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
themePolicy: {
|
||||||
|
...DEFAULT_THEME_POLICY,
|
||||||
|
...prev.themePolicy,
|
||||||
|
disabledThemes: isDisabled
|
||||||
|
? disabled.filter((id: string) => id !== themeId)
|
||||||
|
: [...disabled, themeId],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDefaultTheme(themeId: string | null) {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
themePolicy: {
|
||||||
|
...DEFAULT_THEME_POLICY,
|
||||||
|
...prev.themePolicy,
|
||||||
|
defaultThemeId: themeId,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSavePolicy() {
|
||||||
|
setSavingPolicy(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/policy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(policy),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Theme policy saved. Users will see changes on next login.' });
|
||||||
|
setPolicyDirty(false);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save policy' });
|
||||||
|
} finally {
|
||||||
|
setSavingPolicy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchThemes() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/themes');
|
||||||
|
if (res.ok) setThemes(await res.json());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||||
|
setMessage({ type: 'success', text: `Theme "${data.theme.name}" installed${warnings}` });
|
||||||
|
await fetchThemes();
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Upload failed' });
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTheme(id: string, enabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, enabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setThemes(prev => prev.map(t => t.id === id ? { ...t, enabled } : t));
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setThemes(prev => prev.map(t => t.id === id ? { ...t, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : t));
|
||||||
|
setPolicy(prev => {
|
||||||
|
const current = prev.forceEnabledThemes || [];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
forceEnabledThemes: forceEnabled
|
||||||
|
? [...current.filter(tid => tid !== id), id]
|
||||||
|
: current.filter(tid => tid !== id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceEnableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const disabled = themes.filter(t => !t.enabled);
|
||||||
|
if (disabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All themes are already enabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const t of disabled) {
|
||||||
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: t.id, enabled: true }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'success', text: `All ${disabled.length} theme(s) enabled` });
|
||||||
|
} else {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'error', text: `${failed} theme(s) failed to enable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceDisableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const enabled = themes.filter(t => t.enabled);
|
||||||
|
if (enabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All themes are already disabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const t of enabled) {
|
||||||
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: t.id, enabled: false }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'success', text: `All ${enabled.length} theme(s) disabled` });
|
||||||
|
} else {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'error', text: `${failed} theme(s) failed to disable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTheme(id: string, name: string) {
|
||||||
|
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
setMessage(null);
|
||||||
|
const res = await apiFetch('/api/admin/themes', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setThemes(prev => prev.filter(t => t.id !== id));
|
||||||
|
setMessage({ type: 'success', text: `Theme "${name}" removed` });
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Delete failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const themesEnabled = policy.features.themesEnabled ?? true;
|
||||||
|
const userThemesEnabled = policy.features.userThemesEnabled ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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 flex-wrap 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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={`shrink-0 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>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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={`shrink-0 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>
|
||||||
|
|
||||||
|
{themes.length > 0 && (
|
||||||
|
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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 shrink-0">
|
||||||
|
<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 className="px-4 py-3">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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 w-full sm:w-auto shrink-0 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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<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,237 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
ShieldAlert,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
|
||||||
|
|
||||||
|
interface VersionAdminStatus {
|
||||||
|
current: string;
|
||||||
|
build: string;
|
||||||
|
endpoint: string;
|
||||||
|
defaultEndpoint: string;
|
||||||
|
disabledByEnv: boolean;
|
||||||
|
lastCheckedAt: string | null;
|
||||||
|
lastSuccessAt: string | null;
|
||||||
|
nextScheduledAt: string | null;
|
||||||
|
status: UpdateStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso: string | null): string {
|
||||||
|
if (!iso) return 'never';
|
||||||
|
const d = Date.now() - new Date(iso).getTime();
|
||||||
|
if (d < 0) return new Date(iso).toLocaleString();
|
||||||
|
const m = Math.floor(d / 60000);
|
||||||
|
if (m < 1) return 'just now';
|
||||||
|
if (m < 60) return `${m} min ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 48) return `${h} hours ago`;
|
||||||
|
return `${Math.floor(h / 24)} days ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityChip(severity: UpdateSeverity) {
|
||||||
|
switch (severity) {
|
||||||
|
case 'security':
|
||||||
|
return {
|
||||||
|
label: 'Security update',
|
||||||
|
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
|
||||||
|
Icon: ShieldAlert,
|
||||||
|
};
|
||||||
|
case 'deprecated':
|
||||||
|
return {
|
||||||
|
label: 'Deprecated',
|
||||||
|
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
|
||||||
|
Icon: ShieldAlert,
|
||||||
|
};
|
||||||
|
case 'normal':
|
||||||
|
return {
|
||||||
|
label: 'Update available',
|
||||||
|
className: 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30',
|
||||||
|
Icon: AlertTriangle,
|
||||||
|
};
|
||||||
|
case 'unknown':
|
||||||
|
return {
|
||||||
|
label: 'Unknown',
|
||||||
|
className: 'bg-muted text-muted-foreground border-border',
|
||||||
|
Icon: AlertTriangle,
|
||||||
|
};
|
||||||
|
case 'none':
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
label: 'Up to date',
|
||||||
|
className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30',
|
||||||
|
Icon: CheckCircle2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VersionTab() {
|
||||||
|
const [data, setData] = useState<VersionAdminStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [checkResult, setCheckResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/version');
|
||||||
|
if (!r.ok) throw new Error('failed to load');
|
||||||
|
setData((await r.json()) as VersionAdminStatus);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { void refresh(); }, []);
|
||||||
|
|
||||||
|
async function checkNow(): Promise<void> {
|
||||||
|
setChecking(true);
|
||||||
|
setCheckResult(null);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('/api/admin/version', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'check-now' }),
|
||||||
|
});
|
||||||
|
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string };
|
||||||
|
setCheckResult({
|
||||||
|
ok: !!j.ok,
|
||||||
|
msg: j.ok ? 'Update check completed.' : `Failed: ${j.error ?? 'unknown'}`,
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = data.status;
|
||||||
|
const chip = severityChip(status?.severity ?? 'none');
|
||||||
|
const ChipIcon = chip.Icon;
|
||||||
|
const releaseUrl = status?.url ?? null;
|
||||||
|
const newer = status?.latest && status.latest !== data.current ? status.latest : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Version</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Hourly check against the Bulwark version server. Severity is decided server-side and
|
||||||
|
disable with <code>BULWARK_UPDATE_CHECK=off</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={checking}
|
||||||
|
onClick={() => void checkNow()}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||||
|
Check now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{checkResult && (
|
||||||
|
<div
|
||||||
|
className={`text-sm rounded-md px-3 py-2 ${
|
||||||
|
checkResult.ok
|
||||||
|
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
|
||||||
|
: 'bg-destructive/10 text-destructive'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{checkResult.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SettingsSection title="Status">
|
||||||
|
<SettingItem label="Severity">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${chip.className}`}
|
||||||
|
>
|
||||||
|
<ChipIcon className="h-3 w-3" />
|
||||||
|
{chip.label}
|
||||||
|
</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Running" description={data.build !== 'unknown' ? `Build ${data.build}` : undefined}>
|
||||||
|
<span className="text-sm font-mono text-foreground">{data.current}</span>
|
||||||
|
</SettingItem>
|
||||||
|
{newer && (
|
||||||
|
<SettingItem label="Latest release">
|
||||||
|
{releaseUrl ? (
|
||||||
|
<a
|
||||||
|
href={releaseUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-mono text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{newer} <ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-mono text-foreground">{newer}</span>
|
||||||
|
)}
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
|
{status?.advisory && (
|
||||||
|
<SettingItem label="Advisory">
|
||||||
|
<span className="text-sm font-mono text-red-600 dark:text-red-400">{status.advisory}</span>
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection title="Schedule" description="Hourly polling with ±5 minute jitter.">
|
||||||
|
<SettingItem label="Last checked">
|
||||||
|
<span className="text-sm text-foreground">{timeAgo(data.lastCheckedAt)}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Last success">
|
||||||
|
<span className="text-sm text-foreground">{timeAgo(data.lastSuccessAt)}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Next scheduled">
|
||||||
|
<span className="text-sm text-foreground">{timeAgo(data.nextScheduledAt)}</span>
|
||||||
|
</SettingItem>
|
||||||
|
{status?.checkedAt && (
|
||||||
|
<SettingItem label="Server timestamp" description="When the server last refreshed its release list.">
|
||||||
|
<span className="text-sm text-foreground">{new Date(status.checkedAt).toLocaleString()}</span>
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection title="Source">
|
||||||
|
<SettingItem
|
||||||
|
label="Endpoint"
|
||||||
|
description={data.endpoint === data.defaultEndpoint ? 'Default endpoint.' : `Default: ${data.defaultEndpoint}`}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={data.endpoint}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-foreground hover:underline break-all"
|
||||||
|
>
|
||||||
|
{data.endpoint} <ExternalLink className="w-3 h-3 shrink-0" />
|
||||||
|
</a>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Disabled by env" description="Set BULWARK_UPDATE_CHECK=off to disable.">
|
||||||
|
<span className={`text-sm font-medium ${data.disabledByEnv ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'}`}>
|
||||||
|
{data.disabledByEnv ? 'Yes' : 'No'}
|
||||||
|
</span>
|
||||||
|
</SettingItem>
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+3
-381
@@ -1,383 +1,5 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
export default function Page() {
|
||||||
import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
redirect('/admin?tab=auth');
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
|
||||||
|
|
||||||
interface ConfigEntry {
|
|
||||||
value: unknown;
|
|
||||||
source: 'admin' | 'env' | 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminAuthPage() {
|
|
||||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
|
||||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => { fetchConfig(); }, []);
|
|
||||||
|
|
||||||
async function fetchConfig() {
|
|
||||||
setLoading(true);
|
|
||||||
const res = await apiFetch('/api/admin/config');
|
|
||||||
if (res.ok) setConfig(await res.json());
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleChange(key: string, value: unknown) {
|
|
||||||
setEdits(prev => ({ ...prev, [key]: value }));
|
|
||||||
setMessage(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentValue(key: string): unknown {
|
|
||||||
if (key in edits) return edits[key];
|
|
||||||
return config[key]?.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (Object.keys(edits).length === 0) return;
|
|
||||||
setSaving(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
const res = await apiFetch('/api/admin/config', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(edits),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setMessage({ type: 'success', text: 'Authentication settings saved.' });
|
|
||||||
setEdits({});
|
|
||||||
await fetchConfig();
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRevert(key: string) {
|
|
||||||
const res = await apiFetch('/api/admin/config', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ key }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setEdits(prev => { const next = { ...prev }; delete next[key]; return next; });
|
|
||||||
await fetchConfig();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [setupRunning, setSetupRunning] = useState(false);
|
|
||||||
const [setupOpen, setSetupOpen] = useState(false);
|
|
||||||
const [setupOrigin, setSetupOrigin] = useState('');
|
|
||||||
const [setupIssuer, setSetupIssuer] = useState('');
|
|
||||||
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
|
|
||||||
|
|
||||||
function openSetupDialog() {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const origin = window.location.origin;
|
|
||||||
const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
|
|
||||||
setSetupOrigin(origin);
|
|
||||||
setSetupIssuer(jmapUrl || origin);
|
|
||||||
setSetupOauthOnly(currentValue('oauthOnly') === true);
|
|
||||||
setSetupOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAutoSetup() {
|
|
||||||
setSetupRunning(true);
|
|
||||||
setMessage(null);
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/admin/oauth/setup', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
origin: setupOrigin.trim().replace(/\/+$/, ''),
|
|
||||||
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
|
|
||||||
oauthOnly: setupOauthOnly,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok) {
|
|
||||||
setMessage({
|
|
||||||
type: 'success',
|
|
||||||
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
|
|
||||||
});
|
|
||||||
setEdits({});
|
|
||||||
setSetupOpen(false);
|
|
||||||
await fetchConfig();
|
|
||||||
} else {
|
|
||||||
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
|
|
||||||
setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
|
|
||||||
} finally {
|
|
||||||
setSetupRunning(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
|
|
||||||
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
|
|
||||||
|
|
||||||
const hasEdits = Object.keys(edits).length > 0;
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
|
|
||||||
</div>
|
|
||||||
{hasEdits && (
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
|
||||||
>
|
|
||||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
||||||
Save changes
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
|
||||||
{message.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Auto-setup */}
|
|
||||||
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Sparkles className="w-4 h-4 text-primary shrink-0" />
|
|
||||||
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
|
|
||||||
Requires your Stalwart account to have admin permissions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={openSetupDialog}
|
|
||||||
disabled={setupRunning}
|
|
||||||
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
|
||||||
>
|
|
||||||
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
|
||||||
{setupRunning ? 'Configuring…' : 'Set up automagically'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Auto-setup dialog */}
|
|
||||||
{setupOpen && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="oauth-setup-title"
|
|
||||||
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
|
|
||||||
>
|
|
||||||
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
|
|
||||||
<div className="px-5 py-4 border-b border-border">
|
|
||||||
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="px-5 py-4 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
|
|
||||||
Webmail origin
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="setup-origin"
|
|
||||||
type="url"
|
|
||||||
value={setupOrigin}
|
|
||||||
onChange={(e) => setSetupOrigin(e.target.value)}
|
|
||||||
disabled={setupRunning}
|
|
||||||
placeholder="https://webmail.example.com"
|
|
||||||
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
<p className="text-[11px] text-muted-foreground mt-1">
|
|
||||||
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback</code>) on Stalwart.
|
|
||||||
</p>
|
|
||||||
{!setupOriginValid && setupOrigin.length > 0 && (
|
|
||||||
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
|
|
||||||
Stalwart issuer URL
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="setup-issuer"
|
|
||||||
type="url"
|
|
||||||
value={setupIssuer}
|
|
||||||
onChange={(e) => setSetupIssuer(e.target.value)}
|
|
||||||
disabled={setupRunning}
|
|
||||||
placeholder="https://mail.example.com"
|
|
||||||
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
<p className="text-[11px] text-muted-foreground mt-1">
|
|
||||||
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
|
|
||||||
</p>
|
|
||||||
{!setupIssuerValid && setupIssuer.length > 0 && (
|
|
||||||
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={setupOauthOnly}
|
|
||||||
onChange={(e) => setSetupOauthOnly(e.target.checked)}
|
|
||||||
className="h-3.5 w-3.5 rounded border-input"
|
|
||||||
disabled={setupRunning}
|
|
||||||
/>
|
|
||||||
Also enable “OAuth only” (hide password login)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
|
|
||||||
<button
|
|
||||||
onClick={() => setSetupOpen(false)}
|
|
||||||
disabled={setupRunning}
|
|
||||||
className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleAutoSetup}
|
|
||||||
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
|
|
||||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
|
||||||
>
|
|
||||||
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
|
||||||
{setupRunning ? 'Configuring…' : 'Configure'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* OAuth */}
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-298
@@ -1,300 +1,5 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
export default function Page() {
|
||||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
redirect('/admin?tab=branding');
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
|
||||||
|
|
||||||
interface ConfigEntry {
|
|
||||||
value: unknown;
|
|
||||||
source: 'admin' | 'env' | 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
const IMAGE_FIELDS = [
|
|
||||||
{ key: 'faviconUrl', label: 'Favicon', accept: '.svg,.png,.ico,.webp' },
|
|
||||||
{ key: 'appLogoLightUrl', label: 'App Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
|
||||||
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
|
||||||
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
|
||||||
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TEXT_FIELDS = [
|
|
||||||
{ key: 'loginCompanyName', label: 'Company Name' },
|
|
||||||
{ key: 'loginImprintUrl', label: 'Imprint URL' },
|
|
||||||
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
|
|
||||||
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AdminBrandingPage() {
|
|
||||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
|
||||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [uploading, setUploading] = useState<string | null>(null);
|
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
||||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchConfig();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function fetchConfig() {
|
|
||||||
setLoading(true);
|
|
||||||
const res = await apiFetch('/api/admin/config');
|
|
||||||
if (res.ok) setConfig(await res.json());
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleChange(key: string, value: string) {
|
|
||||||
setEdits(prev => ({ ...prev, [key]: value }));
|
|
||||||
setMessage(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentValue(key: string): string {
|
|
||||||
if (key in edits) return edits[key] as string;
|
|
||||||
return (config[key]?.value as string) ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (Object.keys(edits).length === 0) return;
|
|
||||||
setSaving(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
const res = await apiFetch('/api/admin/config', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(edits),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' });
|
|
||||||
setEdits({});
|
|
||||||
await fetchConfig();
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpload(slot: string, file: File) {
|
|
||||||
setUploading(slot);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
formData.append('slot', slot);
|
|
||||||
|
|
||||||
const res = await apiFetch('/api/admin/branding', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
|
|
||||||
// Remove any pending URL edit for this slot since upload sets it
|
|
||||||
setEdits(prev => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[slot];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
// Update config to reflect the uploaded URL
|
|
||||||
setConfig(prev => ({
|
|
||||||
...prev,
|
|
||||||
[slot]: { value: data.url, source: 'admin' },
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
|
||||||
}
|
|
||||||
setUploading(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteUpload(slot: string) {
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
const res = await apiFetch('/api/admin/branding', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ slot }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setMessage({ type: 'success', text: 'Uploaded file removed. Reverted to default.' });
|
|
||||||
setEdits(prev => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[slot];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
await fetchConfig();
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Failed to remove' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRevert(key: string) {
|
|
||||||
const res = await apiFetch('/api/admin/config', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ key }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setEdits(prev => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[key];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
await fetchConfig();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isUploadedFile = (key: string): boolean => {
|
|
||||||
const val = currentValue(key);
|
|
||||||
return val.startsWith('/api/admin/branding/');
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasEdits = Object.keys(edits).length > 0;
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Branding</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
|
|
||||||
</div>
|
|
||||||
{hasEdits && (
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
|
||||||
>
|
|
||||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
||||||
Save changes
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
|
||||||
{message.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="border border-border rounded-lg">
|
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
|
||||||
<h2 className="text-sm font-medium text-foreground">Images & Logos</h2>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)</p>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{IMAGE_FIELDS.map(field => (
|
|
||||||
<div key={field.key} className="px-4 py-3">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
|
|
||||||
{config[field.key]?.source === 'admin' && (
|
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
|
||||||
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={currentValue(field.key)}
|
|
||||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
|
||||||
placeholder="Enter URL or upload a file"
|
|
||||||
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref={el => { fileInputRefs.current[field.key] = el; }}
|
|
||||||
type="file"
|
|
||||||
accept={field.accept}
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) handleUpload(field.key, file);
|
|
||||||
e.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
|
||||||
disabled={uploading === field.key}
|
|
||||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
|
||||||
title="Upload file"
|
|
||||||
>
|
|
||||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
|
||||||
</button>
|
|
||||||
{isUploadedFile(field.key) && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteUpload(field.key)}
|
|
||||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
|
||||||
title="Remove uploaded file"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
|
|
||||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Preview */}
|
|
||||||
{currentValue(field.key) && (
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
|
||||||
<img
|
|
||||||
src={currentValue(field.key)}
|
|
||||||
alt={field.label}
|
|
||||||
className="max-h-6 max-w-[200px] object-contain"
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border border-border rounded-lg">
|
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
|
||||||
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{TEXT_FIELDS.map(field => (
|
|
||||||
<div key={field.key} className="px-4 py-3 flex items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
|
|
||||||
{config[field.key]?.source === 'admin' && (
|
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={currentValue(field.key)}
|
|
||||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
|
||||||
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
|
|
||||||
className="h-8 w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
{config[field.key]?.source === 'admin' && (
|
|
||||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+250
-71
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useAdminTabStore, type AdminTabId } from '@/stores/admin-tab-store';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -15,50 +16,60 @@ import {
|
|||||||
Puzzle,
|
Puzzle,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
Activity,
|
Activity,
|
||||||
|
Package,
|
||||||
Mail,
|
Mail,
|
||||||
Calendar,
|
Calendar,
|
||||||
BookUser,
|
BookUser,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
ArrowLeft,
|
|
||||||
Store,
|
Store,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
const NAV_GROUPS = [
|
// Single-page tab navigation: clicks update a Zustand store. The URL stays
|
||||||
|
// at /admin so React doesn't fire a route transition on every tab switch -
|
||||||
|
// matches the regular settings page pattern, fixes the dev-mode "Rendering…"
|
||||||
|
// hang we saw with both /admin/<segment> routes and ?tab= search params.
|
||||||
|
const NAV_GROUPS: ReadonlyArray<{
|
||||||
|
label: string;
|
||||||
|
items: ReadonlyArray<{ tab: AdminTabId; label: string; icon: typeof LayoutDashboard }>;
|
||||||
|
}> = [
|
||||||
{
|
{
|
||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/admin', label: 'Dashboard', icon: LayoutDashboard },
|
{ tab: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Configuration',
|
label: 'Configuration',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/admin/settings', label: 'Settings', icon: Settings },
|
{ tab: 'settings', label: 'Settings', icon: Settings },
|
||||||
{ href: '/admin/branding', label: 'Branding', icon: Palette },
|
{ tab: 'branding', label: 'Branding', icon: Palette },
|
||||||
{ href: '/admin/auth', label: 'Authentication', icon: Shield },
|
{ tab: 'auth', label: 'Authentication', icon: Shield },
|
||||||
{ href: '/admin/policy', label: 'Policy', icon: Scale },
|
{ tab: 'policy', label: 'Policy', icon: Scale },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Extensions',
|
label: 'Extensions',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/admin/plugins', label: 'Plugins', icon: Puzzle },
|
{ tab: 'plugins', label: 'Plugins', icon: Puzzle },
|
||||||
{ href: '/admin/themes', label: 'Themes', icon: SwatchBook },
|
{ tab: 'themes', label: 'Themes', icon: SwatchBook },
|
||||||
{ href: '/admin/marketplace', label: 'Marketplace', icon: Store },
|
{ tab: 'marketplace', label: 'Marketplace', icon: Store },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'System',
|
label: 'System',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
|
{ tab: 'version', label: 'Version', icon: Package },
|
||||||
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
{ tab: 'telemetry', label: 'Telemetry', icon: Activity },
|
||||||
|
{ tab: 'logs', label: 'Audit Log', icon: ScrollText },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -66,15 +77,41 @@ const NAV_GROUPS = [
|
|||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const storeActiveTab = useAdminTabStore((s) => s.activeTab);
|
||||||
|
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
|
||||||
|
// Highlight the active tab only on /admin itself - on dynamic routes
|
||||||
|
// (e.g. /admin/plugins/[id]) no tab is "current".
|
||||||
|
const activeTab = pathname === '/admin' ? storeActiveTab : null;
|
||||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||||
const [authError, setAuthError] = useState<string | null>(null);
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
const logoUrl = resolvedTheme === 'dark'
|
const logoUrl = resolvedTheme === 'dark'
|
||||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||||
|
|
||||||
|
// Match the navigation rail: red for security/deprecated, amber for normal.
|
||||||
|
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||||
|
const updateSeverity = useUpdateStore((s) => s.status?.severity);
|
||||||
|
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
|
||||||
|
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
|
||||||
|
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileNavOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mobileNavOpen) return;
|
||||||
|
const previous = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previous;
|
||||||
|
};
|
||||||
|
}, [mobileNavOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pathname === '/admin/login') return;
|
if (pathname === '/admin/login') return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -140,10 +177,97 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const navContent = (
|
||||||
|
<>
|
||||||
|
<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(({ tab, label, icon: Icon }) => {
|
||||||
|
const active = activeTab === tab;
|
||||||
|
const showDot = tab === 'version' && hasUpdate;
|
||||||
|
const handleClick = () => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
// From a dynamic route (/admin/plugins/[id], /admin/marketplace/[slug])
|
||||||
|
// we still need a real navigation back to /admin so the page renders.
|
||||||
|
if (pathname !== '/admin') router.push('/admin');
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="relative shrink-0">
|
||||||
|
<Icon className={cn(
|
||||||
|
'w-4 h-4',
|
||||||
|
active ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||||
|
)} />
|
||||||
|
{showDot && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2',
|
||||||
|
active ? 'ring-accent' : 'ring-background',
|
||||||
|
updateImportant ? 'bg-red-500' : 'bg-amber-500',
|
||||||
|
)}
|
||||||
|
aria-label={updateImportant ? 'Important update available' : 'Update available'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-background">
|
<div className="min-h-screen flex bg-background">
|
||||||
{/* Slim webmail nav rail */}
|
{/* Slim webmail nav rail (desktop only) */}
|
||||||
<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">
|
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
||||||
) : (
|
) : (
|
||||||
@@ -191,8 +315,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Admin Sidebar */}
|
{/* Admin Sidebar (desktop only) */}
|
||||||
<aside className="w-60 border-r border-border bg-secondary flex flex-col sticky top-0 h-screen">
|
<aside className="hidden md:flex w-60 border-r border-border bg-secondary flex-col sticky top-0 h-screen">
|
||||||
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||||
@@ -201,74 +325,71 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
)}
|
)}
|
||||||
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
|
{navContent}
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto py-2">
|
{/* Mobile drawer overlay */}
|
||||||
<div className="px-2 space-y-0.5">
|
{mobileNavOpen && (
|
||||||
{NAV_GROUPS.map((group, groupIndex) => (
|
<div
|
||||||
<div key={group.label}>
|
className="md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
|
||||||
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
onClick={() => setMobileNavOpen(false)}
|
||||||
<div className="px-3 pt-2.5 pb-1">
|
aria-hidden="true"
|
||||||
<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">
|
{/* Mobile drawer */}
|
||||||
{!isStalwartAdmin && (
|
<aside
|
||||||
<Link
|
|
||||||
href="/admin/change-password"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-r border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
|
||||||
pathname === '/admin/change-password'
|
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
|
||||||
: 'hover:bg-muted text-foreground'
|
|
||||||
)}
|
)}
|
||||||
|
aria-label="Admin navigation"
|
||||||
|
aria-hidden={!mobileNavOpen}
|
||||||
>
|
>
|
||||||
<KeyRound className={cn(
|
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
|
||||||
'w-4 h-4 shrink-0',
|
<div className="flex items-center min-w-0">
|
||||||
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
|
{logoUrl ? (
|
||||||
)} />
|
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||||
Change Password
|
) : (
|
||||||
</Link>
|
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||||
)}
|
)}
|
||||||
|
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
type="button"
|
||||||
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"
|
onClick={() => setMobileNavOpen(false)}
|
||||||
|
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
aria-label="Close navigation"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
<X className="w-5 h-5" />
|
||||||
Sign out
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{navContent}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 min-w-0 overflow-x-hidden">
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
{/* Mobile header */}
|
||||||
|
<div className="md:hidden sticky top-0 z-30 h-14 flex items-center gap-2 px-3 border-b border-border bg-background">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileNavOpen(true)}
|
||||||
|
className="flex items-center justify-center w-9 h-9 rounded-md text-foreground hover:bg-muted transition-colors"
|
||||||
|
aria-label="Open navigation"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center min-w-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 truncate">Admin Panel</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto p-4 md:p-6 pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-6">
|
||||||
{authError ? (
|
{authError ? (
|
||||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
<p className="font-medium">Admin authentication failed</p>
|
<p className="font-medium">Admin authentication failed</p>
|
||||||
@@ -283,6 +404,64 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Mobile bottom nav (main webmail nav) */}
|
||||||
|
<nav
|
||||||
|
className="md:hidden fixed inset-x-0 bottom-0 z-30 flex items-center bg-background border-t border-border pb-[env(safe-area-inset-bottom)]"
|
||||||
|
aria-label="Main navigation"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||||
|
title="Mail"
|
||||||
|
>
|
||||||
|
<Mail className="w-5 h-5" />
|
||||||
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/calendar"
|
||||||
|
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||||
|
title="Calendar"
|
||||||
|
>
|
||||||
|
<Calendar className="w-5 h-5" />
|
||||||
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/contacts"
|
||||||
|
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||||
|
title="Contacts"
|
||||||
|
>
|
||||||
|
<BookUser className="w-5 h-5" />
|
||||||
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/files"
|
||||||
|
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||||
|
title="Files"
|
||||||
|
>
|
||||||
|
<HardDrive className="w-5 h-5" />
|
||||||
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
|
||||||
|
</a>
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
|
||||||
|
title="Admin"
|
||||||
|
aria-current="page"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Shield className="w-5 h-5" />
|
||||||
|
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/settings"
|
||||||
|
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Settings</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-148
@@ -1,150 +1,5 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
export default function Page() {
|
||||||
import { RefreshCw } from 'lucide-react';
|
redirect('/admin?tab=logs');
|
||||||
import type { AuditEntry } from '@/lib/admin/types';
|
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
|
||||||
|
|
||||||
export default function AdminLogsPage() {
|
|
||||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [actionFilter, setActionFilter] = useState('');
|
|
||||||
const limit = 50;
|
|
||||||
|
|
||||||
const fetchLogs = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
|
||||||
if (actionFilter) params.set('action', actionFilter);
|
|
||||||
|
|
||||||
const res = await apiFetch(`/api/admin/audit?${params}`);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setEntries(data.entries || []);
|
|
||||||
setTotal(data.total || 0);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}, [page, actionFilter]);
|
|
||||||
|
|
||||||
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={fetchLogs}
|
|
||||||
className="inline-flex items-center gap-2 h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-accent transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<select
|
|
||||||
value={actionFilter}
|
|
||||||
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
|
|
||||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<option value="">All actions</option>
|
|
||||||
<option value="admin.login">Login</option>
|
|
||||||
<option value="admin.logout">Logout</option>
|
|
||||||
<option value="admin.login_failed">Login Failed</option>
|
|
||||||
<option value="admin.login_blocked">Login Blocked</option>
|
|
||||||
<option value="admin.change-password">Password Change</option>
|
|
||||||
<option value="config.update">Config Update</option>
|
|
||||||
<option value="config.revert">Config Revert</option>
|
|
||||||
<option value="policy.update">Policy Update</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<div className="border border-border rounded-lg overflow-hidden">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border bg-muted/30">
|
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Time</th>
|
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Action</th>
|
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
|
|
||||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">IP</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{loading && entries.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">Loading...</td>
|
|
||||||
</tr>
|
|
||||||
) : entries.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">No entries found</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
entries.map((entry, i) => (
|
|
||||||
<tr key={i} className="hover:bg-muted/20">
|
|
||||||
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">
|
|
||||||
{new Date(entry.ts).toLocaleString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
|
||||||
{entry.action}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-xs text-foreground max-w-xs truncate">
|
|
||||||
{formatDetail(entry.detail)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-xs text-muted-foreground font-mono">
|
|
||||||
{entry.ip}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Page {page} of {totalPages}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
||||||
disabled={page === 1}
|
|
||||||
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={page === totalPages}
|
|
||||||
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDetail(detail: Record<string, unknown>): string {
|
|
||||||
if (!detail || Object.keys(detail).length === 0) return '-';
|
|
||||||
if (detail.reason) return String(detail.reason);
|
|
||||||
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
|
||||||
if (detail.changes && Array.isArray(detail.changes)) {
|
|
||||||
return detail.changes.map((c: Record<string, unknown>) => `${c.key}`).join(', ');
|
|
||||||
}
|
|
||||||
if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`;
|
|
||||||
return JSON.stringify(detail).slice(0, 100);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ interface PreviewData {
|
|||||||
githubRepo: string | null;
|
githubRepo: string | null;
|
||||||
license: string | null;
|
license: string | null;
|
||||||
minAppVersion: string | null;
|
minAppVersion: string | null;
|
||||||
|
iconUrl: string | null;
|
||||||
|
bannerUrl: string | null;
|
||||||
author: {
|
author: {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
githubLogin: string;
|
githubLogin: string;
|
||||||
@@ -209,18 +211,38 @@ export default function MarketplacePreviewPage() {
|
|||||||
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
|
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Banner / hero */}
|
||||||
|
{ext.bannerUrl && (
|
||||||
|
<div className="mb-6 overflow-hidden rounded-lg border border-border bg-muted">
|
||||||
|
<img
|
||||||
|
src={ext.bannerUrl}
|
||||||
|
alt=""
|
||||||
|
className="block w-full max-h-64 object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||||
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
<div className="flex items-start gap-4 flex-1 min-w-0">
|
||||||
{isPlugin ? (
|
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
|
{ext.iconUrl ? (
|
||||||
|
<img
|
||||||
|
src={ext.iconUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-14 h-14 object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : isPlugin ? (
|
||||||
<Puzzle className="w-7 h-7 text-muted-foreground" />
|
<Puzzle className="w-7 h-7 text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<SwatchBook className="w-7 h-7 text-muted-foreground" />
|
<SwatchBook className="w-7 h-7 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<h1 className="text-2xl font-semibold text-foreground truncate">{ext.name}</h1>
|
<h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
|
||||||
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
|
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
|
||||||
{data.installed && (
|
{data.installed && (
|
||||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium">
|
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium">
|
||||||
@@ -247,9 +269,10 @@ export default function MarketplacePreviewPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||||
{data.installed ? (
|
{data.installed ? (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -1,366 +1,5 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
export default function Page() {
|
||||||
import Link from 'next/link';
|
redirect('/admin?tab=marketplace');
|
||||||
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react';
|
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
|
||||||
|
|
||||||
interface Extension {
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
type: 'plugin' | 'theme';
|
|
||||||
pluginType: string | null;
|
|
||||||
description: string;
|
|
||||||
permissions: string[];
|
|
||||||
tags: string[];
|
|
||||||
totalDownloads: number;
|
|
||||||
featured: boolean;
|
|
||||||
minAppVersion: string | null;
|
|
||||||
latestVersion: string | null;
|
|
||||||
installed: boolean;
|
|
||||||
author: {
|
|
||||||
displayName: string;
|
|
||||||
githubLogin: string;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchResult {
|
|
||||||
data: Extension[];
|
|
||||||
meta: {
|
|
||||||
page: number;
|
|
||||||
perPage: number;
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type TypeFilter = 'all' | 'plugin' | 'theme';
|
|
||||||
|
|
||||||
export default function AdminMarketplacePage() {
|
|
||||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [perPage] = useState(12);
|
|
||||||
const [installing, setInstalling] = useState<string | null>(null);
|
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchExtensions = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (query) params.set('q', query);
|
|
||||||
if (typeFilter !== 'all') params.set('type', typeFilter);
|
|
||||||
params.set('page', String(page));
|
|
||||||
params.set('perPage', String(perPage));
|
|
||||||
params.set('sort', 'newest');
|
|
||||||
|
|
||||||
const res = await apiFetch(`/api/admin/marketplace?${params}`);
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
setError(data.error || 'Failed to connect to extension directory');
|
|
||||||
setExtensions([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: SearchResult = await res.json();
|
|
||||||
setExtensions(data.data || []);
|
|
||||||
setTotal(data.meta?.total || 0);
|
|
||||||
} catch {
|
|
||||||
setError('Failed to connect to extension directory. Make sure it is running.');
|
|
||||||
setExtensions([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [query, typeFilter, page, perPage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchExtensions();
|
|
||||||
}, [fetchExtensions]);
|
|
||||||
|
|
||||||
// Debounced search
|
|
||||||
const [searchInput, setSearchInput] = useState('');
|
|
||||||
useEffect(() => {
|
|
||||||
const t = setTimeout(() => {
|
|
||||||
setQuery(searchInput);
|
|
||||||
setPage(1);
|
|
||||||
}, 300);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, [searchInput]);
|
|
||||||
|
|
||||||
async function handleInstall(ext: Extension) {
|
|
||||||
setInstalling(ext.slug);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/admin/marketplace', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
slug: ext.slug,
|
|
||||||
version: ext.latestVersion || '1.0.0',
|
|
||||||
type: ext.type,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
|
||||||
setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` });
|
|
||||||
// Mark as installed in the UI
|
|
||||||
setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e));
|
|
||||||
} else {
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
|
||||||
} finally {
|
|
||||||
setInstalling(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / perPage);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Marketplace</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Browse and install plugins and themes from the BulwarkMail extension directory
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
|
||||||
{message.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Search & Filters */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search extensions..."
|
|
||||||
value={searchInput}
|
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
|
||||||
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5">
|
|
||||||
{(['all', 'plugin', 'theme'] as const).map((t) => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
onClick={() => { setTypeFilter(t); setPage(1); }}
|
|
||||||
className={`h-8 px-3 rounded text-sm font-medium transition-colors ${
|
|
||||||
typeFilter === t
|
|
||||||
? 'bg-accent text-accent-foreground'
|
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t === 'all' ? 'All' : t === 'plugin' ? 'Plugins' : 'Themes'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error State */}
|
|
||||||
{error && (
|
|
||||||
<div className="border border-border rounded-lg p-12 text-center">
|
|
||||||
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
|
||||||
<p className="text-sm text-muted-foreground">{error}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Start the extension directory server on the configured port
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={fetchExtensions}
|
|
||||||
className="mt-4 inline-flex items-center gap-2 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading State */}
|
|
||||||
{loading && !error && (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
|
||||||
<span className="ml-2 text-sm text-muted-foreground">Searching extensions...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty State */}
|
|
||||||
{!loading && !error && extensions.length === 0 && (
|
|
||||||
<div className="border border-border rounded-lg p-12 text-center">
|
|
||||||
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
|
||||||
<p className="text-sm text-muted-foreground">No extensions found</p>
|
|
||||||
{query && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Try a different search term
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Extension Grid */}
|
|
||||||
{!loading && !error && extensions.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{total} extension{total !== 1 ? 's' : ''} found
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{extensions.map((ext) => (
|
|
||||||
<ExtensionCard
|
|
||||||
key={ext.slug}
|
|
||||||
extension={ext}
|
|
||||||
installing={installing === ext.slug}
|
|
||||||
onInstall={() => handleInstall(ext)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-center gap-2 pt-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
||||||
disabled={page <= 1}
|
|
||||||
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Page {page} of {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExtensionCard({
|
|
||||||
extension,
|
|
||||||
installing,
|
|
||||||
onInstall,
|
|
||||||
}: {
|
|
||||||
extension: Extension;
|
|
||||||
installing: boolean;
|
|
||||||
onInstall: () => void;
|
|
||||||
}) {
|
|
||||||
const isPlugin = extension.type === 'plugin';
|
|
||||||
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
|
||||||
<Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
|
|
||||||
{/* 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 group-hover:underline">
|
|
||||||
{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 (download count + permissions) */}
|
|
||||||
<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>
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground">
|
|
||||||
<Eye className="w-3 h-3" />
|
|
||||||
Preview
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Quick install button (sits over the link, stops navigation) */}
|
|
||||||
<div className="px-4 pb-4 -mt-1">
|
|
||||||
{extension.installed ? (
|
|
||||||
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
|
|
||||||
<Check className="w-3 h-3" />
|
|
||||||
Installed
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
|
||||||
disabled={installing}
|
|
||||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{installing ? (
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
Quick install
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-221
@@ -1,230 +1,49 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { useAdminTabStore, isAdminTab } from '@/stores/admin-tab-store';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
import { DashboardTab } from './_tabs/dashboard';
|
||||||
import type { AuditEntry } from '@/lib/admin/types';
|
import { SettingsTab } from './_tabs/settings';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { BrandingTab } from './_tabs/branding';
|
||||||
|
import { AuthTab } from './_tabs/auth';
|
||||||
|
import { PolicyTab } from './_tabs/policy';
|
||||||
|
import { PluginsTab } from './_tabs/plugins';
|
||||||
|
import { ThemesTab } from './_tabs/themes';
|
||||||
|
import { MarketplaceTab } from './_tabs/marketplace';
|
||||||
|
import { VersionTab } from './_tabs/version';
|
||||||
|
import { TelemetryTab } from './_tabs/telemetry';
|
||||||
|
import { LogsTab } from './_tabs/logs';
|
||||||
|
|
||||||
interface AdminStatus {
|
export default function AdminPage() {
|
||||||
enabled: boolean;
|
const activeTab = useAdminTabStore((s) => s.activeTab);
|
||||||
authenticated: boolean;
|
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
|
||||||
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 [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
|
|
||||||
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
|
||||||
|
|
||||||
|
// Honour deep links from the old route structure: /admin?tab=settings
|
||||||
|
// (emitted by the redirect pages in /admin/<x>/page.tsx) sets the store
|
||||||
|
// once on mount, then strips the param so the URL stays at /admin and
|
||||||
|
// subsequent tab clicks don't accumulate query strings.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDashboardData();
|
if (typeof window === 'undefined') return;
|
||||||
}, []);
|
const url = new URL(window.location.href);
|
||||||
|
const fromUrl = url.searchParams.get('tab');
|
||||||
async function fetchDashboardData() {
|
if (isAdminTab(fromUrl)) {
|
||||||
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes, telemetryRes] = await Promise.all([
|
setActiveTab(fromUrl);
|
||||||
apiFetch('/api/admin/auth'),
|
url.searchParams.delete('tab');
|
||||||
apiFetch('/api/admin/audit?limit=10'),
|
window.history.replaceState(null, '', url.pathname + url.search + url.hash);
|
||||||
apiFetch('/api/config'),
|
|
||||||
apiFetch('/api/admin/config'),
|
|
||||||
apiFetch('/api/admin/plugins').catch(() => null),
|
|
||||||
apiFetch('/api/admin/themes').catch(() => null),
|
|
||||||
apiFetch('/api/admin/policy').catch(() => null),
|
|
||||||
apiFetch('/api/admin/telemetry').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);
|
|
||||||
}
|
}
|
||||||
|
}, [setActiveTab]);
|
||||||
|
|
||||||
if (pluginRes?.ok) {
|
switch (activeTab) {
|
||||||
const plugins = await pluginRes.json();
|
case 'dashboard': return <DashboardTab />;
|
||||||
setPluginCount(Array.isArray(plugins) ? plugins.length : 0);
|
case 'settings': return <SettingsTab />;
|
||||||
|
case 'branding': return <BrandingTab />;
|
||||||
|
case 'auth': return <AuthTab />;
|
||||||
|
case 'policy': return <PolicyTab />;
|
||||||
|
case 'plugins': return <PluginsTab />;
|
||||||
|
case 'themes': return <ThemesTab />;
|
||||||
|
case 'marketplace': return <MarketplaceTab />;
|
||||||
|
case 'version': return <VersionTab />;
|
||||||
|
case 'telemetry': return <TelemetryTab />;
|
||||||
|
case 'logs': return <LogsTab />;
|
||||||
}
|
}
|
||||||
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 (telemetryRes?.ok) {
|
|
||||||
const telemetry = await telemetryRes.json();
|
|
||||||
if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') {
|
|
||||||
setAccountCounts(telemetry.accountCounts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (configData?.jmapServerUrl) {
|
|
||||||
try {
|
|
||||||
const jmapRes = await apiFetch('/api/config');
|
|
||||||
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
|
||||||
} catch {
|
|
||||||
setJmapHealth('error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const w: string[] = [];
|
|
||||||
if (adminConfigRes.ok) {
|
|
||||||
const sources = await adminConfigRes.json();
|
|
||||||
setConfigSources(sources);
|
|
||||||
const sessionSecret = sources?.sessionSecret;
|
|
||||||
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') {
|
|
||||||
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
|
|
||||||
}
|
|
||||||
const adminPassword = sources?.adminPassword;
|
|
||||||
if (adminPassword?.value && adminPassword.source === 'env') {
|
|
||||||
w.push('ADMIN_PASSWORD is still set in environment variables. Remove it now that the hash is stored securely.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setWarnings(w);
|
|
||||||
}
|
|
||||||
|
|
||||||
const jmapUrl = config?.jmapServerUrl || '-';
|
|
||||||
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-3xl space-y-8">
|
|
||||||
{/* Warnings */}
|
|
||||||
{warnings.map((msg, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
|
|
||||||
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
|
|
||||||
<p className="text-sm text-warning">{msg}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{status && !status.lastLogin && (
|
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
|
|
||||||
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-warning">First login detected</p>
|
|
||||||
<p className="text-sm text-warning/80 mt-0.5">
|
|
||||||
Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Server Info */}
|
|
||||||
<SettingsSection title="Server" description="Application and connection details">
|
|
||||||
<SettingItem label="Application">
|
|
||||||
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
|
|
||||||
</SettingItem>
|
|
||||||
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
|
||||||
<span className="text-sm text-foreground">{jmapHostname}</span>
|
|
||||||
</SettingItem>
|
|
||||||
<SettingItem label="JMAP Connection">
|
|
||||||
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${
|
|
||||||
jmapHealth === 'ok' ? 'text-green-600 dark:text-green-400' : jmapHealth === 'error' ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground'
|
|
||||||
}`}>
|
|
||||||
<span className={`w-2 h-2 rounded-full ${
|
|
||||||
jmapHealth === 'ok' ? 'bg-green-500' : jmapHealth === 'error' ? 'bg-red-500' : 'bg-muted-foreground/40'
|
|
||||||
}`} />
|
|
||||||
{jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'}
|
|
||||||
</span>
|
|
||||||
</SettingItem>
|
|
||||||
<SettingItem label="Last Login">
|
|
||||||
<span className="text-sm text-foreground">
|
|
||||||
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
|
||||||
</span>
|
|
||||||
</SettingItem>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<SettingsSection title="Features" description="Enabled integrations and modules">
|
|
||||||
<SettingItem label="Admin Panel" description="Administrative access to server configuration">
|
|
||||||
<ToggleSwitch checked={!!status?.enabled} onChange={() => {}} disabled />
|
|
||||||
</SettingItem>
|
|
||||||
<SettingItem label="Settings Sync" description="Synchronize user settings across devices">
|
|
||||||
<ToggleSwitch checked={!!config?.settingsSyncEnabled} onChange={() => {}} disabled />
|
|
||||||
</SettingItem>
|
|
||||||
<SettingItem label="OAuth" description="OAuth authentication provider">
|
|
||||||
<ToggleSwitch checked={!!config?.oauthEnabled} onChange={() => {}} disabled />
|
|
||||||
</SettingItem>
|
|
||||||
<SettingItem label="Stalwart Integration" description="Stalwart mail server features">
|
|
||||||
<ToggleSwitch checked={config?.stalwartFeaturesEnabled !== false} onChange={() => {}} disabled />
|
|
||||||
</SettingItem>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
{/* Accounts */}
|
|
||||||
<SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days">
|
|
||||||
<SettingItem label="Total accounts" description="Distinct identities seen in the retention window">
|
|
||||||
<span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span>
|
|
||||||
</SettingItem>
|
|
||||||
<SettingItem label="Active in last 7 days" description="Identities with a login in the past week">
|
|
||||||
<span className="text-sm text-foreground">{accountCounts?.active7d ?? '-'}</span>
|
|
||||||
</SettingItem>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
{/* Extensions */}
|
|
||||||
<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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,286 +1,7 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
// Inline panel handles plugin config now - see _tabs/plugin-config-panel.tsx.
|
||||||
import { useParams } from 'next/navigation';
|
// Old deep links land on the plugins tab; the user clicks the gear again.
|
||||||
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
export default function Page() {
|
||||||
import Link from 'next/link';
|
redirect('/admin?tab=plugins');
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
|
||||||
|
|
||||||
interface ConfigField {
|
|
||||||
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
|
||||||
label: string;
|
|
||||||
description?: string;
|
|
||||||
required?: boolean;
|
|
||||||
default?: unknown;
|
|
||||||
placeholder?: string;
|
|
||||||
options?: { label: string; value: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PluginConfig {
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PluginInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
version: string;
|
|
||||||
author: string;
|
|
||||||
type: string;
|
|
||||||
permissions: string[];
|
|
||||||
enabled: boolean;
|
|
||||||
configSchema?: Record<string, ConfigField>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PluginConfigPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const pluginId = params.id as string;
|
|
||||||
const [plugin, setPlugin] = useState<PluginInfo | null>(null);
|
|
||||||
const [config, setConfig] = useState<PluginConfig>({});
|
|
||||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
|
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [pluginId]);
|
|
||||||
|
|
||||||
// Initialize form values from config + schema defaults when data loads
|
|
||||||
useEffect(() => {
|
|
||||||
if (!plugin?.configSchema) return;
|
|
||||||
const initial: Record<string, string> = {};
|
|
||||||
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
|
||||||
const stored = config[key];
|
|
||||||
if (stored !== undefined && stored !== null) {
|
|
||||||
initial[key] = String(stored);
|
|
||||||
} else if (field.default !== undefined) {
|
|
||||||
initial[key] = String(field.default);
|
|
||||||
} else {
|
|
||||||
initial[key] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setFormValues(initial);
|
|
||||||
}, [plugin, config]);
|
|
||||||
|
|
||||||
async function fetchData() {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [pluginsRes, configRes] = await Promise.all([
|
|
||||||
apiFetch('/api/admin/plugins'),
|
|
||||||
apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (pluginsRes.ok) {
|
|
||||||
const plugins: PluginInfo[] = await pluginsRes.json();
|
|
||||||
setPlugin(plugins.find(p => p.id === pluginId) || null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (configRes.ok) {
|
|
||||||
setConfig(await configRes.json());
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveAll() {
|
|
||||||
if (!plugin?.configSchema) return;
|
|
||||||
setSaving(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
|
||||||
if (field.required && !formValues[key]?.trim()) {
|
|
||||||
setMessage({ type: 'error', text: `"${field.label}" is required` });
|
|
||||||
setSaving(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Save each changed field
|
|
||||||
let hasError = false;
|
|
||||||
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
|
||||||
const newVal = formValues[key] ?? '';
|
|
||||||
const oldVal = config[key] !== undefined ? String(config[key]) : '';
|
|
||||||
|
|
||||||
// Skip unchanged fields (and skip secret fields that show as empty when they have a stored value)
|
|
||||||
if (newVal === oldVal) continue;
|
|
||||||
if (field.type === 'secret' && !newVal && config[key]) continue;
|
|
||||||
|
|
||||||
// Convert types
|
|
||||||
let value: unknown = newVal;
|
|
||||||
if (field.type === 'boolean') value = newVal === 'true';
|
|
||||||
else if (field.type === 'number') value = Number(newVal);
|
|
||||||
|
|
||||||
// Delete if clearing a non-required field
|
|
||||||
if (!newVal && !field.required) {
|
|
||||||
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ key }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setConfig(prev => { const next = { ...prev }; delete next[key]; return next; });
|
|
||||||
} else {
|
|
||||||
hasError = true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ key, value }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setConfig(prev => ({ ...prev, [key]: value }));
|
|
||||||
} else {
|
|
||||||
hasError = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessage(hasError
|
|
||||||
? { type: 'error', text: 'Some settings failed to save' }
|
|
||||||
: { type: 'success', text: 'Configuration saved' }
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
setMessage({ type: 'error', text: 'Failed to save configuration' });
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!plugin) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
|
||||||
<ArrowLeft className="w-4 h-4" /> Back to Plugins
|
|
||||||
</Link>
|
|
||||||
<p className="text-sm text-destructive">Plugin not found: {pluginId}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const schema = plugin.configSchema;
|
|
||||||
const hasSchema = schema && Object.keys(schema).length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
|
||||||
<Puzzle className="w-5 h-5" />
|
|
||||||
{plugin.name} Configuration
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
|
||||||
v{plugin.version} by {plugin.author}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
|
||||||
{message.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasSchema ? (
|
|
||||||
<div className="border border-border rounded-lg">
|
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
|
||||||
<h2 className="text-sm font-medium text-foreground">Settings</h2>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 space-y-5">
|
|
||||||
{Object.entries(schema).map(([key, field]) => (
|
|
||||||
<div key={key}>
|
|
||||||
<label className="text-sm font-medium text-foreground block mb-1">
|
|
||||||
{field.label}
|
|
||||||
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
|
||||||
</label>
|
|
||||||
{field.description && (
|
|
||||||
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{field.type === 'boolean' ? (
|
|
||||||
<select
|
|
||||||
value={formValues[key] ?? String(field.default ?? 'false')}
|
|
||||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
|
||||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
>
|
|
||||||
<option value="true">Enabled</option>
|
|
||||||
<option value="false">Disabled</option>
|
|
||||||
</select>
|
|
||||||
) : field.type === 'select' && field.options ? (
|
|
||||||
<select
|
|
||||||
value={formValues[key] ?? ''}
|
|
||||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
|
||||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
>
|
|
||||||
<option value="">- Select -</option>
|
|
||||||
{field.options.map(opt => (
|
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : field.type === 'secret' ? (
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type={revealSecrets[key] ? 'text' : 'password'}
|
|
||||||
value={formValues[key] ?? ''}
|
|
||||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
|
||||||
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
|
|
||||||
className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRevealSecrets(prev => ({ ...prev, [key]: !prev[key] }))}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
|
||||||
aria-label={revealSecrets[key] ? 'Hide' : 'Show'}
|
|
||||||
>
|
|
||||||
{revealSecrets[key] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type={field.type === 'number' ? 'number' : 'text'}
|
|
||||||
value={formValues[key] ?? ''}
|
|
||||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
|
||||||
placeholder={field.placeholder || ''}
|
|
||||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSaveAll}
|
|
||||||
disabled={saving}
|
|
||||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
|
||||||
>
|
|
||||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
|
||||||
Save Configuration
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="border border-border rounded-lg p-8 text-center">
|
|
||||||
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-456
@@ -1,458 +1,5 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
export default function Page() {
|
||||||
import Link from 'next/link';
|
redirect('/admin?tab=plugins');
|
||||||
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
|
||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
|
||||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
|
||||||
|
|
||||||
interface PluginEntry {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
author: string;
|
|
||||||
description: string;
|
|
||||||
type: string;
|
|
||||||
enabled: boolean;
|
|
||||||
forceEnabled?: boolean;
|
|
||||||
permissions: string[];
|
|
||||||
installedAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminPluginsPage() {
|
|
||||||
const [plugins, setPlugins] = useState<PluginEntry[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
|
||||||
const [policyDirty, setPolicyDirty] = useState(false);
|
|
||||||
const [savingPolicy, setSavingPolicy] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => { fetchPlugins(); fetchPolicy(); }, []);
|
|
||||||
|
|
||||||
async function fetchPolicy() {
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/admin/policy');
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setPolicy(data);
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePluginsEnabled() {
|
|
||||||
setPolicy(prev => ({
|
|
||||||
...prev,
|
|
||||||
features: { ...prev.features, pluginsEnabled: !prev.features.pluginsEnabled },
|
|
||||||
}));
|
|
||||||
setPolicyDirty(true);
|
|
||||||
setMessage(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePluginsUploadEnabled() {
|
|
||||||
setPolicy(prev => ({
|
|
||||||
...prev,
|
|
||||||
features: { ...prev.features, pluginsUploadEnabled: !prev.features.pluginsUploadEnabled },
|
|
||||||
}));
|
|
||||||
setPolicyDirty(true);
|
|
||||||
setMessage(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRequirePluginApproval() {
|
|
||||||
setPolicy(prev => ({
|
|
||||||
...prev,
|
|
||||||
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
|
|
||||||
}));
|
|
||||||
setPolicyDirty(true);
|
|
||||||
setMessage(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSavePolicy() {
|
|
||||||
setSavingPolicy(true);
|
|
||||||
setMessage(null);
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/admin/policy', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(policy),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setMessage({ type: 'success', text: 'Plugin policy saved. Users will see changes on next login.' });
|
|
||||||
setPolicyDirty(false);
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setMessage({ type: 'error', text: 'Failed to save policy' });
|
|
||||||
} finally {
|
|
||||||
setSavingPolicy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchPlugins() {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/admin/plugins');
|
|
||||||
if (res.ok) setPlugins(await res.json());
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setUploading(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/admin/plugins', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok) {
|
|
||||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
|
||||||
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
|
|
||||||
await fetchPlugins();
|
|
||||||
} else {
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setMessage({ type: 'error', text: 'Upload failed' });
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function togglePlugin(id: string, enabled: boolean) {
|
|
||||||
setMessage(null);
|
|
||||||
const res = await apiFetch('/api/admin/plugins', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ id, enabled }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setPlugins(prev => prev.map(p => p.id === id ? { ...p, enabled } : p));
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
|
|
||||||
setMessage(null);
|
|
||||||
// If force-enabling, also ensure the plugin is enabled
|
|
||||||
const body: Record<string, unknown> = { id, forceEnabled };
|
|
||||||
if (forceEnabled) body.enabled = true;
|
|
||||||
|
|
||||||
const res = await apiFetch('/api/admin/plugins', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p));
|
|
||||||
// Also update policy
|
|
||||||
setPolicy(prev => {
|
|
||||||
const current = prev.forceEnabledPlugins || [];
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
forceEnabledPlugins: forceEnabled
|
|
||||||
? [...current.filter(pid => pid !== id), id]
|
|
||||||
: current.filter(pid => pid !== id),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setPolicyDirty(true);
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function forceEnableAll() {
|
|
||||||
setMessage(null);
|
|
||||||
const disabled = plugins.filter(p => !p.enabled);
|
|
||||||
if (disabled.length === 0) {
|
|
||||||
setMessage({ type: 'success', text: 'All plugins are already enabled' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let failed = 0;
|
|
||||||
for (const p of disabled) {
|
|
||||||
const res = await apiFetch('/api/admin/plugins', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ id: p.id, enabled: true }),
|
|
||||||
});
|
|
||||||
if (!res.ok) failed++;
|
|
||||||
}
|
|
||||||
setPlugins(prev => prev.map(p => failed === 0 ? { ...p, enabled: true } : p));
|
|
||||||
if (failed === 0) {
|
|
||||||
await fetchPlugins();
|
|
||||||
setMessage({ type: 'success', text: `All ${disabled.length} plugin(s) enabled` });
|
|
||||||
} else {
|
|
||||||
await fetchPlugins();
|
|
||||||
setMessage({ type: 'error', text: `${failed} plugin(s) failed to enable` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function forceDisableAll() {
|
|
||||||
setMessage(null);
|
|
||||||
const enabled = plugins.filter(p => p.enabled);
|
|
||||||
if (enabled.length === 0) {
|
|
||||||
setMessage({ type: 'success', text: 'All plugins are already disabled' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let failed = 0;
|
|
||||||
for (const p of enabled) {
|
|
||||||
const res = await apiFetch('/api/admin/plugins', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ id: p.id, enabled: false }),
|
|
||||||
});
|
|
||||||
if (!res.ok) failed++;
|
|
||||||
}
|
|
||||||
if (failed === 0) {
|
|
||||||
await fetchPlugins();
|
|
||||||
setMessage({ type: 'success', text: `All ${enabled.length} plugin(s) disabled` });
|
|
||||||
} else {
|
|
||||||
await fetchPlugins();
|
|
||||||
setMessage({ type: 'error', text: `${failed} plugin(s) failed to disable` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deletePlugin(id: string, name: string) {
|
|
||||||
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
|
||||||
|
|
||||||
setMessage(null);
|
|
||||||
const res = await apiFetch('/api/admin/plugins', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ id }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setPlugins(prev => prev.filter(p => p.id !== id));
|
|
||||||
setMessage({ type: 'success', text: `Plugin "${name}" removed` });
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Delete failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
|
||||||
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
|
||||||
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{policyDirty && (
|
|
||||||
<button
|
|
||||||
onClick={handleSavePolicy}
|
|
||||||
disabled={savingPolicy}
|
|
||||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
|
||||||
>
|
|
||||||
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
||||||
Save Policy
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<label className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 cursor-pointer transition-all shadow-sm">
|
|
||||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
|
||||||
Upload Plugin
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".zip"
|
|
||||||
onChange={handleUpload}
|
|
||||||
disabled={uploading}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
|
||||||
{message.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Plugin Policy */}
|
|
||||||
<div className="border border-border rounded-lg">
|
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Shield className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<h2 className="text-sm font-medium text-foreground">Plugin Policy</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-foreground">Plugins Enabled</span>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={togglePluginsEnabled}
|
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
|
||||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-foreground">User Plugin Uploads</span>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={togglePluginsUploadEnabled}
|
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsUploadEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
|
||||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsUploadEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-foreground">Require Admin Approval</span>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={toggleRequirePluginApproval}
|
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
|
||||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Force enable / disable all */}
|
|
||||||
{plugins.length > 0 && (
|
|
||||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={forceEnableAll}
|
|
||||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
|
|
||||||
>
|
|
||||||
<Power className="w-3.5 h-3.5" />
|
|
||||||
Enable All
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={forceDisableAll}
|
|
||||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<PowerOff className="w-3.5 h-3.5" />
|
|
||||||
Disable All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Deployed Plugins */}
|
|
||||||
<div className="border border-border rounded-lg">
|
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Package className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<h2 className="text-sm font-medium text-foreground">Deployed Plugins</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded plugins for all users</p>
|
|
||||||
</div>
|
|
||||||
{plugins.length === 0 ? (
|
|
||||||
<div className="p-12 text-center">
|
|
||||||
<Package className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
|
||||||
<p className="text-sm text-muted-foreground">No plugins installed</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">Upload a plugin ZIP file to get started</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{plugins.map(plugin => (
|
|
||||||
<div key={plugin.id} className="px-4 py-4 flex items-center justify-between gap-4">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-foreground">{plugin.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
|
|
||||||
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
|
|
||||||
{plugin.enabled ? 'Enabled' : 'Disabled'}
|
|
||||||
</span>
|
|
||||||
{plugin.forceEnabled && (
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
|
|
||||||
<Lock className="w-3 h-3" /> Forced
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{plugin.description && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{plugin.description}</p>
|
|
||||||
)}
|
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
|
||||||
by {plugin.author} · {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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-218
@@ -1,220 +1,5 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
export default function Page() {
|
||||||
import { Save, Loader2, Lock } from 'lucide-react';
|
redirect('/admin?tab=policy');
|
||||||
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
|
||||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
|
||||||
|
|
||||||
// Feature gates managed on their own admin pages (excluded from this list)
|
|
||||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
|
||||||
|
|
||||||
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
|
||||||
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
|
||||||
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
|
|
||||||
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
|
||||||
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
|
||||||
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
|
||||||
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
|
||||||
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
|
||||||
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
|
|
||||||
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
|
||||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
|
||||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
|
||||||
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const RESTRICTABLE_SETTINGS = [
|
|
||||||
{ key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] },
|
|
||||||
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
|
||||||
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
|
||||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
|
||||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
|
||||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
|
||||||
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
|
|
||||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
|
||||||
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
|
||||||
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
|
||||||
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
|
|
||||||
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
|
|
||||||
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
|
|
||||||
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
|
||||||
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
|
||||||
{ key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' },
|
|
||||||
{ key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AdminPolicyPage() {
|
|
||||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
||||||
const [dirty, setDirty] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => { fetchPolicy(); }, []);
|
|
||||||
|
|
||||||
async function fetchPolicy() {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/admin/policy');
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setPolicy(data);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleFeature(key: keyof FeatureGates) {
|
|
||||||
setPolicy(prev => ({
|
|
||||||
...prev,
|
|
||||||
features: { ...prev.features, [key]: !prev.features[key] },
|
|
||||||
}));
|
|
||||||
setDirty(true);
|
|
||||||
setMessage(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleLocked(settingKey: string) {
|
|
||||||
setPolicy(prev => {
|
|
||||||
const existing = prev.restrictions[settingKey] || {};
|
|
||||||
const newRestrictions = { ...prev.restrictions };
|
|
||||||
if (existing.locked) {
|
|
||||||
delete newRestrictions[settingKey];
|
|
||||||
} else {
|
|
||||||
newRestrictions[settingKey] = { ...existing, locked: true };
|
|
||||||
}
|
|
||||||
return { ...prev, restrictions: newRestrictions };
|
|
||||||
});
|
|
||||||
setDirty(true);
|
|
||||||
setMessage(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleHidden(settingKey: string) {
|
|
||||||
setPolicy(prev => {
|
|
||||||
const existing = prev.restrictions[settingKey] || {};
|
|
||||||
const newRestrictions = { ...prev.restrictions };
|
|
||||||
newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden };
|
|
||||||
if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) {
|
|
||||||
delete newRestrictions[settingKey];
|
|
||||||
}
|
|
||||||
return { ...prev, restrictions: newRestrictions };
|
|
||||||
});
|
|
||||||
setDirty(true);
|
|
||||||
setMessage(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
setSaving(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
const res = await apiFetch('/api/admin/policy', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(policy),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setMessage({ type: 'success', text: 'Policy saved. Users will see changes on next login.' });
|
|
||||||
setDirty(false);
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
|
|
||||||
</div>
|
|
||||||
{dirty && (
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
|
||||||
>
|
|
||||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
||||||
Save policy
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
|
||||||
{message.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Feature Gates */}
|
|
||||||
<div className="border border-border rounded-lg">
|
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
|
||||||
<h2 className="text-sm font-medium text-foreground">Feature Gates</h2>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.</p>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[])
|
|
||||||
.filter(key => !EXCLUDED_FEATURE_GATES.includes(key))
|
|
||||||
.map(key => {
|
|
||||||
const meta = FEATURE_GATE_LABELS[key];
|
|
||||||
if (!meta) return null;
|
|
||||||
const { label, description } = meta;
|
|
||||||
const enabled = policy.features[key];
|
|
||||||
return (
|
|
||||||
<div key={key} className="px-4 py-3 flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-foreground">{label}</span>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => toggleFeature(key)}
|
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
|
||||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Setting Restrictions */}
|
|
||||||
{categories.map(category => (
|
|
||||||
<div key={category} className="border border-border rounded-lg">
|
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
|
||||||
<h2 className="text-sm font-medium text-foreground">{category}</h2>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
|
|
||||||
const restriction = policy.restrictions[setting.key] || {};
|
|
||||||
return (
|
|
||||||
<div key={setting.key} className="px-4 py-3 flex items-center justify-between gap-4">
|
|
||||||
<span className="text-sm text-foreground">{setting.label}</span>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
|
||||||
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
|
|
||||||
className="rounded border-input" />
|
|
||||||
<Lock className="w-3 h-3" /> Lock
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
|
||||||
<input type="checkbox" checked={!!restriction.hidden} onChange={() => toggleHidden(setting.key)}
|
|
||||||
className="rounded border-input" />
|
|
||||||
Hide
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-246
@@ -1,248 +1,5 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
export default function Page() {
|
||||||
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
redirect('/admin?tab=settings');
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
|
||||||
|
|
||||||
interface ConfigEntry {
|
|
||||||
value: unknown;
|
|
||||||
source: 'admin' | 'env' | 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminSettingsPage() {
|
|
||||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
|
||||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchConfig();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function fetchConfig() {
|
|
||||||
setLoading(true);
|
|
||||||
const res = await apiFetch('/api/admin/config');
|
|
||||||
if (res.ok) {
|
|
||||||
setConfig(await res.json());
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleChange(key: string, value: unknown) {
|
|
||||||
setEdits(prev => ({ ...prev, [key]: value }));
|
|
||||||
setMessage(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentValue(key: string): unknown {
|
|
||||||
if (key in edits) return edits[key];
|
|
||||||
return config[key]?.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (Object.keys(edits).length === 0) return;
|
|
||||||
setSaving(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
const res = await apiFetch('/api/admin/config', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(edits),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setMessage({ type: 'success', text: 'Settings saved. Changes take effect on next page load.' });
|
|
||||||
setEdits({});
|
|
||||||
await fetchConfig();
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRevert(key: string) {
|
|
||||||
const res = await apiFetch('/api/admin/config', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ key }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setEdits(prev => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[key];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
await fetchConfig();
|
|
||||||
setMessage({ type: 'success', text: `${key} reverted to default` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasEdits = Object.keys(edits).length > 0;
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">General server configuration</p>
|
|
||||||
</div>
|
|
||||||
{hasEdits && (
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
|
||||||
>
|
|
||||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
||||||
Save changes
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
|
||||||
{message.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* General */}
|
|
||||||
<SettingsSection title="General">
|
|
||||||
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
|
||||||
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
|
||||||
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
|
||||||
{!!currentValue('allowCustomJmapEndpoint') && (
|
|
||||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
|
||||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
|
||||||
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
|
||||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
{/* Logging */}
|
|
||||||
<SettingsSection title="Logging">
|
|
||||||
<SelectSetting label="Log Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
|
|
||||||
<SelectSetting label="Log Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
{/* Settings Sync */}
|
|
||||||
<SettingsSection title="Settings Sync">
|
|
||||||
<ToggleSetting label="Settings Sync Enabled" description="Requires SESSION_SECRET to be set" configKey="settingsSyncEnabled" value={currentValue('settingsSyncEnabled') as boolean} source={config.settingsSyncEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
|
||||||
</SettingsSection>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="border border-border rounded-lg">
|
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
|
||||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SourceBadge({ source }: { source?: string }) {
|
|
||||||
if (!source || source === 'default') return null;
|
|
||||||
return (
|
|
||||||
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
|
||||||
{source}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextSetting({ label, configKey, value, source, onChange, onRevert, placeholder }: {
|
|
||||||
label: string; configKey: string; value: string; source?: string;
|
|
||||||
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<label className="text-sm text-foreground whitespace-nowrap">{label}</label>
|
|
||||||
<SourceBadge source={source} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={value ?? ''}
|
|
||||||
onChange={(e) => onChange(configKey, e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
{source === 'admin' && (
|
|
||||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToggleSetting({ label, description, configKey, value, source, onChange, onRevert }: {
|
|
||||||
label: string; description?: string; configKey: string; value: boolean; source?: string;
|
|
||||||
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-foreground">{label}</span>
|
|
||||||
<SourceBadge source={source} />
|
|
||||||
</div>
|
|
||||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onChange(configKey, !value)}
|
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}
|
|
||||||
>
|
|
||||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
|
||||||
</button>
|
|
||||||
{source === 'admin' && (
|
|
||||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectSetting({ label, configKey, value, source, options, onChange, onRevert }: {
|
|
||||||
label: string; configKey: string; value: string; source?: string; options: string[];
|
|
||||||
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-foreground">{label}</span>
|
|
||||||
<SourceBadge source={source} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={value ?? ''}
|
|
||||||
onChange={(e) => onChange(configKey, e.target.value)}
|
|
||||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
{options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
|
||||||
</select>
|
|
||||||
{source === 'admin' && (
|
|
||||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,250 +1,5 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
export default function Page() {
|
||||||
import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react';
|
redirect('/admin?tab=telemetry');
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
|
||||||
|
|
||||||
interface TelemetryStatus {
|
|
||||||
consent: 'pending' | 'on' | 'off';
|
|
||||||
consentSource: 'env' | 'file';
|
|
||||||
endpoint: string;
|
|
||||||
defaultEndpoint: string;
|
|
||||||
consentedAt: string | null;
|
|
||||||
lastSentAt: string | null;
|
|
||||||
nextScheduledAt: string | null;
|
|
||||||
payloadPreview: Record<string, unknown>;
|
|
||||||
accountCounts: { total: number; active7d: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeAgo(iso: string | null): string {
|
|
||||||
if (!iso) return 'never';
|
|
||||||
const d = Date.now() - new Date(iso).getTime();
|
|
||||||
if (d < 0) return new Date(iso).toLocaleString();
|
|
||||||
const m = Math.floor(d / 60000);
|
|
||||||
if (m < 1) return 'just now';
|
|
||||||
if (m < 60) return `${m} min ago`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 48) return `${h} hours ago`;
|
|
||||||
const days = Math.floor(h / 24);
|
|
||||||
return `${days} days ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminTelemetryPage() {
|
|
||||||
const [status, setStatus] = useState<TelemetryStatus | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
|
||||||
const [endpointDraft, setEndpointDraft] = useState('');
|
|
||||||
const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
|
||||||
|
|
||||||
async function refresh(): Promise<void> {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const r = await apiFetch('/api/admin/telemetry');
|
|
||||||
if (!r.ok) throw new Error('failed to load');
|
|
||||||
const data = (await r.json()) as TelemetryStatus;
|
|
||||||
setStatus(data);
|
|
||||||
setEndpointDraft(data.endpoint);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
useEffect(() => { void refresh(); }, []);
|
|
||||||
|
|
||||||
async function setConsent(consent: 'on' | 'off'): Promise<void> {
|
|
||||||
setBusy('consent');
|
|
||||||
try {
|
|
||||||
const r = await apiFetch('/api/admin/telemetry', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ action: 'set-consent', consent }),
|
|
||||||
});
|
|
||||||
if (!r.ok) {
|
|
||||||
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
|
||||||
alert(j.error ?? 'failed');
|
|
||||||
}
|
|
||||||
await refresh();
|
|
||||||
} finally { setBusy(null); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEndpoint(): Promise<void> {
|
|
||||||
setBusy('endpoint');
|
|
||||||
try {
|
|
||||||
const r = await apiFetch('/api/admin/telemetry', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
|
|
||||||
});
|
|
||||||
if (!r.ok) {
|
|
||||||
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
|
||||||
alert(j.error ?? 'failed');
|
|
||||||
}
|
|
||||||
await refresh();
|
|
||||||
} finally { setBusy(null); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendNow(): Promise<void> {
|
|
||||||
setBusy('send');
|
|
||||||
setSendResult(null);
|
|
||||||
try {
|
|
||||||
const r = await apiFetch('/api/admin/telemetry', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ action: 'send-now' }),
|
|
||||||
});
|
|
||||||
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string };
|
|
||||||
setSendResult({
|
|
||||||
ok: !!j.ok,
|
|
||||||
msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`,
|
|
||||||
});
|
|
||||||
await refresh();
|
|
||||||
} finally { setBusy(null); }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading || !status) {
|
|
||||||
return (
|
|
||||||
<div className="p-8 flex items-center gap-2 text-muted-foreground">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" /> loading…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const envOverridden = status.consentSource === 'env';
|
|
||||||
const isOn = status.consent === 'on';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-3xl mx-auto p-6 space-y-6">
|
|
||||||
<header className="space-y-2">
|
|
||||||
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
|
|
||||||
running, on what platforms, and which features they use. <strong>Enabled by default</strong>;
|
|
||||||
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
|
|
||||||
<a
|
|
||||||
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="underline inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
Full schema and policy <ExternalLink className="h-3 w-3" />
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="rounded-lg border p-4 space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">Status</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
|
|
||||||
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
|
|
||||||
{status.consent === 'off' && 'Heartbeats are off.'}
|
|
||||||
{envOverridden && (
|
|
||||||
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy === 'consent' || envOverridden || isOn}
|
|
||||||
onClick={() => void setConsent('on')}
|
|
||||||
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Enable
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy === 'consent' || envOverridden || status.consent === 'off'}
|
|
||||||
onClick={() => void setConsent('off')}
|
|
||||||
className="px-3 py-1.5 rounded-md border hover:bg-accent disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Disable
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<dl className="grid grid-cols-2 gap-2 text-sm pt-2 border-t">
|
|
||||||
<dt className="text-muted-foreground">Last sent</dt>
|
|
||||||
<dd>{timeAgo(status.lastSentAt)}</dd>
|
|
||||||
<dt className="text-muted-foreground">Next scheduled</dt>
|
|
||||||
<dd>{timeAgo(status.nextScheduledAt)}</dd>
|
|
||||||
<dt className="text-muted-foreground">Consented at</dt>
|
|
||||||
<dd>{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</dd>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-lg border p-4 space-y-2">
|
|
||||||
<div className="font-medium">Account activity</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Unique accounts that have logged in over the last 90 days. Identities are stored as a
|
|
||||||
per-instance HMAC, never as plaintext usernames. These are the numbers reported in the
|
|
||||||
heartbeat as bucketed ranges.
|
|
||||||
</p>
|
|
||||||
<dl className="grid grid-cols-2 gap-2 text-sm pt-1">
|
|
||||||
<dt className="text-muted-foreground">Total (90d)</dt>
|
|
||||||
<dd className="font-mono">{status.accountCounts?.total ?? 0}</dd>
|
|
||||||
<dt className="text-muted-foreground">Active (7d)</dt>
|
|
||||||
<dd className="font-mono">{status.accountCounts?.active7d ?? 0}</dd>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-lg border p-4 space-y-3">
|
|
||||||
<div className="font-medium">Endpoint</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Where heartbeats are sent. Defaults to the project's collector. Point at your own collector
|
|
||||||
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={endpointDraft}
|
|
||||||
onChange={(e) => setEndpointDraft(e.target.value)}
|
|
||||||
placeholder={status.defaultEndpoint}
|
|
||||||
className="flex-1 px-3 py-1.5 rounded-md border bg-background"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
|
|
||||||
onClick={() => void saveEndpoint()}
|
|
||||||
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<Save className="h-4 w-4" /> Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-lg border p-4 space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">Payload preview</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Exactly what the next heartbeat would send from this install, right now.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy === 'send' || !isOn}
|
|
||||||
onClick={() => void sendNow()}
|
|
||||||
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4" /> Send now
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{sendResult && (
|
|
||||||
<div
|
|
||||||
className={`text-sm flex items-center gap-2 ${
|
|
||||||
sendResult.ok ? 'text-emerald-600' : 'text-red-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{sendResult.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
|
||||||
{sendResult.msg}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
|
|
||||||
{JSON.stringify(status.payloadPreview, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-551
@@ -1,553 +1,5 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
export default function Page() {
|
||||||
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
redirect('/admin?tab=themes');
|
||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
|
||||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
|
||||||
|
|
||||||
const BUILTIN_THEME_OPTIONS = [
|
|
||||||
{ id: 'builtin-nord', name: 'Nord' },
|
|
||||||
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
|
|
||||||
{ id: 'builtin-solarized', name: 'Solarized' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ThemeEntry {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
author: string;
|
|
||||||
description: string;
|
|
||||||
variants: string[];
|
|
||||||
enabled: boolean;
|
|
||||||
forceEnabled?: boolean;
|
|
||||||
installedAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminThemesPage() {
|
|
||||||
const [themes, setThemes] = useState<ThemeEntry[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
|
||||||
const [policyDirty, setPolicyDirty] = useState(false);
|
|
||||||
const [savingPolicy, setSavingPolicy] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => { fetchThemes(); fetchPolicy(); }, []);
|
|
||||||
|
|
||||||
async function fetchPolicy() {
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/admin/policy');
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setPolicy({
|
|
||||||
...data,
|
|
||||||
themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleThemesEnabled() {
|
|
||||||
setPolicy(prev => ({
|
|
||||||
...prev,
|
|
||||||
features: { ...prev.features, themesEnabled: !prev.features.themesEnabled },
|
|
||||||
}));
|
|
||||||
setPolicyDirty(true);
|
|
||||||
setMessage(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleUserThemeUploads() {
|
|
||||||
setPolicy(prev => ({
|
|
||||||
...prev,
|
|
||||||
features: { ...prev.features, userThemesEnabled: !prev.features.userThemesEnabled },
|
|
||||||
}));
|
|
||||||
setPolicyDirty(true);
|
|
||||||
setMessage(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleBuiltinTheme(themeId: string) {
|
|
||||||
setPolicy(prev => {
|
|
||||||
const disabled = prev.themePolicy?.disabledBuiltinThemes || [];
|
|
||||||
const isDisabled = disabled.includes(themeId);
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
themePolicy: {
|
|
||||||
...DEFAULT_THEME_POLICY,
|
|
||||||
...prev.themePolicy,
|
|
||||||
disabledBuiltinThemes: isDisabled
|
|
||||||
? disabled.filter((id: string) => id !== themeId)
|
|
||||||
: [...disabled, themeId],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setPolicyDirty(true);
|
|
||||||
setMessage(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAdminTheme(themeId: string) {
|
|
||||||
setPolicy(prev => {
|
|
||||||
const disabled = prev.themePolicy?.disabledThemes || [];
|
|
||||||
const isDisabled = disabled.includes(themeId);
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
themePolicy: {
|
|
||||||
...DEFAULT_THEME_POLICY,
|
|
||||||
...prev.themePolicy,
|
|
||||||
disabledThemes: isDisabled
|
|
||||||
? disabled.filter((id: string) => id !== themeId)
|
|
||||||
: [...disabled, themeId],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setPolicyDirty(true);
|
|
||||||
setMessage(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDefaultTheme(themeId: string | null) {
|
|
||||||
setPolicy(prev => ({
|
|
||||||
...prev,
|
|
||||||
themePolicy: {
|
|
||||||
...DEFAULT_THEME_POLICY,
|
|
||||||
...prev.themePolicy,
|
|
||||||
defaultThemeId: themeId,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
setPolicyDirty(true);
|
|
||||||
setMessage(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSavePolicy() {
|
|
||||||
setSavingPolicy(true);
|
|
||||||
setMessage(null);
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/admin/policy', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(policy),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setMessage({ type: 'success', text: 'Theme policy saved. Users will see changes on next login.' });
|
|
||||||
setPolicyDirty(false);
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setMessage({ type: 'error', text: 'Failed to save policy' });
|
|
||||||
} finally {
|
|
||||||
setSavingPolicy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchThemes() {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/admin/themes');
|
|
||||||
if (res.ok) setThemes(await res.json());
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setUploading(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/admin/themes', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok) {
|
|
||||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
|
||||||
setMessage({ type: 'success', text: `Theme "${data.theme.name}" installed${warnings}` });
|
|
||||||
await fetchThemes();
|
|
||||||
} else {
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setMessage({ type: 'error', text: 'Upload failed' });
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleTheme(id: string, enabled: boolean) {
|
|
||||||
setMessage(null);
|
|
||||||
const res = await apiFetch('/api/admin/themes', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ id, enabled }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setThemes(prev => prev.map(t => t.id === id ? { ...t, enabled } : t));
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
|
|
||||||
setMessage(null);
|
|
||||||
const body: Record<string, unknown> = { id, forceEnabled };
|
|
||||||
if (forceEnabled) body.enabled = true;
|
|
||||||
|
|
||||||
const res = await apiFetch('/api/admin/themes', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setThemes(prev => prev.map(t => t.id === id ? { ...t, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : t));
|
|
||||||
setPolicy(prev => {
|
|
||||||
const current = prev.forceEnabledThemes || [];
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
forceEnabledThemes: forceEnabled
|
|
||||||
? [...current.filter(tid => tid !== id), id]
|
|
||||||
: current.filter(tid => tid !== id),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setPolicyDirty(true);
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function forceEnableAll() {
|
|
||||||
setMessage(null);
|
|
||||||
const disabled = themes.filter(t => !t.enabled);
|
|
||||||
if (disabled.length === 0) {
|
|
||||||
setMessage({ type: 'success', text: 'All themes are already enabled' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let failed = 0;
|
|
||||||
for (const t of disabled) {
|
|
||||||
const res = await apiFetch('/api/admin/themes', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ id: t.id, enabled: true }),
|
|
||||||
});
|
|
||||||
if (!res.ok) failed++;
|
|
||||||
}
|
|
||||||
if (failed === 0) {
|
|
||||||
await fetchThemes();
|
|
||||||
setMessage({ type: 'success', text: `All ${disabled.length} theme(s) enabled` });
|
|
||||||
} else {
|
|
||||||
await fetchThemes();
|
|
||||||
setMessage({ type: 'error', text: `${failed} theme(s) failed to enable` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function forceDisableAll() {
|
|
||||||
setMessage(null);
|
|
||||||
const enabled = themes.filter(t => t.enabled);
|
|
||||||
if (enabled.length === 0) {
|
|
||||||
setMessage({ type: 'success', text: 'All themes are already disabled' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let failed = 0;
|
|
||||||
for (const t of enabled) {
|
|
||||||
const res = await apiFetch('/api/admin/themes', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ id: t.id, enabled: false }),
|
|
||||||
});
|
|
||||||
if (!res.ok) failed++;
|
|
||||||
}
|
|
||||||
if (failed === 0) {
|
|
||||||
await fetchThemes();
|
|
||||||
setMessage({ type: 'success', text: `All ${enabled.length} theme(s) disabled` });
|
|
||||||
} else {
|
|
||||||
await fetchThemes();
|
|
||||||
setMessage({ type: 'error', text: `${failed} theme(s) failed to disable` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteTheme(id: string, name: string) {
|
|
||||||
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
|
||||||
|
|
||||||
setMessage(null);
|
|
||||||
const res = await apiFetch('/api/admin/themes', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ id }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setThemes(prev => prev.filter(t => t.id !== id));
|
|
||||||
setMessage({ type: 'success', text: `Theme "${name}" removed` });
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
setMessage({ type: 'error', text: data.error || 'Delete failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const themesEnabled = policy.features.themesEnabled ?? true;
|
|
||||||
const userThemesEnabled = policy.features.userThemesEnabled ?? true;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Themes</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{policyDirty && (
|
|
||||||
<button
|
|
||||||
onClick={handleSavePolicy}
|
|
||||||
disabled={savingPolicy}
|
|
||||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
|
||||||
>
|
|
||||||
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
||||||
Save Policy
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<label className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 cursor-pointer transition-all shadow-sm">
|
|
||||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
|
||||||
Upload Theme
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".zip"
|
|
||||||
onChange={handleUpload}
|
|
||||||
disabled={uploading}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
|
||||||
{message.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Theme Policy */}
|
|
||||||
<div className="border border-border rounded-lg">
|
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Shield className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<h2 className="text-sm font-medium text-foreground">Theme Policy</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Control theme availability and defaults for users</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{/* Master toggle */}
|
|
||||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-foreground">Themes Enabled</span>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={toggleThemesEnabled}
|
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
|
||||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User uploads toggle */}
|
|
||||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-foreground">User Theme Uploads</span>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={toggleUserThemeUploads}
|
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
|
||||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Force enable / disable all */}
|
|
||||||
{themes.length > 0 && (
|
|
||||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={forceEnableAll}
|
|
||||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
|
|
||||||
>
|
|
||||||
<Power className="w-3.5 h-3.5" />
|
|
||||||
Enable All
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={forceDisableAll}
|
|
||||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<PowerOff className="w-3.5 h-3.5" />
|
|
||||||
Disable All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Default Theme */}
|
|
||||||
<div className="px-4 py-3">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-foreground">Default Theme</span>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={policy.themePolicy?.defaultThemeId || ''}
|
|
||||||
onChange={(e) => setDefaultTheme(e.target.value || null)}
|
|
||||||
className="h-8 px-2 rounded-md border border-input bg-background text-sm text-foreground"
|
|
||||||
>
|
|
||||||
<option value="">System Default</option>
|
|
||||||
<optgroup label="Built-in">
|
|
||||||
{BUILTIN_THEME_OPTIONS
|
|
||||||
.filter(t => !(policy.themePolicy?.disabledBuiltinThemes || []).includes(t.id))
|
|
||||||
.map(t => (
|
|
||||||
<option key={t.id} value={t.id}>{t.name}</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
{themes.length > 0 && (
|
|
||||||
<optgroup label="Admin-deployed">
|
|
||||||
{themes
|
|
||||||
.filter(t => !(policy.themePolicy?.disabledThemes || []).includes(t.id))
|
|
||||||
.map(t => (
|
|
||||||
<option key={t.id} value={t.id}>{t.name}</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Built-in themes */}
|
|
||||||
<div className="px-4 py-3">
|
|
||||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Built-in Themes</span>
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
{BUILTIN_THEME_OPTIONS.map(theme => {
|
|
||||||
const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id);
|
|
||||||
return (
|
|
||||||
<div key={theme.id} className="flex items-center justify-between gap-4">
|
|
||||||
<span className="text-sm text-foreground">{theme.name}</span>
|
|
||||||
<button onClick={() => toggleBuiltinTheme(theme.id)}
|
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
|
||||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Admin-deployed themes */}
|
|
||||||
{themes.length > 0 && (
|
|
||||||
<div className="px-4 py-3">
|
|
||||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Admin-deployed Themes</span>
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
{themes.map(theme => {
|
|
||||||
const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id);
|
|
||||||
return (
|
|
||||||
<div key={theme.id} className="flex items-center justify-between gap-4">
|
|
||||||
<span className="text-sm text-foreground">{theme.name}</span>
|
|
||||||
<button onClick={() => toggleAdminTheme(theme.id)}
|
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
|
||||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Deployed Themes */}
|
|
||||||
<div className="border border-border rounded-lg">
|
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Palette className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<h2 className="text-sm font-medium text-foreground">Deployed Themes</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded themes available to all users</p>
|
|
||||||
</div>
|
|
||||||
{themes.length === 0 ? (
|
|
||||||
<div className="p-12 text-center">
|
|
||||||
<Palette className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
|
||||||
<p className="text-sm text-muted-foreground">No themes installed</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">Upload a theme ZIP file to get started</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{themes.map(theme => (
|
|
||||||
<div key={theme.id} className="px-4 py-4 flex items-center justify-between gap-4">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-foreground">{theme.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">v{theme.version}</span>
|
|
||||||
<span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
|
|
||||||
{theme.enabled ? 'Enabled' : 'Disabled'}
|
|
||||||
</span>
|
|
||||||
{theme.forceEnabled && (
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
|
|
||||||
<Lock className="w-3 h-3" /> Forced
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{theme.description && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{theme.description}</p>
|
|
||||||
)}
|
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
|
||||||
by {theme.author} · {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,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
redirect('/admin?tab=version');
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { configManager } from '@/lib/admin/config-manager';
|
|||||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
import { auditLog } from '@/lib/admin/audit';
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
||||||
|
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +48,23 @@ export async function PATCH(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: `Unknown config keys: ${invalidKeys.join(', ')}` }, { status: 400 });
|
return NextResponse.json({ error: `Unknown config keys: ${invalidKeys.join(', ')}` }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize jmapServers: pass through the parser so invalid entries are
|
||||||
|
// rejected (bad ids, duplicate ids, non-HTTP URLs) before they're persisted.
|
||||||
|
if ('jmapServers' in updates) {
|
||||||
|
const incoming = updates.jmapServers;
|
||||||
|
if (incoming != null && !Array.isArray(incoming)) {
|
||||||
|
return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const sanitized = parseJmapServers(incoming);
|
||||||
|
const incomingCount = Array.isArray(incoming) ? incoming.length : 0;
|
||||||
|
if (sanitized.length !== incomingCount) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'One or more jmapServers entries are invalid (each needs a unique id, label, and HTTP(S) url).',
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
updates.jmapServers = sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
// Get old values for audit
|
// Get old values for audit
|
||||||
const oldValues: Record<string, unknown> = {};
|
const oldValues: Record<string, unknown> = {};
|
||||||
for (const key of Object.keys(updates)) {
|
for (const key of Object.keys(updates)) {
|
||||||
|
|||||||
@@ -168,6 +168,11 @@ export async function GET(
|
|||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const fileUrl = (path: unknown): string | null =>
|
||||||
|
typeof path === 'string' && path
|
||||||
|
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
|
||||||
|
: null;
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
extension: {
|
extension: {
|
||||||
@@ -184,6 +189,8 @@ export async function GET(
|
|||||||
githubRepo: extension.githubRepo ?? null,
|
githubRepo: extension.githubRepo ?? null,
|
||||||
license: extension.license ?? null,
|
license: extension.license ?? null,
|
||||||
minAppVersion: extension.minAppVersion ?? null,
|
minAppVersion: extension.minAppVersion ?? null,
|
||||||
|
iconUrl: fileUrl(extension.iconPath),
|
||||||
|
bannerUrl: fileUrl(extension.bannerPath),
|
||||||
author: extension.author ?? null,
|
author: extension.author ?? null,
|
||||||
latestVersion,
|
latestVersion,
|
||||||
versions,
|
versions,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '@/lib/admin/plugin-registry';
|
} from '@/lib/admin/plugin-registry';
|
||||||
import {
|
import {
|
||||||
sanitizeFrameOrigins,
|
sanitizeFrameOrigins,
|
||||||
|
sanitizeHttpOrigins,
|
||||||
invalidateFrameOriginsCache,
|
invalidateFrameOriginsCache,
|
||||||
} from '@/lib/admin/csp-frame-origins';
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
@@ -60,9 +61,16 @@ export async function GET(request: NextRequest) {
|
|||||||
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
|
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
|
||||||
const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
|
const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
|
||||||
|
|
||||||
|
const fileUrl = (path: unknown): string | null =>
|
||||||
|
typeof path === 'string' && path
|
||||||
|
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
|
||||||
|
: null;
|
||||||
|
|
||||||
if (data.data) {
|
if (data.data) {
|
||||||
data.data = data.data.map((ext: Record<string, unknown>) => ({
|
data.data = data.data.map((ext: Record<string, unknown>) => ({
|
||||||
...ext,
|
...ext,
|
||||||
|
iconUrl: fileUrl(ext.iconPath),
|
||||||
|
bannerUrl: fileUrl(ext.bannerPath),
|
||||||
installed: ext.type === 'theme'
|
installed: ext.type === 'theme'
|
||||||
? installedThemes.has(ext.slug as string)
|
? installedThemes.has(ext.slug as string)
|
||||||
: installedPlugins.has(ext.slug as string),
|
: installedPlugins.has(ext.slug as string),
|
||||||
@@ -246,6 +254,18 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||||
|
const droppedHttpOrigins = Array.isArray(manifest.httpOrigins)
|
||||||
|
? (manifest.httpOrigins as unknown[]).filter(
|
||||||
|
(v) => typeof v !== 'string' || !declaredHttpOrigins.includes(v),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
if (droppedHttpOrigins.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Ignored invalid httpOrigins: ${droppedHttpOrigins.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
id: (manifest.id as string) || slug,
|
id: (manifest.id as string) || slug,
|
||||||
name: (manifest.name as string) || slug,
|
name: (manifest.name as string) || slug,
|
||||||
@@ -261,11 +281,14 @@ export async function POST(request: NextRequest) {
|
|||||||
...(declaredFrameOrigins.length > 0
|
...(declaredFrameOrigins.length > 0
|
||||||
? { frameOrigins: declaredFrameOrigins }
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(declaredHttpOrigins.length > 0
|
||||||
|
? { httpOrigins: declaredHttpOrigins }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
invalidateFrameOriginsCache();
|
invalidateFrameOriginsCache();
|
||||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins }, ip);
|
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, plugin, warnings });
|
return NextResponse.json({ success: true, plugin, warnings });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
|
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||||
@@ -8,7 +9,7 @@ import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
|||||||
* Only serves plugins that exist in the registry and are enabled.
|
* Only serves plugins that exist in the registry and are enabled.
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -19,6 +20,21 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dev plugins are read (and optionally bundled) straight from disk and
|
||||||
|
// served with no caching so every refresh picks up the latest source.
|
||||||
|
const devEntry = await getDevPlugin(id);
|
||||||
|
if (devEntry) {
|
||||||
|
const code = await readDevBundle(devEntry);
|
||||||
|
return new NextResponse(code, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/javascript; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
||||||
|
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const plugin = await getPlugin(id);
|
const plugin = await getPlugin(id);
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
@@ -33,13 +49,22 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Bundle not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Bundle not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NextResponse(code, {
|
// Use the registry's bundleHash as the ETag so the browser can revalidate
|
||||||
headers: {
|
// cheaply. Cache-Control: no-cache forces revalidation on every request,
|
||||||
|
// but a matching If-None-Match returns 304 with no body.
|
||||||
|
const etag = plugin.bundleHash ? `"${plugin.bundleHash}"` : undefined;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/javascript; charset=utf-8',
|
'Content-Type': 'application/javascript; charset=utf-8',
|
||||||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
'Cache-Control': 'private, no-cache, must-revalidate',
|
||||||
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
};
|
||||||
},
|
if (etag) headers['ETag'] = etag;
|
||||||
});
|
|
||||||
|
if (etag && request.headers.get('if-none-match') === etag) {
|
||||||
|
return new NextResponse(null, { status: 304, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
headers['Content-Length'] = String(Buffer.byteLength(code, 'utf-8'));
|
||||||
|
return new NextResponse(code, { headers });
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getPlugin } from '@/lib/admin/plugin-registry';
|
import { getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
|
import { getDevPlugin } from '@/lib/admin/plugin-dev';
|
||||||
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
||||||
import { requireAdminAuth } from '@/lib/admin/session';
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
|
/** Resolve a plugin from the persisted registry first, then PLUGIN_DEV_DIR. */
|
||||||
|
async function resolvePlugin(id: string) {
|
||||||
|
const registered = await getPlugin(id);
|
||||||
|
if (registered) return registered;
|
||||||
|
const dev = await getDevPlugin(id);
|
||||||
|
return dev?.plugin ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins/[id]/config - Read plugin config
|
* GET /api/admin/plugins/[id]/config - Read plugin config
|
||||||
*
|
*
|
||||||
@@ -35,7 +44,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin = await getPlugin(id);
|
const plugin = await resolvePlugin(id);
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -80,7 +89,7 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin = await getPlugin(id);
|
const plugin = await resolvePlugin(id);
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
deletePlugin as removePlugin,
|
deletePlugin as removePlugin,
|
||||||
type ServerPlugin,
|
type ServerPlugin,
|
||||||
} from '@/lib/admin/plugin-registry';
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||||
import {
|
import {
|
||||||
sanitizeFrameOrigins,
|
sanitizeFrameOrigins,
|
||||||
|
sanitizeHttpOrigins,
|
||||||
invalidateFrameOriginsCache,
|
invalidateFrameOriginsCache,
|
||||||
} from '@/lib/admin/csp-frame-origins';
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
|
|
||||||
@@ -34,8 +36,20 @@ export async function GET() {
|
|||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth();
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const registry = await getPluginRegistry();
|
const [registry, devEntries] = await Promise.all([
|
||||||
return NextResponse.json(registry.plugins, {
|
getPluginRegistry(),
|
||||||
|
listDevPlugins(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Dev plugins win on id collision so admins see what users actually load.
|
||||||
|
const devIds = new Set(devEntries.map(e => e.plugin.id));
|
||||||
|
const merged = [
|
||||||
|
...devEntries.map(e => ({ ...e.plugin, dev: true as const })),
|
||||||
|
...registry.plugins
|
||||||
|
.filter(p => !devIds.has(p.id))
|
||||||
|
.map(p => ({ ...p, dev: false as const })),
|
||||||
|
];
|
||||||
|
return NextResponse.json(merged, {
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -157,6 +171,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
|
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
@@ -172,16 +187,22 @@ export async function POST(request: NextRequest) {
|
|||||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||||
|
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||||
|
: {}),
|
||||||
...(declaredFrameOrigins.length > 0
|
...(declaredFrameOrigins.length > 0
|
||||||
? { frameOrigins: declaredFrameOrigins }
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(declaredHttpOrigins.length > 0
|
||||||
|
? { httpOrigins: declaredHttpOrigins }
|
||||||
|
: {}),
|
||||||
installedAt: now,
|
installedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
invalidateFrameOriginsCache();
|
invalidateFrameOriginsCache();
|
||||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins }, ip);
|
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip);
|
||||||
|
|
||||||
return NextResponse.json({ plugin });
|
return NextResponse.json({ plugin });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth } from '@/lib/admin/session';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import {
|
||||||
|
loadState,
|
||||||
|
checkOnce,
|
||||||
|
effectiveEndpoint,
|
||||||
|
disabledByEnv,
|
||||||
|
DEFAULT_VERSION_ENDPOINT,
|
||||||
|
} from '@/lib/version-check';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/version
|
||||||
|
* Returns the cached update status, last check times, and effective config.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const auth = await requireAdminAuth();
|
||||||
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
|
const state = await loadState();
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
current: process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0',
|
||||||
|
build: process.env.NEXT_PUBLIC_GIT_COMMIT || 'unknown',
|
||||||
|
endpoint: effectiveEndpoint(state),
|
||||||
|
defaultEndpoint: DEFAULT_VERSION_ENDPOINT,
|
||||||
|
disabledByEnv: disabledByEnv(),
|
||||||
|
lastCheckedAt: state.lastCheckedAt,
|
||||||
|
lastSuccessAt: state.lastSuccessAt,
|
||||||
|
nextScheduledAt: state.nextScheduledAt,
|
||||||
|
status: state.status,
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('version admin GET error', {
|
||||||
|
error: err instanceof Error ? err.message : 'unknown',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/version
|
||||||
|
* { action: 'check-now' } - force a fresh upstream fetch.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const auth = await requireAdminAuth();
|
||||||
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => null)) as { action?: string } | null;
|
||||||
|
if (!body || body.action !== 'check-now') {
|
||||||
|
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await checkOnce({ reason: 'admin-trigger' });
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('version admin POST error', {
|
||||||
|
error: err instanceof Error ? err.message : 'unknown',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
...getCookieOptions(),
|
...getCookieOptions(),
|
||||||
@@ -39,10 +40,11 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
// Pin the upstream URL to a configured JMAP server so an unauthenticated
|
||||||
// caller cannot point this route at internal hosts. Only when no server URL
|
// caller cannot point this route at internal hosts. We accept the global
|
||||||
// is configured AND the deployment explicitly allows custom JMAP endpoints
|
// `jmapServerUrl` and any entry from `jmapServers`. When neither matches,
|
||||||
// do we honor the body URL — and even then it must be a public URL.
|
// we fall back to the request URL only if `allowCustomJmapEndpoint` is on
|
||||||
|
// - and even then the URL must resolve to a public address.
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const configuredServerUrl =
|
const configuredServerUrl =
|
||||||
configManager.get<string>('jmapServerUrl', '') ||
|
configManager.get<string>('jmapServerUrl', '') ||
|
||||||
@@ -50,11 +52,13 @@ export async function POST(request: NextRequest) {
|
|||||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||||
'';
|
'';
|
||||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||||
|
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||||
|
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);
|
||||||
|
|
||||||
let upstreamUrl: string;
|
let upstreamUrl: string;
|
||||||
let upstreamTrusted: boolean;
|
let upstreamTrusted: boolean;
|
||||||
if (configuredServerUrl) {
|
if (trustedUrl) {
|
||||||
upstreamUrl = configuredServerUrl;
|
upstreamUrl = trustedUrl;
|
||||||
upstreamTrusted = true;
|
upstreamTrusted = true;
|
||||||
} else if (allowCustomEndpoint) {
|
} else if (allowCustomEndpoint) {
|
||||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cookies } from 'next/headers';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { decryptPayload } from '@/lib/auth/crypto';
|
import { decryptPayload } from '@/lib/auth/crypto';
|
||||||
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
|
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
|
||||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
@@ -13,12 +13,18 @@ export async function POST(request: NextRequest) {
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { code, state } = await request.json();
|
const { code, state, slot: bodySlot } = await request.json();
|
||||||
|
|
||||||
if (!code || !state) {
|
if (!code || !state) {
|
||||||
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-account refresh-token cookie slot. Without this the route hardcoded
|
||||||
|
// slot 0, so the "+ Add Account" flow overwrote the first account's
|
||||||
|
// refresh-token cookie. Default to 0 for back-compat with any caller that
|
||||||
|
// omits slot. Mirrors the validation in /api/auth/token POST.
|
||||||
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||||
|
|
||||||
// Read and decrypt the pending SSO cookie
|
// Read and decrypt the pending SSO cookie
|
||||||
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
|
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
|
||||||
if (!pendingCookie) {
|
if (!pendingCookie) {
|
||||||
@@ -49,6 +55,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const codeVerifier = pending.code_verifier as string;
|
const codeVerifier = pending.code_verifier as string;
|
||||||
const redirectUri = pending.redirect_uri as string;
|
const redirectUri = pending.redirect_uri as string;
|
||||||
|
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
|
||||||
|
|
||||||
if (!codeVerifier || !redirectUri) {
|
if (!codeVerifier || !redirectUri) {
|
||||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
@@ -56,13 +63,19 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Exchange code for tokens
|
// Exchange code for tokens
|
||||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
|
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
||||||
|
|
||||||
// Store refresh token
|
// Store refresh token in the per-account cookie slot.
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
const cookieName = refreshTokenCookieName(0);
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
}
|
}
|
||||||
|
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||||
|
if (pendingServerId) {
|
||||||
|
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions());
|
||||||
|
} else {
|
||||||
|
cookieStore.delete(serverCookieName);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete pending cookie
|
// Delete pending cookie
|
||||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { redirect_uri, locale } = await request.json();
|
const { redirect_uri, locale, server_id: bodyServerId } = await request.json();
|
||||||
|
|
||||||
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||||
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||||
|
|
||||||
// Validate redirect_uri origin matches the request origin to prevent open redirects
|
// Validate redirect_uri origin matches the request origin to prevent open redirects
|
||||||
const requestOrigin = request.headers.get('origin') || request.nextUrl.origin;
|
const requestOrigin = request.headers.get('origin') || request.nextUrl.origin;
|
||||||
try {
|
try {
|
||||||
@@ -36,7 +38,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { clientId, discoveryUrl } = getRequiredConfig();
|
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
||||||
const metadata = await discoverOAuth(discoveryUrl);
|
const metadata = await discoverOAuth(discoveryUrl);
|
||||||
|
|
||||||
if (!metadata?.authorization_endpoint) {
|
if (!metadata?.authorization_endpoint) {
|
||||||
@@ -48,12 +50,14 @@ export async function POST(request: NextRequest) {
|
|||||||
const codeChallenge = generateCodeChallengeServer(codeVerifier);
|
const codeChallenge = generateCodeChallengeServer(codeVerifier);
|
||||||
const state = generateStateServer();
|
const state = generateStateServer();
|
||||||
|
|
||||||
// Encrypt and store in httpOnly cookie
|
// Encrypt and store in httpOnly cookie. server_id is captured here so the
|
||||||
|
// /complete handler reaches the same OAuth endpoint we used to authorize.
|
||||||
const pendingData = {
|
const pendingData = {
|
||||||
state,
|
state,
|
||||||
code_verifier: codeVerifier,
|
code_verifier: codeVerifier,
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
|
...(serverId ? { server_id: serverId } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const encrypted = encryptPayload(pendingData);
|
const encrypted = encryptPayload(pendingData);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
|||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||||
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
||||||
@@ -26,10 +27,9 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
// Pin the upstream URL to a configured JMAP server (single `jmapServerUrl`
|
||||||
// caller cannot point this route at internal hosts. Only when no server URL
|
// or any entry in `jmapServers`). Falls back to the request URL only when
|
||||||
// is configured AND the deployment explicitly allows custom JMAP endpoints
|
// `allowCustomJmapEndpoint` is enabled, and even then it must be public.
|
||||||
// do we honor the body URL — and even then it must be a public URL.
|
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const configuredServerUrl =
|
const configuredServerUrl =
|
||||||
configManager.get<string>('jmapServerUrl', '') ||
|
configManager.get<string>('jmapServerUrl', '') ||
|
||||||
@@ -37,11 +37,13 @@ export async function POST(request: NextRequest) {
|
|||||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||||
'';
|
'';
|
||||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||||
|
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||||
|
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);
|
||||||
|
|
||||||
let upstreamUrl: string;
|
let upstreamUrl: string;
|
||||||
let upstreamTrusted: boolean;
|
let upstreamTrusted: boolean;
|
||||||
if (configuredServerUrl) {
|
if (trustedUrl) {
|
||||||
upstreamUrl = configuredServerUrl;
|
upstreamUrl = trustedUrl;
|
||||||
upstreamTrusted = true;
|
upstreamTrusted = true;
|
||||||
} else if (allowCustomEndpoint) {
|
} else if (allowCustomEndpoint) {
|
||||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
|
|||||||
+27
-10
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||||
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
@@ -15,26 +15,36 @@ function getSlot(request: NextRequest): number {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json();
|
const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||||
|
|
||||||
if (!code || !code_verifier || !redirect_uri) {
|
if (!code || !code_verifier || !redirect_uri) {
|
||||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||||
|
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||||
|
|
||||||
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri);
|
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
|
||||||
|
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
expires_in: tokens.expires_in,
|
expires_in: tokens.expires_in,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
const cookieName = refreshTokenCookieName(slot);
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
|
||||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
}
|
}
|
||||||
|
// Persist which server entry minted this refresh token so the PUT/DELETE
|
||||||
|
// handlers can route the refresh/revocation calls to the right token
|
||||||
|
// endpoint without the client having to track it across page loads.
|
||||||
|
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||||
|
if (serverId) {
|
||||||
|
cookieStore.set(serverCookieName, serverId, getCookieOptions());
|
||||||
|
} else {
|
||||||
|
cookieStore.delete(serverCookieName);
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -49,17 +59,18 @@ export async function PUT(request: NextRequest) {
|
|||||||
const cookieName = refreshTokenCookieName(slot);
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const refreshToken = cookieStore.get(cookieName)?.value;
|
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||||
|
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
|
||||||
|
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenEndpoint = await getTokenEndpoint();
|
const tokenEndpoint = await getTokenEndpoint(serverId);
|
||||||
|
|
||||||
const params = buildOAuthParams({
|
const params = buildOAuthParams({
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
});
|
}, serverId);
|
||||||
|
|
||||||
const tokenResponse = await fetch(tokenEndpoint, {
|
const tokenResponse = await fetch(tokenEndpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -71,6 +82,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
const errorText = await tokenResponse.text();
|
const errorText = await tokenResponse.text();
|
||||||
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
||||||
cookieStore.delete(cookieName);
|
cookieStore.delete(cookieName);
|
||||||
|
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,13 +116,15 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
for (let i = 0; i <= 4; i++) {
|
for (let i = 0; i <= 4; i++) {
|
||||||
const name = refreshTokenCookieName(i);
|
const name = refreshTokenCookieName(i);
|
||||||
|
const serverCookieName = refreshTokenServerCookieName(i);
|
||||||
const token = cookieStore.get(name)?.value;
|
const token = cookieStore.get(name)?.value;
|
||||||
|
const slotServerId = cookieStore.get(serverCookieName)?.value || null;
|
||||||
if (token) {
|
if (token) {
|
||||||
// Best-effort revocation
|
// Best-effort revocation
|
||||||
try {
|
try {
|
||||||
const metadata = await getMetadata().catch(() => null);
|
const metadata = await getMetadata(slotServerId).catch(() => null);
|
||||||
if (metadata?.revocation_endpoint) {
|
if (metadata?.revocation_endpoint) {
|
||||||
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' });
|
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' }, slotServerId);
|
||||||
await fetch(metadata.revocation_endpoint, {
|
await fetch(metadata.revocation_endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
@@ -120,6 +134,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
} catch { /* best effort */ }
|
} catch { /* best effort */ }
|
||||||
cookieStore.delete(name);
|
cookieStore.delete(name);
|
||||||
}
|
}
|
||||||
|
cookieStore.delete(serverCookieName);
|
||||||
}
|
}
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -128,7 +143,8 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const cookieName = refreshTokenCookieName(slot);
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const refreshToken = cookieStore.get(cookieName)?.value;
|
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||||
const metadata = await getMetadata().catch((err) => {
|
const slotServerId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
|
||||||
|
const metadata = await getMetadata(slotServerId).catch((err) => {
|
||||||
logger.warn('Failed to discover OAuth metadata during logout', {
|
logger.warn('Failed to discover OAuth metadata during logout', {
|
||||||
error: err instanceof Error ? err.message : 'Unknown error',
|
error: err instanceof Error ? err.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
@@ -140,7 +156,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const params = buildOAuthParams({
|
const params = buildOAuthParams({
|
||||||
token: refreshToken,
|
token: refreshToken,
|
||||||
token_type_hint: 'refresh_token',
|
token_type_hint: 'refresh_token',
|
||||||
});
|
}, slotServerId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const revocationResponse = await fetch(metadata.revocation_endpoint, {
|
const revocationResponse = await fetch(metadata.revocation_endpoint, {
|
||||||
@@ -158,6 +174,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
|
|
||||||
cookieStore.delete(cookieName);
|
cookieStore.delete(cookieName);
|
||||||
}
|
}
|
||||||
|
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||||
|
|
||||||
let end_session_url: string | undefined;
|
let end_session_url: string | undefined;
|
||||||
if (metadata?.end_session_endpoint) {
|
if (metadata?.end_session_endpoint) {
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||||
@@ -78,18 +79,19 @@ async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { serverUrl, username, password, slot: bodySlot } = await request.json();
|
const { serverUrl, username, password, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||||
|
|
||||||
if (!serverUrl || !username || !password) {
|
if (!serverUrl || !username || !password) {
|
||||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||||
|
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||||
|
|
||||||
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
||||||
// caller cannot point this route at internal hosts. Only when no server
|
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
|
||||||
// URL is configured (and the deployment explicitly allows custom JMAP
|
// no server is configured (and the deployment explicitly allows custom
|
||||||
// endpoints) do we fall back to the user-supplied URL - and even then
|
// JMAP endpoints) do we fall back to the user-supplied URL - and even then
|
||||||
// it must resolve to a public address.
|
// it must resolve to a public address.
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const configuredServerUrl =
|
const configuredServerUrl =
|
||||||
@@ -98,9 +100,17 @@ export async function POST(request: NextRequest) {
|
|||||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||||
'';
|
'';
|
||||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||||
|
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||||
|
|
||||||
let upstreamUrl: string;
|
let upstreamUrl: string;
|
||||||
if (configuredServerUrl) {
|
let resolvedServerId: string | null = null;
|
||||||
|
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||||
|
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||||
|
|
||||||
|
if (matchedEntry) {
|
||||||
|
upstreamUrl = matchedEntry.url;
|
||||||
|
resolvedServerId = matchedEntry.id;
|
||||||
|
} else if (configuredServerUrl) {
|
||||||
upstreamUrl = configuredServerUrl;
|
upstreamUrl = configuredServerUrl;
|
||||||
} else if (allowCustomEndpoint) {
|
} else if (allowCustomEndpoint) {
|
||||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
@@ -118,7 +128,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot);
|
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot, resolvedServerId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
@@ -131,11 +141,21 @@ async function attemptAllStrategies(
|
|||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
slot: number,
|
slot: number,
|
||||||
|
serverId: string | null,
|
||||||
): Promise<NextResponse> {
|
): Promise<NextResponse> {
|
||||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||||
|
|
||||||
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
// Per-server OAuth credentials override the global ones when the requested
|
||||||
const clientSecret = configManager.get<string>('oauthClientSecret', '') || process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
// server entry has its own oauth block configured.
|
||||||
|
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||||
|
const entry = findServerById(serverList, serverId);
|
||||||
|
const clientId = entry?.oauth?.clientId
|
||||||
|
|| configManager.get<string>('oauthClientId', '')
|
||||||
|
|| process.env.OAUTH_CLIENT_ID;
|
||||||
|
const clientSecret = entry?.oauth?.clientSecret
|
||||||
|
|| configManager.get<string>('oauthClientSecret', '')
|
||||||
|
|| process.env.OAUTH_CLIENT_SECRET
|
||||||
|
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||||
|
|
||||||
@@ -147,7 +167,7 @@ async function attemptAllStrategies(
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||||
void recordLogin(username, serverUrl);
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot, serverId);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||||
}
|
}
|
||||||
@@ -159,7 +179,7 @@ async function attemptAllStrategies(
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||||
void recordLogin(username, serverUrl);
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot, serverId);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||||
}
|
}
|
||||||
@@ -171,7 +191,7 @@ async function attemptAllStrategies(
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||||
void recordLogin(username, serverUrl);
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot, serverId);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||||
}
|
}
|
||||||
@@ -183,7 +203,7 @@ async function attemptAllStrategies(
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||||
void recordLogin(username, serverUrl);
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot, serverId);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||||
}
|
}
|
||||||
@@ -199,12 +219,19 @@ async function attemptAllStrategies(
|
|||||||
async function storeAndRespond(
|
async function storeAndRespond(
|
||||||
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||||
slot: number,
|
slot: number,
|
||||||
|
serverId: string | null,
|
||||||
): Promise<NextResponse> {
|
): Promise<NextResponse> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
const cookieName = refreshTokenCookieName(slot);
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
|
||||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
}
|
}
|
||||||
|
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||||
|
if (serverId) {
|
||||||
|
cookieStore.set(serverCookieName, serverId, getCookieOptions());
|
||||||
|
} else {
|
||||||
|
cookieStore.delete(serverCookieName);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime configuration endpoint
|
* Runtime configuration endpoint
|
||||||
@@ -49,6 +50,8 @@ export async function GET() {
|
|||||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||||
demoMode: configManager.get<boolean>('demoMode', false),
|
demoMode: configManager.get<boolean>('demoMode', false),
|
||||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||||
|
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||||
|
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
|
||||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
||||||
|
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,15 +11,21 @@ import { logger } from '@/lib/logger';
|
|||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const [pluginRegistry, themeRegistry] = await Promise.all([
|
const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([
|
||||||
getPluginRegistry(),
|
getPluginRegistry(),
|
||||||
getThemeRegistry(),
|
getThemeRegistry(),
|
||||||
|
listDevPlugins(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Only serve enabled plugins
|
// Dev plugins win on id collision so a developer can shadow an installed
|
||||||
const plugins = pluginRegistry.plugins
|
// plugin without uninstalling it first.
|
||||||
.filter(p => p.enabled)
|
const devIds = new Set(devEntries.map(e => e.plugin.id));
|
||||||
.map(p => ({
|
const installedEnabled = pluginRegistry.plugins.filter(p => p.enabled && !devIds.has(p.id));
|
||||||
|
|
||||||
|
const plugins = [
|
||||||
|
...devEntries.map(e => ({ ...e.plugin, dev: true })),
|
||||||
|
...installedEnabled.map(p => ({ ...p, dev: false })),
|
||||||
|
].map(p => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
version: p.version,
|
version: p.version,
|
||||||
@@ -28,7 +35,17 @@ export async function GET() {
|
|||||||
permissions: p.permissions,
|
permissions: p.permissions,
|
||||||
entrypoint: p.entrypoint,
|
entrypoint: p.entrypoint,
|
||||||
forceEnabled: p.forceEnabled || false,
|
forceEnabled: p.forceEnabled || false,
|
||||||
settingsSchema: undefined, // Will be read from the bundle's manifest
|
// Content hash + updatedAt let clients detect re-uploads even when
|
||||||
|
// the manifest version is unchanged.
|
||||||
|
bundleHash: p.bundleHash,
|
||||||
|
updatedAt: p.updatedAt,
|
||||||
|
// Marks plugins loaded from PLUGIN_DEV_DIR. Surface in UI as a badge.
|
||||||
|
dev: p.dev,
|
||||||
|
// Surface so clients can enforce api.http.fetch origin allowlists.
|
||||||
|
httpOrigins: p.httpOrigins,
|
||||||
|
// Per-user settings schema, captured from the manifest at upload/load
|
||||||
|
// time so the client can render the settings UI without re-parsing.
|
||||||
|
settingsSchema: p.settingsSchema,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Only serve enabled themes
|
// Only serve enabled themes
|
||||||
|
|||||||
@@ -40,10 +40,13 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
|
return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the inbox, then pull the most recent unread message in it. We use
|
const inboxRes = await fetch(apiUrl, {
|
||||||
// a single batched JMAP request with back-references so this round-trip
|
method: 'POST',
|
||||||
// is one POST regardless of how many messages exist.
|
headers: {
|
||||||
const requestBody = {
|
Authorization: creds.authHeader,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
||||||
methodCalls: [
|
methodCalls: [
|
||||||
[
|
[
|
||||||
@@ -51,6 +54,39 @@ export async function GET(request: NextRequest) {
|
|||||||
{ accountId, filter: { role: 'inbox' }, limit: 1 },
|
{ accountId, filter: { role: 'inbox' }, limit: 1 },
|
||||||
'mb',
|
'mb',
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!inboxRes.ok) {
|
||||||
|
return NextResponse.json({ error: 'JMAP mailbox query failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inboxData = (await inboxRes.json()) as {
|
||||||
|
methodResponses: [string, Record<string, unknown>, string][];
|
||||||
|
};
|
||||||
|
|
||||||
|
const inboxBody = inboxData.methodResponses.find(
|
||||||
|
([method]) => method === 'Mailbox/query',
|
||||||
|
)?.[1] as { ids?: string[] } | undefined;
|
||||||
|
|
||||||
|
const inboxId = inboxBody?.ids?.[0];
|
||||||
|
|
||||||
|
if (!inboxId) {
|
||||||
|
return NextResponse.json({
|
||||||
|
email: null,
|
||||||
|
unreadTotal: 0,
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the most recent unread message from the resolved Inbox mailbox.
|
||||||
|
const requestBody = {
|
||||||
|
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
||||||
|
methodCalls: [
|
||||||
[
|
[
|
||||||
'Email/query',
|
'Email/query',
|
||||||
{
|
{
|
||||||
@@ -58,7 +94,7 @@ export async function GET(request: NextRequest) {
|
|||||||
filter: {
|
filter: {
|
||||||
operator: 'AND',
|
operator: 'AND',
|
||||||
conditions: [
|
conditions: [
|
||||||
{ inMailbox: { resultOf: 'mb', name: 'Mailbox/query', path: '/ids/0' } },
|
{ inMailbox: inboxId },
|
||||||
{ notKeyword: '$seen' },
|
{ notKeyword: '$seen' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -127,7 +163,7 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// `fetch failed` from undici is too generic to debug — the real reason
|
// `fetch failed` from undici is too generic to debug - the real reason
|
||||||
// (ENOTFOUND, ECONNREFUSED, TLS error, …) is on `error.cause`.
|
// (ENOTFOUND, ECONNREFUSED, TLS error, …) is on `error.cause`.
|
||||||
const err = error as Error & { cause?: { code?: string; message?: string } };
|
const err = error as Error & { cause?: { code?: string; message?: string } };
|
||||||
logger.error('push preview failed', {
|
logger.error('push preview failed', {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { checkOnce, loadState } from '@/lib/version-check';
|
||||||
|
|
||||||
|
// Public endpoint that returns the latest cached update status. Fed by the
|
||||||
|
// background scheduler started in instrumentation.node.ts; in production we
|
||||||
|
// never trigger a fresh upstream fetch from this route so an unauthenticated
|
||||||
|
// client can't use it to amplify traffic to the version server.
|
||||||
|
//
|
||||||
|
// In development we force a fresh fetch on every hit so changes to the
|
||||||
|
// version server's overrides take effect on the next page reload instead of
|
||||||
|
// requiring a dev-server restart. The 5s upstream timeout in fetchStatus
|
||||||
|
// caps the worst-case latency added to a dev reload.
|
||||||
|
export async function GET() {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
await checkOnce({ reason: 'dev-reload' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await loadState();
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: state.status,
|
||||||
|
lastCheckedAt: state.lastCheckedAt,
|
||||||
|
lastSuccessAt: state.lastSuccessAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -522,6 +522,15 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scroll-hidden::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-hidden {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile backdrop blur support */
|
/* Mobile backdrop blur support */
|
||||||
@supports (backdrop-filter: blur(8px)) {
|
@supports (backdrop-filter: blur(8px)) {
|
||||||
.mobile-backdrop {
|
.mobile-backdrop {
|
||||||
@@ -756,3 +765,32 @@ body {
|
|||||||
.tiptap.resize-cursor {
|
.tiptap.resize-cursor {
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Smooth halo applied to a setting row when the user clicks a sub-result in
|
||||||
|
the settings search. The element bg + a 10px box-shadow fill the row plus
|
||||||
|
a 10px ring of the same tint, then a 2px outline sits exactly at the outer
|
||||||
|
edge. Uses the primary theme color so it adapts to light/dark. */
|
||||||
|
@keyframes settings-search-pulse {
|
||||||
|
0% {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||||
|
outline-color: color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
box-shadow: 0 0 0 10px color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
outline-color: color-mix(in srgb, var(--color-primary) 60%, transparent);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||||
|
box-shadow: 0 0 0 10px color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||||
|
outline-color: color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search-highlight {
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
animation: settings-search-pulse 1.6s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
|||||||
+20
-14
@@ -2,6 +2,12 @@ import type { MetadataRoute } from "next";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// Manifest paths must include the deployment subpath - browsers resolve them
|
||||||
|
// against the document origin, not the manifest's location, and Next.js does
|
||||||
|
// not auto-prefix string literals inside MetadataRoute payloads.
|
||||||
|
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
||||||
|
const withBase = (p: string) => `${BASE_PATH}${p}`;
|
||||||
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
const appName =
|
const appName =
|
||||||
process.env.APP_NAME ||
|
process.env.APP_NAME ||
|
||||||
@@ -21,26 +27,26 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
|
|
||||||
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||||
? [
|
? [
|
||||||
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "any" },
|
{ src: withBase("/api/pwa-icon/192"), sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "any" },
|
{ src: withBase("/api/pwa-icon/512"), sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
{ src: withBase("/api/pwa-icon/192"), sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
{ src: withBase("/api/pwa-icon/512"), sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ src: "/icon-192x192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
{ src: withBase("/icon-192x192.png"), sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
{ src: "/icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
{ src: withBase("/icon-512x512.png"), sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
{ src: "/icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
{ src: withBase("/icon-maskable-light-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
{ src: "/icon-maskable-light-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
{ src: withBase("/icon-maskable-light-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
{ src: "/icon-maskable-dark-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
{ src: withBase("/icon-maskable-dark-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
{ src: "/icon-maskable-dark-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
{ src: withBase("/icon-maskable-dark-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: appName,
|
name: appName,
|
||||||
short_name: shortName,
|
short_name: shortName,
|
||||||
description,
|
description,
|
||||||
start_url: "/",
|
start_url: withBase("/"),
|
||||||
scope: "/",
|
scope: withBase("/"),
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
orientation: "portrait-primary",
|
orientation: "portrait-primary",
|
||||||
theme_color: themeColor,
|
theme_color: themeColor,
|
||||||
@@ -48,8 +54,8 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
icons,
|
icons,
|
||||||
categories: ["productivity"],
|
categories: ["productivity"],
|
||||||
screenshots: [
|
screenshots: [
|
||||||
{ src: "/screenshot-540x720.png", sizes: "540x720", type: "image/png" },
|
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
||||||
{ src: "/screenshot-1280x720.png", sizes: "1280x720", type: "image/png" },
|
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-7
@@ -2,34 +2,43 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// Don't redirect admin routes to the webmail login page
|
const prefix = getPathPrefix();
|
||||||
const isAdminRoute = window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/');
|
// Don't redirect admin routes to the webmail login page. Admin paths
|
||||||
|
// are mounted relative to the deployment prefix, so account for it.
|
||||||
|
const adminBase = `${prefix}/admin`;
|
||||||
|
const isAdminRoute = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
|
||||||
if (!isAdminRoute) {
|
if (!isAdminRoute) {
|
||||||
window.location.href = "/login";
|
window.location.href = `${prefix}/login`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// Allow admin routes to render the 404 without redirecting
|
let isAdmin = false;
|
||||||
const isAdmin = typeof window !== 'undefined' &&
|
if (typeof window !== 'undefined') {
|
||||||
(window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/'));
|
const prefix = getPathPrefix();
|
||||||
|
const adminBase = `${prefix}/admin`;
|
||||||
|
isAdmin = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
|
||||||
|
}
|
||||||
if (!isAdmin) return null;
|
if (!isAdmin) return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prefix = typeof window !== 'undefined' ? getPathPrefix() : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
<div className="text-center max-w-md px-4">
|
<div className="text-center max-w-md px-4">
|
||||||
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
|
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
|
||||||
<p className="text-muted-foreground mb-6">This page could not be found.</p>
|
<p className="text-muted-foreground mb-6">This page could not be found.</p>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href={`${prefix}/`}
|
||||||
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
|
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
|
||||||
>
|
>
|
||||||
Go home
|
Go home
|
||||||
|
|||||||
@@ -144,6 +144,12 @@ export function CalendarSidebarPanel({
|
|||||||
{cal.id === BIRTHDAY_CALENDAR_ID && (
|
{cal.id === BIRTHDAY_CALENDAR_ID && (
|
||||||
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
|
{!cal.isShared && Object.keys(cal.shareWith || {}).length > 0 && (
|
||||||
|
<Users
|
||||||
|
className="w-3 h-3 text-muted-foreground flex-shrink-0"
|
||||||
|
aria-label={tMgmt('share')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -150,9 +150,8 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full h-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
|
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||||
continuesBefore && "rounded-l-sm",
|
|
||||||
continuesAfter && "rounded-r-sm",
|
continuesAfter && "rounded-r-sm",
|
||||||
continuesBefore && "-ml-0.5",
|
continuesBefore && "-ml-0.5",
|
||||||
continuesAfter && "pr-2",
|
continuesAfter && "pr-2",
|
||||||
@@ -182,7 +181,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
{...dragProps}
|
{...dragProps}
|
||||||
data-calendar-event
|
data-calendar-event
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full h-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
|
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||||
isSelected && "ring-2 ring-primary",
|
isSelected && "ring-2 ring-primary",
|
||||||
isBeingDragged && "opacity-50",
|
isBeingDragged && "opacity-50",
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { PluginSlot } from "@/components/plugins/plugin-slot";
|
|||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { generateUUID } from "@/lib/utils";
|
import { generateUUID } from "@/lib/utils";
|
||||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||||
|
import { calendarHooks } from "@/lib/plugin-hooks";
|
||||||
|
import type { ConflictWarning } from "@/lib/plugin-types";
|
||||||
|
|
||||||
export interface PendingEventPreview {
|
export interface PendingEventPreview {
|
||||||
start: Date;
|
start: Date;
|
||||||
@@ -242,6 +244,31 @@ export function EventModal({
|
|||||||
const [sendInvitations, setSendInvitations] = useState(true);
|
const [sendInvitations, setSendInvitations] = useState(true);
|
||||||
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
||||||
|
|
||||||
|
// Plugin transform: collect conflict warnings for the current event form.
|
||||||
|
// Re-runs (debounced) whenever fields that affect scheduling change.
|
||||||
|
const [pluginConflictWarnings, setPluginConflictWarnings] = useState<ConflictWarning[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const t = setTimeout(async () => {
|
||||||
|
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
|
||||||
|
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
|
||||||
|
const warnings = await calendarHooks.onCheckEventConflicts.transform([] as ConflictWarning[], {
|
||||||
|
event: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
start: startStr,
|
||||||
|
end: endStr,
|
||||||
|
isAllDay: allDay,
|
||||||
|
location,
|
||||||
|
virtualLocation,
|
||||||
|
calendarId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!cancelled) setPluginConflictWarnings(warnings);
|
||||||
|
}, 250);
|
||||||
|
return () => { cancelled = true; clearTimeout(t); };
|
||||||
|
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId]);
|
||||||
|
|
||||||
// Report live preview to parent for grid outline
|
// Report live preview to parent for grid outline
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onPreviewChange || isEdit) return;
|
if (!onPreviewChange || isEdit) return;
|
||||||
@@ -923,6 +950,26 @@ export function EventModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{pluginConflictWarnings.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{pluginConflictWarnings.map(w => (
|
||||||
|
<div
|
||||||
|
key={w.key}
|
||||||
|
className={
|
||||||
|
w.severity === 'error'
|
||||||
|
? 'text-sm rounded-md border border-destructive/50 bg-destructive/10 text-destructive px-3 py-2'
|
||||||
|
: w.severity === 'info'
|
||||||
|
? 'text-sm rounded-md border border-border bg-muted/40 text-muted-foreground px-3 py-2'
|
||||||
|
: 'text-sm rounded-md border border-yellow-500/50 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300 px-3 py-2'
|
||||||
|
}
|
||||||
|
title={w.message}
|
||||||
|
>
|
||||||
|
{w.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{calendars.length > 1 && (
|
{calendars.length > 1 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
|
<label className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
|
||||||
|
|||||||
@@ -685,7 +685,13 @@ function AddressBookItem({
|
|||||||
>
|
>
|
||||||
<Book className="w-4 h-4 flex-shrink-0" />
|
<Book className="w-4 h-4 flex-shrink-0" />
|
||||||
<span className="truncate">{book.name}</span>
|
<span className="truncate">{book.name}</span>
|
||||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
{!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
|
||||||
|
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ml-auto" />
|
||||||
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs text-muted-foreground tabular-nums",
|
||||||
|
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ml-auto"
|
||||||
|
)}>
|
||||||
{contactCount}
|
{contactCount}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
} from '@/lib/calendar-invitation';
|
} from '@/lib/calendar-invitation';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { sanitizeColor } from '@/components/calendar/event-card';
|
import { sanitizeColor } from '@/components/calendar/event-card';
|
||||||
|
import { RecipientPopover } from './recipient-popover';
|
||||||
|
|
||||||
interface InvitationChangeItem {
|
interface InvitationChangeItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -326,25 +328,26 @@ function buildParticipantsForRsvp(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMethodAccentClass(method: InvitationMethod, actorStatus?: string | null): string {
|
function getMethodIconTone(method: InvitationMethod, actorStatus?: string | null): string {
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case 'cancel':
|
case 'cancel':
|
||||||
case 'declinecounter':
|
case 'declinecounter':
|
||||||
return 'border-l-red-500 dark:border-l-red-400';
|
return 'bg-destructive/15 text-destructive';
|
||||||
case 'request':
|
|
||||||
case 'add':
|
|
||||||
return 'border-l-blue-500 dark:border-l-blue-400';
|
|
||||||
case 'counter':
|
case 'counter':
|
||||||
return 'border-l-amber-500 dark:border-l-amber-400';
|
return 'bg-warning/15 text-warning';
|
||||||
case 'reply':
|
case 'reply':
|
||||||
switch (actorStatus) {
|
switch (actorStatus) {
|
||||||
case 'accepted': return 'border-l-green-500 dark:border-l-green-400';
|
case 'accepted': return 'bg-success/15 text-success';
|
||||||
case 'tentative': return 'border-l-amber-500 dark:border-l-amber-400';
|
case 'tentative': return 'bg-warning/15 text-warning';
|
||||||
case 'declined': return 'border-l-red-500 dark:border-l-red-400';
|
case 'declined': return 'bg-destructive/15 text-destructive';
|
||||||
default: return 'border-l-blue-500 dark:border-l-blue-400';
|
default: return 'bg-primary/15 text-primary';
|
||||||
}
|
}
|
||||||
|
case 'request':
|
||||||
|
case 'add':
|
||||||
|
case 'publish':
|
||||||
|
return 'bg-primary/15 text-primary';
|
||||||
default:
|
default:
|
||||||
return 'border-l-slate-400 dark:border-l-slate-500';
|
return 'bg-muted text-muted-foreground';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,9 +374,11 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
|
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
|
||||||
|
const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
const pickerTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
|
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
|
||||||
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
|
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
|
||||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
|
||||||
const attachment = findCalendarAttachment(email);
|
const attachment = findCalendarAttachment(email);
|
||||||
|
|
||||||
@@ -437,6 +442,17 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
}
|
}
|
||||||
}, [calendars, selectedCalendarId]);
|
}, [calendars, selectedCalendarId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showCalendarPicker) return;
|
||||||
|
const close = () => setShowCalendarPicker(false);
|
||||||
|
window.addEventListener('scroll', close, true);
|
||||||
|
window.addEventListener('resize', close);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', close, true);
|
||||||
|
window.removeEventListener('resize', close);
|
||||||
|
};
|
||||||
|
}, [showCalendarPicker]);
|
||||||
|
|
||||||
if (!attachment || !calendarInvitationParsingEnabled) return null;
|
if (!attachment || !calendarInvitationParsingEnabled) return null;
|
||||||
|
|
||||||
const detectedMethod = parsedEvent ? getInvitationMethod(parsedEvent, { email, attachment }) : 'unknown';
|
const detectedMethod = parsedEvent ? getInvitationMethod(parsedEvent, { email, attachment }) : 'unknown';
|
||||||
@@ -444,8 +460,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
|
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
|
||||||
const isCancellation = method === 'cancel';
|
const isCancellation = method === 'cancel';
|
||||||
const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter';
|
const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter';
|
||||||
const canCollapse = method === 'reply';
|
const showDetails = !isCollapsed;
|
||||||
const showDetails = !canCollapse || !isCollapsed;
|
|
||||||
const allowsRsvp = method === 'request';
|
const allowsRsvp = method === 'request';
|
||||||
const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown';
|
const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown';
|
||||||
|
|
||||||
@@ -486,7 +501,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
const actorName = actorSummary?.name || actorSummary?.email || t('actor_unknown');
|
const actorName = actorSummary?.name || actorSummary?.email || t('actor_unknown');
|
||||||
const actorStatus = getParticipationLabel(t, actorSummary?.participationStatus ?? null);
|
const actorStatus = getParticipationLabel(t, actorSummary?.participationStatus ?? null);
|
||||||
const actorMessage = actorSummary ? getActorMessage(t, method, actorName, actorStatus) : null;
|
const actorMessage = actorSummary ? getActorMessage(t, method, actorName, actorStatus) : null;
|
||||||
const actionFeedback = actionNotice;
|
|
||||||
// For REQUEST method, allow RSVP even if we can't find the user in participants:
|
// For REQUEST method, allow RSVP even if we can't find the user in participants:
|
||||||
// the email was sent TO the user, so they are an attendee. handleRsvp handles
|
// the email was sent TO the user, so they are an attendee. handleRsvp handles
|
||||||
// the import-then-find-participant flow for this case.
|
// the import-then-find-participant flow for this case.
|
||||||
@@ -721,130 +735,167 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const accentClass = getMethodAccentClass(method, actorSummary?.participationStatus);
|
|
||||||
|
|
||||||
if (state === 'loading') {
|
if (state === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 flex items-center gap-2.5">
|
<div className="flex items-center gap-3">
|
||||||
<Calendar className="w-4 h-4 text-primary flex-shrink-0" />
|
<div className="w-10 h-10 rounded-full bg-muted text-muted-foreground flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
<Calendar className="w-5 h-5" />
|
||||||
<span className="text-sm text-muted-foreground">{t('loading')}</span>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
<span>{t('loading')}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === 'error') {
|
if (state === 'error') {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-destructive/20 bg-destructive/10 px-4 py-3 flex items-center gap-2.5">
|
<div className="flex items-center gap-3">
|
||||||
<AlertCircle className="w-4 h-4 text-destructive flex-shrink-0" />
|
<div className="w-10 h-10 rounded-full bg-destructive/15 text-destructive flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
<span className="text-sm text-destructive">{t('parse_error')}</span>
|
<span className="text-sm text-destructive">{t('parse_error')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const iconTone = getMethodIconTone(method, actorSummary?.participationStatus);
|
||||||
|
|
||||||
|
const hasStatusPills = Boolean(
|
||||||
|
existingEvent
|
||||||
|
|| userIsOrganizer
|
||||||
|
|| (participationLabel && myParticipant)
|
||||||
|
|| actionNotice
|
||||||
|
|| (parsedEvent?.status && parsedEvent.status !== 'confirmed')
|
||||||
|
);
|
||||||
|
|
||||||
|
const showActionsRow = showDetails && (
|
||||||
|
canRespond
|
||||||
|
|| (supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation)
|
||||||
|
|| canApplyProposal
|
||||||
|
|| (supportsCalendar && (existingEvent || parsedEvent))
|
||||||
|
|| !supportsCalendar
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("rounded-lg border border-border overflow-hidden border-l-4", accentClass)}>
|
<div
|
||||||
{/* Header */}
|
className={cn(
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b border-border">
|
"flex items-start gap-3 rounded-md transition-colors",
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
isCollapsed && "cursor-pointer hover:bg-muted/50",
|
||||||
{isCancellation ? (
|
|
||||||
<CalendarX className="w-4 h-4 text-destructive flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<Calendar className="w-4 h-4 text-primary flex-shrink-0" />
|
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium text-foreground truncate">{bannerTitle}</span>
|
onClick={isCollapsed ? () => setIsCollapsed(false) : undefined}
|
||||||
</div>
|
onKeyDown={isCollapsed ? (e) => {
|
||||||
{canCollapse && (
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
<button
|
e.preventDefault();
|
||||||
type="button"
|
setIsCollapsed(false);
|
||||||
onClick={() => setIsCollapsed((prev) => !prev)}
|
}
|
||||||
aria-expanded={!isCollapsed}
|
} : undefined}
|
||||||
aria-label={isCollapsed ? t('expand') : t('collapse')}
|
role={isCollapsed ? 'button' : undefined}
|
||||||
title={isCollapsed ? t('expand') : t('collapse')}
|
tabIndex={isCollapsed ? 0 : undefined}
|
||||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
|
aria-expanded={isCollapsed ? false : undefined}
|
||||||
>
|
>
|
||||||
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
|
{/* Avatar-style icon */}
|
||||||
</button>
|
<div className={cn(
|
||||||
|
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
|
||||||
|
iconTone,
|
||||||
|
)}>
|
||||||
|
{isCancellation ? (
|
||||||
|
<CalendarX className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<Calendar className="w-5 h-5" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content column */}
|
||||||
{showDetails && (
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
<div className="px-4 py-3 space-y-2.5">
|
{/* Eyebrow + title + collapse */}
|
||||||
<div className="lg:flex lg:gap-6">
|
|
||||||
{/* Left: Event info */}
|
|
||||||
<div className="lg:flex-1 space-y-2.5 min-w-0">
|
|
||||||
{/* Event title */}
|
|
||||||
{summary?.title && (
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{bannerTitle}
|
||||||
|
</div>
|
||||||
|
{summary?.title && (
|
||||||
<h3 className={cn(
|
<h3 className={cn(
|
||||||
"text-base font-semibold leading-snug",
|
"text-sm font-semibold leading-snug break-words",
|
||||||
isCancellation ? "line-through text-muted-foreground" : "text-foreground"
|
isCancellation ? "line-through text-muted-foreground" : "text-foreground",
|
||||||
)}>
|
)}>
|
||||||
{summary.title}
|
{summary.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
|
{parsedEvent?.sequence != null && parsedEvent.sequence > 0 && (
|
||||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground flex-shrink-0 whitespace-nowrap">
|
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground whitespace-nowrap">
|
||||||
{t('event_updated', { sequence: parsedEvent.sequence })}
|
{t('event_updated', { sequence: parsedEvent.sequence })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsCollapsed((prev) => !prev);
|
||||||
|
}}
|
||||||
|
aria-expanded={!isCollapsed}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors"
|
||||||
|
>
|
||||||
|
{isCollapsed ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
|
||||||
|
{isCollapsed ? t('expand') : t('collapse')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Event details */}
|
{/* Meta rows */}
|
||||||
<div className="lg:flex lg:items-center lg:gap-4 lg:flex-wrap space-y-1 lg:space-y-0">
|
{showDetails && summary && (summary.start || summary.location || summary.attendeeCount > 0) && (
|
||||||
{summary?.start && (
|
<div className="flex flex-col gap-1 text-sm text-muted-foreground sm:flex-row sm:flex-wrap sm:items-center sm:gap-x-4 sm:gap-y-1">
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
{summary.start && (
|
||||||
|
<span className="flex items-center gap-1.5 min-w-0">
|
||||||
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
<span>
|
<span className="truncate">
|
||||||
{formatDateTime(summary.start)}
|
{formatDateTime(summary.start)}
|
||||||
{summary.end && ` – ${formatDateTime(summary.end)}`}
|
{summary.end && ` – ${formatDateTime(summary.end)}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{summary?.location && (
|
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
||||||
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
|
|
||||||
<span>{summary.location}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{summary?.organizer && (
|
|
||||||
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
||||||
<Users className="w-3.5 h-3.5 flex-shrink-0" />
|
|
||||||
{t('organizer', { name: summary.organizer })}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{summary && summary.attendeeCount > 0 && (
|
{summary.location && (
|
||||||
<span className="text-sm text-muted-foreground">{t('attendees', { count: summary.attendeeCount })}</span>
|
<span className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
|
<span className="truncate">{summary.location}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{summary.attendeeCount > 0 && (
|
||||||
|
<span className="text-muted-foreground/80">{t('attendees', { count: summary.attendeeCount })}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Right: Info & actor messages on large screens */}
|
{/* Organizer row (clickable, left-aligned) */}
|
||||||
<div className="lg:flex-shrink-0 lg:text-right lg:max-w-xs mt-2.5 lg:mt-0 space-y-1">
|
{showDetails && summary?.organizer && (
|
||||||
{bannerInfo && (
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground min-w-0">
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">{bannerInfo}</p>
|
<Users className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
)}
|
<span className="flex-shrink-0">{t('organizer_label')}</span>
|
||||||
{actorMessage && (
|
{summary.organizerEmail ? (
|
||||||
<p className="text-xs text-muted-foreground">{actorMessage}</p>
|
<RecipientPopover
|
||||||
)}
|
name={summary.organizer}
|
||||||
{actorSummary?.participationComment && (
|
email={summary.organizerEmail}
|
||||||
<p className="text-xs text-muted-foreground italic">
|
className="text-sm truncate"
|
||||||
{t('actor_note', { comment: actorSummary.participationComment })}
|
/>
|
||||||
</p>
|
) : (
|
||||||
|
<span className="truncate text-foreground">{summary.organizer}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Status badges */}
|
{/* Status pills */}
|
||||||
{(existingEvent || userIsOrganizer || (participationLabel && myParticipant) || actionFeedback || (parsedEvent?.status && parsedEvent.status !== 'confirmed')) && (
|
{showDetails && hasStatusPills && (
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{parsedEvent?.status && parsedEvent.status !== 'confirmed' && (
|
{parsedEvent?.status && parsedEvent.status !== 'confirmed' && (
|
||||||
<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-destructive/15 text-destructive"
|
? "bg-destructive/15 text-destructive"
|
||||||
: "bg-warning/15 text-warning"
|
: "bg-warning/15 text-warning",
|
||||||
)}>
|
)}>
|
||||||
{t(`event_status_${parsedEvent.status}`)}
|
{t(`event_status_${parsedEvent.status}`)}
|
||||||
</span>
|
</span>
|
||||||
@@ -862,21 +913,45 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
{participationLabel && myParticipant && (
|
{participationLabel && myParticipant && (
|
||||||
<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',
|
||||||
getParticipationTone(currentRsvp)
|
getParticipationTone(currentRsvp),
|
||||||
)}>
|
)}>
|
||||||
{t('your_response', { status: participationLabel })}
|
{t('your_response', { status: participationLabel })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{actionFeedback && (
|
{actionNotice && (
|
||||||
<span className="rounded-full bg-success/15 px-2 py-0.5 text-[11px] font-medium text-success">
|
<span className="rounded-full bg-success/15 px-2 py-0.5 text-[11px] font-medium text-success">
|
||||||
{actionFeedback}
|
{actionNotice}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Info / actor messages */}
|
||||||
|
{showDetails && (bannerInfo || actorMessage || actorSummary?.participationComment) && (
|
||||||
|
<div className="space-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{bannerInfo && <p className="leading-relaxed">{bannerInfo}</p>}
|
||||||
|
{actorMessage && <p>{actorMessage}</p>}
|
||||||
|
{actorSummary?.participationComment && (
|
||||||
|
<p className="italic">{t('actor_note', { comment: actorSummary.participationComment })}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Trust warning */}
|
||||||
|
{showDetails && trustMessage && trustAssessment && (
|
||||||
|
<div className={cn(
|
||||||
|
'flex items-start gap-2 text-sm rounded-md px-3 py-2 border',
|
||||||
|
trustAssessment.level === 'warning'
|
||||||
|
? 'bg-destructive/10 text-destructive border-destructive/30'
|
||||||
|
: 'bg-warning/10 text-warning border-warning/30',
|
||||||
|
)}>
|
||||||
|
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="flex-1">{trustMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Proposed changes */}
|
{/* Proposed changes */}
|
||||||
{proposedChanges.length > 0 && (
|
{showDetails && proposedChanges.length > 0 && (
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
|
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
|
||||||
<div className="font-medium text-foreground mb-1.5">{t('proposed_changes')}</div>
|
<div className="font-medium text-foreground mb-1.5">{t('proposed_changes')}</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -890,32 +965,17 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Trust warning */}
|
|
||||||
{trustMessage && trustAssessment && (
|
|
||||||
<div className={cn(
|
|
||||||
'flex items-start gap-1.5 text-xs rounded-md px-3 py-2',
|
|
||||||
trustAssessment.level === 'warning'
|
|
||||||
? 'bg-destructive/10 text-destructive border border-destructive/20'
|
|
||||||
: 'bg-warning/10 text-warning border border-warning/20'
|
|
||||||
)}>
|
|
||||||
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
|
||||||
<span>{trustMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action error */}
|
{/* Action error */}
|
||||||
{actionError && (
|
{showDetails && actionError && (
|
||||||
<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">
|
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-destructive/10 text-destructive border border-destructive/30">
|
||||||
<AlertCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
<span>{actionError}</span>
|
<span className="flex-1">{actionError}</span>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{showDetails && (
|
{showActionsRow && (
|
||||||
<div className="px-4 py-2.5 border-t border-border bg-muted/20 flex items-center gap-2 flex-wrap">
|
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||||
{canRespond && (
|
{canRespond && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -925,8 +985,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-success/15 text-success border-success/20"
|
? "bg-success/15 text-success border-success/30"
|
||||||
: "text-muted-foreground hover:text-success border-border hover:border-success/30 hover:bg-success/10"
|
: "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" />
|
||||||
@@ -939,8 +999,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-warning/15 text-warning border-warning/20"
|
? "bg-warning/15 text-warning border-warning/30"
|
||||||
: "text-muted-foreground hover:text-warning border-border hover:border-warning/30 hover:bg-warning/10"
|
: "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" />
|
||||||
@@ -953,27 +1013,35 @@ 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-destructive/15 text-destructive border-destructive/20"
|
? "bg-destructive/15 text-destructive border-destructive/30"
|
||||||
: "text-muted-foreground hover:text-destructive border-border hover:border-destructive/30 hover:bg-destructive/10"
|
: "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" />
|
||||||
{t('decline')}
|
{t('decline')}
|
||||||
</button>
|
</button>
|
||||||
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
<div className="w-px h-5 bg-border" />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation && (
|
{supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation && (
|
||||||
<div className="relative">
|
<>
|
||||||
<button
|
<button
|
||||||
|
ref={pickerTriggerRef}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (calendars.length <= 1) {
|
if (calendars.length <= 1) {
|
||||||
handleImport();
|
handleImport();
|
||||||
} else {
|
return;
|
||||||
setShowCalendarPicker(!showCalendarPicker);
|
|
||||||
}
|
}
|
||||||
|
if (showCalendarPicker) {
|
||||||
|
setShowCalendarPicker(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pickerTriggerRef.current) {
|
||||||
|
const rect = pickerTriggerRef.current.getBoundingClientRect();
|
||||||
|
setPickerPosition({ top: rect.bottom + 4, left: rect.left });
|
||||||
|
}
|
||||||
|
setShowCalendarPicker(true);
|
||||||
}}
|
}}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50"
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50"
|
||||||
@@ -983,8 +1051,11 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
{calendars.length > 1 && <ChevronDown className="w-3 h-3" />}
|
{calendars.length > 1 && <ChevronDown className="w-3 h-3" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showCalendarPicker && calendars.length > 1 && (
|
{showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal(
|
||||||
<div className="absolute left-0 top-full mt-1 w-52 bg-background rounded-lg shadow-lg border border-border z-10 py-1">
|
<div
|
||||||
|
className="fixed w-52 bg-background rounded-lg shadow-lg border border-border z-50 py-1"
|
||||||
|
style={{ top: pickerPosition.top, left: pickerPosition.left }}
|
||||||
|
>
|
||||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
{t('select_calendar')}
|
{t('select_calendar')}
|
||||||
</div>
|
</div>
|
||||||
@@ -1004,9 +1075,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
<span className="truncate text-foreground">{cal.name}</span>
|
<span className="truncate text-foreground">{cal.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canApplyProposal && (
|
{canApplyProposal && (
|
||||||
@@ -1041,5 +1113,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
|||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||||
|
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||||
|
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
@@ -326,6 +328,9 @@ export function EmailComposer({
|
|||||||
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||||
: '';
|
: '';
|
||||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||||
|
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
|
||||||
|
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||||
|
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||||
const addTemplate = useTemplateStore((s) => s.addTemplate);
|
const addTemplate = useTemplateStore((s) => s.addTemplate);
|
||||||
const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
|
const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
|
||||||
const smimeStore = useSmimeStore();
|
const smimeStore = useSmimeStore();
|
||||||
@@ -446,10 +451,13 @@ export function EmailComposer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
autocompleteTimeoutRef.current = setTimeout(() => {
|
autocompleteTimeoutRef.current = setTimeout(async () => {
|
||||||
const results = getAutocomplete(lastPart);
|
const localResults = getAutocomplete(lastPart);
|
||||||
setAutocompleteResults(results);
|
// Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …).
|
||||||
setActiveAutoField(results.length > 0 ? field : null);
|
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email }));
|
||||||
|
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query: lastPart });
|
||||||
|
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
|
||||||
|
setActiveAutoField(merged.length > 0 ? field : null);
|
||||||
setAutoSelectedIndex(-1);
|
setAutoSelectedIndex(-1);
|
||||||
}, 200);
|
}, 200);
|
||||||
}, [getAutocomplete]);
|
}, [getAutocomplete]);
|
||||||
@@ -559,6 +567,19 @@ export function EmailComposer({
|
|||||||
const addFiles = useCallback(async (files: File[]) => {
|
const addFiles = useCallback(async (files: File[]) => {
|
||||||
if (!client || files.length === 0) return;
|
if (!client || files.length === 0) return;
|
||||||
|
|
||||||
|
// Let plugins veto each upload before it's queued.
|
||||||
|
const allowedFiles: File[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const ok = await emailHooks.onBeforeAttachmentUpload.intercept({
|
||||||
|
name: file.name,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
size: file.size,
|
||||||
|
});
|
||||||
|
if (ok) allowedFiles.push(file);
|
||||||
|
}
|
||||||
|
if (allowedFiles.length === 0) return;
|
||||||
|
files = allowedFiles;
|
||||||
|
|
||||||
const newAttachments: ComposerAttachment[] = files.map(file => {
|
const newAttachments: ComposerAttachment[] = files.map(file => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
return {
|
return {
|
||||||
@@ -587,6 +608,12 @@ export function EmailComposer({
|
|||||||
: att
|
: att
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
emailHooks.onAfterAttachmentUpload.emit({
|
||||||
|
name: file.name,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
size: file.size,
|
||||||
|
blobId,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller?.signal.aborted) continue;
|
if (controller?.signal.aborted) continue;
|
||||||
debug.error(`Failed to upload ${file.name}:`, error);
|
debug.error(`Failed to upload ${file.name}:`, error);
|
||||||
@@ -782,6 +809,19 @@ export function EmailComposer({
|
|||||||
|
|
||||||
// Set new timeout for auto-save (2 seconds after last change)
|
// Set new timeout for auto-save (2 seconds after last change)
|
||||||
saveTimeoutRef.current = setTimeout(() => {
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
|
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
|
||||||
|
emailHooks.onDraftChange.emit({
|
||||||
|
to: to.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
|
cc: cc.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
|
bcc: bcc.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
|
subject,
|
||||||
|
htmlBody: plainTextMode ? '' : body,
|
||||||
|
textBody: plainTextMode ? body : htmlToPlainText(body),
|
||||||
|
identityId: selectedIdentityId || '',
|
||||||
|
attachments: attachments
|
||||||
|
.filter(a => a.blobId && !a.uploading && !a.error)
|
||||||
|
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
|
||||||
|
});
|
||||||
saveDraft();
|
saveDraft();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
@@ -923,7 +963,7 @@ export function EmailComposer({
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
// RFC 5322 §3.6.4 threading — only continues the chain on a reply, not a forward.
|
// RFC 5322 §3.6.4 threading - only continues the chain on a reply, not a forward.
|
||||||
const threadingHeaders = (mode === 'reply' || mode === 'replyAll')
|
const threadingHeaders = (mode === 'reply' || mode === 'replyAll')
|
||||||
? computeReplyThreadingHeaders(replyTo)
|
? computeReplyThreadingHeaders(replyTo)
|
||||||
: null;
|
: null;
|
||||||
@@ -940,6 +980,26 @@ export function EmailComposer({
|
|||||||
const inlineAttachments = rewritten?.attachments ?? [];
|
const inlineAttachments = rewritten?.attachments ?? [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Let plugins veto the send (external-mail warning, mistyped-domain
|
||||||
|
// guards, etc.). Returning false from any handler aborts before either
|
||||||
|
// the S/MIME or standard JMAP path runs.
|
||||||
|
const sendablePreview: OutgoingEmail = {
|
||||||
|
to: toAddresses,
|
||||||
|
cc: ccAddresses,
|
||||||
|
bcc: bccAddresses,
|
||||||
|
subject,
|
||||||
|
htmlBody: finalHtmlBody || '',
|
||||||
|
textBody: finalBody,
|
||||||
|
identityId: currentIdentity?.id || '',
|
||||||
|
fromEmail,
|
||||||
|
attachments: attachments
|
||||||
|
.filter(att => att.blobId && !att.uploading && !att.error)
|
||||||
|
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
|
||||||
|
inReplyTo: threadingHeaders?.inReplyTo?.[0],
|
||||||
|
};
|
||||||
|
const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview);
|
||||||
|
if (!sendAllowed) return;
|
||||||
|
|
||||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||||
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
|
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
|
||||||
// 1. Resolve S/MIME key
|
// 1. Resolve S/MIME key
|
||||||
@@ -1065,21 +1125,49 @@ export function EmailComposer({
|
|||||||
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
|
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
|
||||||
uploadedAttachments.push(...inlineAttachments);
|
uploadedAttachments.push(...inlineAttachments);
|
||||||
|
|
||||||
await onSend?.({
|
// Let plugins (signatures, link-rewriting, encryption, AI rewrite, …)
|
||||||
|
// transform the outgoing message immediately before submission.
|
||||||
|
const transformInput: OutgoingEmail = {
|
||||||
to: toAddresses,
|
to: toAddresses,
|
||||||
cc: ccAddresses,
|
cc: ccAddresses,
|
||||||
bcc: bccAddresses,
|
bcc: bccAddresses,
|
||||||
subject,
|
subject,
|
||||||
body: finalBody,
|
htmlBody: finalHtmlBody || '',
|
||||||
htmlBody: finalHtmlBody,
|
textBody: finalBody,
|
||||||
|
identityId: currentIdentity?.id || '',
|
||||||
|
fromEmail,
|
||||||
|
attachments: uploadedAttachments.map(a => ({ name: a.name, type: a.type, size: a.size })),
|
||||||
|
inReplyTo: threadingHeaders?.inReplyTo?.[0],
|
||||||
|
};
|
||||||
|
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
|
||||||
|
|
||||||
|
await onSend?.({
|
||||||
|
to: outgoing.to,
|
||||||
|
cc: outgoing.cc,
|
||||||
|
bcc: outgoing.bcc,
|
||||||
|
subject: outgoing.subject,
|
||||||
|
body: outgoing.textBody,
|
||||||
|
htmlBody: outgoing.htmlBody || undefined,
|
||||||
draftId: finalDraftId || undefined,
|
draftId: finalDraftId || undefined,
|
||||||
fromEmail,
|
fromEmail,
|
||||||
fromName: currentIdentity?.name || undefined,
|
fromName: currentIdentity?.name || undefined,
|
||||||
identityId: currentIdentity?.id,
|
identityId: outgoing.identityId || currentIdentity?.id,
|
||||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||||
inReplyTo: threadingHeaders?.inReplyTo,
|
inReplyTo: threadingHeaders?.inReplyTo,
|
||||||
references: threadingHeaders?.references,
|
references: threadingHeaders?.references,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (mode === 'reply' || mode === 'replyAll') {
|
||||||
|
for (const recipient of [...outgoing.to, ...outgoing.cc].filter(Boolean)) {
|
||||||
|
if (trustedSendersAddressBook && client) {
|
||||||
|
addToTrustedSendersBook(client, recipient).catch(err => {
|
||||||
|
debug.error('Failed to add trusted sender to address book:', err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addTrustedSender(recipient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTo("");
|
setTo("");
|
||||||
|
|||||||
@@ -178,6 +178,14 @@ export function EmailList({
|
|||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
await batchDelete(client, isInTrash);
|
await batchDelete(client, isInTrash);
|
||||||
|
const storeError = useEmailStore.getState().error;
|
||||||
|
if (storeError) {
|
||||||
|
const { toast } = await import('sonner');
|
||||||
|
toast.error(storeError);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const { toast } = await import('sonner');
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to delete emails');
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => setIsProcessing(false), 500);
|
setTimeout(() => setIsProcessing(false), 500);
|
||||||
}
|
}
|
||||||
@@ -275,7 +283,7 @@ export function EmailList({
|
|||||||
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
|
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-3 duration-300">
|
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-3 duration-300">
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{selectedEmailIds.size} {selectedEmailIds.size === 1 ? 'email' : 'emails'} selected
|
{t('batch_actions.selected_messages', { count: selectedEmailIds.size })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
|
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
|
||||||
@@ -330,7 +338,7 @@ export function EmailList({
|
|||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('batch_actions.clear_selection')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+802
-627
File diff suppressed because it is too large
Load Diff
@@ -12,13 +12,22 @@ interface SmimeStatusBannerProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SmimeVariant = 'success' | 'warning' | 'error' | 'info';
|
||||||
|
|
||||||
|
const variantTone: Record<SmimeVariant, string> = {
|
||||||
|
success: 'bg-success/15 text-success',
|
||||||
|
warning: 'bg-warning/15 text-warning',
|
||||||
|
error: 'bg-destructive/15 text-destructive',
|
||||||
|
info: 'bg-info/15 text-info',
|
||||||
|
};
|
||||||
|
|
||||||
export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatusBannerProps) {
|
export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatusBannerProps) {
|
||||||
const t = useTranslations('smime');
|
const t = useTranslations('smime');
|
||||||
|
|
||||||
const items: Array<{
|
const items: Array<{
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
text: string;
|
text: string;
|
||||||
variant: 'success' | 'warning' | 'error' | 'info';
|
variant: SmimeVariant;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
// Encryption status
|
// Encryption status
|
||||||
@@ -26,26 +35,26 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
|||||||
if (status.decryptionError) {
|
if (status.decryptionError) {
|
||||||
if (status.decryptionError === 'locked') {
|
if (status.decryptionError === 'locked') {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <Lock className="w-4 h-4" />,
|
icon: <Lock className="w-5 h-5" />,
|
||||||
text: t('unlock_key_desc'),
|
text: t('unlock_key_desc'),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
});
|
});
|
||||||
} else if (status.decryptionError === 'no-key') {
|
} else if (status.decryptionError === 'no-key') {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <Lock className="w-4 h-4" />,
|
icon: <Lock className="w-5 h-5" />,
|
||||||
text: t('status_encrypted_no_key'),
|
text: t('status_encrypted_no_key'),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <ShieldX className="w-4 h-4" />,
|
icon: <ShieldX className="w-5 h-5" />,
|
||||||
text: t('status_encrypted_failed'),
|
text: t('status_encrypted_failed'),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <LockOpen className="w-4 h-4" />,
|
icon: <LockOpen className="w-5 h-5" />,
|
||||||
text: t('status_encrypted_ok'),
|
text: t('status_encrypted_ok'),
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
});
|
});
|
||||||
@@ -57,26 +66,26 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
|||||||
if (status.signatureValid === true) {
|
if (status.signatureValid === true) {
|
||||||
if (status.selfSigned) {
|
if (status.selfSigned) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <AlertTriangle className="w-4 h-4" />,
|
icon: <AlertTriangle className="w-5 h-5" />,
|
||||||
text: t('status_signed_self_signed'),
|
text: t('status_signed_self_signed'),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
});
|
});
|
||||||
} else if (status.signerEmailMatch === false) {
|
} else if (status.signerEmailMatch === false) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <AlertTriangle className="w-4 h-4" />,
|
icon: <AlertTriangle className="w-5 h-5" />,
|
||||||
text: t('status_signed_mismatch'),
|
text: t('status_signed_mismatch'),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <ShieldCheck className="w-4 h-4" />,
|
icon: <ShieldCheck className="w-5 h-5" />,
|
||||||
text: t('status_signed_valid'),
|
text: t('status_signed_valid'),
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (status.signatureValid === false) {
|
} else if (status.signatureValid === false) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <ShieldAlert className="w-4 h-4" />,
|
icon: <ShieldAlert className="w-5 h-5" />,
|
||||||
text: status.signatureError || t('status_signed_invalid'),
|
text: status.signatureError || t('status_signed_invalid'),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
});
|
});
|
||||||
@@ -86,7 +95,7 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
|||||||
// Unsupported S/MIME
|
// Unsupported S/MIME
|
||||||
if (status.unsupportedReason) {
|
if (status.unsupportedReason) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <Info className="w-4 h-4" />,
|
icon: <Info className="w-5 h-5" />,
|
||||||
text: t('status_unsupported'),
|
text: t('status_unsupported'),
|
||||||
variant: 'info',
|
variant: 'info',
|
||||||
});
|
});
|
||||||
@@ -94,34 +103,35 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
|
|||||||
|
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
const variantStyles = {
|
|
||||||
success: 'bg-success/10 text-success border-success/30',
|
|
||||||
warning: 'bg-warning/10 text-warning border-warning/30',
|
|
||||||
error: 'bg-destructive/10 text-destructive border-destructive/30',
|
|
||||||
info: 'bg-info/10 text-info border-info/30',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-1.5 py-1", className)}>
|
<div className={cn("flex flex-col gap-3 py-1", className)}>
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
<div
|
<div key={i} className="flex items-start gap-3">
|
||||||
key={i}
|
<div className={cn(
|
||||||
className={cn(
|
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
|
||||||
"flex items-center gap-2 px-3 py-1.5 rounded-md text-sm border",
|
variantTone[item.variant],
|
||||||
variantStyles[item.variant],
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.icon}
|
{item.icon}
|
||||||
<span className="flex-1">{item.text}</span>
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
S/MIME
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-foreground break-words">
|
||||||
|
{item.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{item.variant === 'warning' && status.decryptionError === 'locked' && onUnlockKey && (
|
{item.variant === 'warning' && status.decryptionError === 'locked' && onUnlockKey && (
|
||||||
<button
|
<button
|
||||||
onClick={onUnlockKey}
|
onClick={onUnlockKey}
|
||||||
className="text-xs font-medium underline hover:no-underline"
|
className="text-xs font-medium underline hover:no-underline flex-shrink-0"
|
||||||
>
|
>
|
||||||
{t('unlock_key')}
|
{t('unlock_key')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -159,10 +159,13 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -221,12 +224,17 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && fileType === "pdf" && objectUrl && (
|
{!loading && !error && fileType === "pdf" && objectUrl && (
|
||||||
<iframe
|
<object
|
||||||
src={objectUrl}
|
data={objectUrl}
|
||||||
sandbox="allow-scripts"
|
type="application/pdf"
|
||||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||||
title={name}
|
aria-label={name}
|
||||||
/>
|
>
|
||||||
|
<Button onClick={() => void onDownload()}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
{t("download")}
|
||||||
|
</Button>
|
||||||
|
</object>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && fileType === "audio" && objectUrl && (
|
{!loading && !error && fileType === "audio" && objectUrl && (
|
||||||
|
|||||||
@@ -40,8 +40,11 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
|||||||
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
|
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
|
||||||
|
|
||||||
const accounts = useAccountStore((s) => s.accounts);
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
const activeAccountId = useAccountStore((s) => s.activeAccountId);
|
|
||||||
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
|
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
|
||||||
|
// Read activeAccountId from authStore so the selector matches the actually-loaded
|
||||||
|
// session (primaryIdentity, JMAP client). accountStore.activeAccountId is a separate
|
||||||
|
// persisted copy that can drift out of sync across hydration / partial persist writes.
|
||||||
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
const activeAccount = accounts.find((a) => a.id === activeAccountId);
|
const activeAccount = accounts.find((a) => a.id === activeAccountId);
|
||||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||||
const logout = useAuthStore((s) => s.logout);
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Mailbox } from "@/lib/jmap/types";
|
import { Mailbox } from "@/lib/jmap/types";
|
||||||
|
import { getMailboxPath } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
@@ -10,8 +11,10 @@ import {
|
|||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
import {
|
import {
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
ChevronRight,
|
||||||
MailOpen,
|
MailOpen,
|
||||||
Mails,
|
Mails,
|
||||||
|
MoreHorizontal,
|
||||||
Trash2,
|
Trash2,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -28,12 +31,51 @@ export type MailboxContextTarget =
|
|||||||
| { kind: "mailbox"; mailbox: Mailbox; hasChildren: boolean }
|
| { kind: "mailbox"; mailbox: Mailbox; hasChildren: boolean }
|
||||||
| { kind: "folders-section" };
|
| { kind: "folders-section" };
|
||||||
|
|
||||||
|
const PATH_SEPARATOR = " › ";
|
||||||
|
const MAX_PATH_LENGTH = 40;
|
||||||
|
const MAX_SEGMENT_LENGTH = 16;
|
||||||
|
|
||||||
|
function truncateSegment(name: string): string {
|
||||||
|
return name.length > MAX_SEGMENT_LENGTH
|
||||||
|
? `${name.slice(0, MAX_SEGMENT_LENGTH - 1)}…`
|
||||||
|
: name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPathSegments(segments: string[]): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 align-middle">
|
||||||
|
{segments.map((seg, i) => (
|
||||||
|
<span key={i} className="inline-flex items-center gap-1">
|
||||||
|
{i > 0 && <ChevronRight className="w-3.5 h-3.5 opacity-60" />}
|
||||||
|
{seg === "…" ? <MoreHorizontal className="w-3.5 h-3.5" /> : <span>{seg}</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShortenedPath(fullPath: string): React.ReactNode {
|
||||||
|
const segments = fullPath.split(PATH_SEPARATOR);
|
||||||
|
if (fullPath.length <= MAX_PATH_LENGTH) {
|
||||||
|
return renderPathSegments(segments);
|
||||||
|
}
|
||||||
|
if (segments.length <= 2) {
|
||||||
|
return renderPathSegments(segments.map(truncateSegment));
|
||||||
|
}
|
||||||
|
return renderPathSegments([
|
||||||
|
truncateSegment(segments[0]),
|
||||||
|
"…",
|
||||||
|
truncateSegment(segments[segments.length - 1]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
interface MailboxContextMenuProps {
|
interface MailboxContextMenuProps {
|
||||||
target: MailboxContextTarget | null;
|
target: MailboxContextTarget | null;
|
||||||
position: Position;
|
position: Position;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
mailboxes: Mailbox[];
|
||||||
onMarkFolderRead?: (mailboxId: string) => void;
|
onMarkFolderRead?: (mailboxId: string) => void;
|
||||||
onMarkFolderTreeRead?: (mailboxId: string) => void;
|
onMarkFolderTreeRead?: (mailboxId: string) => void;
|
||||||
onMarkAllFoldersRead?: () => void;
|
onMarkAllFoldersRead?: () => void;
|
||||||
@@ -51,6 +93,7 @@ export function MailboxContextMenu({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
menuRef,
|
menuRef,
|
||||||
|
mailboxes,
|
||||||
onMarkFolderRead,
|
onMarkFolderRead,
|
||||||
onMarkFolderTreeRead,
|
onMarkFolderTreeRead,
|
||||||
onMarkAllFoldersRead,
|
onMarkAllFoldersRead,
|
||||||
@@ -107,9 +150,13 @@ export function MailboxContextMenu({
|
|||||||
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
|
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
|
||||||
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
|
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
|
||||||
|
|
||||||
|
const fullPath = getMailboxPath(mailbox, mailboxes);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||||
<ContextMenuHeader>{mailbox.name}</ContextMenuHeader>
|
<ContextMenuHeader>
|
||||||
|
<span title={fullPath}>{renderShortenedPath(fullPath)}</span>
|
||||||
|
</ContextMenuHeader>
|
||||||
|
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={MailOpen}
|
icon={MailOpen}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
|
||||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||||
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
|
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
|
||||||
import { cn, formatFileSize } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
@@ -175,10 +176,17 @@ export function NavigationRail({
|
|||||||
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
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);
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
|
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||||
|
const updateSeverity = useUpdateStore((s) => s.status?.severity);
|
||||||
|
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
|
||||||
|
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
|
||||||
|
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
|
||||||
|
|
||||||
// Account list for rail
|
// Account list for rail
|
||||||
const accounts = useAccountStore((s) => s.accounts);
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
const activeAccountId = useAccountStore((s) => s.activeAccountId);
|
// Read activeAccountId from authStore so the rail's account row matches the actually-loaded
|
||||||
|
// session - accountStore has its own persisted copy that can drift out of sync.
|
||||||
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||||
const logout = useAuthStore((s) => s.logout);
|
const logout = useAuthStore((s) => s.logout);
|
||||||
const logoutAll = useAuthStore((s) => s.logoutAll);
|
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||||
@@ -345,7 +353,18 @@ export function NavigationRail({
|
|||||||
"text-muted-foreground hover:text-foreground"
|
"text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<span className="relative">
|
||||||
<Shield className="w-5 h-5" />
|
<Shield className="w-5 h-5" />
|
||||||
|
{hasUpdate && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2 ring-background",
|
||||||
|
updateImportant ? "bg-red-500" : "bg-amber-500",
|
||||||
|
)}
|
||||||
|
aria-label={updateImportant ? "Important update available" : "Update available"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
@@ -400,7 +419,7 @@ export function NavigationRail({
|
|||||||
|
|
||||||
<nav
|
<nav
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col",
|
"flex flex-col flex-1 min-h-0 overflow-y-auto scroll-hidden",
|
||||||
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
|
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
|
||||||
)}
|
)}
|
||||||
role="navigation"
|
role="navigation"
|
||||||
@@ -515,10 +534,19 @@ export function NavigationRail({
|
|||||||
{isStalwartAdmin && (
|
{isStalwartAdmin && (
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
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"
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted relative"
|
||||||
title={t("admin") || "Admin"}
|
title={t("admin") || "Admin"}
|
||||||
>
|
>
|
||||||
<Shield className="w-[18px] h-[18px]" />
|
<Shield className="w-[18px] h-[18px]" />
|
||||||
|
{hasUpdate && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute top-2 right-2 w-2 h-2 rounded-full ring-2 ring-background",
|
||||||
|
updateImportant ? "bg-red-500" : "bg-amber-500",
|
||||||
|
)}
|
||||||
|
aria-label={updateImportant ? "Important update available" : "Update available"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1028,6 +1028,7 @@ export function Sidebar({
|
|||||||
isOpen={mailboxContextMenu.isOpen}
|
isOpen={mailboxContextMenu.isOpen}
|
||||||
onClose={closeMailboxContextMenu}
|
onClose={closeMailboxContextMenu}
|
||||||
menuRef={mailboxMenuRef}
|
menuRef={mailboxMenuRef}
|
||||||
|
mailboxes={mailboxes}
|
||||||
onMarkFolderRead={onMarkFolderRead}
|
onMarkFolderRead={onMarkFolderRead}
|
||||||
onMarkFolderTreeRead={onMarkFolderTreeRead}
|
onMarkFolderTreeRead={onMarkFolderTreeRead}
|
||||||
onMarkAllFoldersRead={onMarkAllFoldersRead}
|
onMarkAllFoldersRead={onMarkAllFoldersRead}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
||||||
|
|
||||||
export function ServiceWorkerRegistration() {
|
export function ServiceWorkerRegistration() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||||
@@ -23,7 +25,7 @@ export function ServiceWorkerRegistration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register("/sw.js")
|
.register(`${BASE_PATH}/sw.js`, { scope: `${BASE_PATH}/` })
|
||||||
.then((registration) => {
|
.then((registration) => {
|
||||||
console.log("Service Worker registered successfully:", registration);
|
console.log("Service Worker registered successfully:", registration);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,18 +1,51 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
|
import { useUpdateStore } from '@/stores/update-store';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { SpamSiegeGame } from './spam-siege-game';
|
import { SpamSiegeGame } from './spam-siege-game';
|
||||||
|
|
||||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||||
|
|
||||||
|
function VersionUpdateTag() {
|
||||||
|
const status = useUpdateStore((s) => s.status);
|
||||||
|
const startPolling = useUpdateStore((s) => s.startPolling);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
startPolling();
|
||||||
|
}, [startPolling]);
|
||||||
|
|
||||||
|
if (!status?.updateAvailable) return null;
|
||||||
|
if (status.severity === 'unknown' || status.severity === 'none') return null;
|
||||||
|
|
||||||
|
const important = status.severity === 'security' || status.severity === 'deprecated';
|
||||||
|
const label =
|
||||||
|
status.severity === 'security' ? 'security'
|
||||||
|
: status.severity === 'deprecated' ? 'deprecated'
|
||||||
|
: status.latest ?? 'update';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-2 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium align-middle",
|
||||||
|
important
|
||||||
|
? "bg-red-500/15 text-red-700 dark:text-red-300"
|
||||||
|
: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{important ? label : `update: ${label}`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AboutDataSettings() {
|
export function AboutDataSettings() {
|
||||||
const t = useTranslations('settings.advanced');
|
const t = useTranslations('settings.advanced');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
@@ -106,6 +139,7 @@ export function AboutDataSettings() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
|
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
|
||||||
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
|
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
|
||||||
|
<VersionUpdateTag />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -139,6 +139,19 @@ export function AddressBookManagementSettings() {
|
|||||||
{t("default")}
|
{t("default")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{(() => {
|
||||||
|
const shareCount = Object.keys(book.shareWith || {}).length;
|
||||||
|
if (shareCount === 0 || book.isShared) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full"
|
||||||
|
title={t("share")}
|
||||||
|
>
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
{shareCount}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
{canRename && (
|
{canRename && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -516,6 +516,20 @@ export function CalendarManagementSettings() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const shareCount = Object.keys(cal.shareWith || {}).length;
|
||||||
|
if (shareCount === 0 || cal.isShared) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full"
|
||||||
|
title={t('share')}
|
||||||
|
>
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
{shareCount}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useConfig } from '@/hooks/use-config';
|
|||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||||
import { Mail, X } from 'lucide-react';
|
import { Mail, X } from 'lucide-react';
|
||||||
|
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||||
import {
|
import {
|
||||||
SUPPORTED_SUB_ADDRESS_DELIMITERS,
|
SUPPORTED_SUB_ADDRESS_DELIMITERS,
|
||||||
isSupportedSubAddressDelimiter,
|
isSupportedSubAddressDelimiter,
|
||||||
@@ -32,7 +33,7 @@ export function ComposingSettings() {
|
|||||||
const handleSetDefaultMailProgram = useCallback(() => {
|
const handleSetDefaultMailProgram = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
|
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
|
||||||
navigator.registerProtocolHandler('mailto', `${window.location.origin}/compose?mailto=%s`);
|
navigator.registerProtocolHandler('mailto', `${window.location.origin}${getPathPrefix()}/compose?mailto=%s`);
|
||||||
setDefaultMailStatus('success');
|
setDefaultMailStatus('success');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { usePluginStore } from '@/stores/plugin-store';
|
import { usePluginStore } from '@/stores/plugin-store';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
import { SettingsSection, ToggleSwitch } from './settings-section';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Upload, Trash2, AlertTriangle, Puzzle, Lock, Server } from 'lucide-react';
|
import { AlertTriangle, Puzzle, Lock, Server } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { toast } from '@/stores/toast-store';
|
import { toast } from '@/stores/toast-store';
|
||||||
import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types';
|
import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types';
|
||||||
|
|
||||||
@@ -19,11 +18,9 @@ const STATUS_COLORS: Record<PluginStatus, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function PluginsSettings() {
|
export function PluginsSettings() {
|
||||||
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore();
|
const { plugins, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore();
|
||||||
const { isFeatureEnabled, isPluginForceEnabled, isPluginApproved, fetchPolicy, loaded } = usePolicyStore();
|
const { isFeatureEnabled, isPluginForceEnabled, isPluginApproved, fetchPolicy, loaded } = usePolicyStore();
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
|
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
@@ -32,40 +29,22 @@ export function PluginsSettings() {
|
|||||||
initializePlugins();
|
initializePlugins();
|
||||||
}, [fetchPolicy, initializePlugins, loaded]);
|
}, [fetchPolicy, initializePlugins, loaded]);
|
||||||
|
|
||||||
|
// Listen for "expand this plugin" events fired by the settings search when
|
||||||
|
// the user clicks a plugin-setting sub-result. Expanding the card mounts
|
||||||
|
// the per-field rows so the highlight effect can find them.
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const id = (e as CustomEvent<{ pluginId: string }>).detail?.pluginId;
|
||||||
|
if (id) setExpandedPlugin(id);
|
||||||
|
};
|
||||||
|
window.addEventListener('settings-plugin-expand', handler);
|
||||||
|
return () => window.removeEventListener('settings-plugin-expand', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!isFeatureEnabled('pluginsEnabled')) {
|
if (!isFeatureEnabled('pluginsEnabled')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginUploadsEnabled = isFeatureEnabled('pluginsUploadEnabled');
|
|
||||||
|
|
||||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (!pluginUploadsEnabled) {
|
|
||||||
toast.info('Plugin uploads are disabled by your administrator');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setIsUploading(true);
|
|
||||||
try {
|
|
||||||
const result = await installPlugin(file);
|
|
||||||
if (result.success) {
|
|
||||||
toast.success('Plugin installed');
|
|
||||||
if (result.warnings?.length) {
|
|
||||||
toast.warning('Plugin warnings', { message: result.warnings.join('\n') });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error('Plugin installation failed', { message: result.error });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast.error('Plugin installation failed', { message: err instanceof Error ? err.message : 'Unknown error' });
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggle = async (plugin: InstalledPlugin) => {
|
const handleToggle = async (plugin: InstalledPlugin) => {
|
||||||
if (!initialized) return;
|
if (!initialized) return;
|
||||||
|
|
||||||
@@ -91,27 +70,14 @@ export function PluginsSettings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUninstall = (plugin: InstalledPlugin) => {
|
|
||||||
if (!initialized) return;
|
|
||||||
|
|
||||||
const isForceEnabled = plugin.forceEnabled || isPluginForceEnabled(plugin.id);
|
|
||||||
if (isForceEnabled) {
|
|
||||||
toast.info(`Plugin "${plugin.name}" is forced by admin and cannot be uninstalled`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
uninstallPlugin(plugin.id);
|
|
||||||
toast.success(`Plugin "${plugin.name}" removed`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title="Plugins" description="Manage installed plugins. Upload plugin .zip files to add new functionality." experimental experimentalDescription="Plugins is an experimental feature. The plugin API is not yet stable and may change between releases, which could break existing plugins. Plugins run in a sandboxed environment but have access to your data within the application. Only install plugins from sources you trust.">
|
<SettingsSection title="Plugins" description="Plugins deployed by your administrator. Toggle to enable or disable for your account.">
|
||||||
{/* Plugin List */}
|
{/* Plugin List */}
|
||||||
{plugins.length === 0 ? (
|
{plugins.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<Puzzle className="w-12 h-12 text-muted-foreground/30 mb-3" />
|
<Puzzle className="w-12 h-12 text-muted-foreground/30 mb-3" />
|
||||||
<p className="text-sm text-muted-foreground mb-1">No plugins installed</p>
|
<p className="text-sm text-muted-foreground mb-1">No plugins available</p>
|
||||||
<p className="text-xs text-muted-foreground/70">Upload a plugin .zip file to get started</p>
|
<p className="text-xs text-muted-foreground/70">Your administrator has not deployed any plugins</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -130,7 +96,6 @@ export function PluginsSettings() {
|
|||||||
controlsDisabled={!initialized}
|
controlsDisabled={!initialized}
|
||||||
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
|
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
|
||||||
onToggle={() => handleToggle(plugin)}
|
onToggle={() => handleToggle(plugin)}
|
||||||
onUninstall={() => handleUninstall(plugin)}
|
|
||||||
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
|
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -141,33 +106,6 @@ export function PluginsSettings() {
|
|||||||
{!initialized && plugins.length > 0 && (
|
{!initialized && plugins.length > 0 && (
|
||||||
<p className="text-xs text-muted-foreground">Syncing plugin policy and managed state...</p>
|
<p className="text-xs text-muted-foreground">Syncing plugin policy and managed state...</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Upload */}
|
|
||||||
{pluginUploadsEnabled ? (
|
|
||||||
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file">
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".zip"
|
|
||||||
onChange={handleUpload}
|
|
||||||
className="hidden"
|
|
||||||
aria-label="Upload plugin file"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
|
||||||
<Upload className="w-4 h-4 mr-1.5" />
|
|
||||||
{isUploading ? 'Installing...' : 'Upload .zip'}
|
|
||||||
</Button>
|
|
||||||
</SettingItem>
|
|
||||||
) : (
|
|
||||||
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file">
|
|
||||||
<span className="text-xs text-muted-foreground">Disabled by administrator policy</span>
|
|
||||||
</SettingItem>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -183,16 +121,18 @@ interface PluginCardProps {
|
|||||||
controlsDisabled: boolean;
|
controlsDisabled: boolean;
|
||||||
onToggleExpand: () => void;
|
onToggleExpand: () => void;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onUninstall: () => void;
|
|
||||||
onUpdateSettings: (settings: Record<string, unknown>) => void;
|
onUpdateSettings: (settings: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, needsApproval, controlsDisabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
|
function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, needsApproval, controlsDisabled, onToggleExpand, onToggle, onUpdateSettings }: PluginCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div
|
||||||
|
data-search-label={plugin.name}
|
||||||
|
className={cn(
|
||||||
'rounded-lg border transition-colors',
|
'rounded-lg border transition-colors',
|
||||||
plugin.status === 'error' ? 'border-destructive/40' : 'border-border',
|
plugin.status === 'error' ? 'border-destructive/40' : 'border-border',
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 p-3">
|
<div className="flex items-center gap-3 p-3">
|
||||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={onToggleExpand}>
|
<div className="flex-1 min-w-0 cursor-pointer" onClick={onToggleExpand}>
|
||||||
@@ -233,7 +173,7 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, needsApprov
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-border p-3 space-y-3">
|
<div className="border-t border-border p-3 space-y-3">
|
||||||
{isForceEnabled && (
|
{isForceEnabled && (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">This plugin is forced by an administrator and cannot be disabled or uninstalled.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">This plugin is forced by an administrator and cannot be disabled.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{needsApproval && (
|
{needsApproval && (
|
||||||
@@ -282,14 +222,6 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, needsApprov
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Uninstall */}
|
|
||||||
<div className="flex justify-end pt-2 border-t border-border">
|
|
||||||
<Button variant="destructive" size="sm" onClick={onUninstall} disabled={controlsDisabled || isForceEnabled}>
|
|
||||||
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
|
||||||
Uninstall
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -309,7 +241,7 @@ function PluginSettingField({ schema, value, onChange }: PluginSettingFieldProps
|
|||||||
switch (schema.type) {
|
switch (schema.type) {
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div data-search-label={schema.label} className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs text-foreground">{schema.label}</span>
|
<span className="text-xs text-foreground">{schema.label}</span>
|
||||||
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
|
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
|
||||||
@@ -320,7 +252,7 @@ function PluginSettingField({ schema, value, onChange }: PluginSettingFieldProps
|
|||||||
|
|
||||||
case 'select':
|
case 'select':
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div data-search-label={schema.label} className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs text-foreground">{schema.label}</span>
|
<span className="text-xs text-foreground">{schema.label}</span>
|
||||||
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
|
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
|
||||||
@@ -339,7 +271,7 @@ function PluginSettingField({ schema, value, onChange }: PluginSettingFieldProps
|
|||||||
|
|
||||||
case 'string':
|
case 'string':
|
||||||
return (
|
return (
|
||||||
<div>
|
<div data-search-label={schema.label}>
|
||||||
<span className="text-xs text-foreground">{schema.label}</span>
|
<span className="text-xs text-foreground">{schema.label}</span>
|
||||||
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
|
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
|
||||||
<input
|
<input
|
||||||
@@ -353,7 +285,7 @@ function PluginSettingField({ schema, value, onChange }: PluginSettingFieldProps
|
|||||||
|
|
||||||
case 'number':
|
case 'number':
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div data-search-label={schema.label} className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs text-foreground">{schema.label}</span>
|
<span className="text-xs text-foreground">{schema.label}</span>
|
||||||
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
|
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function ReadingSettings() {
|
|||||||
hoverActionsMode,
|
hoverActionsMode,
|
||||||
hoverActionsCorner,
|
hoverActionsCorner,
|
||||||
hideInlineImageAttachments,
|
hideInlineImageAttachments,
|
||||||
|
attachmentImagePreviewsEnabled,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
|
|
||||||
@@ -206,6 +207,13 @@ export function ReadingSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem label={t('attachment_image_previews.label')} description={t('attachment_image_previews.description')}>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={attachmentImagePreviewsEnabled}
|
||||||
|
onChange={(checked) => updateSetting('attachmentImagePreviewsEnabled', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
||||||
<div className="py-3 border-b border-border space-y-3">
|
<div className="py-3 border-b border-border space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,29 +1,16 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { Lock, FlaskConical } from 'lucide-react';
|
import { Lock } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface SettingsSectionProps {
|
interface SettingsSectionProps {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
experimental?: boolean;
|
|
||||||
experimentalDescription?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSection({ title, description, children, experimental, experimentalDescription }: SettingsSectionProps) {
|
export function SettingsSection({ title, description, children }: SettingsSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div data-search-label={title} className="space-y-4">
|
||||||
{experimental && (
|
|
||||||
<div className="flex gap-3 rounded-lg border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-950/30">
|
|
||||||
<FlaskConical className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">Experimental Feature</p>
|
|
||||||
{experimentalDescription && (
|
|
||||||
<p className="text-xs text-amber-700 dark:text-amber-400/80 mt-1">{experimentalDescription}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-foreground">{title}</h3>
|
<h3 className="text-lg font-medium text-foreground">{title}</h3>
|
||||||
{description && (
|
{description && (
|
||||||
@@ -44,8 +31,11 @@ interface SettingItemProps {
|
|||||||
|
|
||||||
export function SettingItem({ label, description, children, locked }: SettingItemProps) {
|
export function SettingItem({ label, description, children, locked }: SettingItemProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-start justify-between py-3 border-b border-border last:border-0", locked && "opacity-60")}>
|
<div
|
||||||
<div className="flex-1 pr-4">
|
data-search-label={label}
|
||||||
|
className={cn("flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0", locked && "opacity-60")}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0 sm:pr-4">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<label className="text-sm font-medium text-foreground">{label}</label>
|
<label className="text-sm font-medium text-foreground">{label}</label>
|
||||||
{locked && <Lock className="w-3 h-3 text-muted-foreground" aria-label="Managed by administrator" />}
|
{locked && <Lock className="w-3 h-3 text-muted-foreground" aria-label="Managed by administrator" />}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function ShareCollectionDialog({
|
|||||||
const t = useTranslations("sharing");
|
const t = useTranslations("sharing");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const [principals, setPrincipals] = useState<Principal[]>([]);
|
const [allPrincipals, setAllPrincipals] = useState<Principal[]>([]);
|
||||||
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
|
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [savingId, setSavingId] = useState<string | null>(null);
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
@@ -91,23 +91,28 @@ export function ShareCollectionDialog({
|
|||||||
setLoadingPrincipals(true);
|
setLoadingPrincipals(true);
|
||||||
client.getPrincipals().then((list) => {
|
client.getPrincipals().then((list) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
// Exclude the user themselves and any principal that already has a share
|
setAllPrincipals(list);
|
||||||
const existing = new Set(Object.keys(shareWith || {}));
|
|
||||||
const filtered = list.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
|
|
||||||
setPrincipals(filtered);
|
|
||||||
setLoadingPrincipals(false);
|
setLoadingPrincipals(false);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (!cancelled) setLoadingPrincipals(false);
|
if (!cancelled) setLoadingPrincipals(false);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [client, ownAccountId, shareWith]);
|
}, [client]);
|
||||||
|
|
||||||
// Map principal id -> Principal for displayed shares
|
// Map of every fetched principal by id, used for name/description lookups in
|
||||||
|
// the shared list. Must include principals that already have a share so the
|
||||||
|
// list shows their name rather than the raw id.
|
||||||
const allPrincipalsById = useMemo(() => {
|
const allPrincipalsById = useMemo(() => {
|
||||||
const map = new Map<string, Principal>();
|
const map = new Map<string, Principal>();
|
||||||
for (const p of principals) map.set(p.id, p);
|
for (const p of allPrincipals) map.set(p.id, p);
|
||||||
return map;
|
return map;
|
||||||
}, [principals]);
|
}, [allPrincipals]);
|
||||||
|
|
||||||
|
// Principals available to add: exclude self and anyone already shared with.
|
||||||
|
const principals = useMemo(() => {
|
||||||
|
const existing = new Set(Object.keys(shareWith || {}));
|
||||||
|
return allPrincipals.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
|
||||||
|
}, [allPrincipals, ownAccountId, shareWith]);
|
||||||
|
|
||||||
// Close on Escape, focus trap, click outside
|
// Close on Escape, focus trap, click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -155,8 +160,6 @@ export function ShareCollectionDialog({
|
|||||||
setSavingId(principal.id);
|
setSavingId(principal.id);
|
||||||
try {
|
try {
|
||||||
await onShare(principal.id, rights);
|
await onShare(principal.id, rights);
|
||||||
// Move principal out of the "to add" list
|
|
||||||
setPrincipals((prev) => prev.filter((p) => p.id !== principal.id));
|
|
||||||
setShowAdd(false);
|
setShowAdd(false);
|
||||||
setSearch("");
|
setSearch("");
|
||||||
toast.success(t("share_added"));
|
toast.success(t("share_added"));
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
import { SettingsSection, SettingItem } from './settings-section';
|
import { SettingsSection } from './settings-section';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Upload, Trash2, Check, Palette, Lock } from 'lucide-react';
|
import { Check, Palette, Lock } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { toast } from '@/stores/toast-store';
|
import { toast } from '@/stores/toast-store';
|
||||||
import type { InstalledTheme } from '@/lib/plugin-types';
|
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
|
|
||||||
export function ThemesSettings() {
|
export function ThemesSettings() {
|
||||||
const { installedThemes, activeThemeId, installTheme, uninstallTheme, activateTheme } = useThemeStore();
|
const { installedThemes, activeThemeId, activateTheme } = useThemeStore();
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const { isThemeDisabled, getThemePolicy, getForcedThemeId, isThemeForceEnabled } = usePolicyStore();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const { isFeatureEnabled, isThemeDisabled, getThemePolicy, getForcedThemeId, isThemeForceEnabled } = usePolicyStore();
|
|
||||||
const canUpload = isFeatureEnabled('userThemesEnabled');
|
|
||||||
const themePolicy = getThemePolicy();
|
const themePolicy = getThemePolicy();
|
||||||
const forcedThemeId = getForcedThemeId(installedThemes.map((theme) => theme.id));
|
const forcedThemeId = getForcedThemeId(installedThemes.map((theme) => theme.id));
|
||||||
|
|
||||||
@@ -40,30 +35,6 @@ export function ThemesSettings() {
|
|||||||
}
|
}
|
||||||
}, [activeThemeId, activateTheme, forcedThemeId, installedThemes, isThemeDisabled]);
|
}, [activeThemeId, activateTheme, forcedThemeId, installedThemes, isThemeDisabled]);
|
||||||
|
|
||||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setIsUploading(true);
|
|
||||||
try {
|
|
||||||
const result = await installTheme(file);
|
|
||||||
if (result.success) {
|
|
||||||
toast.success('Theme installed');
|
|
||||||
if (result.warnings?.length) {
|
|
||||||
toast.warning('Theme warnings', { message: result.warnings.join('\n') });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error('Theme installation failed', { message: result.error });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast.error('Theme installation failed', { message: err instanceof Error ? err.message : 'Unknown error' });
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
// Reset file input
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActivate = (id: string | null) => {
|
const handleActivate = (id: string | null) => {
|
||||||
if (forcedThemeId && id !== forcedThemeId) {
|
if (forcedThemeId && id !== forcedThemeId) {
|
||||||
const forcedTheme = installedThemes.find((theme) => theme.id === forcedThemeId);
|
const forcedTheme = installedThemes.find((theme) => theme.id === forcedThemeId);
|
||||||
@@ -75,19 +46,8 @@ export function ThemesSettings() {
|
|||||||
toast.success(id ? 'Theme activated' : 'Default theme restored');
|
toast.success(id ? 'Theme activated' : 'Default theme restored');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUninstall = (theme: InstalledTheme) => {
|
|
||||||
if (theme.builtIn) return;
|
|
||||||
if (theme.id === forcedThemeId || theme.forceEnabled || isThemeForceEnabled(theme.id)) {
|
|
||||||
toast.info(`Theme "${theme.name}" is forced by admin and cannot be removed`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
uninstallTheme(theme.id);
|
|
||||||
toast.success('Theme removed');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title="Themes" description="Customize the appearance with color themes. Upload .zip theme files or activate built-in presets." experimental experimentalDescription="Themes is an experimental feature. Custom themes may not cover all UI elements, and theme formats could change in future updates. Built-in presets are stable, but uploaded themes may require updates after application upgrades.">
|
<SettingsSection title="Themes" description="Choose from themes deployed by your administrator and built-in presets.">
|
||||||
|
|
||||||
{forcedThemeId && (
|
{forcedThemeId && (
|
||||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300">
|
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300">
|
||||||
@@ -124,34 +84,10 @@ export function ThemesSettings() {
|
|||||||
disabled={Boolean(forcedThemeId) && !isForceEnabled}
|
disabled={Boolean(forcedThemeId) && !isForceEnabled}
|
||||||
variants={theme.variants}
|
variants={theme.variants}
|
||||||
onActivate={() => handleActivate(theme.id)}
|
onActivate={() => handleActivate(theme.id)}
|
||||||
onRemove={!theme.builtIn && !isForceEnabled ? () => handleUninstall(theme) : undefined}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upload */}
|
|
||||||
{canUpload && (
|
|
||||||
<SettingItem label="Upload Theme" description="Install a custom theme from a .zip file containing manifest.json and theme.css">
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".zip"
|
|
||||||
onChange={handleUpload}
|
|
||||||
className="hidden"
|
|
||||||
aria-label="Upload theme file"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
|
||||||
<Upload className="w-4 h-4 mr-1.5" />
|
|
||||||
{isUploading ? 'Installing...' : 'Upload .zip'}
|
|
||||||
</Button>
|
|
||||||
</SettingItem>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -169,12 +105,11 @@ interface ThemeCardProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
variants?: ('light' | 'dark')[];
|
variants?: ('light' | 'dark')[];
|
||||||
onActivate: () => void;
|
onActivate: () => void;
|
||||||
onRemove?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate, onRemove }: ThemeCardProps) {
|
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate }: ThemeCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div data-search-label={name} className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onActivate}
|
onClick={onActivate}
|
||||||
@@ -224,18 +159,6 @@ function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Remove button */}
|
|
||||||
{onRemove && !isActive && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRemove}
|
|
||||||
className="absolute top-2 right-2 p-1 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
|
||||||
title="Remove theme"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
|
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter, usePathname } from "@/i18n/navigation";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||||
@@ -34,6 +34,7 @@ export function useTour() {
|
|||||||
|
|
||||||
export function TourProvider({ children }: { children: ReactNode }) {
|
export function TourProvider({ children }: { children: ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const { isDemoMode } = useAuthStore();
|
const { isDemoMode } = useAuthStore();
|
||||||
const { supportsCalendar } = useCalendarStore();
|
const { supportsCalendar } = useCalendarStore();
|
||||||
const { supportsWebDAV } = useWebDAVStore();
|
const { supportsWebDAV } = useWebDAVStore();
|
||||||
@@ -63,9 +64,16 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
|||||||
// If the resume step is beyond the current steps, start from 0
|
// If the resume step is beyond the current steps, start from 0
|
||||||
if (resumeStep >= steps.length) resumeStep = 0;
|
if (resumeStep >= steps.length) resumeStep = 0;
|
||||||
|
|
||||||
|
// Most steps live on the mailbox - navigate there (or to the step's specific page)
|
||||||
|
// so we don't start the tour on a page where the targets don't exist.
|
||||||
|
const targetPage = steps[resumeStep]?.page ?? "/";
|
||||||
|
if (pathname !== targetPage) {
|
||||||
|
router.push(targetPage);
|
||||||
|
}
|
||||||
|
|
||||||
setCurrentStep(resumeStep);
|
setCurrentStep(resumeStep);
|
||||||
setIsActive(true);
|
setIsActive(true);
|
||||||
}, [steps.length]);
|
}, [steps, pathname, router]);
|
||||||
|
|
||||||
const stopTour = useCallback(() => {
|
const stopTour = useCallback(() => {
|
||||||
setIsActive(false);
|
setIsActive(false);
|
||||||
@@ -90,14 +98,17 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
const next = currentStep + 1;
|
const next = currentStep + 1;
|
||||||
const nextStepDef = steps[next];
|
const nextStepDef = steps[next];
|
||||||
|
const currentStepDef = steps[currentStep];
|
||||||
setCurrentStep(next);
|
setCurrentStep(next);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(next));
|
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(next));
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
|
|
||||||
// Navigate if the next step requires a different page
|
// Navigate when the next step is on a different page (treat "no page" as the mailbox)
|
||||||
if (nextStepDef?.page) {
|
const nextPage = nextStepDef?.page ?? "/";
|
||||||
router.push(nextStepDef.page);
|
const currentPage = currentStepDef?.page ?? "/";
|
||||||
|
if (nextPage !== currentPage) {
|
||||||
|
router.push(nextPage);
|
||||||
}
|
}
|
||||||
}, [currentStep, steps, completeTour, router]);
|
}, [currentStep, steps, completeTour, router]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { forwardRef, useState, useRef, useEffect } from "react";
|
import { forwardRef, useState, useRef, useEffect, useLayoutEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
@@ -17,26 +17,72 @@ interface ContextMenuProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VIEWPORT_MARGIN = 10;
|
||||||
|
|
||||||
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||||
({ isOpen, position, onClose: _onClose, children }, ref) => {
|
({ isOpen, position, onClose: _onClose, children }, ref) => {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [adjustedPosition, setAdjustedPosition] = useState<Position | null>(null);
|
||||||
|
const localRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Measure the rendered menu and clamp it inside the viewport before the
|
||||||
|
// browser paints. We hide the element until this runs so the user never
|
||||||
|
// sees the menu jump from an unclamped position to a clamped one.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setAdjustedPosition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const node = localRef.current;
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
|
||||||
|
let x = position.x;
|
||||||
|
let y = position.y;
|
||||||
|
|
||||||
|
if (x + rect.width > vw - VIEWPORT_MARGIN) {
|
||||||
|
x = vw - rect.width - VIEWPORT_MARGIN;
|
||||||
|
}
|
||||||
|
if (y + rect.height > vh - VIEWPORT_MARGIN) {
|
||||||
|
y = vh - rect.height - VIEWPORT_MARGIN;
|
||||||
|
}
|
||||||
|
x = Math.max(VIEWPORT_MARGIN, x);
|
||||||
|
y = Math.max(VIEWPORT_MARGIN, y);
|
||||||
|
|
||||||
|
setAdjustedPosition({ x, y });
|
||||||
|
}, [isOpen, position.x, position.y]);
|
||||||
|
|
||||||
|
const setRefs = (node: HTMLDivElement | null) => {
|
||||||
|
localRef.current = node;
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
ref(node);
|
||||||
|
} else if (ref) {
|
||||||
|
ref.current = node;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!mounted || !isOpen) return null;
|
if (!mounted || !isOpen) return null;
|
||||||
|
|
||||||
|
const renderPosition = adjustedPosition ?? position;
|
||||||
|
const isPositioned = adjustedPosition !== null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={setRefs}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed z-50 min-w-[200px] bg-background rounded-md shadow-lg border border-border",
|
"fixed z-50 min-w-[200px] bg-background rounded-md shadow-lg border border-border"
|
||||||
"animate-in fade-in-0 zoom-in-95 duration-100"
|
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
left: position.x,
|
left: renderPosition.x,
|
||||||
top: position.y,
|
top: renderPosition.y,
|
||||||
|
visibility: isPositioned ? "visible" : "hidden",
|
||||||
}}
|
}}
|
||||||
role="menu"
|
role="menu"
|
||||||
aria-orientation="vertical"
|
aria-orientation="vertical"
|
||||||
@@ -112,22 +158,41 @@ export function ContextMenuSubMenu({
|
|||||||
children,
|
children,
|
||||||
}: ContextMenuSubMenuProps) {
|
}: ContextMenuSubMenuProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [subMenuPosition, setSubMenuPosition] = useState<"right" | "left">("right");
|
const [subMenuPos, setSubMenuPos] = useState<Position | null>(null);
|
||||||
const itemRef = useRef<HTMLDivElement>(null);
|
const itemRef = useRef<HTMLDivElement>(null);
|
||||||
const subMenuRef = useRef<HTMLDivElement>(null);
|
const subMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (isOpen && itemRef.current) {
|
if (!isOpen) {
|
||||||
const rect = itemRef.current.getBoundingClientRect();
|
setSubMenuPos(null);
|
||||||
const viewportWidth = window.innerWidth;
|
return;
|
||||||
|
}
|
||||||
|
const itemEl = itemRef.current;
|
||||||
|
const subEl = subMenuRef.current;
|
||||||
|
if (!itemEl || !subEl) return;
|
||||||
|
|
||||||
if (rect.right + 200 > viewportWidth - 10) {
|
const itemRect = itemEl.getBoundingClientRect();
|
||||||
setSubMenuPosition("left");
|
const subRect = subEl.getBoundingClientRect();
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
|
||||||
|
let left: number;
|
||||||
|
if (itemRect.right + subRect.width <= vw - VIEWPORT_MARGIN) {
|
||||||
|
left = itemRect.right;
|
||||||
|
} else if (itemRect.left - subRect.width >= VIEWPORT_MARGIN) {
|
||||||
|
left = itemRect.left - subRect.width;
|
||||||
} else {
|
} else {
|
||||||
setSubMenuPosition("right");
|
left = Math.max(VIEWPORT_MARGIN, vw - subRect.width - VIEWPORT_MARGIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let top = itemRect.top;
|
||||||
|
if (top + subRect.height > vh - VIEWPORT_MARGIN) {
|
||||||
|
top = vh - subRect.height - VIEWPORT_MARGIN;
|
||||||
}
|
}
|
||||||
|
top = Math.max(VIEWPORT_MARGIN, top);
|
||||||
|
|
||||||
|
setSubMenuPos({ x: left, y: top });
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -173,14 +238,15 @@ export function ContextMenuSubMenu({
|
|||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
ref={subMenuRef}
|
ref={subMenuRef}
|
||||||
className={cn(
|
className="fixed z-50 min-w-[180px] bg-background rounded-md shadow-lg border border-border"
|
||||||
"absolute top-0 min-w-[180px] bg-background rounded-md shadow-lg border border-border",
|
style={{
|
||||||
"animate-in fade-in-0 zoom-in-95 duration-100",
|
left: subMenuPos?.x ?? 0,
|
||||||
subMenuPosition === "right" ? "left-full" : "right-full"
|
top: subMenuPos?.y ?? 0,
|
||||||
)}
|
visibility: subMenuPos ? "visible" : "hidden",
|
||||||
|
}}
|
||||||
role="menu"
|
role="menu"
|
||||||
>
|
>
|
||||||
<div className="py-1 max-h-[300px] overflow-y-auto">
|
<div className="py-1 max-h-[min(300px,calc(100vh-40px))] overflow-y-auto">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -190,6 +190,17 @@ export function FlagCN(props: FlagProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Czech Republic - White and red horizontal bands with a blue triangle */
|
||||||
|
export function FlagCS(props: FlagProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
||||||
|
<rect width="3" height="2" fill="#fff" />
|
||||||
|
<rect y="1" width="3" height="1" fill="#D7141A" />
|
||||||
|
<polygon points="0,0 1.5,1 0,2" fill="#11457E" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Map locale codes to flag components */
|
/** Map locale codes to flag components */
|
||||||
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
|
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
|
||||||
en: FlagGB,
|
en: FlagGB,
|
||||||
@@ -207,4 +218,5 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
|
|||||||
tr: FlagTR,
|
tr: FlagTR,
|
||||||
uk: FlagUA,
|
uk: FlagUA,
|
||||||
zh: FlagCN,
|
zh: FlagCN,
|
||||||
|
cs: FlagCS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"toast-item group relative flex items-start gap-3 w-[380px] rounded-lg",
|
"toast-item group relative flex items-start gap-3 w-[380px] rounded-r-lg",
|
||||||
"bg-background/95 dark:bg-neutral-900/95 backdrop-blur-sm",
|
"bg-background/95 dark:bg-neutral-900/95 backdrop-blur-sm",
|
||||||
"border border-border/60 dark:border-neutral-700/60",
|
"border border-border/60 dark:border-neutral-700/60",
|
||||||
"shadow-[0_8px_30px_rgb(0,0,0,0.08)] dark:shadow-[0_8px_30px_rgb(0,0,0,0.3)]",
|
"shadow-[0_8px_30px_rgb(0,0,0,0.08)] dark:shadow-[0_8px_30px_rgb(0,0,0,0.3)]",
|
||||||
@@ -99,7 +99,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left accent bar */}
|
{/* Left accent bar */}
|
||||||
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l-lg", progressBarStyles[toast.type])} />
|
<div className={cn("absolute left-0 top-0 bottom-0 w-1", progressBarStyles[toast.type])} />
|
||||||
|
|
||||||
<div className="flex items-start gap-3 p-3.5 pl-4.5 flex-1 min-w-0">
|
<div className="flex items-start gap-3 p-3.5 pl-4.5 flex-1 min-w-0">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useCalendarNotificationStore } from '@/stores/calendar-notification-sto
|
|||||||
import { useToastStore } from '@/stores/toast-store';
|
import { useToastStore } from '@/stores/toast-store';
|
||||||
import { getPendingAlerts, getPendingTaskAlerts, buildAlertKey } from '@/lib/calendar-alerts';
|
import { getPendingAlerts, getPendingTaskAlerts, buildAlertKey } from '@/lib/calendar-alerts';
|
||||||
import { playNotificationSound } from '@/lib/notification-sound';
|
import { playNotificationSound } from '@/lib/notification-sound';
|
||||||
|
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||||
|
|
||||||
const CHECK_INTERVAL_MS = 60 * 1000;
|
const CHECK_INTERVAL_MS = 60 * 1000;
|
||||||
@@ -68,7 +69,7 @@ export function useCalendarAlerts() {
|
|||||||
message,
|
message,
|
||||||
duration: 15000,
|
duration: 15000,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
window.location.href = `/${locale}/calendar`;
|
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -97,7 +98,7 @@ export function useCalendarAlerts() {
|
|||||||
message: taskMsg,
|
message: taskMsg,
|
||||||
duration: 15000,
|
duration: 15000,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
window.location.href = `/${locale}/calendar`;
|
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import type { PublicJmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
interface ConfigData {
|
interface ConfigData {
|
||||||
appName: string;
|
appName: string;
|
||||||
@@ -27,6 +28,8 @@ interface ConfigData {
|
|||||||
demoMode: boolean;
|
demoMode: boolean;
|
||||||
autoSsoEnabled: boolean;
|
autoSsoEnabled: boolean;
|
||||||
allowCustomJmapEndpoint: boolean;
|
allowCustomJmapEndpoint: boolean;
|
||||||
|
jmapServers: PublicJmapServerEntry[];
|
||||||
|
jmapServerAutoPickByDomain: boolean;
|
||||||
embeddedMode: boolean;
|
embeddedMode: boolean;
|
||||||
parentOrigin: string;
|
parentOrigin: string;
|
||||||
}
|
}
|
||||||
@@ -103,6 +106,8 @@ export function useConfig(): AppConfig {
|
|||||||
demoMode: configCache?.demoMode || false,
|
demoMode: configCache?.demoMode || false,
|
||||||
autoSsoEnabled: configCache?.autoSsoEnabled || false,
|
autoSsoEnabled: configCache?.autoSsoEnabled || false,
|
||||||
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
|
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
|
||||||
|
jmapServers: configCache?.jmapServers || [],
|
||||||
|
jmapServerAutoPickByDomain: configCache?.jmapServerAutoPickByDomain || false,
|
||||||
embeddedMode: configCache?.embeddedMode || false,
|
embeddedMode: configCache?.embeddedMode || false,
|
||||||
parentOrigin: configCache?.parentOrigin || '',
|
parentOrigin: configCache?.parentOrigin || '',
|
||||||
isLoading: !configCache,
|
isLoading: !configCache,
|
||||||
@@ -135,6 +140,8 @@ export function useConfig(): AppConfig {
|
|||||||
demoMode: configCache.demoMode,
|
demoMode: configCache.demoMode,
|
||||||
autoSsoEnabled: configCache.autoSsoEnabled,
|
autoSsoEnabled: configCache.autoSsoEnabled,
|
||||||
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
|
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
|
||||||
|
jmapServers: configCache.jmapServers || [],
|
||||||
|
jmapServerAutoPickByDomain: configCache.jmapServerAutoPickByDomain || false,
|
||||||
embeddedMode: configCache.embeddedMode,
|
embeddedMode: configCache.embeddedMode,
|
||||||
parentOrigin: configCache.parentOrigin,
|
parentOrigin: configCache.parentOrigin,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -168,6 +175,8 @@ export function useConfig(): AppConfig {
|
|||||||
demoMode: data.demoMode,
|
demoMode: data.demoMode,
|
||||||
autoSsoEnabled: data.autoSsoEnabled,
|
autoSsoEnabled: data.autoSsoEnabled,
|
||||||
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
|
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
|
||||||
|
jmapServers: data.jmapServers || [],
|
||||||
|
jmapServerAutoPickByDomain: data.jmapServerAutoPickByDomain || false,
|
||||||
embeddedMode: data.embeddedMode,
|
embeddedMode: data.embeddedMode,
|
||||||
parentOrigin: data.parentOrigin,
|
parentOrigin: data.parentOrigin,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface UseContextMenuReturn<T> {
|
|||||||
|
|
||||||
const MENU_WIDTH = 200;
|
const MENU_WIDTH = 200;
|
||||||
const MENU_HEIGHT = 320; // Approximate max height
|
const MENU_HEIGHT = 320; // Approximate max height
|
||||||
|
const VIEWPORT_MARGIN = 10;
|
||||||
|
|
||||||
export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState<T>>({
|
const [contextMenu, setContextMenu] = useState<ContextMenuState<T>>({
|
||||||
@@ -40,18 +41,18 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
|||||||
let y = clientY;
|
let y = clientY;
|
||||||
|
|
||||||
// Adjust for right edge
|
// Adjust for right edge
|
||||||
if (x + MENU_WIDTH > viewportWidth - 10) {
|
if (x + MENU_WIDTH > viewportWidth - VIEWPORT_MARGIN) {
|
||||||
x = viewportWidth - MENU_WIDTH - 10;
|
x = viewportWidth - MENU_WIDTH - VIEWPORT_MARGIN;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust for bottom edge
|
// Adjust for bottom edge
|
||||||
if (y + MENU_HEIGHT > viewportHeight - 10) {
|
if (y + MENU_HEIGHT > viewportHeight - VIEWPORT_MARGIN) {
|
||||||
y = viewportHeight - MENU_HEIGHT - 10;
|
y = viewportHeight - MENU_HEIGHT - VIEWPORT_MARGIN;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure minimum position
|
// Ensure minimum position
|
||||||
x = Math.max(10, x);
|
x = Math.max(VIEWPORT_MARGIN, x);
|
||||||
y = Math.max(10, y);
|
y = Math.max(VIEWPORT_MARGIN, y);
|
||||||
|
|
||||||
return { x, y };
|
return { x, y };
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
+7
-37
@@ -2,49 +2,12 @@ import { readFileSync } from "fs";
|
|||||||
import { configManager } from "./lib/admin/config-manager";
|
import { configManager } from "./lib/admin/config-manager";
|
||||||
import { initAdminPassword } from "./lib/admin/password";
|
import { initAdminPassword } from "./lib/admin/password";
|
||||||
|
|
||||||
const VERSION_CHECK_URL =
|
|
||||||
"https://raw.githubusercontent.com/bulwarkmail/webmail/main/VERSION";
|
|
||||||
|
|
||||||
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
||||||
|
|
||||||
function compareVersions(current: string, remote: string): number {
|
|
||||||
const a = current.split(".").map(Number);
|
|
||||||
const b = remote.split(".").map(Number);
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
if ((b[i] ?? 0) > (a[i] ?? 0)) return 1;
|
|
||||||
if ((b[i] ?? 0) < (a[i] ?? 0)) return -1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pkg = JSON.parse(
|
const pkg = JSON.parse(
|
||||||
readFileSync(`${process.cwd()}/package.json`, "utf-8")
|
readFileSync(`${process.cwd()}/package.json`, "utf-8")
|
||||||
);
|
);
|
||||||
const current: string = pkg.version ?? "0.0.0";
|
const current: string = pkg.version ?? "0.0.0";
|
||||||
console.info(`Bulwark Webmail v${current}`);
|
console.info(`Bulwark Webmail v${current}`);
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "production") {
|
|
||||||
fetch(VERSION_CHECK_URL, {
|
|
||||||
cache: "no-store",
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
if (!res.ok) return;
|
|
||||||
return res.text();
|
|
||||||
})
|
|
||||||
.then((text) => {
|
|
||||||
if (!text) return;
|
|
||||||
const remote = text.trim();
|
|
||||||
if (!SEMVER_RE.test(remote)) return;
|
|
||||||
if (compareVersions(current, remote) > 0) {
|
|
||||||
console.info(
|
|
||||||
`Update available: v${remote} - https://github.com/bulwarkmail/webmail`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize admin config and password bootstrap
|
// Initialize admin config and password bootstrap
|
||||||
configManager.load()
|
configManager.load()
|
||||||
.then(() => initAdminPassword())
|
.then(() => initAdminPassword())
|
||||||
@@ -59,6 +22,13 @@ configManager.load()
|
|||||||
markProcessStart();
|
markProcessStart();
|
||||||
await startScheduler();
|
await startScheduler();
|
||||||
})
|
})
|
||||||
|
.then(async () => {
|
||||||
|
// Hourly check against version.telemetry.bulwarkmail.org. Disable with
|
||||||
|
// BULWARK_UPDATE_CHECK=off or override the endpoint with
|
||||||
|
// BULWARK_UPDATE_CHECK_URL.
|
||||||
|
const { startScheduler } = await import("./lib/version-check");
|
||||||
|
await startScheduler();
|
||||||
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
|
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ describe('JMAPClient.sendEmail threading headers', () => {
|
|||||||
const create = setCall[1].create as Record<string, Record<string, unknown>>;
|
const create = setCall[1].create as Record<string, Record<string, unknown>>;
|
||||||
const draft = Object.values(create)[0];
|
const draft = Object.values(create)[0];
|
||||||
|
|
||||||
// Bare msg-ids per RFC 8621 — angle brackets stripped.
|
// Bare msg-ids per RFC 8621 - angle brackets stripped.
|
||||||
expect(draft.inReplyTo).toEqual(['parent@example.com']);
|
expect(draft.inReplyTo).toEqual(['parent@example.com']);
|
||||||
expect(draft.references).toEqual(['root@example.com', 'parent@example.com']);
|
expect(draft.references).toEqual(['root@example.com', 'parent@example.com']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ function parseEnvValue(value: string, type: string): unknown {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return value === 'true';
|
return value === 'true';
|
||||||
|
case 'json':
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
case 'string':
|
case 'string':
|
||||||
case 'url':
|
case 'url':
|
||||||
case 'enum':
|
case 'enum':
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ export function sanitizeFrameOrigins(input: unknown): string[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same syntax + validation as `sanitizeFrameOrigins`, but for the
|
||||||
|
* `httpOrigins` manifest field. Kept as a separate exported function so the
|
||||||
|
* intent is explicit at every call site (frame embedding vs. HTTP fetch).
|
||||||
|
*/
|
||||||
|
export const sanitizeHttpOrigins = sanitizeFrameOrigins;
|
||||||
|
export const isValidHttpOrigin = isValidFrameOrigin;
|
||||||
|
|
||||||
// In-memory cache. The proxy fires on every page navigation; reading the
|
// In-memory cache. The proxy fires on every page navigation; reading the
|
||||||
// registry JSON every time is fine but cheap to skip when nothing has
|
// registry JSON every time is fine but cheap to skip when nothing has
|
||||||
// changed. Five seconds is short enough to make plugin install/uninstall
|
// changed. Five seconds is short enough to make plugin install/uninstall
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* Multi-server JMAP support: schema, parsing, lookup, and redaction helpers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface JmapServerOAuthConfig {
|
||||||
|
clientId?: string;
|
||||||
|
issuerUrl?: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JmapServerEntry {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
domains?: string[];
|
||||||
|
oauth?: JmapServerOAuthConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicJmapServerEntry {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
domains: string[];
|
||||||
|
oauth?: {
|
||||||
|
clientId?: string;
|
||||||
|
issuerUrl?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
||||||
|
|
||||||
|
function trimUrl(url: string): string {
|
||||||
|
return url.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttpUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return u.protocol === 'https:' || u.protocol === 'http:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the raw config value (may be array, string JSON, or null). */
|
||||||
|
export function parseJmapServers(raw: unknown): JmapServerEntry[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
let value = raw;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (!value.trim()) return [];
|
||||||
|
try {
|
||||||
|
value = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: JmapServerEntry[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (!item || typeof item !== 'object') continue;
|
||||||
|
const e = item as Record<string, unknown>;
|
||||||
|
const id = typeof e.id === 'string' ? e.id.trim() : '';
|
||||||
|
const label = typeof e.label === 'string' ? e.label.trim() : '';
|
||||||
|
const url = typeof e.url === 'string' ? trimUrl(e.url) : '';
|
||||||
|
if (!id || !ID_RE.test(id) || seen.has(id)) continue;
|
||||||
|
if (!url || !isHttpUrl(url)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
const domains = Array.isArray(e.domains)
|
||||||
|
? e.domains
|
||||||
|
.filter((d): d is string => typeof d === 'string')
|
||||||
|
.map((d) => d.trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
let oauth: JmapServerOAuthConfig | undefined;
|
||||||
|
if (e.oauth && typeof e.oauth === 'object') {
|
||||||
|
const o = e.oauth as Record<string, unknown>;
|
||||||
|
const clientId = typeof o.clientId === 'string' ? o.clientId.trim() : '';
|
||||||
|
const issuerUrl = typeof o.issuerUrl === 'string' ? trimUrl(o.issuerUrl) : '';
|
||||||
|
const clientSecret = typeof o.clientSecret === 'string' ? o.clientSecret : '';
|
||||||
|
if (clientId || issuerUrl || clientSecret) {
|
||||||
|
oauth = {};
|
||||||
|
if (clientId) oauth.clientId = clientId;
|
||||||
|
if (issuerUrl && isHttpUrl(issuerUrl)) oauth.issuerUrl = issuerUrl;
|
||||||
|
if (clientSecret) oauth.clientSecret = clientSecret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
id,
|
||||||
|
label: label || id,
|
||||||
|
url,
|
||||||
|
...(domains.length > 0 ? { domains } : {}),
|
||||||
|
...(oauth ? { oauth } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip secrets for client-side exposure. */
|
||||||
|
export function redactJmapServers(servers: JmapServerEntry[]): PublicJmapServerEntry[] {
|
||||||
|
return servers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
label: s.label,
|
||||||
|
url: s.url,
|
||||||
|
domains: s.domains ?? [],
|
||||||
|
...(s.oauth && (s.oauth.clientId || s.oauth.issuerUrl)
|
||||||
|
? {
|
||||||
|
oauth: {
|
||||||
|
...(s.oauth.clientId ? { clientId: s.oauth.clientId } : {}),
|
||||||
|
...(s.oauth.issuerUrl ? { issuerUrl: s.oauth.issuerUrl } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findServerById(servers: JmapServerEntry[], id: string | null | undefined): JmapServerEntry | undefined {
|
||||||
|
if (!id) return undefined;
|
||||||
|
return servers.find((s) => s.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(trimUrl(url));
|
||||||
|
return `${u.protocol}//${u.host.toLowerCase()}${u.pathname.replace(/\/+$/, '')}`;
|
||||||
|
} catch {
|
||||||
|
return trimUrl(url).toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findServerByUrl(servers: JmapServerEntry[], url: string | null | undefined): JmapServerEntry | undefined {
|
||||||
|
if (!url) return undefined;
|
||||||
|
const target = normalizeUrl(url);
|
||||||
|
return servers.find((s) => normalizeUrl(s.url) === target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the server whose `domains` array matches the given email's domain (case-insensitive). */
|
||||||
|
export function findServerByEmailDomain(servers: JmapServerEntry[], email: string | null | undefined): JmapServerEntry | undefined {
|
||||||
|
if (!email || !email.includes('@')) return undefined;
|
||||||
|
const domain = email.split('@')[1]?.trim().toLowerCase();
|
||||||
|
if (!domain) return undefined;
|
||||||
|
return servers.find((s) => (s.domains ?? []).some((d) => d.toLowerCase() === domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a client-supplied JMAP URL to a trusted upstream URL by checking it
|
||||||
|
* against the configured server list and the global `jmapServerUrl`. Returns
|
||||||
|
* null when no match is found. Used by API routes that need to forward auth
|
||||||
|
* requests upstream without being tricked into hitting internal hosts.
|
||||||
|
*/
|
||||||
|
export function resolveTrustedJmapUrl(
|
||||||
|
requestedUrl: string | null | undefined,
|
||||||
|
globalServerUrl: string | null | undefined,
|
||||||
|
servers: JmapServerEntry[],
|
||||||
|
): string | null {
|
||||||
|
if (!requestedUrl) {
|
||||||
|
return globalServerUrl ? trimUrl(globalServerUrl) : null;
|
||||||
|
}
|
||||||
|
const target = normalizeUrl(requestedUrl);
|
||||||
|
if (globalServerUrl && normalizeUrl(globalServerUrl) === target) {
|
||||||
|
return trimUrl(globalServerUrl);
|
||||||
|
}
|
||||||
|
const matched = servers.find((s) => normalizeUrl(s.url) === target);
|
||||||
|
if (matched) return matched.url;
|
||||||
|
// No match - caller decides whether to honor the request anyway (e.g. when
|
||||||
|
// allowCustomJmapEndpoint is enabled).
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import type { ServerPlugin } from './plugin-registry';
|
||||||
|
import { sanitizeFrameOrigins, sanitizeHttpOrigins } from './csp-frame-origins';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev-mode plugin loading.
|
||||||
|
*
|
||||||
|
* Set PLUGIN_DEV_DIR to a directory whose immediate subfolders are plugin
|
||||||
|
* sources. Each subfolder must contain a `manifest.json`. The bundle file
|
||||||
|
* (declared as `entrypoint` in the manifest) is resolved in this order:
|
||||||
|
*
|
||||||
|
* 1. `src/<entrypoint>` → bundled on-demand via esbuild (preferred).
|
||||||
|
* Lets you edit source files directly and just refresh the browser.
|
||||||
|
* 2. `<entrypoint>` at the plugin root → served raw.
|
||||||
|
* 3. `dist/<entrypoint>` → served raw (output of a manual build).
|
||||||
|
*
|
||||||
|
* Bundles are recomputed on every request so any save in `src/` propagates
|
||||||
|
* to all connected clients on their next page refresh. The content hash
|
||||||
|
* doubles as the HTTP ETag and the `?v=` cache-buster.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DevPluginEntry {
|
||||||
|
plugin: ServerPlugin;
|
||||||
|
/** Absolute path to either a source file (needs bundling) or a built file. */
|
||||||
|
bundlePath: string;
|
||||||
|
manifestPath: string;
|
||||||
|
/** True when bundlePath points at an unbundled source file under `src/`. */
|
||||||
|
needsBundle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
||||||
|
|
||||||
|
export function getPluginDevDir(): string | null {
|
||||||
|
const dir = process.env.PLUGIN_DEV_DIR;
|
||||||
|
if (!dir) return null;
|
||||||
|
const resolved = path.resolve(dir);
|
||||||
|
if (!existsSync(resolved)) {
|
||||||
|
logger.warn(`PLUGIN_DEV_DIR is set but does not exist: ${resolved}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(v: unknown, fallback = ''): string {
|
||||||
|
return typeof v === 'string' ? v : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readManifest(manifestPath: string): Promise<Record<string, unknown> | null> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(manifestPath, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return typeof parsed === 'object' && parsed !== null ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedBundle {
|
||||||
|
bundlePath: string;
|
||||||
|
needsBundle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBundlePath(pluginDir: string, entrypoint: string): ResolvedBundle | null {
|
||||||
|
const srcCandidate = path.join(pluginDir, 'src', entrypoint);
|
||||||
|
if (existsSync(srcCandidate)) return { bundlePath: srcCandidate, needsBundle: true };
|
||||||
|
|
||||||
|
const rootCandidate = path.join(pluginDir, entrypoint);
|
||||||
|
if (existsSync(rootCandidate)) return { bundlePath: rootCandidate, needsBundle: false };
|
||||||
|
|
||||||
|
const distCandidate = path.join(pluginDir, 'dist', entrypoint);
|
||||||
|
if (existsSync(distCandidate)) return { bundlePath: distCandidate, needsBundle: false };
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and bundle a dev plugin's code. For `src/` sources this runs esbuild
|
||||||
|
* on every call so saves are reflected immediately. Errors are surfaced as
|
||||||
|
* a JS module that throws at activation time - that way the dev sees the
|
||||||
|
* failure in the browser console instead of a silent 404.
|
||||||
|
*/
|
||||||
|
export async function readDevBundle(entry: DevPluginEntry): Promise<string> {
|
||||||
|
if (!entry.needsBundle) {
|
||||||
|
return readFile(entry.bundlePath, 'utf-8');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const esbuild = await import('esbuild');
|
||||||
|
const result = await esbuild.build({
|
||||||
|
entryPoints: [entry.bundlePath],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
write: false,
|
||||||
|
logLevel: 'silent',
|
||||||
|
sourcemap: 'inline',
|
||||||
|
target: ['es2020'],
|
||||||
|
// React/ReactDOM are exposed on globalThis.__PLUGIN_EXTERNALS__ by the
|
||||||
|
// host, so we mark them external - the bundle won't try to ship them.
|
||||||
|
external: ['react', 'react-dom', 'react/jsx-runtime'],
|
||||||
|
});
|
||||||
|
const out = result.outputFiles?.[0]?.text;
|
||||||
|
if (!out) throw new Error('esbuild produced no output');
|
||||||
|
return out;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.warn(`[plugin-dev] esbuild failed for ${entry.plugin.id}`, { error: message });
|
||||||
|
// Return a module that throws on load so the dev sees the error.
|
||||||
|
return `throw new Error(${JSON.stringify(`[plugin-dev:${entry.plugin.id}] esbuild failed: ${message}`)});`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null> {
|
||||||
|
// Prefer the root manifest.json. Fall back to dist/manifest.json for
|
||||||
|
// pre-built plugins that don't keep a manifest at the root.
|
||||||
|
let manifestPath = path.join(pluginDir, 'manifest.json');
|
||||||
|
if (!existsSync(manifestPath)) {
|
||||||
|
manifestPath = path.join(pluginDir, 'dist', 'manifest.json');
|
||||||
|
}
|
||||||
|
if (!existsSync(manifestPath)) {
|
||||||
|
logger.warn(`[plugin-dev] no manifest.json at root or dist/ in ${pluginDir}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = await readManifest(manifestPath);
|
||||||
|
if (!manifest) {
|
||||||
|
logger.warn(`[plugin-dev] manifest unreadable or not a JSON object: ${manifestPath}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const id = asString(manifest.id);
|
||||||
|
if (!PLUGIN_ID_RE.test(id)) {
|
||||||
|
logger.warn(`[plugin-dev] manifest id "${id}" rejected by id regex (${manifestPath})`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entrypoint = asString(manifest.entrypoint, 'index.js');
|
||||||
|
const resolved = resolveBundlePath(pluginDir, entrypoint);
|
||||||
|
if (!resolved) {
|
||||||
|
logger.warn(`[plugin-dev] entrypoint "${entrypoint}" not found at src/, root, or dist/ for ${id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash from the on-disk source so any edit propagates. For src/ sources
|
||||||
|
// we hash the source - close enough for dev-time change detection (we
|
||||||
|
// don't need to re-hash transitive imports).
|
||||||
|
let bundleHash: string;
|
||||||
|
try {
|
||||||
|
const code = await readFile(resolved.bundlePath);
|
||||||
|
bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[plugin-dev] failed to read ${resolved.bundlePath} for ${id}`, {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let installedAt = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const stats = await stat(resolved.bundlePath);
|
||||||
|
installedAt = stats.mtime.toISOString();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = Array.isArray(manifest.permissions)
|
||||||
|
? manifest.permissions.filter((p): p is string => typeof p === 'string')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const frameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
|
const httpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||||
|
|
||||||
|
const plugin: ServerPlugin = {
|
||||||
|
id,
|
||||||
|
name: asString(manifest.name, id),
|
||||||
|
version: asString(manifest.version, '0.0.0-dev'),
|
||||||
|
author: asString(manifest.author),
|
||||||
|
description: asString(manifest.description),
|
||||||
|
type: asString(manifest.type, 'hook'),
|
||||||
|
permissions,
|
||||||
|
entrypoint,
|
||||||
|
enabled: true,
|
||||||
|
forceEnabled: false,
|
||||||
|
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||||
|
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||||
|
: {}),
|
||||||
|
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||||
|
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||||
|
: {}),
|
||||||
|
...(frameOrigins.length > 0 ? { frameOrigins } : {}),
|
||||||
|
...(httpOrigins.length > 0 ? { httpOrigins } : {}),
|
||||||
|
installedAt,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
bundleHash,
|
||||||
|
};
|
||||||
|
return { plugin, bundlePath: resolved.bundlePath, manifestPath, needsBundle: resolved.needsBundle };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDevPlugins(): Promise<DevPluginEntry[]> {
|
||||||
|
const dir = getPluginDevDir();
|
||||||
|
if (!dir) return [];
|
||||||
|
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = await readdir(dir);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to read PLUGIN_DEV_DIR', {
|
||||||
|
dir,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: DevPluginEntry[] = [];
|
||||||
|
for (const name of entries) {
|
||||||
|
if (name.startsWith('.') || name === 'node_modules') continue;
|
||||||
|
const fullPath = path.join(dir, name);
|
||||||
|
let isDir = false;
|
||||||
|
try { isDir = (await stat(fullPath)).isDirectory(); } catch { continue; }
|
||||||
|
if (!isDir) continue;
|
||||||
|
|
||||||
|
const entry = await loadDevPlugin(fullPath);
|
||||||
|
if (entry) out.push(entry);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDevPlugin(id: string): Promise<DevPluginEntry | null> {
|
||||||
|
if (!PLUGIN_ID_RE.test(id)) return null;
|
||||||
|
const list = await listDevPlugins();
|
||||||
|
return list.find(e => e.plugin.id === id) ?? null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
|
import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
@@ -27,6 +28,21 @@ export interface PluginConfigField {
|
|||||||
options?: { label: string; value: string }[];
|
options?: { label: string; value: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-user setting field, mirrors the manifest's `settingsSchema` shape
|
||||||
|
* (see lib/plugin-types.ts SettingFieldSchema). The server passes these
|
||||||
|
* through unchanged so the client can render the per-user settings UI.
|
||||||
|
*/
|
||||||
|
export interface PluginSettingsField {
|
||||||
|
type: 'boolean' | 'string' | 'number' | 'select';
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
default: unknown;
|
||||||
|
options?: string[];
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerPlugin {
|
export interface ServerPlugin {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -39,13 +55,25 @@ export interface ServerPlugin {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
forceEnabled?: boolean;
|
forceEnabled?: boolean;
|
||||||
configSchema?: Record<string, PluginConfigField>;
|
configSchema?: Record<string, PluginConfigField>;
|
||||||
|
settingsSchema?: Record<string, PluginSettingsField>;
|
||||||
installedAt: string;
|
installedAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
/**
|
||||||
|
* SHA-256 hex of the bundle code (first 16 chars). Refreshed every save so
|
||||||
|
* the same version re-uploaded with new code still appears as a change to
|
||||||
|
* the client. Also doubles as the HTTP ETag for the bundle endpoint.
|
||||||
|
*/
|
||||||
|
bundleHash?: string;
|
||||||
/**
|
/**
|
||||||
* Validated CSP origins (https-only, single-origin form) the plugin may
|
* Validated CSP origins (https-only, single-origin form) the plugin may
|
||||||
* embed. Merged into the host frame-src by the proxy.
|
* embed. Merged into the host frame-src by the proxy.
|
||||||
*/
|
*/
|
||||||
frameOrigins?: string[];
|
frameOrigins?: string[];
|
||||||
|
/**
|
||||||
|
* Validated HTTPS origins the plugin may target via `api.http.fetch()`.
|
||||||
|
* Same syntax as `frameOrigins`. Surfaced to clients via /api/plugins.
|
||||||
|
*/
|
||||||
|
httpOrigins?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerTheme {
|
export interface ServerTheme {
|
||||||
@@ -120,13 +148,24 @@ export async function savePlugin(
|
|||||||
const bundlePath = path.join(dir, `${plugin.id}.js`);
|
const bundlePath = path.join(dir, `${plugin.id}.js`);
|
||||||
await writeFile(bundlePath, code, 'utf-8');
|
await writeFile(bundlePath, code, 'utf-8');
|
||||||
|
|
||||||
// Update registry
|
// Stamp content hash + updatedAt so clients can detect re-uploads even
|
||||||
|
// when the manifest version hasn't changed. Preserve the original
|
||||||
|
// installedAt across re-uploads.
|
||||||
|
const bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
const registry = await getPluginRegistry();
|
const registry = await getPluginRegistry();
|
||||||
const idx = registry.plugins.findIndex(p => p.id === plugin.id);
|
const idx = registry.plugins.findIndex(p => p.id === plugin.id);
|
||||||
|
const next: ServerPlugin = {
|
||||||
|
...plugin,
|
||||||
|
bundleHash,
|
||||||
|
updatedAt: now,
|
||||||
|
installedAt: idx >= 0 ? registry.plugins[idx].installedAt : plugin.installedAt,
|
||||||
|
};
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
registry.plugins[idx] = plugin;
|
registry.plugins[idx] = next;
|
||||||
} else {
|
} else {
|
||||||
registry.plugins.push(plugin);
|
registry.plugins.push(next);
|
||||||
}
|
}
|
||||||
await writeJsonFile(pluginRegistryPath(), registry);
|
await writeJsonFile(pluginRegistryPath(), registry);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -108,7 +108,7 @@ export interface AuditEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Config keys that map to environment variables */
|
/** Config keys that map to environment variables */
|
||||||
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
|
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum' | 'json'; defaultValue: unknown; enumValues?: string[] }> = {
|
||||||
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
|
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
|
||||||
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
||||||
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
|
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
|
||||||
@@ -129,6 +129,8 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
|||||||
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
|
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||||
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
||||||
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
||||||
|
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
|
||||||
|
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
|
||||||
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
||||||
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
||||||
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
|
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
|
||||||
|
|||||||
@@ -8,17 +8,27 @@ export function replaceWindowLocation(url: string): void {
|
|||||||
window.location.replace(url);
|
window.location.replace(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build-time constant injected by next.config.ts. When the app is built with
|
||||||
|
// NEXT_PUBLIC_BASE_PATH=/webmail, Next.js itself prefixes routes and assets;
|
||||||
|
// helpers below use the same value so client code stays consistent.
|
||||||
|
const STATIC_BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the mount prefix from the current URL.
|
* Returns the mount prefix the app is served at.
|
||||||
* When the app is served behind a reverse proxy at e.g. /bulwark,
|
|
||||||
* the browser sees /bulwark/en/login while Next.js sees /en/login.
|
|
||||||
*
|
*
|
||||||
* If a locale is supplied (e.g. from route params) it is used directly;
|
* Resolution order:
|
||||||
* otherwise the first path segment that matches a known locale is used.
|
* 1. The build-time `NEXT_PUBLIC_BASE_PATH` constant (set in next.config.ts).
|
||||||
|
* 2. Runtime detection from `window.location.pathname` for legacy deploys
|
||||||
|
* where the reverse proxy mounts the app at a subpath without rebuilding.
|
||||||
|
*
|
||||||
|
* If a locale is supplied (e.g. from route params) it anchors the runtime
|
||||||
|
* detection; otherwise the first path segment that matches a known locale is
|
||||||
|
* used.
|
||||||
*
|
*
|
||||||
* Returns '' when there is no prefix.
|
* Returns '' when there is no prefix.
|
||||||
*/
|
*/
|
||||||
export function getPathPrefix(locale?: string): string {
|
export function getPathPrefix(locale?: string): string {
|
||||||
|
if (STATIC_BASE_PATH) return STATIC_BASE_PATH;
|
||||||
if (typeof window === 'undefined') return '';
|
if (typeof window === 'undefined') return '';
|
||||||
|
|
||||||
const segments = window.location.pathname.split('/').filter(Boolean);
|
const segments = window.location.pathname.split('/').filter(Boolean);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* In-Reply-To = parent.Message-ID
|
* In-Reply-To = parent.Message-ID
|
||||||
* References = parent.References (if any) + parent.Message-ID
|
* References = parent.References (if any) + parent.Message-ID
|
||||||
*
|
*
|
||||||
* Bare msg-ids only — angle brackets are stripped because JMAP RFC 8621
|
* Bare msg-ids only - angle brackets are stripped because JMAP RFC 8621
|
||||||
* §4.1.2.3 stores Message-IDs without them.
|
* §4.1.2.3 stores Message-IDs without them.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
+32
-5
@@ -300,6 +300,16 @@ function stripMessageIdBrackets(id: string): string {
|
|||||||
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
|
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Some servers (notably Stalwart) return Identity.name in RFC 5322 mailbox
|
||||||
|
// form: `Display Name <addr@example.com>`. Re-emitting that as the JMAP
|
||||||
|
// from.name field produces a doubled From header (`"Name <addr>" <addr>`)
|
||||||
|
// whose display-name is invalid per RFC 5322 §3.4 and gets rejected by the
|
||||||
|
// submission validator - the email then sits forever in Drafts.
|
||||||
|
function sanitizeIdentityDisplayName(name: string | undefined | null): string {
|
||||||
|
if (!name) return '';
|
||||||
|
return name.replace(/\s*<[^>]*>\s*$/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
export class JMAPClient implements IJMAPClient {
|
export class JMAPClient implements IJMAPClient {
|
||||||
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
|
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
|
||||||
|
|
||||||
@@ -1754,7 +1764,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (response.methodResponses?.[0]?.[0] === "Identity/get") {
|
if (response.methodResponses?.[0]?.[0] === "Identity/get") {
|
||||||
return (response.methodResponses[0][1].list || []) as Identity[];
|
const list = (response.methodResponses[0][1].list || []) as Identity[];
|
||||||
|
return list.map((id) => ({ ...id, name: sanitizeIdentityDisplayName(id.name) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
@@ -1964,8 +1975,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[];
|
attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sanitizedFromName = sanitizeIdentityDisplayName(fromName);
|
||||||
const emailData: EmailDraft = {
|
const emailData: EmailDraft = {
|
||||||
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
|
from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }],
|
||||||
to: to.map(email => ({ email })),
|
to: to.map(email => ({ email })),
|
||||||
cc: cc?.map(email => ({ email })),
|
cc: cc?.map(email => ({ email })),
|
||||||
bcc: bcc?.map(email => ({ email })),
|
bcc: bcc?.map(email => ({ email })),
|
||||||
@@ -2087,9 +2099,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const normalizedInReplyTo = inReplyTo?.map(stripMessageIdBrackets).filter(Boolean);
|
const normalizedInReplyTo = inReplyTo?.map(stripMessageIdBrackets).filter(Boolean);
|
||||||
const normalizedReferences = references?.map(stripMessageIdBrackets).filter(Boolean);
|
const normalizedReferences = references?.map(stripMessageIdBrackets).filter(Boolean);
|
||||||
|
|
||||||
|
const sanitizedFromName = sanitizeIdentityDisplayName(fromName);
|
||||||
// Always create a new email with the final body content
|
// Always create a new email with the final body content
|
||||||
const emailCreate: Record<string, unknown> = {
|
const emailCreate: Record<string, unknown> = {
|
||||||
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
|
from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }],
|
||||||
replyTo: identityReplyTo?.length ? identityReplyTo : undefined,
|
replyTo: identityReplyTo?.length ? identityReplyTo : undefined,
|
||||||
to: to.map(email => ({ email })),
|
to: to.map(email => ({ email })),
|
||||||
cc: cc?.map(email => ({ email })),
|
cc: cc?.map(email => ({ email })),
|
||||||
@@ -3079,11 +3092,19 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private contactUsing(): string[] {
|
private contactUsing(): string[] {
|
||||||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"];
|
const using = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"];
|
||||||
|
if (this.hasCapability("urn:ietf:params:jmap:principals")) {
|
||||||
|
using.push("urn:ietf:params:jmap:principals:owner");
|
||||||
|
}
|
||||||
|
return using;
|
||||||
}
|
}
|
||||||
|
|
||||||
private calendarUsing(): string[] {
|
private calendarUsing(): string[] {
|
||||||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"];
|
const using = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"];
|
||||||
|
if (this.hasCapability("urn:ietf:params:jmap:principals")) {
|
||||||
|
using.push("urn:ietf:params:jmap:principals:owner");
|
||||||
|
}
|
||||||
|
return using;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCalendarCapableAccountIds(): string[] {
|
private getCalendarCapableAccountIds(): string[] {
|
||||||
@@ -3293,6 +3314,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const err = result.notUpdated[calendarId];
|
const err = result.notUpdated[calendarId];
|
||||||
throw new Error(err.description || "Failed to update calendar share");
|
throw new Error(err.description || "Failed to update calendar share");
|
||||||
}
|
}
|
||||||
|
if (!result?.updated || !(calendarId in result.updated)) {
|
||||||
|
throw new Error("Server did not confirm the share update");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3318,6 +3342,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const err = result.notUpdated[addressBookId];
|
const err = result.notUpdated[addressBookId];
|
||||||
throw new Error(err.description || "Failed to update address book share");
|
throw new Error(err.description || "Failed to update address book share");
|
||||||
}
|
}
|
||||||
|
if (!result?.updated || !(addressBookId in result.updated)) {
|
||||||
|
throw new Error("Server did not confirm the share update");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchPaginatedContacts(
|
private async fetchPaginatedContacts(
|
||||||
|
|||||||
+36
-15
@@ -3,17 +3,31 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
|
|||||||
import type { OAuthMetadata } from '@/lib/oauth/discovery';
|
import type { OAuthMetadata } from '@/lib/oauth/discovery';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
function getClientSecret(): string {
|
function getGlobalClientSecret(): string {
|
||||||
const adminSecret = configManager.get<string>('oauthClientSecret', '');
|
const adminSecret = configManager.get<string>('oauthClientSecret', '');
|
||||||
if (adminSecret) return adminSecret;
|
if (adminSecret) return adminSecret;
|
||||||
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
|
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRequiredConfig() {
|
function getServerEntry(serverId?: string | null) {
|
||||||
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
if (!serverId) return undefined;
|
||||||
const serverUrl = configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
const servers = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||||
const issuerUrl = configManager.get<string>('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL;
|
return findServerById(servers, serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequiredConfig(serverId?: string | null) {
|
||||||
|
const entry = getServerEntry(serverId);
|
||||||
|
|
||||||
|
const globalClientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
||||||
|
const globalServerUrl = configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
||||||
|
const globalIssuerUrl = configManager.get<string>('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL;
|
||||||
|
|
||||||
|
const clientId = entry?.oauth?.clientId || globalClientId;
|
||||||
|
const serverUrl = entry?.url || globalServerUrl;
|
||||||
|
const issuerUrl = entry?.oauth?.issuerUrl || globalIssuerUrl;
|
||||||
|
|
||||||
if (!clientId || !serverUrl) {
|
if (!clientId || !serverUrl) {
|
||||||
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
|
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
|
||||||
}
|
}
|
||||||
@@ -21,11 +35,17 @@ export function getRequiredConfig() {
|
|||||||
if (issuerUrl !== undefined && issuerUrl !== '' && !issuerUrl.trim()) {
|
if (issuerUrl !== undefined && issuerUrl !== '' && !issuerUrl.trim()) {
|
||||||
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
|
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
|
||||||
}
|
}
|
||||||
return { clientId, serverUrl, discoveryUrl };
|
return { clientId, serverUrl, discoveryUrl, serverId: entry?.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTokenEndpoint(): Promise<string> {
|
function getClientSecret(serverId?: string | null): string {
|
||||||
const { discoveryUrl } = getRequiredConfig();
|
const entry = getServerEntry(serverId);
|
||||||
|
if (entry?.oauth?.clientSecret) return entry.oauth.clientSecret;
|
||||||
|
return getGlobalClientSecret();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTokenEndpoint(serverId?: string | null): Promise<string> {
|
||||||
|
const { discoveryUrl } = getRequiredConfig(serverId);
|
||||||
const metadata = await discoverOAuth(discoveryUrl);
|
const metadata = await discoverOAuth(discoveryUrl);
|
||||||
if (!metadata?.token_endpoint) {
|
if (!metadata?.token_endpoint) {
|
||||||
throw new Error('OAuth token endpoint not found');
|
throw new Error('OAuth token endpoint not found');
|
||||||
@@ -33,15 +53,15 @@ export async function getTokenEndpoint(): Promise<string> {
|
|||||||
return metadata.token_endpoint;
|
return metadata.token_endpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMetadata(): Promise<OAuthMetadata | null> {
|
export async function getMetadata(serverId?: string | null): Promise<OAuthMetadata | null> {
|
||||||
const { discoveryUrl } = getRequiredConfig();
|
const { discoveryUrl } = getRequiredConfig(serverId);
|
||||||
return discoverOAuth(discoveryUrl);
|
return discoverOAuth(discoveryUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildOAuthParams(base: Record<string, string>): URLSearchParams {
|
export function buildOAuthParams(base: Record<string, string>, serverId?: string | null): URLSearchParams {
|
||||||
const { clientId } = getRequiredConfig();
|
const { clientId } = getRequiredConfig(serverId);
|
||||||
const params = new URLSearchParams({ ...base, client_id: clientId });
|
const params = new URLSearchParams({ ...base, client_id: clientId });
|
||||||
const secret = getClientSecret();
|
const secret = getClientSecret(serverId);
|
||||||
if (secret) {
|
if (secret) {
|
||||||
params.set('client_secret', secret);
|
params.set('client_secret', secret);
|
||||||
}
|
}
|
||||||
@@ -58,15 +78,16 @@ export async function exchangeCodeForTokens(
|
|||||||
code: string,
|
code: string,
|
||||||
codeVerifier: string,
|
codeVerifier: string,
|
||||||
redirectUri: string,
|
redirectUri: string,
|
||||||
|
serverId?: string | null,
|
||||||
): Promise<TokenResult> {
|
): Promise<TokenResult> {
|
||||||
const tokenEndpoint = await getTokenEndpoint();
|
const tokenEndpoint = await getTokenEndpoint(serverId);
|
||||||
|
|
||||||
const params = buildOAuthParams({
|
const params = buildOAuthParams({
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code,
|
code,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
code_verifier: codeVerifier,
|
code_verifier: codeVerifier,
|
||||||
});
|
}, serverId);
|
||||||
|
|
||||||
const tokenResponse = await fetch(tokenEndpoint, {
|
const tokenResponse = await fetch(tokenEndpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user