feat(auth): surface canEdit in the topbar with an access badge
Browse files`/api/auth/status` already returned a `canEdit` flag derived from the
HF whoami roleInOrg (Space owner, or org write/admin/contributor with
a write resource group), but the frontend was ignoring it. Result:
read-only users saw an enabled Publish button + the regular user chip,
then hit a confusing 403 from the backend on every mutating action.
- App.tsx plumbs `canEdit` from /api/auth/status into a new state and
forwards it to TopBar.
- TopBar renders a small badge next to the user chip: "Editor" (green
outline + PencilLine icon) when canEdit, "Read-only" (muted + Lock
icon) otherwise, both with a tooltip explaining the state.
- Publish button is disabled when !canEdit with a matching tooltip,
so the read-only restriction is visible up front instead of after a
failed network round-trip.
- `.chip--editor` and `.chip--readonly` variants added to _ui.css.
Co-authored-by: Cursor <cursoragent@cursor.com>
- frontend/src/App.tsx +5 -0
- frontend/src/components/TopBar.tsx +40 -6
- frontend/src/styles/_ui.css +20 -0
|
@@ -37,6 +37,9 @@ export default function App() {
|
|
| 37 |
const [user, setUser] = useState<CollabUser>(stableFallbackUser);
|
| 38 |
const [loginUrl, setLoginUrl] = useState<string | null>(null);
|
| 39 |
const [isAuthenticated, setIsAuthenticated] = useState(true);
|
|
|
|
|
|
|
|
|
|
| 40 |
const [chatUserId, setChatUserId] = useState(() => user.name);
|
| 41 |
|
| 42 |
useEffect(() => {
|
|
@@ -44,6 +47,7 @@ export default function App() {
|
|
| 44 |
.then((r) => r.json())
|
| 45 |
.then((data) => {
|
| 46 |
setIsAuthenticated(data.authenticated);
|
|
|
|
| 47 |
if (data.authenticated && data.user) {
|
| 48 |
const name = data.user.fullName || data.user.name;
|
| 49 |
setUser({
|
|
@@ -1093,6 +1097,7 @@ export default function App() {
|
|
| 1093 |
user={user}
|
| 1094 |
loginUrl={loginUrl}
|
| 1095 |
isAuthenticated={isAuthenticated}
|
|
|
|
| 1096 |
isPublishing={publishStatus.active}
|
| 1097 |
publishingUserName={publishStatus.userName}
|
| 1098 |
onToggleTheme={toggleTheme}
|
|
|
|
| 37 |
const [user, setUser] = useState<CollabUser>(stableFallbackUser);
|
| 38 |
const [loginUrl, setLoginUrl] = useState<string | null>(null);
|
| 39 |
const [isAuthenticated, setIsAuthenticated] = useState(true);
|
| 40 |
+
// Defaults to true so the UI is permissive until /api/auth/status answers.
|
| 41 |
+
// When OAuth is disabled (local dev), the backend always returns canEdit=true.
|
| 42 |
+
const [canEdit, setCanEdit] = useState(true);
|
| 43 |
const [chatUserId, setChatUserId] = useState(() => user.name);
|
| 44 |
|
| 45 |
useEffect(() => {
|
|
|
|
| 47 |
.then((r) => r.json())
|
| 48 |
.then((data) => {
|
| 49 |
setIsAuthenticated(data.authenticated);
|
| 50 |
+
setCanEdit(Boolean(data.canEdit));
|
| 51 |
if (data.authenticated && data.user) {
|
| 52 |
const name = data.user.fullName || data.user.name;
|
| 53 |
setUser({
|
|
|
|
| 1097 |
user={user}
|
| 1098 |
loginUrl={loginUrl}
|
| 1099 |
isAuthenticated={isAuthenticated}
|
| 1100 |
+
canEdit={canEdit}
|
| 1101 |
isPublishing={publishStatus.active}
|
| 1102 |
publishingUserName={publishStatus.userName}
|
| 1103 |
onToggleTheme={toggleTheme}
|
|
@@ -15,6 +15,8 @@ import {
|
|
| 15 |
MoreHorizontal,
|
| 16 |
FileText,
|
| 17 |
Trash2,
|
|
|
|
|
|
|
| 18 |
} from "lucide-react";
|
| 19 |
import { Tooltip } from "./Tooltip";
|
| 20 |
import { SyncIndicator } from "./SyncIndicator";
|
|
@@ -29,6 +31,17 @@ interface TopBarProps {
|
|
| 29 |
user: CollabUser;
|
| 30 |
loginUrl: string | null;
|
| 31 |
isAuthenticated: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
/** True while any collaborator has a publish in progress. */
|
| 33 |
isPublishing?: boolean;
|
| 34 |
/** Name of the user who initiated the running publish, if known. */
|
|
@@ -52,6 +65,7 @@ export function TopBar({
|
|
| 52 |
user,
|
| 53 |
loginUrl,
|
| 54 |
isAuthenticated,
|
|
|
|
| 55 |
isPublishing = false,
|
| 56 |
publishingUserName = null,
|
| 57 |
onToggleTheme,
|
|
@@ -59,11 +73,17 @@ export function TopBar({
|
|
| 59 |
onOpenPublish,
|
| 60 |
onOpenMobileToc,
|
| 61 |
}: TopBarProps) {
|
| 62 |
-
const publishTooltip =
|
| 63 |
-
?
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
// Dev / utility menu state (Load demo, Reset article). Kept out of
|
| 69 |
// the slash menu so it doesn't clutter the authoring surface.
|
|
@@ -253,7 +273,7 @@ export function TopBar({
|
|
| 253 |
className="icon-btn icon-btn--primary"
|
| 254 |
onClick={onOpenPublish}
|
| 255 |
aria-label="Publish article"
|
| 256 |
-
disabled={isPublishing}
|
| 257 |
aria-busy={isPublishing || undefined}
|
| 258 |
>
|
| 259 |
<Upload size={18} />
|
|
@@ -271,6 +291,19 @@ export function TopBar({
|
|
| 271 |
Sign in with HF
|
| 272 |
</a>
|
| 273 |
) : (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
<div
|
| 275 |
className="top-bar__menu"
|
| 276 |
ref={accountMenuRef}
|
|
@@ -312,6 +345,7 @@ export function TopBar({
|
|
| 312 |
</div>
|
| 313 |
)}
|
| 314 |
</div>
|
|
|
|
| 315 |
)}
|
| 316 |
</div>
|
| 317 |
);
|
|
|
|
| 15 |
MoreHorizontal,
|
| 16 |
FileText,
|
| 17 |
Trash2,
|
| 18 |
+
PencilLine,
|
| 19 |
+
Lock,
|
| 20 |
} from "lucide-react";
|
| 21 |
import { Tooltip } from "./Tooltip";
|
| 22 |
import { SyncIndicator } from "./SyncIndicator";
|
|
|
|
| 31 |
user: CollabUser;
|
| 32 |
loginUrl: string | null;
|
| 33 |
isAuthenticated: boolean;
|
| 34 |
+
/**
|
| 35 |
+
* True when the signed-in user has write access on the backing Space
|
| 36 |
+
* (Space owner, or org member with a `write`/`admin` role, or
|
| 37 |
+
* `contributor` with at least one write resource group). When false
|
| 38 |
+
* the editor is effectively read-only: the backend rejects mutating
|
| 39 |
+
* routes with 403, so we mirror that on the UI by surfacing a
|
| 40 |
+
* "Read-only" badge and disabling the Publish action up front.
|
| 41 |
+
* Defaults to true in callers so the UI is permissive until the
|
| 42 |
+
* status endpoint answers.
|
| 43 |
+
*/
|
| 44 |
+
canEdit?: boolean;
|
| 45 |
/** True while any collaborator has a publish in progress. */
|
| 46 |
isPublishing?: boolean;
|
| 47 |
/** Name of the user who initiated the running publish, if known. */
|
|
|
|
| 65 |
user,
|
| 66 |
loginUrl,
|
| 67 |
isAuthenticated,
|
| 68 |
+
canEdit = true,
|
| 69 |
isPublishing = false,
|
| 70 |
publishingUserName = null,
|
| 71 |
onToggleTheme,
|
|
|
|
| 73 |
onOpenPublish,
|
| 74 |
onOpenMobileToc,
|
| 75 |
}: TopBarProps) {
|
| 76 |
+
const publishTooltip = !canEdit
|
| 77 |
+
? "Read-only access - you don't have write rights on this Space"
|
| 78 |
+
: isPublishing
|
| 79 |
+
? publishingUserName
|
| 80 |
+
? `${publishingUserName} is publishing...`
|
| 81 |
+
: "Publish in progress..."
|
| 82 |
+
: "Publish article";
|
| 83 |
+
|
| 84 |
+
const accessTooltip = canEdit
|
| 85 |
+
? "Editor access - you can write to this Space"
|
| 86 |
+
: "Read-only access - ask an org admin to grant you a write role";
|
| 87 |
|
| 88 |
// Dev / utility menu state (Load demo, Reset article). Kept out of
|
| 89 |
// the slash menu so it doesn't clutter the authoring surface.
|
|
|
|
| 273 |
className="icon-btn icon-btn--primary"
|
| 274 |
onClick={onOpenPublish}
|
| 275 |
aria-label="Publish article"
|
| 276 |
+
disabled={isPublishing || !canEdit}
|
| 277 |
aria-busy={isPublishing || undefined}
|
| 278 |
>
|
| 279 |
<Upload size={18} />
|
|
|
|
| 291 |
Sign in with HF
|
| 292 |
</a>
|
| 293 |
) : (
|
| 294 |
+
<>
|
| 295 |
+
{isAuthenticated && (
|
| 296 |
+
<Tooltip title={accessTooltip}>
|
| 297 |
+
<span
|
| 298 |
+
className={`chip chip--sm ${canEdit ? "chip--editor" : "chip--readonly"}`}
|
| 299 |
+
style={{ marginLeft: 4 }}
|
| 300 |
+
aria-label={canEdit ? "Editor access" : "Read-only access"}
|
| 301 |
+
>
|
| 302 |
+
{canEdit ? <PencilLine size={11} /> : <Lock size={11} />}
|
| 303 |
+
{canEdit ? "Editor" : "Read-only"}
|
| 304 |
+
</span>
|
| 305 |
+
</Tooltip>
|
| 306 |
+
)}
|
| 307 |
<div
|
| 308 |
className="top-bar__menu"
|
| 309 |
ref={accountMenuRef}
|
|
|
|
| 345 |
</div>
|
| 346 |
)}
|
| 347 |
</div>
|
| 348 |
+
</>
|
| 349 |
)}
|
| 350 |
</div>
|
| 351 |
);
|
|
@@ -121,6 +121,26 @@
|
|
| 121 |
.chip--clickable:hover { filter: brightness(1.1); }
|
| 122 |
.chip img { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; }
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
/* ---- Surface (replaces MUI Paper) ---- */
|
| 125 |
.surface {
|
| 126 |
background: var(--ed-surface);
|
|
|
|
| 121 |
.chip--clickable:hover { filter: brightness(1.1); }
|
| 122 |
.chip img { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; }
|
| 123 |
|
| 124 |
+
/* Access badges shown next to the user chip when authenticated.
|
| 125 |
+
--editor confirms write access; --readonly signals the user is signed
|
| 126 |
+
in but lacks a write role on the backing Space (so mutating actions
|
| 127 |
+
are disabled). Both stay subtle so they don't compete with the
|
| 128 |
+
colored user chip. */
|
| 129 |
+
.chip--editor {
|
| 130 |
+
background: color-mix(in srgb, var(--ed-success) 14%, transparent);
|
| 131 |
+
color: var(--ed-success);
|
| 132 |
+
border: 1px solid color-mix(in srgb, var(--ed-success) 40%, transparent);
|
| 133 |
+
gap: 4px;
|
| 134 |
+
font-weight: 500;
|
| 135 |
+
}
|
| 136 |
+
.chip--readonly {
|
| 137 |
+
background: var(--ed-surface-hover);
|
| 138 |
+
color: var(--ed-text-secondary);
|
| 139 |
+
border: 1px solid var(--ed-border);
|
| 140 |
+
gap: 4px;
|
| 141 |
+
font-weight: 500;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
/* ---- Surface (replaces MUI Paper) ---- */
|
| 145 |
.surface {
|
| 146 |
background: var(--ed-surface);
|