feat(repo): hide write UI for users without push access

Fetch the viewer's push permission via GET /repos/{owner}/{repo} when
entering a repo and gate every write affordance behind it: edit and
image-upload buttons in StackedNote, new-fleeting-note and YouTube
buttons in FleetingNotes, and interactive checkboxes in TodoNotes
(rendered disabled when read-only). Anonymous viewers get the same
treatment because the permissions field is absent without auth, which
collapses to canPush = false.

Previously every write button was visible regardless of role, so
read-role collaborators and anonymous viewers could enter edit mode and
type before the save failed with 401/403.
This commit is contained in:
Julien Calixte
2026-05-29 15:24:12 +02:00
parent 455addf760
commit a09e541fa8
7 changed files with 115 additions and 10 deletions

View File

@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const { reposGet } = vi.hoisted(() => ({ reposGet: vi.fn() }))
vi.mock("@/modules/repo/services/octo", () => ({
getOctokit: vi.fn().mockResolvedValue({
repos: { get: reposGet }
}),
runWithAuthRetry: vi.fn()
}))
vi.mock("@/hooks/useMarkdown.hook", () => ({
markdownBuilder: () => ({ render: (s: string) => s })
}))
vi.mock("@/modules/note/cache/prepareNoteCache", () => ({
prepareNoteCache: () => ({
getCachedNote: vi.fn().mockResolvedValue({ note: null }),
saveCacheNote: vi.fn()
})
}))
import { getRepoPermission } from "./repo"
describe("getRepoPermission", () => {
beforeEach(() => {
reposGet.mockReset()
})
it("returns true when permissions.push is true", async () => {
reposGet.mockResolvedValue({
data: { permissions: { push: true } }
})
await expect(getRepoPermission("owner", "repo")).resolves.toBe(true)
})
it("returns false when permissions.push is false", async () => {
reposGet.mockResolvedValue({
data: { permissions: { push: false } }
})
await expect(getRepoPermission("owner", "repo")).resolves.toBe(false)
})
it("returns false when permissions is missing (anonymous request)", async () => {
reposGet.mockResolvedValue({
data: {}
})
await expect(getRepoPermission("owner", "repo")).resolves.toBe(false)
})
it("returns false when owner or repo is empty", async () => {
await expect(getRepoPermission("", "repo")).resolves.toBe(false)
await expect(getRepoPermission("owner", "")).resolves.toBe(false)
expect(reposGet).not.toHaveBeenCalled()
})
})

View File

@@ -4,6 +4,18 @@ import { RepoFile } from "@/modules/repo/interfaces/RepoFile"
import { UserSettings } from "@/modules/repo/interfaces/UserSettings"
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
export const getRepoPermission = async (
owner: string,
repo: string
): Promise<boolean> => {
if (!owner || !repo) {
return false
}
const octokit = await getOctokit()
const { data } = await octokit.repos.get({ owner, repo })
return data.permissions?.push ?? false
}
export const getFiles = async (
owner: string,
repo: string

View File

@@ -10,6 +10,7 @@ import {
getCachedMainReadme,
getFiles,
getMainReadme,
getRepoPermission,
getUserSettingsContent
} from "@/modules/repo/services/repo"
import { refreshToken } from "@/modules/user/service/signIn"
@@ -24,6 +25,7 @@ interface State {
userSettings?: UserSettings | null
needToLogin: boolean
loadError: RepoLoadError
canPush: boolean
_requestId: number
}
@@ -46,6 +48,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
userSettings: undefined,
needToLogin: false,
loadError: null,
canPush: false,
_requestId: 0
}),
actions: {
@@ -78,6 +81,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
this.user = user
this.repo = repo
this.loadError = null
this.canPush = false
let lsLayout: Partial<UserSettings> = {}
try {
@@ -120,6 +124,17 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
if (requestId !== this._requestId) return
getRepoPermission(user, repo)
.then((canPush) => {
if (requestId !== this._requestId) return
this.canPush = canPush
})
.catch((error) => {
if (requestId !== this._requestId) return
console.warn("getRepoPermission failed", error)
this.canPush = false
})
getFiles(user, repo)
.then(async (files) => {
if (requestId !== this._requestId) return
@@ -261,6 +276,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
resetFiles() {
this.files = []
this.readme = null
this.canPush = false
},
setFontFamily(fontFamily: string) {
if (!this.userSettings) {