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

This commit is contained in:
Matthew Meszaros
2026-09-22 03:42:56 -07:00
parent cc898cc040
commit 62ea7ef906
6 changed files with 81 additions and 15 deletions
@@ -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.
@@ -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<string | undefined>(undefined);
React.useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<DeniedDetail>).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 ? <SparklesIcon className="w-5 h-5" /> : <LockIcon className="w-5 h-5" />}
{isPlan ? (
<SparklesIcon className="w-5 h-5" />
) : isMFA ? (
<ShieldCheckIcon className="w-5 h-5" />
) : (
<LockIcon className="w-5 h-5" />
)}
</div>
<h3 className="text-[14px] font-semibold text-slate-900">
{isPlan ? "Upgrade required" : "You don't have permission"}
{isPlan
? "Upgrade required"
: isMFA
? "Two-factor authentication required"
: "You don't have permission"}
</h3>
<p className="text-[12.5px] text-slate-500 leading-relaxed mt-1.5">
{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
</Link>
)}
{isMFA && (
<Link
to="/app/settings/security"
onClick={close}
className="inline-flex items-center h-8 px-3 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12.5px] font-medium transition-colors"
>
Open security settings
</Link>
)}
<button
type="button"
onClick={close}
className={`inline-flex items-center h-8 px-4 rounded-md text-[12.5px] font-medium transition-colors ${
isPlan
isPlan || isMFA
? "border border-slate-200 bg-white hover:bg-slate-50 text-slate-700"
: "bg-slate-900 hover:bg-slate-800 text-white"
}`}
+25 -7
View File
@@ -13,6 +13,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { ArrowUpCircleIcon, Loader2Icon } from "lucide-react";
import useInstanceVersion from "@/lib/api/hooks/auth/useInstanceVersion";
import useUser from "@/lib/api/hooks/auth/useUser";
import { showAdminMFARequired } from "@/hooks/usePermission";
import { isUpdateRunning, runningLabel, useInstanceUpdate } from "@/lib/api/hooks/auth/useInstanceUpdate";
import { clearUpdateStarted, readUpdateStarted } from "@/lib/updateSession";
import { cn } from "@/lib/utils";
@@ -26,11 +27,16 @@ export function VersionPill() {
const versionQ = useInstanceVersion();
const { data: user } = useUser();
// Mirrors the backend gates: view_analytics reads the update state,
// manage_settings is what check and apply require. An admin without the
// latter sees the same read-only badge as a member.
// manage_settings is what check and apply require, and every admin route
// refuses a session that never presented a second factor. An admin without
// the latter sees the same read-only badge as a member; one whose session
// lacks 2FA gets told so on click instead of a refused request.
const perms = user?.admin_permissions ?? 0;
const canView = (perms & ADMIN_VIEW_ANALYTICS) === ADMIN_VIEW_ANALYTICS;
const isAdmin = (perms & ADMIN_MANAGE_SETTINGS) === ADMIN_MANAGE_SETTINGS;
const mfaOK = user?.session_mfa_verified === true;
const holdsAdmin = (perms & ADMIN_MANAGE_SETTINGS) === ADMIN_MANAGE_SETTINGS;
const canView = mfaOK && (perms & ADMIN_VIEW_ANALYTICS) === ADMIN_VIEW_ANALYTICS;
const isAdmin = mfaOK && holdsAdmin;
const needsMFA = holdsAdmin && !mfaOK;
const v = versionQ.data;
const selfHosted = !!v?.self_hosted;
@@ -81,10 +87,14 @@ export function VersionPill() {
let title = available
? isAdmin
? `Warmbly ${latest ?? "newer"} is available; this instance runs ${running}. Click to update.`
: `Warmbly ${latest ?? "newer"} is available; this instance runs ${running}. Ask a platform admin to update.`
: needsMFA
? `Warmbly ${latest ?? "newer"} is available; this instance runs ${running}. Updating needs a session with two-factor authentication.`
: `Warmbly ${latest ?? "newer"} is available; this instance runs ${running}. Ask a platform admin to update.`
: isAdmin
? `Warmbly ${running}, up to date. Click for details.`
: `Warmbly ${running}, up to date.`;
: needsMFA
? `Warmbly ${running}, up to date. Update details need a session with two-factor authentication.`
: `Warmbly ${running}, up to date.`;
if (isAdmin && (updating || restarting)) {
label = restarting ? "Reconnecting" : "Updating";
@@ -96,9 +106,17 @@ export function VersionPill() {
const className = cn(
"inline-flex items-center gap-1.5 h-6 px-2 rounded border text-[11px] font-semibold tracking-[0.02em] transition-colors",
tone,
isAdmin && (available ? "hover:bg-amber-100" : "hover:text-slate-900 hover:bg-white"),
(isAdmin || needsMFA) && (available ? "hover:bg-amber-100" : "hover:text-slate-900 hover:bg-white"),
);
if (needsMFA) {
return (
<button type="button" onClick={showAdminMFARequired} title={title} className={className}>
{icon}
{label}
</button>
);
}
if (!isAdmin) {
return (
<span title={title} className={cn(className, "cursor-default")}>
+16
View File
@@ -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;
+1 -1
View File
@@ -194,7 +194,7 @@ export default async function Request<T>(config: AuthRequestConfig): Promise<T>
if (method !== "GET" && method !== "HEAD") {
window.dispatchEvent(
new CustomEvent("permission-denied", {
detail: { message: appErr.message },
detail: { message: appErr.message, code: appErr.code },
}),
);
}
+3
View File
@@ -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[];