feat: 저장소 설정 권한 게이팅 + Gitea 역동기화 + 설정 로직 정비

설정 탭/권한:
- findOne/update 응답에 현재 사용자 권한(canManage=admin, isOwner=소유자) 포함
  (목록은 캐시·사용자 무관이라 미포함). RepoTabs 가 canManage 일 때만 설정 탭 노출,
  비관리자의 설정 화면 직접 진입은 저장소 상세로 리다이렉트. 입력 비활성화 처리 제거.
- 저장소 삭제는 isOwner 일 때만(비소유자 admin 엔 비활성+안내).

설정 로직 버그/정리:
- update 가 이전 값과 비교해 실제 변경분만 활동/Gitea/캐시 반영
  (무변경 저장 시 허위 'renamed'/'visibility_changed' 활동이 쌓이던 버그 수정).
- 위험구역의 미구현 'Gitea 저장소 이름 변경'(slug rename) 스텁 제거.
- 조직 owner 아바타(하드코딩 'M') 제거.

Gitea 역동기화(Gitea=SSOT):
- 동기화 매칭 시 slug(rename)·공개범위·기본 브랜치를 Gitea 기준으로 끌어옴
  → Gitea 에서 직접 바꿔도 Relay 가 따라가고 slugName 도 갱신돼 쓰기 경로 일치
  (rename 후 옛 이름으로 쓰기 호출이 404 나던 desync 해소). 중복 매칭 가드 추가.
- 표시명/설명은 Gitea 단일 description 합본 구조라 Relay-SSOT 유지(외부 편집 시 깨짐 방지).
- GiteaService.updateRepo 에 default_branch 추가 + update 가 브랜치도 Gitea 에 푸시(양방향 일치).

검증: 백엔드 build/lint 0·20 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 15:39:41 +09:00
parent 56c89d9927
commit 8935f6e488
14 changed files with 154 additions and 79 deletions
+3
View File
@@ -7,6 +7,8 @@ interface Props {
active: 'tasks' | 'members' | 'activity' | 'settings'
taskCount: number
memberCount: number
/** 설정 탭 노출 여부(관리자만). 기본 false — 권한이 확인된 경우에만 노출 */
canManage?: boolean
}
defineProps<Props>()
</script>
@@ -32,6 +34,7 @@ defineProps<Props>()
<span class="tb-label">활동</span>
</RouterLink>
<RouterLink
v-if="canManage"
:to="`/repos/${repoId}/settings`"
:class="{ active: active === 'settings' }"
>
+4
View File
@@ -65,6 +65,10 @@ export interface Repo {
htmlUrl?: string | null
/** Gitea 조직에서 사라진 저장소 표시(역방향 동기화로 갱신) */
giteaMissing?: boolean
/** 현재 사용자가 이 저장소 관리자(admin)인지 — 단건 조회에서만 채워짐(설정 탭 노출/진입) */
canManage?: boolean
/** 현재 사용자가 소유자인지 — 저장소 삭제 권한 */
isOwner?: boolean
}
/** 저장소 상세의 업무 목록 행 */
@@ -111,6 +111,7 @@ const ActIcon = defineComponent({
active="activity"
:task-count="repo.totalCount"
:member-count="memberCount"
:can-manage="repo.canManage"
/>
<!-- 툴바 -->
@@ -165,6 +165,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
active="tasks"
:task-count="counts.all"
:member-count="memberCount"
:can-manage="repo.canManage"
/>
<!-- 툴바 -->
@@ -232,6 +232,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
active="members"
:task-count="repo.totalCount"
:member-count="counts.all"
:can-manage="repo.canManage"
/>
<div class="tab-note">
+32 -42
View File
@@ -15,6 +15,10 @@ const repoId = computed(() => String(route.params.repoId))
const repoStore = useRepoStore()
const repo = ref<Repo | null>(null)
// 현재 사용자 권한 — 단건 응답에 포함된 값(백엔드와 동일 정책: 설정=admin, 삭제=소유자)
const canManage = computed(() => repo.value?.canManage === true)
const isOwner = computed(() => repo.value?.isOwner === true)
const headerMembers = computed(() => repo.value?.members ?? [])
const memberCount = computed(() =>
repo.value ? repo.value.members.length + repo.value.moreCount : 0,
@@ -36,6 +40,11 @@ async function loadRepo() {
return
}
if (!repo.value) return
// 비관리자는 설정 화면에 진입할 수 없음 — 저장소 상세로 되돌린다(탭도 숨겨져 있어 정상 경로로는 도달 불가)
if (!repo.value.canManage) {
void router.replace(`/repos/${repoId.value}`)
return
}
displayTitle.value = repo.value.name
description.value = repo.value.desc
visibility.value = repo.value.visibility
@@ -43,9 +52,9 @@ async function loadRepo() {
}
watch(repoId, loadRepo, { immediate: true })
// 변경 저장
// 변경 저장 (admin 전용)
async function save() {
if (saving.value || !repo.value) return
if (saving.value || !repo.value || !canManage.value) return
saving.value = true
try {
repo.value = await repoStore.update(repoId.value, {
@@ -62,9 +71,9 @@ async function save() {
}
}
// 저장소 삭제
// 저장소 삭제 (소유자 전용)
async function removeRepo() {
if (!repo.value) return
if (!repo.value || !isOwner.value) return
if (!window.confirm('저장소와 그 안의 모든 업무·활동 기록이 영구 삭제됩니다. 계속할까요?')) return
try {
await repoStore.remove(repoId.value)
@@ -73,17 +82,12 @@ async function removeRepo() {
// 인터셉터 전역 처리
}
}
// 저장소 이름(slug) 변경 — 8단계 이후 지원 예정
function renameRepo() {
window.alert('저장소 이름(slug) 변경은 준비 중입니다.')
}
</script>
<template>
<AppShell>
<div
v-if="repo"
v-if="repo && repo.canManage"
class="page"
>
<div class="crumb">
@@ -115,6 +119,7 @@ function renameRepo() {
active="settings"
:task-count="repo.totalCount"
:member-count="memberCount"
:can-manage="repo.canManage"
/>
<div class="settings">
@@ -166,7 +171,7 @@ function renameRepo() {
Gitea 저장소 이름(영문) 주소에 사용됩니다. 변경은 아래 위험 구역에서 있습니다.
</div>
<div class="readonly">
<span class="owner-avatar">M</span> {{ repo.owner }} <span class="slash">/</span> <span class="mono">{{ repo.id }}</span>
{{ repo.owner }} <span class="slash">/</span> <span class="mono">{{ repo.id }}</span>
<span class="lock">
<svg
viewBox="0 0 24 24"
@@ -318,23 +323,6 @@ function renameRepo() {
위험 구역
</h2>
<div class="danger-card">
<div class="danger-row">
<div class="dr-main">
<div class="dr-title">
Gitea 저장소 이름 변경
</div>
<div class="dr-desc">
영문 저장소 이름과 Gitea 주소가 함께 바뀝니다. 기존 링크가 동작하지 않을 있습니다.
</div>
</div>
<button
class="btn-danger"
type="button"
@click="renameRepo"
>
이름 변경
</button>
</div>
<div class="danger-row">
<div class="dr-main">
<div class="dr-title">
@@ -342,11 +330,16 @@ function renameRepo() {
</div>
<div class="dr-desc">
저장소와 안의 모든 업무·활동 기록이 영구 삭제됩니다. 되돌릴 없습니다.
<span
v-if="!isOwner"
class="owner-only"
>소유자만 삭제할 있습니다.</span>
</div>
</div>
<button
class="btn-danger solid"
type="button"
:disabled="!isOwner"
@click="removeRepo"
>
저장소 삭제
@@ -447,6 +440,17 @@ function renameRepo() {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-danger:disabled:hover {
background: var(--red);
}
.owner-only {
color: var(--red);
font-weight: 600;
}
textarea.tarea {
min-height: 5.25rem;
padding: 0.625rem 0.75rem;
@@ -473,17 +477,6 @@ textarea.tarea {
font-weight: 500;
cursor: not-allowed;
}
.readonly .owner-avatar {
width: 1.25rem;
height: 1.25rem;
border-radius: 0.3125rem;
background: #0ea5a3;
color: #fff;
display: grid;
place-items: center;
font-size: 0.656rem;
font-weight: 700;
}
.readonly .slash {
color: var(--text-3);
}
@@ -675,9 +668,6 @@ textarea.tarea {
flex-shrink: 0;
font-family: inherit;
}
.btn-danger:hover {
background: var(--red-weak);
}
.btn-danger.solid {
background: var(--red);
border-color: var(--red);
+2
View File
@@ -42,6 +42,8 @@ function toRepoView(api: ApiRepo): RepoView {
cloneUrl: api.cloneUrl,
htmlUrl: api.htmlUrl,
giteaMissing: api.giteaMissing,
canManage: api.canManage,
isOwner: api.isOwner,
}
}
+3
View File
@@ -26,6 +26,9 @@ export interface ApiRepo {
htmlUrl: string | null // Gitea 웹 UI URL (미연동 시 null)
giteaMissing: boolean // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신)
updatedAt: string
// 현재 사용자 권한 — 단건 조회에서만 내려옴(목록은 미포함)
canManage?: boolean // admin (설정 변경/탭 노출)
isOwner?: boolean // 소유자 (저장소 삭제)
}
// 저장소 생성 요청