From 62ea7ef906c0880d22eb19a670559d804fa0504a Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Tue, 22 Sep 2026 03:42:56 -0700 Subject: [PATCH] feat: gate the dashboard version pill's update actions on a session that presented a second factor, carry session_mfa_verified on the web User model, pass the API error code into the permission-denied event and give admin_mfa_required its own two-factor dialog variant linking to Settings > Security instead of the Roles & access advice, and document the rule on the updates page --- docs/content/docs/development/updates.mdx | 1 + .../app/modals/PermissionDeniedModal.tsx | 42 +++++++++++++++---- web/src/components/layout/VersionPill.tsx | 32 ++++++++++---- web/src/hooks/usePermission.ts | 16 +++++++ web/src/lib/api/client/Request.ts | 2 +- web/src/lib/api/models/auth/User.ts | 3 ++ 6 files changed, 81 insertions(+), 15 deletions(-) diff --git a/docs/content/docs/development/updates.mdx b/docs/content/docs/development/updates.mdx index 65076cef5..8b0cb3e87 100644 --- a/docs/content/docs/development/updates.mdx +++ b/docs/content/docs/development/updates.mdx @@ -34,6 +34,7 @@ Every member of a self-hosted workspace sees the same version pill in the **dash Who can act on it follows platform admin access, not workspace roles: - **Members** see a badge. Its tooltip names the version and says to ask a platform admin. +- **Platform admins whose session has no second factor** get the same badge, and clicking it explains that administrative access needs two-factor authentication, with a link to Settings > Security. Turn on 2FA or add a passkey there and sign in again; the same rule gates the admin panel. - **Platform admins** click it and get the update dialog: the running and available versions with the release notes, the checkout and updater state, a "Check now" button, and **Update and restart**. That button leads to a confirmation pane that spells out what the update does (pull, rebuild and restart, sending pauses and resumes, migrations apply, the tab reconnects) before anything runs. While the update runs the dialog shows a progress bar, the step list with the live step highlighted, and the log behind a toggle. When the backend goes away for the restart the dialog says it is reconnecting and keeps polling; the pill in the header turns into a spinner so the job stays visible with the dialog closed, and a reload picks it back up. When the new backend answers, the dialog shows the result and reloads the dashboard after a short countdown, or, if the dialog was closed, a toast reports the new version and every list refreshes. diff --git a/web/src/components/app/modals/PermissionDeniedModal.tsx b/web/src/components/app/modals/PermissionDeniedModal.tsx index 123299ac1..6bbee4e01 100644 --- a/web/src/components/app/modals/PermissionDeniedModal.tsx +++ b/web/src/components/app/modals/PermissionDeniedModal.tsx @@ -5,21 +5,24 @@ import React from "react"; import { AnimatePresence, motion } from "framer-motion"; -import { LockIcon, SparklesIcon, XIcon } from "lucide-react"; +import { LockIcon, ShieldCheckIcon, SparklesIcon, XIcon } from "lucide-react"; import { Link } from "react-router-dom"; interface DeniedDetail { message?: string; + code?: string; } export default function PermissionDeniedModal() { const [open, setOpen] = React.useState(false); const [message, setMessage] = React.useState(""); + const [code, setCode] = React.useState(undefined); React.useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent).detail; setMessage(detail?.message?.trim() || "You don't have permission to do that."); + setCode(detail?.code); setOpen(true); }; window.addEventListener("permission-denied", handler); @@ -28,7 +31,11 @@ export default function PermissionDeniedModal() { // Plan/billing gates come back as 403 too, but they aren't a role problem — // label them as an upgrade prompt so the message and the call to action match. - const isPlan = /\b(plan|upgrade|trial|subscription|paid)\b/i.test(message); + // Admin routes refuse a session that never presented a second factor. That + // is fixed under Settings > Security by the person themselves, so the + // "ask an admin" advice would be wrong; link them there instead. + const isMFA = code === "admin_mfa_required"; + const isPlan = !isMFA && /\b(plan|upgrade|trial|subscription|paid)\b/i.test(message); const close = () => setOpen(false); return ( @@ -63,17 +70,29 @@ export default function PermissionDeniedModal() { className={`mx-auto mb-3 size-11 rounded-xl border flex items-center justify-center ${ isPlan ? "bg-violet-50 border-violet-200 text-violet-600" - : "bg-amber-50 border-amber-200 text-amber-600" + : isMFA + ? "bg-sky-50 border-sky-200 text-sky-600" + : "bg-amber-50 border-amber-200 text-amber-600" }`} > - {isPlan ? : } + {isPlan ? ( + + ) : isMFA ? ( + + ) : ( + + )}

- {isPlan ? "Upgrade required" : "You don't have permission"} + {isPlan + ? "Upgrade required" + : isMFA + ? "Two-factor authentication required" + : "You don't have permission"}

{message} - {!isPlan && ( + {!isPlan && !isMFA && ( <> {" "} Ask a workspace admin or the owner to grant you access from{" "} @@ -94,11 +113,20 @@ export default function PermissionDeniedModal() { View plans )} + {isMFA && ( + + Open security settings + + )} + ); + } if (!isAdmin) { return ( diff --git a/web/src/hooks/usePermission.ts b/web/src/hooks/usePermission.ts index 1410f4a98..107d39b31 100644 --- a/web/src/hooks/usePermission.ts +++ b/web/src/hooks/usePermission.ts @@ -62,6 +62,22 @@ export function showPermissionDenied(key: PermissionKey) { ); } +// An admin action from a session that never presented a second factor. The +// backend answers admin_mfa_required; raising it before the request gives the +// same dialog without a round trip that is known to fail. +export function showAdminMFARequired() { + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent("permission-denied", { + detail: { + code: "admin_mfa_required", + message: + "Administrative access requires two-factor authentication. Turn on 2FA or add a passkey under Settings > Security, then sign in again.", + }, + }), + ); +} + export interface WriteGuard { /** Whether the current member may perform this write. */ allowed: boolean; diff --git a/web/src/lib/api/client/Request.ts b/web/src/lib/api/client/Request.ts index 7258a5736..fc4e808fa 100644 --- a/web/src/lib/api/client/Request.ts +++ b/web/src/lib/api/client/Request.ts @@ -194,7 +194,7 @@ export default async function Request(config: AuthRequestConfig): Promise if (method !== "GET" && method !== "HEAD") { window.dispatchEvent( new CustomEvent("permission-denied", { - detail: { message: appErr.message }, + detail: { message: appErr.message, code: appErr.code }, }), ); } diff --git a/web/src/lib/api/models/auth/User.ts b/web/src/lib/api/models/auth/User.ts index 55fdc7be2..8bb01d401 100644 --- a/web/src/lib/api/models/auth/User.ts +++ b/web/src/lib/api/models/auth/User.ts @@ -21,6 +21,9 @@ export default interface User { // permission; the dashboard only uses it to link to the admin panel. is_admin?: boolean; admin_permissions?: number; + // Whether this session presented a second factor. Admin routes refuse a + // session that did not, so admin actions in the dashboard gate on it too. + session_mfa_verified?: boolean; tags: Tag[]; categories: Category[];