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

@@ -100,6 +100,7 @@ useTitleNotes(repo)
const store = useUserRepoStore() const store = useUserRepoStore()
const hasBacklinks = computed(() => store.userSettings?.backlink) const hasBacklinks = computed(() => store.userSettings?.backlink)
const canPush = computed(() => store.canPush)
const { displayNoteOverlay } = useNoteOverlay(className.value, index) const { displayNoteOverlay } = useNoteOverlay(className.value, index)
const displayedTitle = computed(() => filenameToNoteTitle(props.title ?? "")) const displayedTitle = computed(() => filenameToNoteTitle(props.title ?? ""))
@@ -303,7 +304,7 @@ const onBadgeClick = async () => {
class="action" class="action"
/> />
<button <button
v-if="isMarkdown" v-if="isMarkdown && canPush"
class="action button is-text is-light" class="action button is-text is-light"
:class="{ 'is-link': mode === 'edit' }" :class="{ 'is-link': mode === 'edit' }"
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''" :style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
@@ -353,7 +354,7 @@ const onBadgeClick = async () => {
</svg> </svg>
</button> </button>
<button <button
v-if="isMarkdown && mode === 'edit'" v-if="isMarkdown && mode === 'edit' && canPush"
class="action button is-text is-light" class="action button is-text is-light"
:title="isUploading ? 'Uploading…' : 'Upload image'" :title="isUploading ? 'Uploading…' : 'Upload image'"
:disabled="isUploading" :disabled="isUploading"

View File

@@ -35,7 +35,8 @@ export const useCheckboxCommit = ({
initialContent, initialContent,
initialSha, initialSha,
containerSelector, containerSelector,
debounceMs = 1000 debounceMs = 1000,
enabled = true
}: { }: {
user: string user: string
repo: string repo: string
@@ -44,6 +45,7 @@ export const useCheckboxCommit = ({
initialSha: Ref<string> | string initialSha: Ref<string> | string
containerSelector: string containerSelector: string
debounceMs?: number debounceMs?: number
enabled?: Ref<boolean> | boolean
}) => { }) => {
const { updateFile } = useGitHubContent({ user, repo }) const { updateFile } = useGitHubContent({ user, repo })
@@ -91,6 +93,8 @@ export const useCheckboxCommit = ({
const debouncedCommit = useDebounceFn(commitChanges, debounceMs) const debouncedCommit = useDebounceFn(commitChanges, debounceMs)
const handleCheckboxChange = (event: Event) => { const handleCheckboxChange = (event: Event) => {
if (!toValue(enabled)) return
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement
if (target.tagName !== "INPUT" || target.type !== "checkbox") { if (target.tagName !== "INPUT" || target.type !== "checkbox") {
@@ -128,8 +132,16 @@ export const useCheckboxCommit = ({
const listenToCheckboxes = () => { const listenToCheckboxes = () => {
removeListeners() removeListeners()
const container = document.querySelector(containerSelector) const container = document.querySelector(containerSelector)
if (container) { if (!container) return
if (toValue(enabled)) {
container.addEventListener("change", handleCheckboxChange) container.addEventListener("change", handleCheckboxChange)
} else {
container
.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
.forEach((checkbox) => {
checkbox.disabled = true
})
} }
} }

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 { UserSettings } from "@/modules/repo/interfaces/UserSettings"
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo" 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 ( export const getFiles = async (
owner: string, owner: string,
repo: string repo: string

View File

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

View File

@@ -9,6 +9,7 @@ import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hoo
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache" import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
import EditNote from "@/modules/note/components/EditNote.vue" import EditNote from "@/modules/note/components/EditNote.vue"
import { useFolderNotes } from "@/modules/note/hooks/useFolderNotes" import { useFolderNotes } from "@/modules/note/hooks/useFolderNotes"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8" import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { confirmMessage, errorMessage } from "@/utils/notif" import { confirmMessage, errorMessage } from "@/utils/notif"
import { extractYouTubeId } from "@/utils/youtube" import { extractYouTubeId } from "@/utils/youtube"
@@ -76,6 +77,9 @@ const { createFile } = useGitHubContent({
const hasTodayNote = computed(() => content.value.includes(today)) const hasTodayNote = computed(() => content.value.includes(today))
const store = useUserRepoStore()
const canPush = computed(() => store.canPush)
watch(mode, async (newMode) => { watch(mode, async (newMode) => {
if (newMode === "read" && newContent.value.trim() !== initialContent) { if (newMode === "read" && newContent.value.trim() !== initialContent) {
const content = `# ${new Date().toLocaleDateString()}\n\n${ const content = `# ${new Date().toLocaleDateString()}\n\n${
@@ -114,13 +118,15 @@ watch(mode, async (newMode) => {
<h3 class="subtitle">Inbox</h3> <h3 class="subtitle">Inbox</h3>
<div class="actions"> <div class="actions">
<button <button
v-if="!hasTodayNote" v-if="!hasTodayNote && canPush"
class="btn btn-secondary" class="btn btn-secondary"
@click="toggleMode" @click="toggleMode"
> >
new fleeting note new fleeting note
</button> </button>
<button class="btn btn-outline" @click="handleYouTube">YouTube</button> <button v-if="canPush" class="btn btn-outline" @click="handleYouTube">
YouTube
</button>
</div> </div>
<div v-if="mode === 'edit'"> <div v-if="mode === 'edit'">

View File

@@ -26,9 +26,10 @@ const todoNote = computed(() =>
const sha = computed(() => todoNote.value?.sha ?? "") const sha = computed(() => todoNote.value?.sha ?? "")
const path = computed(() => todoNote.value?.path) const path = computed(() => todoNote.value?.path)
const canPush = computed(() => store.canPush)
const { toHTML } = markdownBuilder(repo) const { toHTML } = markdownBuilder(repo)
// Setup checkbox commit handler
const { pendingContent, syncContent, listenToCheckboxes, hasPendingChanges } = const { pendingContent, syncContent, listenToCheckboxes, hasPendingChanges } =
useCheckboxCommit({ useCheckboxCommit({
user: props.user, user: props.user,
@@ -37,7 +38,8 @@ const { pendingContent, syncContent, listenToCheckboxes, hasPendingChanges } =
initialContent: "", initialContent: "",
initialSha: sha, initialSha: sha,
containerSelector: ".todo-notes .note-display", containerSelector: ".todo-notes .note-display",
debounceMs: 1000 debounceMs: 1000,
enabled: canPush
}) })
// Render pending content to HTML for display // Render pending content to HTML for display
@@ -64,9 +66,9 @@ watch(
{ immediate: true } { immediate: true }
) )
// Setup checkbox listeners when content renders // Setup checkbox listeners when content renders or canPush changes
watch( watch(
renderedContent, [renderedContent, canPush],
async () => { async () => {
await nextTick() await nextTick()
listenToCheckboxes() listenToCheckboxes()