mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 08:02:04 +00:00
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:
@@ -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"
|
||||
}`}
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user