All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 9s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Introduced a front-end mechanism to notify users of available service updates and enhanced the service info page to reflect update status dynamically. Removed backend audit log writes for version checks to streamline operations and improve performance. Updated styling to visually highlight update notifications.
253 lines
7.4 KiB
JavaScript
253 lines
7.4 KiB
JavaScript
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
import { API_URL } from "./api";
|
|
|
|
const AuthCtx = createContext(null);
|
|
const UI_MODE_KEY = "nexapg_ui_mode";
|
|
|
|
function loadStorage() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem("nexapg_auth") || "null");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function loadUiMode() {
|
|
try {
|
|
const value = localStorage.getItem(UI_MODE_KEY);
|
|
if (value === "easy" || value === "dba") return value;
|
|
} catch {
|
|
// ignore storage errors
|
|
}
|
|
return "dba";
|
|
}
|
|
|
|
export function AuthProvider({ children }) {
|
|
const initial = loadStorage();
|
|
const [tokens, setTokens] = useState(initial?.tokens || null);
|
|
const [me, setMe] = useState(initial?.me || null);
|
|
const [uiMode, setUiModeState] = useState(loadUiMode);
|
|
const [alertStatus, setAlertStatus] = useState({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
|
|
const [alertToasts, setAlertToasts] = useState([]);
|
|
const [serviceInfo, setServiceInfo] = useState(null);
|
|
const knownAlertKeysRef = useRef(new Set());
|
|
const hasAlertSnapshotRef = useRef(false);
|
|
|
|
const persist = (nextTokens, nextMe) => {
|
|
if (nextTokens && nextMe) {
|
|
localStorage.setItem("nexapg_auth", JSON.stringify({ tokens: nextTokens, me: nextMe }));
|
|
} else {
|
|
localStorage.removeItem("nexapg_auth");
|
|
}
|
|
};
|
|
|
|
const refresh = async () => {
|
|
if (!tokens?.refreshToken) return null;
|
|
const res = await fetch(`${API_URL}/auth/refresh`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ refresh_token: tokens.refreshToken }),
|
|
});
|
|
if (!res.ok) {
|
|
setTokens(null);
|
|
setMe(null);
|
|
persist(null, null);
|
|
return null;
|
|
}
|
|
const data = await res.json();
|
|
const nextTokens = { accessToken: data.access_token, refreshToken: data.refresh_token };
|
|
setTokens(nextTokens);
|
|
persist(nextTokens, me);
|
|
return nextTokens;
|
|
};
|
|
|
|
const login = async (email, password) => {
|
|
const res = await fetch(`${API_URL}/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
if (!res.ok) throw new Error("Login failed");
|
|
const data = await res.json();
|
|
const nextTokens = { accessToken: data.access_token, refreshToken: data.refresh_token };
|
|
const meRes = await fetch(`${API_URL}/me`, {
|
|
headers: { Authorization: `Bearer ${nextTokens.accessToken}` },
|
|
});
|
|
if (!meRes.ok) throw new Error("Could not load user profile");
|
|
const profile = await meRes.json();
|
|
setTokens(nextTokens);
|
|
setMe(profile);
|
|
persist(nextTokens, profile);
|
|
};
|
|
|
|
const logout = async () => {
|
|
try {
|
|
if (tokens?.accessToken) {
|
|
await fetch(`${API_URL}/auth/logout`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
});
|
|
}
|
|
} finally {
|
|
setTokens(null);
|
|
setMe(null);
|
|
persist(null, null);
|
|
}
|
|
};
|
|
|
|
const dismissAlertToast = (toastId) => {
|
|
setAlertToasts((prev) => prev.map((t) => (t.id === toastId ? { ...t, closing: true } : t)));
|
|
setTimeout(() => {
|
|
setAlertToasts((prev) => prev.filter((t) => t.id !== toastId));
|
|
}, 220);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!tokens?.accessToken) {
|
|
setAlertStatus({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
|
|
setAlertToasts([]);
|
|
knownAlertKeysRef.current = new Set();
|
|
hasAlertSnapshotRef.current = false;
|
|
return;
|
|
}
|
|
|
|
let mounted = true;
|
|
|
|
const pushToastsForNewItems = (items) => {
|
|
if (!items.length) return;
|
|
const createdAt = Date.now();
|
|
const nextToasts = items.slice(0, 4).map((item, idx) => ({
|
|
id: `${createdAt}-${idx}-${item.alert_key}`,
|
|
alertKey: item.alert_key,
|
|
severity: item.severity,
|
|
title: item.name,
|
|
target: item.target_name,
|
|
message: item.message,
|
|
closing: false,
|
|
}));
|
|
setAlertToasts((prev) => [...nextToasts, ...prev].slice(0, 6));
|
|
for (const toast of nextToasts) {
|
|
setTimeout(() => {
|
|
if (!mounted) return;
|
|
dismissAlertToast(toast.id);
|
|
}, 8000);
|
|
}
|
|
};
|
|
|
|
const loadAlertStatus = async () => {
|
|
const request = async (accessToken) =>
|
|
fetch(`${API_URL}/alerts/status`, {
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
});
|
|
|
|
let res = await request(tokens.accessToken);
|
|
if (res.status === 401 && tokens.refreshToken) {
|
|
const refreshed = await refresh();
|
|
if (refreshed?.accessToken) {
|
|
res = await request(refreshed.accessToken);
|
|
}
|
|
}
|
|
if (!res.ok) return;
|
|
|
|
const payload = await res.json();
|
|
if (!mounted) return;
|
|
setAlertStatus(payload);
|
|
|
|
const currentItems = [...(payload.warnings || []), ...(payload.alerts || [])];
|
|
const currentKeys = new Set(currentItems.map((item) => item.alert_key));
|
|
if (!hasAlertSnapshotRef.current) {
|
|
knownAlertKeysRef.current = currentKeys;
|
|
hasAlertSnapshotRef.current = true;
|
|
return;
|
|
}
|
|
const newItems = currentItems.filter((item) => !knownAlertKeysRef.current.has(item.alert_key));
|
|
knownAlertKeysRef.current = currentKeys;
|
|
pushToastsForNewItems(newItems);
|
|
};
|
|
|
|
loadAlertStatus().catch(() => {});
|
|
const timer = setInterval(() => {
|
|
loadAlertStatus().catch(() => {});
|
|
}, 8000);
|
|
|
|
return () => {
|
|
mounted = false;
|
|
clearInterval(timer);
|
|
};
|
|
}, [tokens?.accessToken, tokens?.refreshToken]);
|
|
|
|
useEffect(() => {
|
|
if (!tokens?.accessToken) {
|
|
setServiceInfo(null);
|
|
return;
|
|
}
|
|
|
|
let mounted = true;
|
|
|
|
const request = async (path, method = "GET") => {
|
|
const doFetch = async (accessToken) =>
|
|
fetch(`${API_URL}${path}`, {
|
|
method,
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
});
|
|
|
|
let res = await doFetch(tokens.accessToken);
|
|
if (res.status === 401 && tokens.refreshToken) {
|
|
const refreshed = await refresh();
|
|
if (refreshed?.accessToken) {
|
|
res = await doFetch(refreshed.accessToken);
|
|
}
|
|
}
|
|
if (!res.ok) return null;
|
|
return res.json();
|
|
};
|
|
|
|
const runServiceCheck = async () => {
|
|
await request("/service/info/check", "POST");
|
|
const info = await request("/service/info", "GET");
|
|
if (mounted && info) setServiceInfo(info);
|
|
};
|
|
|
|
runServiceCheck().catch(() => {});
|
|
const timer = setInterval(() => {
|
|
runServiceCheck().catch(() => {});
|
|
}, 30000);
|
|
|
|
return () => {
|
|
mounted = false;
|
|
clearInterval(timer);
|
|
};
|
|
}, [tokens?.accessToken, tokens?.refreshToken]);
|
|
|
|
const setUiMode = (nextMode) => {
|
|
const mode = nextMode === "easy" ? "easy" : "dba";
|
|
setUiModeState(mode);
|
|
localStorage.setItem(UI_MODE_KEY, mode);
|
|
};
|
|
|
|
const value = useMemo(
|
|
() => ({
|
|
tokens,
|
|
me,
|
|
login,
|
|
logout,
|
|
refresh,
|
|
uiMode,
|
|
setUiMode,
|
|
alertStatus,
|
|
alertToasts,
|
|
dismissAlertToast,
|
|
serviceInfo,
|
|
serviceUpdateAvailable: !!serviceInfo?.update_available,
|
|
}),
|
|
[tokens, me, uiMode, alertStatus, alertToasts, serviceInfo]
|
|
);
|
|
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
|
}
|
|
|
|
export function useAuth() {
|
|
const ctx = useContext(AuthCtx);
|
|
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
|
|
return ctx;
|
|
}
|