feat: show admin panel in sidebar for Stalwart admin users
This commit is contained in:
+55
-1
@@ -14,6 +14,11 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
|
Mail,
|
||||||
|
Calendar,
|
||||||
|
BookUser,
|
||||||
|
HardDrive,
|
||||||
|
ArrowLeft,
|
||||||
} 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';
|
||||||
@@ -139,7 +144,56 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex bg-background">
|
<div className="min-h-screen flex bg-background">
|
||||||
{/* Sidebar */}
|
{/* Slim webmail nav rail */}
|
||||||
|
<nav className="w-14 bg-secondary flex flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
|
||||||
|
{logoUrl ? (
|
||||||
|
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
||||||
|
) : (
|
||||||
|
<div className="w-7 h-7 mb-2" />
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title="Mail"
|
||||||
|
>
|
||||||
|
<Mail className="w-[18px] h-[18px]" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/calendar"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title="Calendar"
|
||||||
|
>
|
||||||
|
<Calendar className="w-[18px] h-[18px]" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/contacts"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title="Contacts"
|
||||||
|
>
|
||||||
|
<BookUser className="w-[18px] h-[18px]" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/files"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title="Files"
|
||||||
|
>
|
||||||
|
<HardDrive className="w-[18px] h-[18px]" />
|
||||||
|
</a>
|
||||||
|
<div className="mt-auto flex flex-col items-center gap-2">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
|
||||||
|
<Shield className="w-[18px] h-[18px]" />
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/settings"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<Settings className="w-[18px] h-[18px]" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Admin Sidebar */}
|
||||||
<aside className="w-60 border-r border-border bg-secondary flex flex-col sticky top-0 h-screen">
|
<aside className="w-60 border-r border-border bg-secondary flex flex-col sticky top-0 h-screen">
|
||||||
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
|
|||||||
+11
-11
@@ -7,24 +7,24 @@ import { logger } from '@/lib/logger';
|
|||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the current user is a Stalwart admin via principal roles.
|
* Check if the current user is a Stalwart admin by probing an admin-only endpoint.
|
||||||
*/
|
*/
|
||||||
async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
|
async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const creds = await getStalwartCredentials(request);
|
const creds = await getStalwartCredentials(request);
|
||||||
if (!creds) return false;
|
if (!creds) return false;
|
||||||
|
|
||||||
const response = await fetch(
|
// Probe admin-only endpoint: listing principals requires admin privileges
|
||||||
`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`,
|
const response = await fetch(`${creds.apiUrl}/api/principal?limit=1`, {
|
||||||
{ method: 'GET', headers: { 'Authorization': creds.authHeader } }
|
method: 'GET',
|
||||||
);
|
headers: { 'Authorization': creds.authHeader },
|
||||||
if (!response.ok) return false;
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const isAdmin = response.ok;
|
||||||
const principal = data.data ?? data;
|
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin });
|
||||||
const roles: string[] = Array.isArray(principal?.roles) ? principal.roles : [];
|
return isAdmin;
|
||||||
return roles.includes('admin');
|
} catch (error) {
|
||||||
} catch {
|
logger.debug('Stalwart admin check error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/stalwart-check
|
* GET /api/admin/stalwart-check
|
||||||
* Check if the currently logged-in user has the 'admin' role in Stalwart.
|
* Check if the currently logged-in user is a Stalwart admin.
|
||||||
* Uses the user's JMAP session credentials.
|
* Probes the admin-only principal-list endpoint — if the user can access it, they're an admin.
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -16,24 +16,16 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
// Probe an admin-only endpoint: listing principals requires admin privileges.
|
||||||
`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`,
|
// Use limit=1 to minimize payload.
|
||||||
{
|
const url = `${creds.apiUrl}/api/principal?limit=1`;
|
||||||
method: 'GET',
|
const response = await fetch(url, {
|
||||||
headers: { 'Authorization': creds.authHeader },
|
method: 'GET',
|
||||||
}
|
headers: { 'Authorization': creds.authHeader },
|
||||||
);
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
const isStalwartAdmin = response.ok;
|
||||||
return NextResponse.json({ isStalwartAdmin: false }, {
|
logger.info('Stalwart admin check', { username: creds.username, status: response.status, isStalwartAdmin });
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const principal = data.data ?? data;
|
|
||||||
const roles: string[] = Array.isArray(principal?.roles) ? principal.roles : [];
|
|
||||||
const isStalwartAdmin = roles.includes('admin');
|
|
||||||
|
|
||||||
return NextResponse.json({ isStalwartAdmin }, {
|
return NextResponse.json({ isStalwartAdmin }, {
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
|||||||
@@ -2,18 +2,20 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus } from "lucide-react";
|
import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield } from "lucide-react";
|
||||||
import { AccountSwitcher } from "./account-switcher";
|
import { AccountSwitcher } from "./account-switcher";
|
||||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { usePathname, Link } from "@/i18n/navigation";
|
import { usePathname, Link } from "@/i18n/navigation";
|
||||||
|
import NextLink from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { cn, formatFileSize } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
|
|
||||||
@@ -165,6 +167,33 @@ export function NavigationRail({
|
|||||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
||||||
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const { client } = useAuthStore.getState();
|
||||||
|
if (!client) return;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Authorization': client.getAuthHeader(),
|
||||||
|
'X-JMAP-Server-URL': client.getServerUrl(),
|
||||||
|
'X-JMAP-Username': client.getUsername(),
|
||||||
|
};
|
||||||
|
fetch('/api/admin/stalwart-check', { headers })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!cancelled && data.isStalwartAdmin) {
|
||||||
|
setIsStalwartAdmin(true);
|
||||||
|
// Pre-create admin session so /admin works even after full page navigation
|
||||||
|
fetch('/api/admin/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...headers },
|
||||||
|
body: JSON.stringify({ stalwartAuth: true }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
||||||
@@ -260,6 +289,21 @@ export function NavigationRail({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Admin (Stalwart admins) */}
|
||||||
|
{isStalwartAdmin && (
|
||||||
|
<NextLink
|
||||||
|
href="/admin"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
|
||||||
|
"transition-colors duration-150",
|
||||||
|
"text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Shield className="w-5 h-5" />
|
||||||
|
<span className="text-[10px] font-medium leading-tight">{t("admin") || "Admin"}</span>
|
||||||
|
</NextLink>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
@@ -420,8 +464,18 @@ export function NavigationRail({
|
|||||||
|
|
||||||
<PluginSlot name="navigation-rail-bottom" />
|
<PluginSlot name="navigation-rail-bottom" />
|
||||||
|
|
||||||
{/* Footer: Settings + Help + Storage Quota + Sign Out + Push Status */}
|
{/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
|
||||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
||||||
|
{isStalwartAdmin && (
|
||||||
|
<NextLink
|
||||||
|
href="/admin"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
title={t("admin") || "Admin"}
|
||||||
|
>
|
||||||
|
<Shield className="w-[18px] h-[18px]" />
|
||||||
|
</NextLink>
|
||||||
|
)}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ export interface StalwartCredentials {
|
|||||||
* (e.g. `https://admin.example.com`).
|
* (e.g. `https://admin.example.com`).
|
||||||
*/
|
*/
|
||||||
function getStalwartApiUrl(jmapServerUrl: string): string {
|
function getStalwartApiUrl(jmapServerUrl: string): string {
|
||||||
return process.env.STALWART_API_URL || jmapServerUrl;
|
const url = process.env.STALWART_API_URL || jmapServerUrl;
|
||||||
|
return url.replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user