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:
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user