feat: make update notice non-dismissible

This commit is contained in:
Linus Rath
2026-05-02 13:07:34 +02:00
parent 4594fb2572
commit bc97a1ac10
3 changed files with 41 additions and 150 deletions
-10
View File
@@ -30,7 +30,6 @@ const THEME_OPTIONS = [
function VersionBadge() { function VersionBadge() {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const banner = useUpdateStore(useShallow(selectBanner)); const banner = useUpdateStore(useShallow(selectBanner));
const dismiss = useUpdateStore((s) => s.dismiss);
const startPolling = useUpdateStore((s) => s.startPolling); const startPolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startPolling(); }, [startPolling]); useEffect(() => { startPolling(); }, [startPolling]);
@@ -95,15 +94,6 @@ function VersionBadge() {
> >
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />} {copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</button> </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> </div>
</div> </div>
-69
View File
@@ -1,69 +0,0 @@
"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>
);
}
+41 -71
View File
@@ -1,5 +1,4 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types'; import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
@@ -9,14 +8,10 @@ interface UpdateState {
status: UpdateStatus | null; status: UpdateStatus | null;
loading: boolean; loading: boolean;
lastFetchedAt: number | null; 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>; fetchStatus: () => Promise<void>;
startPolling: () => void; startPolling: () => void;
stopPolling: () => void; stopPolling: () => void;
dismiss: () => void;
} }
let pollTimer: ReturnType<typeof setInterval> | null = null; let pollTimer: ReturnType<typeof setInterval> | null = null;
@@ -28,65 +23,48 @@ interface ApiResponse {
lastSuccessAt: string | null; lastSuccessAt: string | null;
} }
export const useUpdateStore = create<UpdateState>()( export const useUpdateStore = create<UpdateState>()((set, get) => ({
persist( status: null,
(set, get) => ({ loading: false,
status: null, lastFetchedAt: null,
loading: false,
lastFetchedAt: null,
dismissedVersion: null,
fetchStatus: async () => { fetchStatus: async () => {
if (inFlight) return inFlight; if (inFlight) return inFlight;
set({ loading: true }); set({ loading: true });
inFlight = (async () => { inFlight = (async () => {
try { try {
const res = await apiFetch('/api/system/update-status'); const res = await apiFetch('/api/system/update-status');
if (!res.ok) return; if (!res.ok) return;
const body = (await res.json()) as ApiResponse; const body = (await res.json()) as ApiResponse;
set({ set({
status: body.status, status: body.status,
lastFetchedAt: Date.now(), lastFetchedAt: Date.now(),
}); });
} catch { } catch {
// Silent — banner just won't appear, no need to disrupt the UI. // Silent — banner just won't appear, no need to disrupt the UI.
} finally { } finally {
set({ loading: false }); set({ loading: false });
inFlight = null; inFlight = null;
} }
})(); })();
return inFlight; return inFlight;
}, },
startPolling: () => { startPolling: () => {
if (pollTimer) return; if (pollTimer) return;
void get().fetchStatus(); void get().fetchStatus();
pollTimer = setInterval(() => { pollTimer = setInterval(() => {
void get().fetchStatus(); void get().fetchStatus();
}, POLL_INTERVAL_MS); }, POLL_INTERVAL_MS);
}, },
stopPolling: () => { stopPolling: () => {
if (pollTimer) { if (pollTimer) {
clearInterval(pollTimer); clearInterval(pollTimer);
pollTimer = null; 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 // Selectors. Keep them outside the store creator so components subscribing
// to a single derived value don't re-render on unrelated state changes. // to a single derived value don't re-render on unrelated state changes.
@@ -99,7 +77,6 @@ export interface BannerInfo {
latest: string | null; latest: string | null;
url: string | null; url: string | null;
advisory: string | null; advisory: string | null;
dismissible: boolean;
} }
export function selectBanner(s: UpdateState): BannerInfo | null { export function selectBanner(s: UpdateState): BannerInfo | null {
@@ -114,7 +91,6 @@ export function selectBanner(s: UpdateState): BannerInfo | null {
latest: st.latest, latest: st.latest,
url: st.url, url: st.url,
advisory: st.advisory, advisory: st.advisory,
dismissible: false,
}; };
} }
if (st.severity === 'deprecated') { if (st.severity === 'deprecated') {
@@ -124,25 +100,19 @@ export function selectBanner(s: UpdateState): BannerInfo | null {
latest: st.latest, latest: st.latest,
url: st.url, url: st.url,
advisory: null, advisory: null,
dismissible: false,
}; };
} }
// normal
const target = st.latest ?? st.current;
if (s.dismissedVersion === target) return null;
return { return {
variant: 'amber', variant: 'amber',
severity: 'normal', severity: 'normal',
latest: st.latest, latest: st.latest,
url: st.url, url: st.url,
advisory: null, advisory: null,
dismissible: true,
}; };
} }
// Used by the admin shield to show a dot regardless of dismissal state. We // Used by the admin shield + admin sidebar to show a dot when an update is
// always want admins to see that an update is needed, even if the amber // available. Mirrors selectBanner's "should we show something" logic.
// banner has been dismissed.
export function selectHasUpdate(s: UpdateState): boolean { export function selectHasUpdate(s: UpdateState): boolean {
return !!s.status?.updateAvailable && s.status.severity !== 'unknown'; return !!s.status?.updateAvailable && s.status.severity !== 'unknown';
} }