diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 35f526c..009f5a3 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -17,7 +17,7 @@ import {
saveLayoutLocal,
type SidebarLayout,
} from "./lib/sidebarLayout";
-import { configureNotifications, getNotifSettings, notify, type NotifSettings } from "./lib/notifications";
+import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
import { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel";
@@ -307,12 +307,21 @@ export default function App() {
if (isSupported(prefs.language)) setLanguage(prefs.language);
// Nudge admins when access requests are waiting (in lieu of a server-push bell).
const pending = meQuery.data?.pending_invites ?? 0;
- if (meQuery.data?.role === "admin" && pending > 0) {
- notify({
- level: "warning",
- title: t("common.accessRequestsTitle"),
- message: t("common.accessRequestsMessage", { count: pending }),
- });
+ if (meQuery.data?.role === "admin") {
+ // Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
+ // history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
+ removeByMetaKind("access-requests");
+ if (pending > 0) {
+ notify({
+ level: "warning",
+ title: t("common.accessRequestsTitle"),
+ message: t("common.accessRequestsMessage", { count: pending }),
+ // Durable inbox link (survives reload, unlike a live action callback); the toast also
+ // gets a click via the action.
+ meta: { kind: "access-requests" },
+ action: { label: t("common.accessRequestsReview"), onClick: () => setPage("users") },
+ });
+ }
}
// The demo account is shared: there are no subscriptions, so default it to the whole
// library (the "my" feed would be empty). The communal-state warning is a permanent
diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx
index bac67bf..e4883b5 100644
--- a/frontend/src/components/NotificationsPanel.tsx
+++ b/frontend/src/components/NotificationsPanel.tsx
@@ -1,7 +1,7 @@
import { useEffect, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, X } from "lucide-react";
+import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react";
import { api, type AppNotification, type FeedFilters } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import {
@@ -190,6 +190,7 @@ export default function NotificationsPanel({
onFind={locate}
onRevert={revertState}
onFocusChannel={onFocusChannel}
+ onReviewAccess={() => setPage("users")}
onRemove={() => removeClient(n.id)}
/>
))}
@@ -306,6 +307,7 @@ function ClientActivityRow({
onFind,
onRevert,
onFocusChannel,
+ onReviewAccess,
onRemove,
}: {
n: ClientNotif;
@@ -313,6 +315,7 @@ function ClientActivityRow({
onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onFocusChannel: (name: string) => void;
+ onReviewAccess: () => void;
onRemove: () => void;
}) {
const { icon: Icon, color } = LEVEL_STYLE[n.level];
@@ -321,6 +324,7 @@ function ClientActivityRow({
const watched = n.meta?.kind === "video-watched" ? n.meta : null;
const subscribed: ChannelSubscribedMeta | null =
n.meta?.kind === "channel-subscribed" ? n.meta : null;
+ const access = n.meta?.kind === "access-requests" ? n.meta : null;
return (
+ ) : access ? (
+
) : (
n.action &&
diff --git a/frontend/src/i18n/locales/de/common.json b/frontend/src/i18n/locales/de/common.json
index 70a8846..512fe96 100644
--- a/frontend/src/i18n/locales/de/common.json
+++ b/frontend/src/i18n/locales/de/common.json
@@ -12,7 +12,8 @@
"language": "Sprache",
"somethingWrong": "Etwas ist schiefgelaufen.",
"accessRequestsTitle": "Zugangsanfragen",
- "accessRequestsMessage": "{{count}} ausstehend — prüfe sie unter Einstellungen → Konto.",
+ "accessRequestsMessage": "{{count}} ausstehend — prüfe sie auf der Benutzer-Seite.",
+ "accessRequestsReview": "Überprüfen",
"demoTitle": "Demo-Konto",
"demoSharedNotice": "Du bist im gemeinsamen Demo-Konto. Alles, was du hier tust — Playlists, ausgeblendete Videos, Einstellungen — ist gemeinsam und kann von anderen Demo-Besuchern gleichzeitig geändert werden."
}
diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json
index e282281..8082bbe 100644
--- a/frontend/src/i18n/locales/en/common.json
+++ b/frontend/src/i18n/locales/en/common.json
@@ -12,7 +12,8 @@
"language": "Language",
"somethingWrong": "Something went wrong.",
"accessRequestsTitle": "Access requests",
- "accessRequestsMessage": "{{count}} pending — review in Settings → Account.",
+ "accessRequestsMessage": "{{count}} pending — review them on the Users page.",
+ "accessRequestsReview": "Review",
"demoTitle": "Demo account",
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time."
}
diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json
index 1389955..c004de7 100644
--- a/frontend/src/i18n/locales/hu/common.json
+++ b/frontend/src/i18n/locales/hu/common.json
@@ -12,7 +12,8 @@
"language": "Nyelv",
"somethingWrong": "Hiba történt.",
"accessRequestsTitle": "Hozzáférési kérések",
- "accessRequestsMessage": "{{count}} függőben — nézd át a Beállítások → Fiók menüben.",
+ "accessRequestsMessage": "{{count}} függőben — nézd át a Felhasználók oldalon.",
+ "accessRequestsReview": "Áttekintés",
"demoTitle": "Demo fiók",
"demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják."
}
diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts
index cf5c47e..3df64d5 100644
--- a/frontend/src/lib/notifications.ts
+++ b/frontend/src/lib/notifications.ts
@@ -31,7 +31,16 @@ export type ChannelSubscribedMeta = {
channelId: string;
channelName: string;
};
-export type NotifMeta = VideoHiddenMeta | VideoWatchedMeta | ChannelSubscribedMeta;
+// Admin nudge that pending access requests are waiting — carries a durable inbox link to the
+// Users page (where Access requests live). No payload; the panel provides the navigation.
+export type AccessRequestsMeta = {
+ kind: "access-requests";
+};
+export type NotifMeta =
+ | VideoHiddenMeta
+ | VideoWatchedMeta
+ | ChannelSubscribedMeta
+ | AccessRequestsMeta;
export interface Notification {
id: number;
@@ -173,6 +182,15 @@ export function subscribe(listener: () => void): () => void {
};
}
+/** Drop every notification (active or dismissed history) carrying this meta kind. Used to keep
+ * a singleton nudge — e.g. re-issuing the "access requests pending" notice without piling up a
+ * fresh copy (plus a stale history entry) on every reload. */
+export function removeByMetaKind(kind: NotifMeta["kind"]): void {
+ const before = items.length;
+ items = items.filter((n) => n.meta?.kind !== kind);
+ if (items.length !== before) emit();
+}
+
export function notify(input: NotifyInput): number {
const id = counter++;
const requiresInteraction = input.requiresInteraction ?? false;