feat: add update-available detection
This commit is contained in:
@@ -16,6 +16,7 @@ import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor,
|
||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||
@@ -28,7 +29,13 @@ const THEME_OPTIONS = [
|
||||
|
||||
function VersionBadge() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`;
|
||||
const banner = useUpdateStore(useShallow(selectBanner));
|
||||
const dismiss = useUpdateStore((s) => s.dismiss);
|
||||
const startPolling = useUpdateStore((s) => s.startPolling);
|
||||
|
||||
useEffect(() => { startPolling(); }, [startPolling]);
|
||||
|
||||
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}${banner?.latest ? `\nLatest: ${banner.latest}` : ""}`;
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(versionInfo).then(() => {
|
||||
@@ -37,16 +44,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 (
|
||||
<div className="relative inline-flex justify-center">
|
||||
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
{trigger}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
|
||||
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
|
||||
{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>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
@@ -55,6 +95,15 @@ function VersionBadge() {
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{banner?.dismissible && (
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
aria-label="Dismiss update notice"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+27
-4
@@ -15,6 +15,7 @@ import {
|
||||
Puzzle,
|
||||
SwatchBook,
|
||||
Activity,
|
||||
Package,
|
||||
Mail,
|
||||
Calendar,
|
||||
BookUser,
|
||||
@@ -30,6 +31,7 @@ import { useThemeStore } from '@/stores/theme-store';
|
||||
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';
|
||||
|
||||
const NAV_GROUPS = [
|
||||
@@ -59,6 +61,7 @@ const NAV_GROUPS = [
|
||||
{
|
||||
label: 'System',
|
||||
items: [
|
||||
{ href: '/admin/version', label: 'Version', icon: Package },
|
||||
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
|
||||
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
||||
],
|
||||
@@ -78,6 +81,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||
: (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]);
|
||||
@@ -170,6 +180,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
</div>
|
||||
{group.items.map(({ href, label, icon: Icon }) => {
|
||||
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
||||
const showDot = href === '/admin/version' && hasUpdate;
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
@@ -181,10 +192,22 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(
|
||||
'w-4 h-4 shrink-0',
|
||||
active ? 'text-accent-foreground' : 'text-muted-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}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -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 default function AdminVersionPage() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { loadState } from '@/lib/version-check';
|
||||
|
||||
// Public endpoint that returns the latest cached update status. Fed by the
|
||||
// background scheduler started in instrumentation.node.ts; we never trigger
|
||||
// a fresh upstream fetch from this route, so an unauthenticated client cannot
|
||||
// use it to amplify traffic to the version server.
|
||||
export async function GET() {
|
||||
const state = await loadState();
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: state.status,
|
||||
lastCheckedAt: state.lastCheckedAt,
|
||||
lastSuccessAt: state.lastSuccessAt,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
|
||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
@@ -175,6 +176,11 @@ export function NavigationRail({
|
||||
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||
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
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
@@ -345,7 +351,18 @@ export function NavigationRail({
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Shield className="w-5 h-5" />
|
||||
<span className="relative">
|
||||
<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>
|
||||
</a>
|
||||
)}
|
||||
@@ -515,10 +532,19 @@ export function NavigationRail({
|
||||
{isStalwartAdmin && (
|
||||
<a
|
||||
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"}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,18 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useUpdateStore } from '@/stores/update-store';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SpamSiegeGame } from './spam-siege-game';
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
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() {
|
||||
const t = useTranslations('settings.advanced');
|
||||
const tCommon = useTranslations('common');
|
||||
@@ -106,6 +139,7 @@ export function AboutDataSettings() {
|
||||
</p>
|
||||
<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>
|
||||
<VersionUpdateTag />
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Single-line, low-key update notice. Lives next to the version badge on the
|
||||
// login screen — deliberately understated so it doesn't distract from the
|
||||
// auth flow. Red variants (security / deprecated) still use red text but
|
||||
// stay the same compact shape.
|
||||
export function UpdateNotice({ className }: { className?: string }) {
|
||||
const banner = useUpdateStore(useShallow(selectBanner));
|
||||
const dismiss = useUpdateStore((s) => s.dismiss);
|
||||
const startPolling = useUpdateStore((s) => s.startPolling);
|
||||
|
||||
useEffect(() => {
|
||||
startPolling();
|
||||
}, [startPolling]);
|
||||
|
||||
if (!banner) return null;
|
||||
|
||||
const isRed = banner.variant === "red";
|
||||
|
||||
const text =
|
||||
banner.severity === "security"
|
||||
? `Security update${banner.latest ? `: ${banner.latest}` : ""}`
|
||||
: banner.severity === "deprecated"
|
||||
? "Version no longer supported"
|
||||
: `Update available: ${banner.latest ?? ""}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-[11px] leading-none",
|
||||
isRed
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-muted-foreground/60 hover:text-muted-foreground transition-colors",
|
||||
className,
|
||||
)}
|
||||
role={isRed ? "alert" : "status"}
|
||||
>
|
||||
{isRed && <AlertTriangle className="w-3 h-3 flex-shrink-0" />}
|
||||
{banner.url ? (
|
||||
<a
|
||||
href={banner.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline-offset-2 hover:underline"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
) : (
|
||||
<span>{text}</span>
|
||||
)}
|
||||
{banner.dismissible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="opacity-50 hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+7
-37
@@ -2,49 +2,12 @@ import { readFileSync } from "fs";
|
||||
import { configManager } from "./lib/admin/config-manager";
|
||||
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(
|
||||
readFileSync(`${process.cwd()}/package.json`, "utf-8")
|
||||
);
|
||||
const current: string = pkg.version ?? "0.0.0";
|
||||
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
|
||||
configManager.load()
|
||||
.then(() => initAdminPassword())
|
||||
@@ -59,6 +22,13 @@ configManager.load()
|
||||
markProcessStart();
|
||||
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) => {
|
||||
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { UpdateStatus, UpdateSeverity } from './types';
|
||||
|
||||
const SEVERITIES: ReadonlySet<UpdateSeverity> = new Set([
|
||||
'normal', 'security', 'deprecated', 'none', 'unknown',
|
||||
]);
|
||||
|
||||
function isString(v: unknown): v is string {
|
||||
return typeof v === 'string';
|
||||
}
|
||||
|
||||
function isNullableString(v: unknown): v is string | null {
|
||||
return v === null || typeof v === 'string';
|
||||
}
|
||||
|
||||
// Validate the response from the version server before we trust it. Returns
|
||||
// null on any malformed field so a hostile or buggy upstream can't poison the
|
||||
// UI with arbitrary strings (the URL, in particular, is rendered in <a href>).
|
||||
export function parseStatus(raw: unknown): UpdateStatus | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
if (r.schema !== 1) return null;
|
||||
if (!isString(r.current)) return null;
|
||||
if (!isNullableString(r.latest)) return null;
|
||||
if (typeof r.updateAvailable !== 'boolean') return null;
|
||||
if (!isString(r.severity) || !SEVERITIES.has(r.severity as UpdateSeverity)) return null;
|
||||
if (!isNullableString(r.url)) return null;
|
||||
if (!isNullableString(r.advisory)) return null;
|
||||
if (!isString(r.checkedAt)) return null;
|
||||
|
||||
// Only http(s) URLs are renderable; reject anything else so we don't end
|
||||
// up with a javascript: link in the banner.
|
||||
if (r.url && !/^https?:\/\//i.test(r.url)) return null;
|
||||
|
||||
return {
|
||||
schema: 1,
|
||||
current: r.current,
|
||||
latest: r.latest,
|
||||
updateAvailable: r.updateAvailable,
|
||||
severity: r.severity as UpdateSeverity,
|
||||
url: r.url,
|
||||
advisory: r.advisory,
|
||||
checkedAt: r.checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchStatus(
|
||||
endpoint: string,
|
||||
currentVersion: string,
|
||||
): Promise<{ ok: true; status: UpdateStatus } | { ok: false; error: string }> {
|
||||
if (!endpoint) return { ok: false, error: 'endpoint blank' };
|
||||
if (!currentVersion) return { ok: false, error: 'current version blank' };
|
||||
|
||||
// Build the URL safely — never inject the version as a raw path component.
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch {
|
||||
return { ok: false, error: 'endpoint not a URL' };
|
||||
}
|
||||
url.searchParams.set('v', currentVersion);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: `HTTP ${res.status}` };
|
||||
}
|
||||
const body: unknown = await res.json();
|
||||
const parsed = parseStatus(body);
|
||||
if (!parsed) return { ok: false, error: 'malformed response' };
|
||||
return { ok: true, status: parsed };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.warn('version-check: fetch failed', { error: msg });
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { startScheduler, stopScheduler, checkOnce } from './sender';
|
||||
export { loadState, saveState, effectiveEndpoint, disabledByEnv } from './state';
|
||||
export { fetchStatus, parseStatus } from './fetcher';
|
||||
export type {
|
||||
UpdateStatus, UpdateSeverity, VersionCheckStateFile,
|
||||
} from './types';
|
||||
export { DEFAULT_VERSION_ENDPOINT } from './types';
|
||||
@@ -0,0 +1,100 @@
|
||||
import { logger } from '@/lib/logger';
|
||||
import { disabledByEnv, effectiveEndpoint, loadState, saveState } from './state';
|
||||
import { fetchStatus } from './fetcher';
|
||||
import type { UpdateStatus } from './types';
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const JITTER_MS = 5 * 60 * 1000; // ± 5 min, keeps containers from syncing
|
||||
const FIRST_DELAY_MS = 30 * 1000; // first check ~30s after boot
|
||||
const FAILURE_BACKOFF_MS = 15 * 60 * 1000; // after a failed fetch, retry in 15 min
|
||||
|
||||
let currentTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function jitteredDelay(base: number): number {
|
||||
const j = (Math.random() * 2 - 1) * JITTER_MS;
|
||||
return Math.max(60_000, base + j);
|
||||
}
|
||||
|
||||
function getCurrentVersion(): string {
|
||||
return (process.env.NEXT_PUBLIC_APP_VERSION || '').trim();
|
||||
}
|
||||
|
||||
export async function checkOnce(opts?: { reason?: string }): Promise<{
|
||||
ok: boolean;
|
||||
status?: UpdateStatus;
|
||||
error?: string;
|
||||
}> {
|
||||
if (disabledByEnv()) return { ok: false, error: 'disabled by env' };
|
||||
|
||||
const state = await loadState();
|
||||
const endpoint = effectiveEndpoint(state);
|
||||
if (!endpoint) return { ok: false, error: 'endpoint blank' };
|
||||
|
||||
const current = getCurrentVersion();
|
||||
if (!current) return { ok: false, error: 'current version unset' };
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const result = await fetchStatus(endpoint, current);
|
||||
|
||||
const next = await loadState();
|
||||
next.lastCheckedAt = now;
|
||||
if (result.ok) {
|
||||
next.lastSuccessAt = now;
|
||||
next.status = result.status;
|
||||
}
|
||||
await saveState(next);
|
||||
|
||||
logger.info('version-check: ran', {
|
||||
ok: result.ok,
|
||||
severity: result.ok ? result.status.severity : null,
|
||||
reason: opts?.reason ?? 'scheduled',
|
||||
});
|
||||
|
||||
if (result.ok) return { ok: true, status: result.status };
|
||||
return { ok: false, error: result.error };
|
||||
}
|
||||
|
||||
async function scheduleNext(delayMs: number): Promise<void> {
|
||||
if (currentTimer) clearTimeout(currentTimer);
|
||||
const at = new Date(Date.now() + delayMs).toISOString();
|
||||
const state = await loadState();
|
||||
state.nextScheduledAt = at;
|
||||
await saveState(state);
|
||||
currentTimer = setTimeout(() => { void tick(); }, delayMs);
|
||||
// Don't keep the process alive just for this.
|
||||
currentTimer.unref?.();
|
||||
}
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
const result = await checkOnce({ reason: 'scheduled' });
|
||||
const delay = result.ok ? jitteredDelay(HOUR_MS) : FAILURE_BACKOFF_MS;
|
||||
await scheduleNext(delay);
|
||||
}
|
||||
|
||||
// Idempotent — safe to call from instrumentation hot-reload in dev.
|
||||
export async function startScheduler(): Promise<void> {
|
||||
if (disabledByEnv()) {
|
||||
logger.info('version-check: scheduler not started (disabled by env)');
|
||||
return;
|
||||
}
|
||||
const state = await loadState();
|
||||
if (!effectiveEndpoint(state)) {
|
||||
logger.info('version-check: scheduler not started (no endpoint)');
|
||||
return;
|
||||
}
|
||||
// If a previous schedule was still in the future, honor it (don't blast on
|
||||
// every restart). Cap at one hour so a wildly-in-the-future timestamp can't
|
||||
// permanently silence the check.
|
||||
let delay = FIRST_DELAY_MS;
|
||||
if (state.nextScheduledAt) {
|
||||
const remaining = new Date(state.nextScheduledAt).getTime() - Date.now();
|
||||
if (remaining > 0) delay = Math.min(remaining, HOUR_MS + JITTER_MS);
|
||||
}
|
||||
await scheduleNext(delay);
|
||||
logger.info('version-check: scheduler started', { nextInMs: delay });
|
||||
}
|
||||
|
||||
export async function stopScheduler(): Promise<void> {
|
||||
if (currentTimer) clearTimeout(currentTimer);
|
||||
currentTimer = null;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { VersionCheckStateFile } from './types';
|
||||
import { DEFAULT_VERSION_ENDPOINT } from './types';
|
||||
|
||||
function getDir(): string {
|
||||
return process.env.VERSION_CHECK_DATA_DIR ||
|
||||
path.join(process.cwd(), 'data', 'version-check');
|
||||
}
|
||||
|
||||
function statePath(): string { return path.join(getDir(), 'state.json'); }
|
||||
|
||||
const DEFAULTS: VersionCheckStateFile = {
|
||||
endpoint: DEFAULT_VERSION_ENDPOINT,
|
||||
lastCheckedAt: null,
|
||||
lastSuccessAt: null,
|
||||
nextScheduledAt: null,
|
||||
status: null,
|
||||
};
|
||||
|
||||
export async function ensureDir(): Promise<void> {
|
||||
if (!existsSync(getDir())) await mkdir(getDir(), { recursive: true });
|
||||
}
|
||||
|
||||
export async function loadState(): Promise<VersionCheckStateFile> {
|
||||
await ensureDir();
|
||||
try {
|
||||
const raw = await readFile(statePath(), 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<VersionCheckStateFile>;
|
||||
return { ...DEFAULTS, ...parsed };
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.warn('version-check: state read failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveState(state: VersionCheckStateFile): Promise<void> {
|
||||
await ensureDir();
|
||||
const tmp = statePath() + '.tmp';
|
||||
await writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
|
||||
await rename(tmp, statePath());
|
||||
}
|
||||
|
||||
export function disabledByEnv(): boolean {
|
||||
const v = (process.env.BULWARK_UPDATE_CHECK ?? '').toLowerCase();
|
||||
if (v === 'off' || v === 'false' || v === '0' || v === 'no') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function effectiveEndpoint(state: VersionCheckStateFile): string {
|
||||
// Env var wins over state file so an operator can override at runtime
|
||||
// without editing on-disk state. An explicit empty value disables the check.
|
||||
const envUrl = process.env.BULWARK_UPDATE_CHECK_URL;
|
||||
if (envUrl !== undefined) return envUrl.trim();
|
||||
return state.endpoint || DEFAULT_VERSION_ENDPOINT;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Update-status payload returned by the version server. Mirrors
|
||||
// repos/dashboard/version-server/src/registry.ts LookupResult.
|
||||
|
||||
export type UpdateSeverity = 'normal' | 'security' | 'deprecated' | 'none' | 'unknown';
|
||||
|
||||
export interface UpdateStatus {
|
||||
schema: 1;
|
||||
current: string;
|
||||
latest: string | null;
|
||||
updateAvailable: boolean;
|
||||
severity: UpdateSeverity;
|
||||
url: string | null;
|
||||
advisory: string | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface VersionCheckStateFile {
|
||||
endpoint: string;
|
||||
lastCheckedAt: string | null;
|
||||
lastSuccessAt: string | null;
|
||||
nextScheduledAt: string | null;
|
||||
status: UpdateStatus | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_VERSION_ENDPOINT = 'https://version.telemetry.bulwarkmail.org/';
|
||||
@@ -0,0 +1,148 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
|
||||
|
||||
const POLL_INTERVAL_MS = 15 * 60 * 1000;
|
||||
|
||||
interface UpdateState {
|
||||
status: UpdateStatus | null;
|
||||
loading: boolean;
|
||||
lastFetchedAt: number | null;
|
||||
// Latest version the user has dismissed the amber banner for. A newer
|
||||
// release re-shows the banner. Persisted in localStorage.
|
||||
dismissedVersion: string | null;
|
||||
|
||||
fetchStatus: () => Promise<void>;
|
||||
startPolling: () => void;
|
||||
stopPolling: () => void;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let inFlight: Promise<void> | null = null;
|
||||
|
||||
interface ApiResponse {
|
||||
status: UpdateStatus | null;
|
||||
lastCheckedAt: string | null;
|
||||
lastSuccessAt: string | null;
|
||||
}
|
||||
|
||||
export const useUpdateStore = create<UpdateState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
status: null,
|
||||
loading: false,
|
||||
lastFetchedAt: null,
|
||||
dismissedVersion: null,
|
||||
|
||||
fetchStatus: async () => {
|
||||
if (inFlight) return inFlight;
|
||||
set({ loading: true });
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const res = await apiFetch('/api/system/update-status');
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as ApiResponse;
|
||||
set({
|
||||
status: body.status,
|
||||
lastFetchedAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// Silent — banner just won't appear, no need to disrupt the UI.
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
},
|
||||
|
||||
startPolling: () => {
|
||||
if (pollTimer) return;
|
||||
void get().fetchStatus();
|
||||
pollTimer = setInterval(() => {
|
||||
void get().fetchStatus();
|
||||
}, POLL_INTERVAL_MS);
|
||||
},
|
||||
|
||||
stopPolling: () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
dismiss: () => {
|
||||
const { status } = get();
|
||||
const target = status?.latest ?? status?.current;
|
||||
if (!target) return;
|
||||
set({ dismissedVersion: target });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'bulwark-update-dismissed',
|
||||
// Only persist the dismissal — status is fetched fresh per session.
|
||||
partialize: (s) => ({ dismissedVersion: s.dismissedVersion }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Selectors. Keep them outside the store creator so components subscribing
|
||||
// to a single derived value don't re-render on unrelated state changes.
|
||||
|
||||
export type BannerVariant = 'amber' | 'red';
|
||||
|
||||
export interface BannerInfo {
|
||||
variant: BannerVariant;
|
||||
severity: UpdateSeverity;
|
||||
latest: string | null;
|
||||
url: string | null;
|
||||
advisory: string | null;
|
||||
dismissible: boolean;
|
||||
}
|
||||
|
||||
export function selectBanner(s: UpdateState): BannerInfo | null {
|
||||
const st = s.status;
|
||||
if (!st || !st.updateAvailable) return null;
|
||||
if (st.severity === 'none' || st.severity === 'unknown') return null;
|
||||
|
||||
if (st.severity === 'security') {
|
||||
return {
|
||||
variant: 'red',
|
||||
severity: 'security',
|
||||
latest: st.latest,
|
||||
url: st.url,
|
||||
advisory: st.advisory,
|
||||
dismissible: false,
|
||||
};
|
||||
}
|
||||
if (st.severity === 'deprecated') {
|
||||
return {
|
||||
variant: 'red',
|
||||
severity: 'deprecated',
|
||||
latest: st.latest,
|
||||
url: st.url,
|
||||
advisory: null,
|
||||
dismissible: false,
|
||||
};
|
||||
}
|
||||
// normal
|
||||
const target = st.latest ?? st.current;
|
||||
if (s.dismissedVersion === target) return null;
|
||||
return {
|
||||
variant: 'amber',
|
||||
severity: 'normal',
|
||||
latest: st.latest,
|
||||
url: st.url,
|
||||
advisory: null,
|
||||
dismissible: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Used by the admin shield to show a dot regardless of dismissal state. We
|
||||
// always want admins to see that an update is needed, even if the amber
|
||||
// banner has been dismissed.
|
||||
export function selectHasUpdate(s: UpdateState): boolean {
|
||||
return !!s.status?.updateAvailable && s.status.severity !== 'unknown';
|
||||
}
|
||||
Reference in New Issue
Block a user