feat: make update notice non-dismissible
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user