Compare commits

...

3 Commits

Author SHA1 Message Date
ttipo 42db9b5d00 chore: Gitea 동기화 주기 매시간→10분 단축
RepoSyncService 의 @Cron 을 EVERY_HOUR→EVERY_10_MINUTES 로 변경.
Gitea 직접 변경(이름/공개범위/브랜치)의 Relay 반영 지연이 최대 1시간→10분으로 단축.
기동 직후 1회 동기화는 유지, 중복 실행 가드도 그대로.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:58:46 +09:00
ttipo 8935f6e488 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>
2026-06-19 15:39:41 +09:00
ttipo 56c89d9927 fix: 저장소 활동 피드 상태 변경 문구를 알림·상세 탭과 정렬
task.status_changed 를 일반 라벨('승인 대기로 변경')이 아닌 행위 중심 문구로
파생(statusFrom→statusTo): 시작/다시 시작·승인을 요청·수정을 요청·완료(승인).
업무 상세 활동 탭(task.store)·알림과 동일 표현으로 일관화. 안 쓰게 된
taskStatusLabel·TaskStatus import 제거.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:39:21 +09:00
15 changed files with 176 additions and 100 deletions
+4 -2
View File
@@ -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<void> {
await this.request<GiteaRepoApi>(
'PATCH',
@@ -140,6 +141,7 @@ export class GiteaService {
{
description: patch.description,
private: patch.private,
default_branch: patch.defaultBranch,
},
);
}
+40 -19
View File
@@ -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';
@@ -50,10 +50,10 @@ export class RepoSyncService implements OnApplicationBootstrap {
void this.syncFromGitea('부트스트랩');
}
// 매시간 정각 동기화
@Cron(CronExpression.EVERY_HOUR)
async handleHourlySync(): Promise<void> {
await this.syncFromGitea('스케줄(매시간)');
// 10분마다 동기화
@Cron(CronExpression.EVERY_10_MINUTES)
async handleScheduledSync(): Promise<void> {
await this.syncFromGitea('스케줄(10분)');
}
// 조직 저장소 동기화 본체 — 실패해도 예외를 전파하지 않고 로그만 남긴다.
@@ -80,16 +80,23 @@ export class RepoSyncService implements OnApplicationBootstrap {
}
const seenGiteaIds = new Set<number>();
// 이번 동기화에서 이미 매칭된 Relay 저장소(uuid) — 중복 매칭 방지
const matchedRepoIds = new Set<string>();
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;
+5 -2
View File
@@ -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<RepoResponse> {
return this.repoService.findOne(repoId);
findOne(
@Param('repoId') repoId: string,
@CurrentUser() user: PublicUser,
): Promise<RepoResponse> {
return this.repoService.findOne(repoId, user.id);
}
@Patch(':repoId')
+56 -15
View File
@@ -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<RepoResponse> {
// 응답에 현재 사용자의 권한(canManage/isOwner)을 함께 내려, 프론트가 설정 탭 노출·진입을 결정한다.
async findOne(slugName: string, userId: string): Promise<RepoResponse> {
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,
};
}
}
+6 -3
View File
@@ -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.
@@ -142,9 +145,9 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
##### Gitea → Relay 역방향 동기화 (주기적 import)
조직에 직접 만든 저장소도 Relay 에 보이도록, **서버 기동 직후 1회 + 매시간 정각**에 조직 저장소 목록(`GET /api/v1/orgs/{org}/repos`)을 받아 Relay 와 맞춘다(`RepoSyncService`).
조직에 직접 만든 저장소도 Relay 에 보이도록, **서버 기동 직후 1회 + 10분마다**에 조직 저장소 목록(`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_*` 미설정) 시 동기화는 건너뛴다. 중복 실행 방지 가드 포함.
+2 -2
View File
@@ -83,8 +83,8 @@
**백엔드** `backend/src/modules/gitea/`, `modules/repo/repo-sync.service.ts`
- 문제: 동기화가 단방향(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 후 RelaySSOT.
- `RepoSyncService`: **서버 기동 직후 1회(`OnApplicationBootstrap`, 비차단) + 10분마다(`@Cron(EVERY_10_MINUTES)`)** 동기화. `ScheduleModule.forRoot()``app.module` 에 이미 등록됨.
- **추가형 + 역동기화(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`.
+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);
+16 -15
View File
@@ -6,14 +6,9 @@ import {
escapeHtml,
monthDayKo,
relativeTimeKo,
taskStatusLabel,
} from '@/shared/utils/format'
import type { ApiActivity } from '@/types/activity'
import type {
RepoActivityDay,
RepoActivityItem,
TaskStatus,
} from '@/mock/relay.mock'
import type { RepoActivityDay, RepoActivityItem } from '@/mock/relay.mock'
// payload(자유형)에서 문자열 값 안전 추출
function pstr(payload: Record<string, unknown>, key: string): string {
@@ -71,18 +66,24 @@ function toRepoActivityItem(api: ApiActivity): RepoActivityItem {
case 'task.created':
return { ...base, tone: 'task', icon: 'pencil', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 지시했습니다` }
case 'task.status_changed': {
// 알림·업무 상세 활동 탭과 동일한 행위 중심 문구(from→to 로 파생)
const from = pstr(p, 'statusFrom')
const to = pstr(p, 'statusTo')
const tgt = `<span class="tgt">${title}</span>`
if (to === 'prog') {
const verb = from === 'changes' ? '다시 시작' : '시작'
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 ${tgt} 업무를 <span class="st prog">${verb}</span>했습니다` }
}
if (to === 'review') {
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 ${tgt} 업무의 승인을 요청했습니다` }
}
if (to === 'changes') {
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 ${tgt} 업무의 수정을 요청했습니다` }
}
if (to === 'done') {
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 <span class="st done">완료</span>했습니다` }
}
const label = taskStatusLabel(to as TaskStatus)
const cls = to === 'prog' ? 'prog' : ''
return {
...base,
tone: 'task',
icon: 'check',
html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 <span class="st ${cls}">${escapeHtml(label)}</span>(으)로 변경했습니다`,
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 ${tgt} 업무를 <span class="st done">완료(승인)</span>했습니다` }
}
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 ${tgt} 업무의 상태를 변경했습니다` }
}
case 'task.comment_added':
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무에 댓글을 남겼습니다` }
+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 // 소유자 (저장소 삭제)
}
// 저장소 생성 요청