tfrere HF Staff Cursor commited on
Commit
0e7d2a3
·
1 Parent(s): 0c69852

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 CHANGED
@@ -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}
frontend/src/components/TopBar.tsx CHANGED
@@ -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 = isPublishing
63
- ? publishingUserName
64
- ? `${publishingUserName} is publishing...`
65
- : "Publish in progress..."
66
- : "Publish article";
 
 
 
 
 
 
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
  );
frontend/src/styles/_ui.css CHANGED
@@ -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);