diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 6385f1ee..8e0872cb 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -30,7 +30,6 @@ const THEME_OPTIONS = [ function VersionBadge() { const [copied, setCopied] = useState(false); const banner = useUpdateStore(useShallow(selectBanner)); - const dismiss = useUpdateStore((s) => s.dismiss); const startPolling = useUpdateStore((s) => s.startPolling); useEffect(() => { startPolling(); }, [startPolling]); @@ -95,15 +94,6 @@ function VersionBadge() { > {copied ? : } - {banner?.dismissible && ( - - )} diff --git a/components/system/update-banner.tsx b/components/system/update-banner.tsx deleted file mode 100644 index 5080957a..00000000 --- a/components/system/update-banner.tsx +++ /dev/null @@ -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 ( -
- {isRed && } - {banner.url ? ( - - {text} - - ) : ( - {text} - )} - {banner.dismissible && ( - - )} -
- ); -} diff --git a/stores/update-store.ts b/stores/update-store.ts index 1cb3d014..670eef0c 100644 --- a/stores/update-store.ts +++ b/stores/update-store.ts @@ -1,5 +1,4 @@ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; import { apiFetch } from '@/lib/browser-navigation'; import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types'; @@ -9,14 +8,10 @@ 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; startPolling: () => void; stopPolling: () => void; - dismiss: () => void; } let pollTimer: ReturnType | null = null; @@ -28,65 +23,48 @@ interface ApiResponse { lastSuccessAt: string | null; } -export const useUpdateStore = create()( - persist( - (set, get) => ({ - status: null, - loading: false, - lastFetchedAt: null, - dismissedVersion: null, +export const useUpdateStore = create()((set, get) => ({ + status: null, + loading: false, + lastFetchedAt: 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; - }, + 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); - }, + 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 }), - }, - ), -); + stopPolling: () => { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + }, +})); // Selectors. Keep them outside the store creator so components subscribing // to a single derived value don't re-render on unrelated state changes. @@ -99,7 +77,6 @@ export interface BannerInfo { latest: string | null; url: string | null; advisory: string | null; - dismissible: boolean; } export function selectBanner(s: UpdateState): BannerInfo | null { @@ -114,7 +91,6 @@ export function selectBanner(s: UpdateState): BannerInfo | null { latest: st.latest, url: st.url, advisory: st.advisory, - dismissible: false, }; } if (st.severity === 'deprecated') { @@ -124,25 +100,19 @@ export function selectBanner(s: UpdateState): BannerInfo | null { 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. +// Used by the admin shield + admin sidebar to show a dot when an update is +// available. Mirrors selectBanner's "should we show something" logic. export function selectHasUpdate(s: UpdateState): boolean { return !!s.status?.updateAvailable && s.status.severity !== 'unknown'; }