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:
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
56
src/modules/repo/services/repo.spec.ts
Normal file
56
src/modules/repo/services/repo.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'">
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user