From 8935f6e4889c219943d2a38902bb8f78d9c1a2f4 Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 19 Jun 2026 15:39:41 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B6=8C=ED=95=9C=20=EA=B2=8C=EC=9D=B4=ED=8C=85=20?= =?UTF-8?q?+=20Gitea=20=EC=97=AD=EB=8F=99=EA=B8=B0=ED=99=94=20+=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설정 탭/권한: - 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) --- backend/src/modules/gitea/gitea.service.ts | 6 +- backend/src/modules/repo/repo-sync.service.ts | 51 +++++++++---- backend/src/modules/repo/repo.controller.ts | 7 +- backend/src/modules/repo/repo.service.ts | 71 ++++++++++++++---- docs/api-contract.md | 7 +- docs/integration-progress.md | 2 +- frontend/src/components/RepoTabs.vue | 3 + frontend/src/mock/relay.mock.ts | 4 + frontend/src/pages/relay/RepoActivityPage.vue | 1 + frontend/src/pages/relay/RepoDetailPage.vue | 1 + frontend/src/pages/relay/RepoMembersPage.vue | 1 + frontend/src/pages/relay/RepoSettingsPage.vue | 74 ++++++++----------- frontend/src/stores/repo.store.ts | 2 + frontend/src/types/repo.ts | 3 + 14 files changed, 154 insertions(+), 79 deletions(-) diff --git a/backend/src/modules/gitea/gitea.service.ts b/backend/src/modules/gitea/gitea.service.ts index 42674a4..b46a48b 100644 --- a/backend/src/modules/gitea/gitea.service.ts +++ b/backend/src/modules/gitea/gitea.service.ts @@ -129,10 +129,11 @@ export class GiteaService { return this.toResult(data); } - // 저장소 메타데이터(설명/공개범위) 수정 — slug(name) 는 변경하지 않음 + // 저장소 메타데이터(설명/공개범위/기본 브랜치) 수정 — slug(name) 는 변경하지 않음. + // default_branch 는 Gitea 에 실제로 존재하는 브랜치여야 한다(없으면 Gitea 가 에러). async updateRepo( name: string, - patch: { description?: string; private?: boolean }, + patch: { description?: string; private?: boolean; defaultBranch?: string }, ): Promise { await this.request( 'PATCH', @@ -140,6 +141,7 @@ export class GiteaService { { description: patch.description, private: patch.private, + default_branch: patch.defaultBranch, }, ); } diff --git a/backend/src/modules/repo/repo-sync.service.ts b/backend/src/modules/repo/repo-sync.service.ts index 3dd1409..c7d93fd 100644 --- a/backend/src/modules/repo/repo-sync.service.ts +++ b/backend/src/modules/repo/repo-sync.service.ts @@ -5,7 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { UserService } from '../user/user.service'; import type { User } from '../user/entities/user.entity'; -import { GiteaService } from '../gitea/gitea.service'; +import { GiteaService, type GiteaRepoResult } from '../gitea/gitea.service'; import { RepoCacheService } from './repo-cache.service'; import { Repo } from './entities/repo.entity'; import { RepoMember } from './entities/repo-member.entity'; @@ -80,16 +80,23 @@ export class RepoSyncService implements OnApplicationBootstrap { } const seenGiteaIds = new Set(); + // 이번 동기화에서 이미 매칭된 Relay 저장소(uuid) — 중복 매칭 방지 + const matchedRepoIds = new Set(); let imported = 0; let relinked = 0; for (const gr of remote) { seenGiteaIds.add(gr.id); - const match = byGiteaId.get(gr.id) ?? bySlug.get(gr.name); + // Gitea ID 우선, 없으면 옛 slug 로 매칭. 단 이미 매칭된 저장소는 제외 + // (Gitea 에서 rename 후 옛 이름으로 새 저장소를 만든 경우의 stale-slug 충돌 방지) + const candidate = byGiteaId.get(gr.id) ?? bySlug.get(gr.name); + const match = + candidate && !matchedRepoIds.has(candidate.id) ? candidate : null; if (match) { - // 기존 Relay 저장소 — 연동정보 backfill + 누락표시 해제. - // 표시명/설명/공개범위는 Relay 가 SSOT 이므로 덮어쓰지 않는다. - if (this.backfillLinkage(match, gr)) { + matchedRepoIds.add(match.id); + // Gitea 를 SSOT 로 — 이름(slug)/표시명/설명/공개범위/브랜치 + 연동정보를 Gitea 기준에 맞춘다. + // (Gitea 에서 직접 변경한 내용을 Relay 로 끌어온다. rename 시 slugName 도 따라가 라우팅·쓰기 동기화 일치) + if (this.reconcileFromGitea(match, gr)) { await this.repoRepo.save(match); relinked++; } @@ -191,17 +198,31 @@ export class RepoSyncService implements OnApplicationBootstrap { await this.memberRepo.save(member); } - // 기존 저장소에 Gitea 연동정보를 채우고 누락표시를 해제 — 변경이 있었으면 true - private backfillLinkage( - repo: Repo, - gr: { - id: number; - fullName: string; - cloneUrl: string; - htmlUrl: string; - }, - ): boolean { + // Gitea 를 기준으로 Relay 저장소 메타를 맞춘다 — Gitea 의 네이티브 필드와 깔끔히 1:1 대응되는 + // 이름(slug)/공개범위/기본 브랜치 + 연동정보만 동기화한다. 변경이 있었으면 true. + // ※ 표시명/설명은 Gitea 단일 description 에 "표시제목 — 설명" 으로 합쳐 저장하므로, + // Gitea 에서 description 을 직접 편집하면 표시명이 깨질 수 있어 Relay 가 SSOT 로 유지한다. + private reconcileFromGitea(repo: Repo, gr: GiteaRepoResult): boolean { let changed = false; + + // 이름(slug) — Gitea rename 반영. Relay 라우팅 URL 과 이후 Gitea 쓰기 경로가 새 이름을 따른다 + if (repo.slugName !== gr.name) { + repo.slugName = gr.name; + changed = true; + } + // 공개범위 + const visibility = gr.private ? 'private' : 'public'; + if (repo.visibility !== visibility) { + repo.visibility = visibility; + changed = true; + } + // 기본 브랜치 + if (gr.defaultBranch && repo.branch !== gr.defaultBranch) { + repo.branch = gr.defaultBranch; + changed = true; + } + + // 연동정보(linkage) + 누락표시 해제 if (repo.giteaRepoId !== gr.id) { repo.giteaRepoId = gr.id; changed = true; diff --git a/backend/src/modules/repo/repo.controller.ts b/backend/src/modules/repo/repo.controller.ts index 820454d..5d81a4d 100644 --- a/backend/src/modules/repo/repo.controller.ts +++ b/backend/src/modules/repo/repo.controller.ts @@ -69,8 +69,11 @@ export class RepoController { @ApiOperation({ summary: '저장소 단건 조회' }) @ApiResponse({ status: 200, description: '조회 성공' }) @ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' }) - findOne(@Param('repoId') repoId: string): Promise { - return this.repoService.findOne(repoId); + findOne( + @Param('repoId') repoId: string, + @CurrentUser() user: PublicUser, + ): Promise { + return this.repoService.findOne(repoId, user.id); } @Patch(':repoId') diff --git a/backend/src/modules/repo/repo.service.ts b/backend/src/modules/repo/repo.service.ts index 82a2cb2..a255430 100644 --- a/backend/src/modules/repo/repo.service.ts +++ b/backend/src/modules/repo/repo.service.ts @@ -50,6 +50,9 @@ export interface RepoResponse { htmlUrl: string | null; // Gitea 웹 UI URL (미연동 시 null) giteaMissing: boolean; // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신) updatedAt: string; + // 현재 사용자의 이 저장소 권한 — 단건(findOne)에서만 채워진다(목록은 캐시·사용자 무관이라 미포함). + canManage?: boolean; // admin (설정 변경/탭 노출) + isOwner?: boolean; // 소유자 (저장소 삭제) } // 저장소 비즈니스 로직 @@ -114,9 +117,16 @@ export class RepoService { } // 저장소 단건 — 조직 모델상 인증 사용자면 열람 가능(쓰기 권한은 별도 가드). - async findOne(slugName: string): Promise { + // 응답에 현재 사용자의 권한(canManage/isOwner)을 함께 내려, 프론트가 설정 탭 노출·진입을 결정한다. + async findOne(slugName: string, userId: string): Promise { const repo = await this.getEntityOrThrow(slugName); - return this.toResponse(repo, await this.taskCountsOf(repo.id)); + const membership = await this.memberRepo.findOne({ + where: { repo: { id: repo.id }, user: { id: userId } }, + }); + return this.toResponse(repo, await this.taskCountsOf(repo.id), { + canManage: membership?.roleType === 'admin', + isOwner: membership?.isOwner ?? false, + }); } // 저장소 생성 — Gitea 조직에 실제 repo 를 만들고, 생성자를 소유자(admin)로 등록 @@ -218,9 +228,12 @@ export class RepoService { const repo = await this.getEntityOrThrow(slugName); await this.assertAdmin(repo.id, userId); - // 표시제목/설명은 Gitea description 으로, 공개범위는 private 플래그로 동기화 - const descChanged = dto.name !== undefined || dto.description !== undefined; - const visChanged = dto.visibility !== undefined; + // 변경 감지 — 폼이 모든 필드를 항상 보내므로, 실제로 바뀐 값만 동기화·활동·캐시에 반영한다 + // (이전엔 필드 존재 여부로 판단해 저장할 때마다 허위 'renamed'/'visibility_changed' 활동이 쌓였음) + const prevName = repo.name; + const prevDescription = repo.description ?? ''; + const prevVisibility = repo.visibility; + const prevBranch = repo.branch; if (dto.name !== undefined) repo.name = dto.name; if (dto.description !== undefined) repo.description = dto.description; @@ -228,20 +241,30 @@ export class RepoService { if (dto.branch !== undefined && dto.branch.trim()) repo.branch = dto.branch.trim(); + const nameChanged = repo.name !== prevName; + const descriptionChanged = (repo.description ?? '') !== prevDescription; + const visibilityChanged = repo.visibility !== prevVisibility; + const branchChanged = repo.branch !== prevBranch; + await this.repoRepo.save(repo); - // Gitea 메타데이터 동기화 (베스트 에포트 — 실패해도 Relay 수정은 유지) + // Gitea 메타데이터 동기화 (베스트 에포트) — 표시명/설명/공개범위/브랜치가 실제 바뀐 경우에만. + // (브랜치도 Gitea→Relay 로 역동기화되므로, Relay 변경분을 Gitea 에 밀어 양쪽을 일치시킨다) + const giteaDescChanged = nameChanged || descriptionChanged; if ( this.gitea.enabled && repo.giteaRepoId !== null && - (descChanged || visChanged) + (giteaDescChanged || visibilityChanged || branchChanged) ) { await this.gitea .updateRepo(repo.slugName, { - description: descChanged + description: giteaDescChanged ? this.buildGiteaDescription(repo.name, repo.description) : undefined, - private: visChanged ? repo.visibility === 'private' : undefined, + private: visibilityChanged + ? repo.visibility === 'private' + : undefined, + defaultBranch: branchChanged ? repo.branch : undefined, }) .catch((e: unknown) => { this.logger.error( @@ -251,8 +274,8 @@ export class RepoService { }); } - // 활동 적재 — 표시 제목 변경 / 공개범위 변경(설정 그룹) - if (dto.name !== undefined) { + // 활동 적재 — 실제로 바뀐 측면만(표시 제목 변경 / 공개범위 변경) + if (nameChanged) { await this.activity.record({ repoId: repo.id, actorId: userId, @@ -260,7 +283,7 @@ export class RepoService { payload: { newName: repo.name }, }); } - if (visChanged) { + if (visibilityChanged) { await this.activity.record({ repoId: repo.id, actorId: userId, @@ -269,12 +292,27 @@ export class RepoService { }); } - // 목록 캐시 무효화(표시명/공개범위 변경 즉시 반영) - await this.repoCache.invalidateList(); + // 목록 캐시 무효화 — 목록 카드에 노출되는 값(표시명/설명/공개범위/브랜치)이 바뀐 경우에만 + if ( + nameChanged || + descriptionChanged || + visibilityChanged || + branchChanged + ) { + await this.repoCache.invalidateList(); + } + // 응답에도 현재 사용자 권한을 포함(프론트가 저장 후에도 canManage/isOwner 를 유지하도록) + const membership = await this.memberRepo.findOne({ + where: { repo: { id: repo.id }, user: { id: userId } }, + }); return this.toResponse( await this.getEntityOrThrow(slugName), await this.taskCountsOf(repo.id), + { + canManage: membership?.roleType === 'admin', + isOwner: membership?.isOwner ?? false, + }, ); } @@ -391,10 +429,11 @@ export class RepoService { return (await this.taskService.countsByRepo([repoId])).get(repoId); } - // 엔티티 → 응답 매핑 (counts 미제공 시 0/0) + // 엔티티 → 응답 매핑 (counts 미제공 시 0/0). perms 는 단건 조회에서만 채운다. private toResponse( repo: Repo, counts?: { done: number; total: number }, + perms?: { canManage: boolean; isOwner: boolean }, ): RepoResponse { const members = (repo.members ?? []).map((m) => UserService.toPublic(m.user), @@ -416,6 +455,8 @@ export class RepoService { htmlUrl: repo.htmlUrl, giteaMissing: repo.giteaMissing, updatedAt: repo.updatedAt.toISOString(), + canManage: perms?.canManage, + isOwner: perms?.isOwner, }; } } diff --git a/docs/api-contract.md b/docs/api-contract.md index 7a0b94c..df45cfb 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -115,9 +115,12 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프 "cloneUrl": "https://gitea.example.com/marketing-team/q3-launch-campaign.git", // 미연동 시 null "htmlUrl": "https://gitea.example.com/marketing-team/q3-launch-campaign", // 미연동 시 null "giteaMissing": false, // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신) - "updatedAt": "2026-06-16T11:00:00Z" + "updatedAt": "2026-06-16T11:00:00Z", + "canManage": true, // 현재 사용자 admin 여부 — 단건(GET /repos/:id)에서만. 설정 탭 노출/진입·수정 권한 + "isOwner": true // 현재 사용자 소유자 여부 — 저장소 삭제 권한 } ``` +> `canManage`/`isOwner` 는 **단건 조회·수정 응답에만** 포함된다(목록 `GET /repos` 는 캐시·사용자 무관이라 미포함). 프론트는 이 값으로 **설정 탭을 admin 에게만 노출**하고, 비admin 의 설정 화면 직접 진입을 저장소 상세로 리다이렉트한다. 삭제 버튼은 `isOwner` 일 때만 활성. > `moreCount`, `progressPct/Level/Label`, `updatedAgo` 는 프론트 계산. > `GET /repos` 는 단일 조직 모델상 **조직의 모든 저장소**를 반환한다(멤버십은 역할/권한에만 사용). Gitea-동기화로 import 된 저장소는 멤버 0명으로 노출되며, `giteaMissing:true` 면 프론트에서 '원격 없음' 배지로 표기한다. > 매핑 페이지: RepoListPage, RepoCreatePage, RepoDetailPage(헤더), RepoSettingsPage. @@ -144,7 +147,7 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프 조직에 직접 만든 저장소도 Relay 에 보이도록, **서버 기동 직후 1회 + 매시간 정각**에 조직 저장소 목록(`GET /api/v1/orgs/{org}/repos`)을 받아 Relay 와 맞춘다(`RepoSyncService`). -- **추가형(additive)**: Gitea ID → slug 순으로 매칭. Relay 에 없으면 import(멤버 0명), 양쪽에 있으면 연동정보(`giteaRepoId`/`cloneUrl`/`htmlUrl`)만 backfill. 표시명/설명/공개범위는 import 이후 **Relay 가 SSOT**(재동기화로 덮어쓰지 않음). +- **추가형 + 역동기화(reconcile)**: Gitea ID → slug 순으로 매칭(이번 동기화에서 이미 매칭된 Relay 저장소는 중복 매칭 제외 — rename 후 옛 이름 재사용 충돌 방지). Relay 에 없으면 import(멤버 0명). 매칭되면 **Gitea 의 네이티브 필드와 1:1 대응되는 값을 Gitea 기준(SSOT)으로 끌어온다**: `slugName`(Gitea rename → Relay 라우팅·쓰기 경로가 새 이름을 따름), `visibility`(private 토글), `branch`(기본 브랜치), + 연동정보(`giteaRepoId`/`giteaFullName`/`cloneUrl`/`htmlUrl`, 누락표시 해제). **단 표시명(`name`)·설명(`description`)은 Gitea 단일 `description` 에 "표시제목 — 설명" 으로 합쳐 저장하므로(표시명 필드 부재), Gitea 에서 description 을 직접 편집하면 표시명이 깨질 수 있어 Relay 가 SSOT 로 유지**(역동기화 제외). Relay→Gitea 쓰기(`PATCH`)는 설명/공개범위에 더해 **기본 브랜치(`default_branch`)도 푸시**해 양방향을 일치시킨다(브랜치는 Gitea 에 실제 존재해야 함). - **import 표시명**: Gitea description 의 `"표시제목 — 설명"` 규칙을 파싱(구분자 없으면 slug 를 표시명, 전체를 설명으로). - **누락 표기**: Gitea 에서 사라진(연동 origin) 저장소는 **삭제하지 않고** `giteaMissing:true` 로만 표기. 다시 나타나면 false 로 복구. - Gitea 미연동(`GITEA_*` 미설정) 시 동기화는 건너뛴다. 중복 실행 방지 가드 포함. diff --git a/docs/integration-progress.md b/docs/integration-progress.md index ecebf32..d5a09b3 100644 --- a/docs/integration-progress.md +++ b/docs/integration-progress.md @@ -84,7 +84,7 @@ - 문제: 동기화가 단방향(Relay→Gitea)이라 **Gitea 조직에 직접 만든 저장소가 Relay 목록에 안 떴음**. - `GiteaService.listOrgRepos()`: 조직 저장소 페이지네이션 순회(`GET /orgs/{org}/repos`). 결과에 `name`/`description`/`private` 추가. - `RepoSyncService`: **서버 기동 직후 1회(`OnApplicationBootstrap`, 비차단) + 매시간(`@Cron(EVERY_HOUR)`)** 동기화. `ScheduleModule.forRoot()` 는 `app.module` 에 이미 등록됨. - - **추가형**: Gitea ID→slug 매칭. 없으면 import(멤버 0명), 있으면 연동정보만 backfill. 표시명/설명/공개범위는 import 후 Relay 가 SSOT. + - **추가형 + 역동기화(reconcile, 2026-06-19 확장)**: Gitea ID→slug 매칭(중복 매칭 가드). 없으면 import(멤버 0명). 매칭되면 Gitea 네이티브 필드와 1:1 대응되는 **slug(rename)·공개범위·기본 브랜치 + 연동정보를 Gitea 기준(SSOT)으로 끌어온다** → Gitea 에서 직접 이름/공개범위/브랜치를 바꿔도 Relay 가 따라가고 쓰기 경로(slugName)도 일치. **표시명/설명은 Gitea 단일 description 합본 구조라 Relay-SSOT 유지**(외부 편집 시 표시명 깨짐 방지). Relay→Gitea 수정은 `default_branch` 도 함께 푸시(양방향 일치). - **누락 표기**: Gitea 에서 사라진 연동 origin 저장소는 삭제하지 않고 `giteaMissing:true` 로만 표기(재출현 시 복구). `Repo.giteaMissing` 컬럼 추가, `RepoResponse`/프론트 `ApiRepo` 에 노출. - 미연동 시 건너뜀 + 중복 실행 가드. - **import 저장소 멤버 데드락 해소(2026-06-17)**: import 저장소는 멤버 0명이라 admin 이 없어 아무도 멤버를 초대할 수 없는 막힘이 있었음. 동기화 끝에 **`REPO_IMPORT_ADMIN_EMAIL`(env) 의 가입 사용자를 멤버 0명인 Gitea-연동 저장소에 owner-admin 으로 자동 지정**(신규 import + 기존 멤버 0명 모두 대상, Relay 생성분은 생성자가 이미 멤버라 제외). env 미설정/미가입이면 건너뜀(가입 후 다음 동기화에서 지정). 키 문서화: `backend/.env.{env}.example`. diff --git a/frontend/src/components/RepoTabs.vue b/frontend/src/components/RepoTabs.vue index ee8abec..a2d9b0e 100644 --- a/frontend/src/components/RepoTabs.vue +++ b/frontend/src/components/RepoTabs.vue @@ -7,6 +7,8 @@ interface Props { active: 'tasks' | 'members' | 'activity' | 'settings' taskCount: number memberCount: number + /** 설정 탭 노출 여부(관리자만). 기본 false — 권한이 확인된 경우에만 노출 */ + canManage?: boolean } defineProps() @@ -32,6 +34,7 @@ defineProps() 활동 diff --git a/frontend/src/mock/relay.mock.ts b/frontend/src/mock/relay.mock.ts index 3418d6a..ba719a5 100644 --- a/frontend/src/mock/relay.mock.ts +++ b/frontend/src/mock/relay.mock.ts @@ -65,6 +65,10 @@ export interface Repo { htmlUrl?: string | null /** Gitea 조직에서 사라진 저장소 표시(역방향 동기화로 갱신) */ giteaMissing?: boolean + /** 현재 사용자가 이 저장소 관리자(admin)인지 — 단건 조회에서만 채워짐(설정 탭 노출/진입) */ + canManage?: boolean + /** 현재 사용자가 소유자인지 — 저장소 삭제 권한 */ + isOwner?: boolean } /** 저장소 상세의 업무 목록 행 */ diff --git a/frontend/src/pages/relay/RepoActivityPage.vue b/frontend/src/pages/relay/RepoActivityPage.vue index 3996da3..3530252 100644 --- a/frontend/src/pages/relay/RepoActivityPage.vue +++ b/frontend/src/pages/relay/RepoActivityPage.vue @@ -111,6 +111,7 @@ const ActIcon = defineComponent({ active="activity" :task-count="repo.totalCount" :member-count="memberCount" + :can-manage="repo.canManage" /> diff --git a/frontend/src/pages/relay/RepoDetailPage.vue b/frontend/src/pages/relay/RepoDetailPage.vue index 9078864..6a6b8ef 100644 --- a/frontend/src/pages/relay/RepoDetailPage.vue +++ b/frontend/src/pages/relay/RepoDetailPage.vue @@ -165,6 +165,7 @@ const STATUS_LABEL: Record = { active="tasks" :task-count="counts.all" :member-count="memberCount" + :can-manage="repo.canManage" /> diff --git a/frontend/src/pages/relay/RepoMembersPage.vue b/frontend/src/pages/relay/RepoMembersPage.vue index f6e8367..8f7a115 100644 --- a/frontend/src/pages/relay/RepoMembersPage.vue +++ b/frontend/src/pages/relay/RepoMembersPage.vue @@ -232,6 +232,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown)) active="members" :task-count="repo.totalCount" :member-count="counts.all" + :can-manage="repo.canManage" />
diff --git a/frontend/src/pages/relay/RepoSettingsPage.vue b/frontend/src/pages/relay/RepoSettingsPage.vue index 45b5069..36bdc5e 100644 --- a/frontend/src/pages/relay/RepoSettingsPage.vue +++ b/frontend/src/pages/relay/RepoSettingsPage.vue @@ -15,6 +15,10 @@ const repoId = computed(() => String(route.params.repoId)) const repoStore = useRepoStore() const repo = ref(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) 변경은 준비 중입니다.') -}