feat(files): API tokens in Settings, the Hub folder in Files, a notice before a token runs out
Settings gains an API tokens tab: a row per token (name and prefix; each folder with its rights; last use and expiry, "expires soon" marked), revoked from its menu behind a confirm. A new token is the Hub exchange in one click, or folders and rights picked per folder (no Write where an upload cannot land); expiry 30/90 days, 1 year or never (warned). The token shows once in the same dialog, with Copy and a ready curl line. The Hub folder closes the Sources in the Files rail, takes uploads and moved-in files. A token about to run out warns its user once, a week before (the Files GC's sixth pass); the notice opens the tokens tab.
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FolderOpen,
|
||||
KeyRound,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Trash2,
|
||||
@@ -23,6 +24,7 @@ import { jobRunText } from "../lib/jobResult";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
|
||||
import { focusSettingsTab } from "../lib/settingsTab";
|
||||
import {
|
||||
clearAll as clearClient,
|
||||
dismiss as dismissClient,
|
||||
@@ -37,7 +39,7 @@ import {
|
||||
type VideoHiddenMeta,
|
||||
type VideoWatchedMeta,
|
||||
} from "../lib/notifications";
|
||||
import { channelYouTubeUrl } from "../lib/format";
|
||||
import { channelYouTubeUrl, formatDate } from "../lib/format";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { LEVEL_STYLE } from "./ui/toastLevels";
|
||||
@@ -186,6 +188,10 @@ export default function NotificationsPanel() {
|
||||
const serverMenu = useContextMenu<AppNotification>({ keyOf: byId });
|
||||
const clientMenu = useContextMenu<ClientNotif>({ keyOf: byId });
|
||||
const openScheduler = () => setPage("scheduler");
|
||||
const openTokens = () => {
|
||||
focusSettingsTab("tokens");
|
||||
setPage("settings");
|
||||
};
|
||||
// A finished download opens on its file, in its folder; a notice from before it named one, on Files.
|
||||
const openDone = (n: AppNotification) => {
|
||||
const { job_id: id, folder_id: folder } = n.data ?? {};
|
||||
@@ -225,7 +231,16 @@ export default function NotificationsPanel() {
|
||||
onSelect: () => openDone(n),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
: kind === "token"
|
||||
? [
|
||||
{
|
||||
id: "open",
|
||||
label: t("inbox.openTokens"),
|
||||
icon: icon(KeyRound),
|
||||
onSelect: openTokens,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!n.read
|
||||
? [
|
||||
{
|
||||
@@ -397,6 +412,7 @@ export default function NotificationsPanel() {
|
||||
onDismiss={() => dismissMut.mutate(n.id)}
|
||||
onOpenScheduler={openScheduler}
|
||||
onOpenFile={() => openDone(n)}
|
||||
onOpenTokens={openTokens}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -462,9 +478,10 @@ const DONE_TITLE: Record<string, string | undefined> = {
|
||||
upload: "inbox.uploadDone.title",
|
||||
};
|
||||
|
||||
function serverKind(n: AppNotification): "scheduler" | "download" | null {
|
||||
function serverKind(n: AppNotification): "scheduler" | "download" | "token" | null {
|
||||
if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
|
||||
if (n.type === "download_done") return "download";
|
||||
if (n.type === "api_token_expiring" && typeof n.data?.name === "string") return "token";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -511,6 +528,7 @@ function NotificationRow({
|
||||
onDismiss,
|
||||
onOpenScheduler,
|
||||
onOpenFile,
|
||||
onOpenTokens,
|
||||
}: {
|
||||
n: AppNotification;
|
||||
t: TFunction;
|
||||
@@ -521,6 +539,7 @@ function NotificationRow({
|
||||
onDismiss: () => void;
|
||||
onOpenScheduler: () => void;
|
||||
onOpenFile: () => void;
|
||||
onOpenTokens: () => void;
|
||||
}) {
|
||||
const lang = useTranslation().i18n.language;
|
||||
// The maintenance producer ships the affected videos in `data.videos`; list a few titles.
|
||||
@@ -532,6 +551,7 @@ function NotificationRow({
|
||||
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
|
||||
const isScheduler = serverKind(n) === "scheduler";
|
||||
const isDownloadDone = serverKind(n) === "download";
|
||||
const isTokenExpiring = serverKind(n) === "token";
|
||||
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
|
||||
// A download's life after it is done: shared with you, about to expire, expired, evicted.
|
||||
const lifecycle = DOWNLOAD_LIFECYCLE[n.type];
|
||||
@@ -566,6 +586,11 @@ function NotificationRow({
|
||||
const d = n.data as { title?: string | null; from?: string };
|
||||
title = t(`inbox.${lifecycle}.title`, { from: d.from ?? "" });
|
||||
body = d.title || null;
|
||||
} else if (isTokenExpiring) {
|
||||
// A machine's key to the library runs out within a week: which one, and when.
|
||||
const d = n.data as { name: string; expires_at?: string | null };
|
||||
title = t("inbox.apiTokenExpiring.title");
|
||||
body = t("inbox.apiTokenExpiring.body", { name: d.name, date: formatDate(d.expires_at, lang) });
|
||||
} else if (isDownloadDone) {
|
||||
// "Download ready" / "Edit ready" / "Upload ready" per the producer's kind, with the video's title
|
||||
// as the body (shipped in data.title). Same notification type; the copy differs so an edit or an
|
||||
@@ -614,6 +639,17 @@ function NotificationRow({
|
||||
{t("inbox.openScheduler")}
|
||||
</Button>
|
||||
)}
|
||||
{isTokenExpiring && (
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1.5"
|
||||
data-nav-open=""
|
||||
onClick={onOpenTokens}
|
||||
icon={<KeyRound className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t("inbox.openTokens")}
|
||||
</Button>
|
||||
)}
|
||||
{isDownloadDone && (
|
||||
<Button
|
||||
variant="link"
|
||||
|
||||
Reference in New Issue
Block a user