From 099f12eec2e6255d1bc1c1d2a9b9e78e1807ac3c Mon Sep 17 00:00:00 2001 From: devlasbe Date: Tue, 14 Jul 2026 11:43:58 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[FE-68]=20refactor:=20=ED=9E=88=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20DB=20?= =?UTF-8?q?=EB=A0=88=EB=B2=A8=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EC=85=98=EC=9C=BC=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에는 history 엔드포인트가 getPackageHistory로 전체 릴리즈를 조회(전건 presigned 서명 + diff 서브쿼리)한 뒤 메모리에서 뒤집어 슬라이스했다. 릴리즈 수백 건이면 cold isolate 캐시 미스마다 그만큼 낭비되므로, 관리용 전용 getPackageHistoryPage(LIMIT/OFFSET + COUNT, 서명·diff 생략, 무캐시)를 추가하고 핸들러만 교체한다. OTA(updateCheck)·롤백·중복검사·release.update가 공유하는 getPackageHistory 계약은 미변경. 응답 shape·기본값·정렬 동일. Co-Authored-By: Claude Fable 5 --- apps/server/src/routes/management.ts | 17 ++++------- apps/server/src/storage/d1.ts | 42 +++++++++++++++++++++++++++- apps/server/src/storage/storage.ts | 5 ++++ 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/apps/server/src/routes/management.ts b/apps/server/src/routes/management.ts index 19128ff..d5bb731 100644 --- a/apps/server/src/routes/management.ts +++ b/apps/server/src/routes/management.ts @@ -1771,20 +1771,13 @@ router.openapi(routes.deployments.history, async (c) => { }); } - // getPackageHistory는 uploadTime 오름차순 전체 배열을 반환한다(OTA·롤백·중복검사 등이 - // 공유하는 계약이므로 그대로 둔다). 페이지네이션은 이 핸들러에서만 최신순으로 뒤집어 - // 슬라이스한다. 파라미터가 없으면 page=1/pageSize=20 → 최신 페이지가 기본. - const fullHistory = await storage.getPackageHistory( - accountId, - app.id, + // DB 레벨 LIMIT/OFFSET 페이지네이션(최신순). 파라미터가 없으면 page=1/pageSize=20이 + // 기본이라 쿼리 없이 호출하는 CLI(code-push-standalone)도 기존과 동일하게 최신 20개를 받는다. + const { history, totalCount } = await storage.getPackageHistoryPage( deployment.id, + page, + pageSize, ); - const totalCount = fullHistory.length; - const start = (page - 1) * pageSize; - const history = fullHistory - .slice() - .reverse() - .slice(start, start + pageSize); return c.json({ history, totalCount, page, pageSize }); }); diff --git a/apps/server/src/storage/d1.ts b/apps/server/src/storage/d1.ts index a6a6000..c3bc950 100644 --- a/apps/server/src/storage/d1.ts +++ b/apps/server/src/storage/d1.ts @@ -1,4 +1,4 @@ -import { and, eq, isNull } from "drizzle-orm"; +import { and, count, desc, eq, isNull, sql } from "drizzle-orm"; import { type DrizzleD1Database, drizzle } from "drizzle-orm/d1"; import type { Context } from "hono"; import * as schema from "../db/schema"; @@ -915,6 +915,46 @@ export class D1StorageProvider implements StorageProvider { return result; } + // 관리용 history 엔드포인트 전용. OTA(updateCheck)·롤백·중복검사가 쓰는 + // getPackageHistory(전체·오름차순·diff·presigned·캐시)와 별개 경로다. + // 웹·CLI가 blobUrl/diffPackageMap을 쓰지 않으므로 서명·diff 조회를 생략하고, + // 캐시하지 않는다(캐시하면 commitPackage/updatePackage 등 4곳 무효화에 전부 걸어야 함). + async getPackageHistoryPage( + deploymentId: string, + page: number, + pageSize: number, + ): Promise<{ history: Package[]; totalCount: number }> { + const where = and( + eq(schema.packages.deploymentId, deploymentId), + isNull(schema.packages.deletedAt), + ); + + const [countResult] = await this.db + .select({ totalCount: count() }) + .from(schema.packages) + .where(where); + + // uploadTime 동률 대비 보조 정렬키. label("v12")은 문자열 비교 시 "v10" < "v2"라 + // 숫자로 캐스팅해 배포 순서와 일치시킨다. + const packages = await this.db.query.packages.findMany({ + where, + orderBy: [ + desc(schema.packages.uploadTime), + sql`CAST(substr(${schema.packages.label}, 2) AS INTEGER) DESC`, + ], + limit: pageSize, + offset: (page - 1) * pageSize, + }); + + const history = packages.map((p) => ({ + ...this.mapPackageFromDB(p), + blobUrl: "", + manifestBlobUrl: "", + })); + + return { history, totalCount: countResult?.totalCount ?? 0 }; + } + async getPackageHistoryFromDeploymentKey( deploymentKey: string, ): Promise { diff --git a/apps/server/src/storage/storage.ts b/apps/server/src/storage/storage.ts index 3391fcf..1f26e63 100644 --- a/apps/server/src/storage/storage.ts +++ b/apps/server/src/storage/storage.ts @@ -97,6 +97,11 @@ export interface StorageProvider { appId: string, deploymentId: string, ): Promise; + getPackageHistoryPage( + deploymentId: string, + page: number, + pageSize: number, + ): Promise<{ history: Package[]; totalCount: number }>; getPackageHistoryFromDeploymentKey(deploymentKey: string): Promise; updatePackageHistory( accountId: string, From 0c6babc01dc20e4fc14a397c64c0dbca8a6c0a07 Mon Sep 17 00:00:00 2001 From: devlasbe Date: Tue, 14 Jul 2026 12:00:15 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[FE-69]=20chore:=20Slack=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=EC=97=90=EC=84=9C=20deployment=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=C2=B7=EC=B2=B4=ED=81=AC=20=ED=91=9C?= =?UTF-8?q?=EA=B8=B0=20=EC=8B=AC=ED=94=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - context 줄의 📦 {deploymentName} 항목 제거 (실사용 deployment가 Production 하나뿐) - Mandatory/Disabled 표시 ✅/❌ → ✓/✗ - sendReleaseNotification 호출부 2곳에서 deploymentName 전달 제거 Co-Authored-By: Claude Fable 5 --- apps/server/src/routes/management.ts | 2 -- apps/server/src/utils/slack.ts | 8 +++----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/server/src/routes/management.ts b/apps/server/src/routes/management.ts index d5bb731..cdcf45b 100644 --- a/apps/server/src/routes/management.ts +++ b/apps/server/src/routes/management.ts @@ -1336,7 +1336,6 @@ router.openapi(routes.deployments.release.create, async (c) => { c.executionCtx.waitUntil( sendReleaseNotification(c.env, { appName: app.name, - deploymentName, label: releasedPackage.label, appVersion: releasedPackage.appVersion, description: releasedPackage.description, @@ -1411,7 +1410,6 @@ router.openapi(routes.deployments.release.update, async (c) => { c.executionCtx.waitUntil( sendReleaseNotification(c.env, { appName: app.name, - deploymentName, label: updatedRelease.label, appVersion: updatedRelease.appVersion, description: updatedRelease.description, diff --git a/apps/server/src/utils/slack.ts b/apps/server/src/utils/slack.ts index 6ca3693..c122f50 100644 --- a/apps/server/src/utils/slack.ts +++ b/apps/server/src/utils/slack.ts @@ -18,7 +18,6 @@ const ACTION_EMOJI: Record = { type ReleaseNotificationType = { appName: string; - deploymentName: string; label?: string; appVersion: string; description?: string; @@ -49,11 +48,10 @@ export const sendReleaseNotification = async ( const summary = `*${info.label || "-"}* · App ${info.appVersion}`; const body = info.description ? `${summary}\n${info.description}` : summary; - // 부가 정보는 작은 context 줄로 (Deployment · Mandatory · Disabled · 작성자) + // 부가 정보는 작은 context 줄로 (Mandatory · Disabled · 작성자) const context = [ - `📦 ${info.deploymentName}`, - `Mandatory ${info.isMandatory ? "✅" : "❌"}`, - `Disabled ${info.isDisabled ? "✅" : "❌"}`, + `Mandatory ${info.isMandatory ? "✓" : "✗"}`, + `Disabled ${info.isDisabled ? "✓" : "✗"}`, `👤 ${info.releasedBy || "-"}`, ].join(" · "); From 14bdac5b4b8713524b866e435d70acb6c737e578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EC=84=B1=EC=9A=B0?= <82026688+devlasbe@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:48:42 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[FE-67=20->=20develop]=20feat:=20=EB=A6=B4?= =?UTF-8?q?=EB=A6=AC=EC=A6=88=20disabled/mandatory=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=9B=B9=20mandatory=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [FE-67] feat: 릴리즈 disabled/mandatory 전용 엔드포인트 분리 및 웹 mandatory 토글 - PATCH /release/disabled, /release/mandatory 신규 (label 생략 시 최신) - updatePackageMetadata 부분 UPDATE로 stale 캐시 lost update 차단 - Slack MandatoryOn/Off 액션 추가, 헤더 이모지 🟢/🔴 → ✓/✕ 단순화 - 웹 Mandatory/Optional 토글 + 승격 경고 confirm, 카드 버튼 outline 통일 - api-client 재생성 Co-Authored-By: Claude Fable 5 * [FE-67] chore: Slack 헤더 문구를 Disabled/Mandatory true·false 형식으로 변경 Co-Authored-By: Claude Fable 5 * [FE-67] chore: Slack 헤더 기호를 on/off가 아닌 카테고리 구분으로 변경 (Disabled=✕, Mandatory=★) Co-Authored-By: Claude Fable 5 * [FE-67] chore: Slack 헤더 이모지를 카테고리 구분 이모지로 변경 (Disabled=🧊, Mandatory=🔥) Co-Authored-By: Claude Fable 5 * [FE-67] refactor: Slack 액션 타입을 종류별 3종(Uploaded/Disabled/Mandatory)으로 정리 on/off는 헤더 텍스트가 isDisabled/isMandatory 값으로 표현 Co-Authored-By: Claude Fable 5 * [FE-67] feat: 릴리즈 업로드 실패 Slack 알림 추가 - 인증·권한 통과 후 업로드 파이프라인(롤아웃 미완/중복 번들/R2·D1 오류) 실패 시 UploadFailed(🚨) 알림 발사 - 에러 메시지를 본문 코드 블록으로 첨부 Co-Authored-By: Claude Fable 5 * [FE-67] feat: 히스토리 카드 버튼에 컬러 아이콘 강조 적용 - Mandatory 버튼: Zap 아이콘 (Mandatory로 전환 시 amber, Optional 전환 시 muted) - Disable 버튼: Power 아이콘 (Disable 시 red, Enable 시 emerald) - 버튼 골격은 무채색 outline 유지 Co-Authored-By: Claude Fable 5 * [FE-67] style: 히스토리 카드 액션 버튼 크기 축소 - 버튼 h-9 → h-7, 패딩 px-2, 글자 text-xs - 아이콘 [&_svg]:size-3 강제 (버튼 베이스 [&_svg]:size-4 오버라이드), gap-1 - 버튼 사이 간격 gap-0.5 Co-Authored-By: Claude Fable 5 * [FE-67] test: disabled/mandatory 토글 엔드포인트 테스트 6종 추가 - label 지정/생략(최신) 대상 선택, 부분 UPDATE(다른 필드 불변), 404, 401 Co-Authored-By: Claude Fable 5 * [FE-67] refactor: HistoryPage 기능 단위 훅·컴포넌트 분리 - hooks/: useHistoryTarget(앱·배포 선택), useReleaseHistory(이력·지표·페이지), useReleaseToggles(disabled/mandatory 토글) - components/: ReleaseCard, HistoryPagination 분리 (formatBytes/formatTime은 ReleaseCard로 이동) - types.ts로 응답 타입 분리 - useEffect 3개 기명화: selectFirstAppOnLoad / selectDefaultDeployment / resetPageOnTargetChange - 동작 동일, 내부 구조만 변경 Co-Authored-By: Claude Fable 5 * [FE-67] fix: 테스트가 실제 Slack 채널로 알림을 발송하던 문제 차단 vitest-pool-workers가 wrangler.toml 경유로 .dev.vars의 CODE_PUSH_NOTI_SLACK을 로드해 토글 테스트가 실채널로 webhook을 쏘고 있었음. 테스트 바인딩에서 빈 값으로 고정. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- apps/server/src/routes/management.ts | 388 +++++++++++++----- apps/server/src/storage/d1.ts | 21 + apps/server/src/storage/storage.ts | 6 + apps/server/src/utils/slack.ts | 36 +- apps/server/test/routes/management.test.ts | 146 +++++++ apps/server/vitest.config.ts | 2 + apps/web/src/pages/history/HistoryPage.tsx | 374 +++-------------- .../history/components/HistoryPagination.tsx | 67 +++ .../pages/history/components/ReleaseCard.tsx | 158 +++++++ .../pages/history/hooks/useHistoryTarget.ts | 52 +++ .../pages/history/hooks/useReleaseHistory.ts | 89 ++++ .../pages/history/hooks/useReleaseToggles.ts | 90 ++++ apps/web/src/pages/history/types.ts | 29 ++ packages/api-client/src/api.ts | 218 ++++++++++ 14 files changed, 1244 insertions(+), 432 deletions(-) create mode 100644 apps/web/src/pages/history/components/HistoryPagination.tsx create mode 100644 apps/web/src/pages/history/components/ReleaseCard.tsx create mode 100644 apps/web/src/pages/history/hooks/useHistoryTarget.ts create mode 100644 apps/web/src/pages/history/hooks/useReleaseHistory.ts create mode 100644 apps/web/src/pages/history/hooks/useReleaseToggles.ts create mode 100644 apps/web/src/pages/history/types.ts diff --git a/apps/server/src/routes/management.ts b/apps/server/src/routes/management.ts index cdcf45b..5d0d1be 100644 --- a/apps/server/src/routes/management.ts +++ b/apps/server/src/routes/management.ts @@ -534,6 +534,58 @@ const routes = { }, }, }), + updateDisabled: createRoute({ + method: "patch", + path: "/apps/:appName/deployments/:deploymentName/release/disabled", + description: "Update release disabled state", + request: { + params: z.object({ + appName: z.string(), + deploymentName: z.string(), + }), + body: { + content: { + "application/json": { + schema: z.object({ + label: z.string().optional(), + isDisabled: z.boolean(), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "Release disabled state updated successfully", + }, + }, + }), + updateMandatory: createRoute({ + method: "patch", + path: "/apps/:appName/deployments/:deploymentName/release/mandatory", + description: "Update release mandatory state", + request: { + params: z.object({ + appName: z.string(), + deploymentName: z.string(), + }), + body: { + content: { + "application/json": { + schema: z.object({ + label: z.string().optional(), + isMandatory: z.boolean(), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "Release mandatory state updated successfully", + }, + }, + }), }, history: createRoute({ method: "get", @@ -1245,116 +1297,278 @@ router.openapi(routes.deployments.release.create, async (c) => { }); } - // Check if there's an unfinished rollout - if ( - deployment.package?.rollout && - deployment.package.rollout < 100 && - !deployment.package.isDisabled - ) { - throw new HTTPException(409, { - message: - "Please update the previous release to 100% rollout before releasing a new package", + const releasePackage = async () => { + // Check if there's an unfinished rollout + if ( + deployment.package?.rollout && + deployment.package.rollout < 100 && + !deployment.package.isDisabled + ) { + throw new HTTPException(409, { + message: + "Please update the previous release to 100% rollout before releasing a new package", + }); + } + + const packageData = await packageFile.arrayBuffer(); + const differ = await createPackageDiffer( + storage, + app.id, + deployment.id, + packageData, + ); + const manifestResult = await differ.generateManifest(); + const packageHash = await manifestResult.computeHash(); + + // Check if this is a duplicate of the current release + const history = await storage.getPackageHistory( + accountId, + app.id, + deployment.id, + ); + const lastPackage = history[history.length - 1]; + if (lastPackage && lastPackage.packageHash === packageHash) { + throw new HTTPException(409, { + message: + "The uploaded package was not released because it is identical to the contents of the specified deployment's current release", + }); + } + + // Store package blob + const blobId = generateKey(); + await storage.addBlob(blobId, packageData, packageData.byteLength); + const blobUrl = await storage.getBlobUrl(blobId); + + // Store manifest if available + let manifestUrl = ""; + if (manifestResult) { + const manifestBlobId = generateKey(); + const manifestJson = manifestResult.serialize(); + const manifestBuffer = new TextEncoder().encode(manifestJson); + await storage.addBlob( + manifestBlobId, + manifestBuffer, + manifestBuffer.length, + ); + manifestUrl = await storage.getBlobUrl(manifestBlobId); + } + + const newPackage: Omit = { + appVersion: packageInfo.appVersion, + description: packageInfo.description, + isDisabled: packageInfo.isDisabled || false, + isMandatory: packageInfo.isMandatory || false, + rollout: packageInfo.rollout, + packageHash, + size: packageData.byteLength, + blobUrl, + manifestBlobUrl: manifestUrl, + uploadTime: Date.now(), + releaseMethod: "Upload", + }; + + const releasedPackage = await storage.commitPackage( + accountId, + app.id, + deployment.id, + newPackage, + ); + + // Generate diffs in background + if (manifestResult) { + await differ.generateDiffs(history); + } + + c.header( + "Location", + urlEncode`/apps/${appName}/deployments/${deploymentName}`, + ); + + const account = await storage.getAccount(accountId); + if (c.executionCtx) { + c.executionCtx.waitUntil( + sendReleaseNotification(c.env, { + appName: app.name, + label: releasedPackage.label, + appVersion: releasedPackage.appVersion, + description: releasedPackage.description, + isMandatory: releasedPackage.isMandatory, + isDisabled: releasedPackage.isDisabled, + action: "Uploaded", + releasedBy: account.name || account.email, + }), + ); + } + + return c.json({ package: releasedPackage }, 201); + }; + + // 인증·권한을 통과한 실제 업로드 시도의 실패(롤아웃 미완/중복 번들/R2·D1 오류)를 Slack에 알린다. + try { + return await releasePackage(); + } catch (error) { + if (c.executionCtx) { + const account = await storage.getAccount(accountId).catch(() => null); + c.executionCtx.waitUntil( + sendReleaseNotification(c.env, { + appName: app.name, + appVersion: packageInfo.appVersion, + description: packageInfo.description, + isMandatory: packageInfo.isMandatory, + isDisabled: packageInfo.isDisabled, + action: "UploadFailed", + releasedBy: account ? account.name || account.email : undefined, + errorMessage: error instanceof Error ? error.message : String(error), + }), + ); + } + throw error; + } +}); + +router.openapi(routes.deployments.release.update, async (c) => { + const storage = getStorageProvider(c); + const accountId = c.var.auth.accountId; + const { appName, deploymentName } = c.req.valid("param"); + const { packageInfo } = c.req.valid("json"); + + const app = await storage.getApp(accountId, { appName }); + if (!app) { + throw new HTTPException(404, { + message: `App "${appName}" not found`, }); } - const packageData = await packageFile.arrayBuffer(); - const differ = await createPackageDiffer( - storage, - app.id, - deployment.id, - packageData, - ); - const manifestResult = await differ.generateManifest(); - const packageHash = await manifestResult.computeHash(); + throwIfInvalidPermissions(app, "Collaborator"); - // Check if this is a duplicate of the current release - const history = await storage.getPackageHistory( + const deployments = await storage.getDeployments(accountId, app.id); + const deployment = deployments.find((d) => d.name === deploymentName); + + if (!deployment) { + throw new HTTPException(404, { + message: `Deployment "${deploymentName}" not found`, + }); + } + + const packageHistory = await storage.getPackageHistory( accountId, app.id, deployment.id, ); - const lastPackage = history[history.length - 1]; - if (lastPackage && lastPackage.packageHash === packageHash) { - throw new HTTPException(409, { - message: - "The uploaded package was not released because it is identical to the contents of the specified deployment's current release", + + const release = packageInfo.label + ? packageHistory.find((p) => p.label === packageInfo.label) + : packageHistory[packageHistory.length - 1]; + + if (!release) { + throw new HTTPException(404, { + message: "Release package not found", }); } + const updatedRelease = { + ...release, + }; + if (packageInfo.appVersion) { + updatedRelease.appVersion = packageInfo.appVersion; + } + if (packageInfo.description) { + updatedRelease.description = packageInfo.description; + } + if (packageInfo.isDisabled != null) { + updatedRelease.isDisabled = packageInfo.isDisabled; + } - // Store package blob - const blobId = generateKey(); - await storage.addBlob(blobId, packageData, packageData.byteLength); - const blobUrl = await storage.getBlobUrl(blobId); - - // Store manifest if available - let manifestUrl = ""; - if (manifestResult) { - const manifestBlobId = generateKey(); - const manifestJson = manifestResult.serialize(); - const manifestBuffer = new TextEncoder().encode(manifestJson); - await storage.addBlob( - manifestBlobId, - manifestBuffer, - manifestBuffer.length, + await storage.updatePackage(updatedRelease, deployment.id); + + // isDisabled 토글이 포함된 요청일 때만 Slack 알림(rollout/version 등 다른 수정은 제외) + if (packageInfo.isDisabled != null && c.executionCtx) { + const account = await storage.getAccount(accountId); + c.executionCtx.waitUntil( + sendReleaseNotification(c.env, { + appName: app.name, + label: updatedRelease.label, + appVersion: updatedRelease.appVersion, + description: updatedRelease.description, + isMandatory: updatedRelease.isMandatory, + isDisabled: updatedRelease.isDisabled, + action: "Disabled", + releasedBy: account.name || account.email, + }), ); - manifestUrl = await storage.getBlobUrl(manifestBlobId); } - const newPackage: Omit = { - appVersion: packageInfo.appVersion, - description: packageInfo.description, - isDisabled: packageInfo.isDisabled || false, - isMandatory: packageInfo.isMandatory || false, - rollout: packageInfo.rollout, - packageHash, - size: packageData.byteLength, - blobUrl, - manifestBlobUrl: manifestUrl, - uploadTime: Date.now(), - releaseMethod: "Upload", - }; + return c.json({ release: updatedRelease }); +}); + +router.openapi(routes.deployments.release.updateDisabled, async (c) => { + const storage = getStorageProvider(c); + const accountId = c.var.auth.accountId; + const { appName, deploymentName } = c.req.valid("param"); + const { label, isDisabled } = c.req.valid("json"); + + const app = await storage.getApp(accountId, { appName }); + if (!app) { + throw new HTTPException(404, { + message: `App "${appName}" not found`, + }); + } + + throwIfInvalidPermissions(app, "Collaborator"); + + const deployments = await storage.getDeployments(accountId, app.id); + const deployment = deployments.find((d) => d.name === deploymentName); - const releasedPackage = await storage.commitPackage( + if (!deployment) { + throw new HTTPException(404, { + message: `Deployment "${deploymentName}" not found`, + }); + } + + const packageHistory = await storage.getPackageHistory( accountId, app.id, deployment.id, - newPackage, ); - // Generate diffs in background - if (manifestResult) { - await differ.generateDiffs(history); + const release = label + ? packageHistory.find((p) => p.label === label) + : packageHistory[packageHistory.length - 1]; + + if (!release?.label) { + throw new HTTPException(404, { + message: "Release package not found", + }); } - c.header( - "Location", - urlEncode`/apps/${appName}/deployments/${deploymentName}`, - ); + const updatedRelease = { ...release, isDisabled }; + await storage.updatePackageMetadata(deployment.id, release.label, { + isDisabled, + }); - const account = await storage.getAccount(accountId); if (c.executionCtx) { + const account = await storage.getAccount(accountId); c.executionCtx.waitUntil( sendReleaseNotification(c.env, { appName: app.name, - label: releasedPackage.label, - appVersion: releasedPackage.appVersion, - description: releasedPackage.description, - isMandatory: releasedPackage.isMandatory, - isDisabled: releasedPackage.isDisabled, - action: "Uploaded", + label: updatedRelease.label, + appVersion: updatedRelease.appVersion, + description: updatedRelease.description, + isMandatory: updatedRelease.isMandatory, + isDisabled: updatedRelease.isDisabled, + action: "Disabled", releasedBy: account.name || account.email, }), ); } - return c.json({ package: releasedPackage }, 201); + return c.json({ release: updatedRelease }); }); -router.openapi(routes.deployments.release.update, async (c) => { +router.openapi(routes.deployments.release.updateMandatory, async (c) => { const storage = getStorageProvider(c); const accountId = c.var.auth.accountId; const { appName, deploymentName } = c.req.valid("param"); - const { packageInfo } = c.req.valid("json"); + const { label, isMandatory } = c.req.valid("json"); const app = await storage.getApp(accountId, { appName }); if (!app) { @@ -1380,32 +1594,22 @@ router.openapi(routes.deployments.release.update, async (c) => { deployment.id, ); - const release = packageInfo.label - ? packageHistory.find((p) => p.label === packageInfo.label) + const release = label + ? packageHistory.find((p) => p.label === label) : packageHistory[packageHistory.length - 1]; - if (!release) { + if (!release?.label) { throw new HTTPException(404, { message: "Release package not found", }); } - const updatedRelease = { - ...release, - }; - if (packageInfo.appVersion) { - updatedRelease.appVersion = packageInfo.appVersion; - } - if (packageInfo.description) { - updatedRelease.description = packageInfo.description; - } - if (packageInfo.isDisabled != null) { - updatedRelease.isDisabled = packageInfo.isDisabled; - } - await storage.updatePackage(updatedRelease, deployment.id); + const updatedRelease = { ...release, isMandatory }; + await storage.updatePackageMetadata(deployment.id, release.label, { + isMandatory, + }); - // isDisabled 토글이 포함된 요청일 때만 Slack 알림(rollout/version 등 다른 수정은 제외) - if (packageInfo.isDisabled != null && c.executionCtx) { + if (c.executionCtx) { const account = await storage.getAccount(accountId); c.executionCtx.waitUntil( sendReleaseNotification(c.env, { @@ -1415,7 +1619,7 @@ router.openapi(routes.deployments.release.update, async (c) => { description: updatedRelease.description, isMandatory: updatedRelease.isMandatory, isDisabled: updatedRelease.isDisabled, - action: updatedRelease.isDisabled ? "Disabled" : "Enabled", + action: "Mandatory", releasedBy: account.name || account.email, }), ); diff --git a/apps/server/src/storage/d1.ts b/apps/server/src/storage/d1.ts index c3bc950..4252c88 100644 --- a/apps/server/src/storage/d1.ts +++ b/apps/server/src/storage/d1.ts @@ -880,6 +880,27 @@ export class D1StorageProvider implements StorageProvider { return pkg; } + // disabled/mandatory 토글 전용 부분 UPDATE. updatePackage(여러 필드 통째 SET)와 달리 + // 대상 컬럼만 갱신한다 — 히스토리 캐시가 isolate별 메모리라 stale 캐시 기반으로 전체 필드를 + // 쓰면 다른 isolate에서 방금 바꾼 필드를 되돌릴 수 있어(lost update), 토글은 이 경로를 쓴다. + async updatePackageMetadata( + deploymentId: string, + label: string, + updates: { isDisabled?: boolean; isMandatory?: boolean }, + ): Promise { + await this.db + .update(schema.packages) + .set(updates) + .where( + and( + eq(schema.packages.deploymentId, deploymentId), + eq(schema.packages.label, label), + isNull(schema.packages.deletedAt), + ), + ); + await this.cache.del(this.cacheKeys.package(deploymentId)); + } + async getPackageHistory( accountId: string, appId: string, diff --git a/apps/server/src/storage/storage.ts b/apps/server/src/storage/storage.ts index 1f26e63..43181de 100644 --- a/apps/server/src/storage/storage.ts +++ b/apps/server/src/storage/storage.ts @@ -92,6 +92,12 @@ export interface StorageProvider { updatePackage(pkg: Package, deploymentId: string): Promise; + updatePackageMetadata( + deploymentId: string, + label: string, + updates: { isDisabled?: boolean; isMandatory?: boolean }, + ): Promise; + getPackageHistory( accountId: string, appId: string, diff --git a/apps/server/src/utils/slack.ts b/apps/server/src/utils/slack.ts index c122f50..d476f0b 100644 --- a/apps/server/src/utils/slack.ts +++ b/apps/server/src/utils/slack.ts @@ -1,19 +1,15 @@ import type { Env } from "../types/env"; -type ReleaseActionType = "Uploaded" | "Enabled" | "Disabled"; +type ReleaseActionType = "Uploaded" | "UploadFailed" | "Disabled" | "Mandatory"; const SLACK_WEBHOOK_BASE = "https://hooks.slack.com/services"; -const ACTION_HEADER: Record = { - Uploaded: "새 릴리즈 업로드", - Enabled: "릴리즈 활성화", - Disabled: "릴리즈 비활성화", -}; - +// 이모지·헤더는 액션 종류만 구분한다. on/off는 헤더 텍스트가 isDisabled/isMandatory 값으로 표현. const ACTION_EMOJI: Record = { Uploaded: "🚀", - Enabled: "🟢", - Disabled: "🔴", + UploadFailed: "🚨", + Disabled: "🧊", + Mandatory: "🔥", }; type ReleaseNotificationType = { @@ -25,6 +21,7 @@ type ReleaseNotificationType = { isDisabled?: boolean; action: ReleaseActionType; releasedBy?: string; + errorMessage?: string; }; /** @@ -42,11 +39,24 @@ export const sendReleaseNotification = async ( const platform = info.appName.toLowerCase().includes("android") ? "Android" : "iOS"; - const header = `${ACTION_EMOJI[info.action]} ${platform} · ${ACTION_HEADER[info.action]}`; + const headerLabel = + info.action === "Uploaded" + ? "새 릴리즈 업로드" + : info.action === "UploadFailed" + ? "릴리즈 업로드 실패" + : info.action === "Disabled" + ? `Disabled ${info.isDisabled ?? false}` + : `Mandatory ${info.isMandatory ?? false}`; + const header = `${ACTION_EMOJI[info.action]} ${platform} · ${headerLabel}`; - // 라벨 + 앱 버전을 굵게, 설명은 있을 때만 다음 줄에. - const summary = `*${info.label || "-"}* · App ${info.appVersion}`; - const body = info.description ? `${summary}\n${info.description}` : summary; + // 라벨 + 앱 버전을 굵게, 설명·에러는 있을 때만 다음 줄에. + const body = [ + `*${info.label || "-"}* · App ${info.appVersion}`, + info.description, + info.errorMessage && `\`${info.errorMessage}\``, + ] + .filter(Boolean) + .join("\n"); // 부가 정보는 작은 context 줄로 (Mandatory · Disabled · 작성자) const context = [ diff --git a/apps/server/test/routes/management.test.ts b/apps/server/test/routes/management.test.ts index ecc8de9..5ed763a 100644 --- a/apps/server/test/routes/management.test.ts +++ b/apps/server/test/routes/management.test.ts @@ -1797,5 +1797,151 @@ describe("Management Routes", () => { expect(data.release.appVersion).toBe("1.0.0"); }); }); + + describe("PATCH /apps/:appName/deployments/:deploymentName/release/{disabled,mandatory}", () => { + let app: typeof schema.app.$inferInsert; + let deployment: typeof schema.deployment.$inferInsert; + + beforeEach(async () => { + app = createTestApp(); + await db.insert(schema.app).values(app); + await db.insert(schema.collaborator).values({ + appId: app.id, + accountId: auth.getCurrentAccountId(), + permission: "Owner", + }); + deployment = createTestDeployment(app.id); + await db.insert(schema.deployment).values(deployment); + // v1: disabled 상태, v2: mandatory 상태(최신) — 부분 UPDATE가 다른 필드를 건드리지 않는지 검증용 + const package1 = createTestPackage(deployment.id, { + label: "v1", + appVersion: "1.0.0", + isDisabled: true, + uploadTime: Date.now() - 1000, + }); + const package2 = createTestPackage(deployment.id, { + label: "v2", + appVersion: "1.0.1", + isMandatory: true, + uploadTime: Date.now(), + }); + await db.insert(schema.packages).values(package1); + await db.insert(schema.packages).values(package2); + await Promise.all([ + createTestBlob(package1.blobPath, "blob1"), + createTestBlob(package1.manifestBlobPath as string, "blob2"), + createTestBlob(package2.blobPath, "blob3"), + createTestBlob(package2.manifestBlobPath as string, "blob4"), + ]); + }); + + const fetchToggle = ( + kind: "disabled" | "mandatory", + body: object, + headers: HeadersInit = {}, + ) => + SELF.fetch( + `https://example.com/apps/${app.name}/deployments/${deployment.name}/release/${kind}`, + { method: "PATCH", headers, body: JSON.stringify(body) }, + ); + + const getRow = async (label: string) => { + const [row] = await db + .select() + .from(schema.packages) + .where( + and( + eq(schema.packages.deploymentId, deployment.id as string), + eq(schema.packages.label, label), + ), + ); + return row; + }; + + it("disables a release by label without touching isMandatory", async () => { + const headers = await auth.getAuthHeaders(); + const response = await fetchToggle( + "disabled", + { label: "v2", isDisabled: true }, + headers, + ); + + expect(response.status).toBe(200); + const data: any = await response.json(); + expect(data.release.label).toBe("v2"); + expect(data.release.isDisabled).toBe(true); + + const row = await getRow("v2"); + expect(row.isDisabled).toBe(true); + expect(row.isMandatory).toBe(true); // 부분 UPDATE — mandatory 불변 + }); + + it("enables a disabled release", async () => { + const headers = await auth.getAuthHeaders(); + const response = await fetchToggle( + "disabled", + { label: "v1", isDisabled: false }, + headers, + ); + + expect(response.status).toBe(200); + const row = await getRow("v1"); + expect(row.isDisabled).toBe(false); + }); + + it("sets mandatory by label without touching isDisabled", async () => { + const headers = await auth.getAuthHeaders(); + const response = await fetchToggle( + "mandatory", + { label: "v1", isMandatory: true }, + headers, + ); + + expect(response.status).toBe(200); + const data: any = await response.json(); + expect(data.release.label).toBe("v1"); + expect(data.release.isMandatory).toBe(true); + + const row = await getRow("v1"); + expect(row.isMandatory).toBe(true); + expect(row.isDisabled).toBe(true); // 부분 UPDATE — disabled 불변 + }); + + it("targets the latest release when label is omitted", async () => { + const headers = await auth.getAuthHeaders(); + const response = await fetchToggle( + "mandatory", + { isMandatory: false }, + headers, + ); + + expect(response.status).toBe(200); + const data: any = await response.json(); + expect(data.release.label).toBe("v2"); + + const v2 = await getRow("v2"); + expect(v2.isMandatory).toBe(false); + const v1 = await getRow("v1"); + expect(v1.isMandatory).toBe(false); // 다른 행 불변 + }); + + it("returns 404 for unknown label", async () => { + const headers = await auth.getAuthHeaders(); + const response = await fetchToggle( + "disabled", + { label: "v99", isDisabled: true }, + headers, + ); + expect(response.status).toBe(404); + }); + + it("returns 401 when not authenticated", async () => { + const response = await fetchToggle("mandatory", { + label: "v1", + isMandatory: true, + }); + expect(response.status).toBe(401); + }); + }); }); diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index f9d8924..e096abb 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -24,6 +24,8 @@ export default defineWorkersProject(async () => { d1Databases: ["DB"], bindings: { TEST_MIGRATIONS: migrations, + // .dev.vars의 실제 webhook이 로드되면 테스트가 실채널로 Slack을 쏜다. 반드시 빈 값 고정. + CODE_PUSH_NOTI_SLACK: "", GITHUB_CLIENT_ID: "xxx", GITHUB_CLIENT_SECRET: "xxx", GITHUB_ORG: "test-org", diff --git a/apps/web/src/pages/history/HistoryPage.tsx b/apps/web/src/pages/history/HistoryPage.tsx index 2ad5d43..3bbd261 100644 --- a/apps/web/src/pages/history/HistoryPage.tsx +++ b/apps/web/src/pages/history/HistoryPage.tsx @@ -1,11 +1,4 @@ -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Select, SelectContent, @@ -14,204 +7,42 @@ import { SelectValue, } from "@/components/ui/select"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { api } from "@/lib/api"; -import { cn } from "@/lib/utils"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - ChevronLeft, - ChevronRight, - ChevronsLeft, - ChevronsRight, - RefreshCw, -} from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; - -const PAGE_SIZE = 20; - -// history 라우트는 서버 OpenAPI 응답 스키마가 비어 있어 api-client가 void로 생성한다. -// 실제 응답은 { history, totalCount, page, pageSize } 이므로 여기서 형태를 명시한다. -interface ReleaseHistoryItem { - label?: string; - appVersion?: string; - isMandatory?: boolean; - isDisabled?: boolean; - description?: string; - size?: number; - rollout?: number | null; - releaseMethod?: string; - uploadTime?: number; -} -interface HistoryResponse { - history: ReleaseHistoryItem[]; - totalCount: number; - page: number; - pageSize: number; -} -interface MetricEntry { - active?: number; - downloads?: number; - installed?: number; - failed?: number; -} -type MergedItem = ReleaseHistoryItem & MetricEntry; - -function formatBytes(bytes?: number): string { - if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return "-"; - if (bytes < 1024) return `${bytes} B`; - const units = ["KB", "MB", "GB", "TB"] as const; - let value = bytes / 1024; - let unitIndex = 0; - while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024; - unitIndex += 1; - } - return `${value.toFixed(1)} ${units[unitIndex]}`; -} - -function formatTime(ts?: number): string { - if (ts == null || !Number.isFinite(ts) || ts <= 0) return "-"; - return new Date(ts).toLocaleString("ko-KR", { - year: "2-digit", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); -} +import { RefreshCw } from "lucide-react"; +import { HistoryPagination } from "./components/HistoryPagination"; +import { ReleaseCard } from "./components/ReleaseCard"; +import { useHistoryTarget } from "./hooks/useHistoryTarget"; +import { useReleaseHistory } from "./hooks/useReleaseHistory"; +import { useReleaseToggles } from "./hooks/useReleaseToggles"; export const HistoryPage = () => { - const queryClient = useQueryClient(); - const [appName, setAppName] = useState(""); - const [deploymentName, setDeploymentName] = useState(""); - const [page, setPage] = useState(1); - - const { data: appsData } = useQuery({ - queryKey: ["apps"], - queryFn: async () => (await api.appsGet()).data, - }); - const apps = appsData?.apps ?? []; - - // 앱 목록 로드 시 첫 앱 선택 - useEffect(() => { - if (!appName && apps.length > 0 && apps[0].name) { - setAppName(apps[0].name); - } - }, [apps, appName]); - - const { data: deploymentsData } = useQuery({ - queryKey: ["deployments", appName], - queryFn: async () => (await api.appsAppNameDeploymentsGet(appName)).data, - enabled: !!appName, - }); - const deployments = deploymentsData?.deployments ?? []; - - // 배포 목록 로드 시 Production 우선, 없으면 첫 배포 선택 - useEffect(() => { - if (deployments.length === 0) return; - const names = deployments.map((d) => d.name).filter(Boolean) as string[]; - if (deploymentName && names.includes(deploymentName)) return; - setDeploymentName(names.includes("Production") ? "Production" : names[0]); - }, [deployments, deploymentName]); - - // 앱·배포를 바꾸면 첫 페이지로 되돌린다. - useEffect(() => { - setPage(1); - }, [appName, deploymentName]); - - const historyEnabled = !!appName && !!deploymentName; + const { + apps, + deployments, + appName, + setAppName, + deploymentName, + setDeploymentName, + enabled, + } = useHistoryTarget(); const { - data: historyData, - isLoading: isHistoryLoading, - isError: isHistoryError, + rows, + page, + setPage, + totalCount, + totalPages, + isLoading, + isError, isFetching, - } = useQuery({ - queryKey: ["history", appName, deploymentName, page], - queryFn: async () => { - const response = await api.appsAppNameDeploymentsDeploymentNameHistoryGet( - appName, - deploymentName, - page, - PAGE_SIZE, - ); - // 위 주석 참조: 서버 실제 응답 형태로 해석 - return response.data as unknown as HistoryResponse; - }, - enabled: historyEnabled, - }); - - const { data: metricsData } = useQuery({ - queryKey: ["metrics", appName, deploymentName], - queryFn: async () => - ( - await api.appsAppNameDeploymentsDeploymentNameMetricsGet( - appName, - deploymentName, - ) - ).data, - enabled: historyEnabled, - }); - - // 서버가 최신순 페이지를 내려주므로 정렬은 서버에 맡기고 지표만 병합한다. - const rows: MergedItem[] = useMemo(() => { - const history = historyData?.history ?? []; - const metrics = (metricsData?.metrics ?? {}) as Record; - return history.map((item) => ({ - ...item, - ...(item.label ? metrics[item.label] : {}), - })); - }, [historyData, metricsData]); + refetch, + } = useReleaseHistory(appName, deploymentName, enabled); - const totalCount = historyData?.totalCount ?? 0; - const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); - - const disableMutation = useMutation({ - mutationFn: async ({ - label, - isDisabled, - }: { - label: string; - isDisabled: boolean; - }) => - api.appsAppNameDeploymentsDeploymentNameReleasePatch( - appName, - deploymentName, - { packageInfo: { label, isDisabled } }, - ), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["history", appName, deploymentName], - }); - }, - onError: (error) => { - const message = - (error as { response?: { data?: { message?: string } } })?.response - ?.data?.message ?? "릴리즈 업데이트에 실패했습니다."; - window.alert(message); - }, - }); - - const onToggleDisable = (item: MergedItem) => { - if (!item.label) return; - const willDisable = !item.isDisabled; - const confirmed = window.confirm( - `Label: ${item.label}\nDescription: ${item.description ?? "-"}\n\n이 버전을 ${ - willDisable ? "비활성화" : "활성화" - }하시겠습니까?`, - ); - if (!confirmed) return; - disableMutation.mutate({ label: item.label, isDisabled: willDisable }); - }; - - const refetch = () => { - queryClient.invalidateQueries({ - queryKey: ["history", appName, deploymentName], - }); - queryClient.invalidateQueries({ - queryKey: ["metrics", appName, deploymentName], - }); - }; + const { + onToggleDisable, + onToggleMandatory, + isDisablePending, + isMandatoryPending, + } = useReleaseToggles(appName, deploymentName); return (
@@ -257,18 +88,18 @@ export const HistoryPage = () => {
- {isHistoryLoading ? ( + {isLoading ? (
데이터를 불러오는 중입니다...
- ) : isHistoryError ? ( + ) : isError ? (
이력을 불러오지 못했습니다. 새로고침하거나 다시 로그인해 주세요.
@@ -280,135 +111,24 @@ export const HistoryPage = () => { <>
{rows.map((item) => ( - 50 && - "border-l-4 border-l-emerald-500", - )} - > - -
-
- - {item.label} - - - App {item.appVersion ?? "-"} - -
-
- {item.isMandatory && ( - - Mandatory - - )} - {item.isDisabled ? ( - - - Disabled - - ) : ( - - - Active - - )} -
-
-

- {item.description || "-"} -

-
- -
-
-
- Installed -
-
- {(item.installed ?? 0).toLocaleString()} -
-
-
-
- Active -
-
- {(item.active ?? 0).toLocaleString()} -
-
-
-
- Size -
-
- {formatBytes(item.size)} -
-
-
-
- - {formatTime(item.uploadTime)} - - -
-
-
+ item={item} + onToggleDisable={onToggleDisable} + onToggleMandatory={onToggleMandatory} + isDisablePending={isDisablePending} + isMandatoryPending={isMandatoryPending} + /> ))}
-
-

- 전체 {totalCount}개 · {page}/{totalPages} 페이지 -

-
- - - - -
-
+ )} diff --git a/apps/web/src/pages/history/components/HistoryPagination.tsx b/apps/web/src/pages/history/components/HistoryPagination.tsx new file mode 100644 index 0000000..4eb8a2f --- /dev/null +++ b/apps/web/src/pages/history/components/HistoryPagination.tsx @@ -0,0 +1,67 @@ +import { Button } from "@/components/ui/button"; +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react"; + +interface HistoryPaginationProps { + page: number; + totalPages: number; + totalCount: number; + isFetching: boolean; + onPageChange: (page: number) => void; +} + +export const HistoryPagination = ({ + page, + totalPages, + totalCount, + isFetching, + onPageChange, +}: HistoryPaginationProps) => { + return ( +
+

+ 전체 {totalCount}개 · {page}/{totalPages} 페이지 +

+
+ + + + +
+
+ ); +}; diff --git a/apps/web/src/pages/history/components/ReleaseCard.tsx b/apps/web/src/pages/history/components/ReleaseCard.tsx new file mode 100644 index 0000000..8467424 --- /dev/null +++ b/apps/web/src/pages/history/components/ReleaseCard.tsx @@ -0,0 +1,158 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { Power, Zap } from "lucide-react"; +import type { MergedItem } from "../types"; + +function formatBytes(bytes?: number): string { + if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return "-"; + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"] as const; + let value = bytes / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(1)} ${units[unitIndex]}`; +} + +function formatTime(ts?: number): string { + if (ts == null || !Number.isFinite(ts) || ts <= 0) return "-"; + return new Date(ts).toLocaleString("ko-KR", { + year: "2-digit", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + +interface ReleaseCardProps { + item: MergedItem; + onToggleDisable: (item: MergedItem) => void; + onToggleMandatory: (item: MergedItem) => void; + isDisablePending: boolean; + isMandatoryPending: boolean; +} + +export const ReleaseCard = ({ + item, + onToggleDisable, + onToggleMandatory, + isDisablePending, + isMandatoryPending, +}: ReleaseCardProps) => { + return ( + 50 && "border-l-4 border-l-emerald-500", + )} + > + +
+
+ {item.label} + + App {item.appVersion ?? "-"} + +
+
+ {item.isMandatory && ( + + Mandatory + + )} + {item.isDisabled ? ( + + + Disabled + + ) : ( + + + Active + + )} +
+
+

+ {item.description || "-"} +

+
+ +
+
+
+ Installed +
+
+ {(item.installed ?? 0).toLocaleString()} +
+
+
+
+ Active +
+
+ {(item.active ?? 0).toLocaleString()} +
+
+
+
+ Size +
+
+ {formatBytes(item.size)} +
+
+
+
+ + {formatTime(item.uploadTime)} + +
+ + +
+
+
+
+ ); +}; diff --git a/apps/web/src/pages/history/hooks/useHistoryTarget.ts b/apps/web/src/pages/history/hooks/useHistoryTarget.ts new file mode 100644 index 0000000..0d62ba9 --- /dev/null +++ b/apps/web/src/pages/history/hooks/useHistoryTarget.ts @@ -0,0 +1,52 @@ +import { api } from "@/lib/api"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; + +// 조회 대상(앱·배포) 선택 상태와 자동 선택 규칙 +export function useHistoryTarget() { + const [appName, setAppName] = useState(""); + const [deploymentName, setDeploymentName] = useState(""); + + const { data: appsData } = useQuery({ + queryKey: ["apps"], + queryFn: async () => (await api.appsGet()).data, + }); + const apps = appsData?.apps ?? []; + + useEffect( + function selectFirstAppOnLoad() { + if (!appName && apps.length > 0 && apps[0].name) { + setAppName(apps[0].name); + } + }, + [apps, appName], + ); + + const { data: deploymentsData } = useQuery({ + queryKey: ["deployments", appName], + queryFn: async () => (await api.appsAppNameDeploymentsGet(appName)).data, + enabled: !!appName, + }); + const deployments = deploymentsData?.deployments ?? []; + + // Production 우선, 없으면 첫 배포 선택 + useEffect( + function selectDefaultDeployment() { + if (deployments.length === 0) return; + const names = deployments.map((d) => d.name).filter(Boolean) as string[]; + if (deploymentName && names.includes(deploymentName)) return; + setDeploymentName(names.includes("Production") ? "Production" : names[0]); + }, + [deployments, deploymentName], + ); + + return { + apps, + deployments, + appName, + setAppName, + deploymentName, + setDeploymentName, + enabled: !!appName && !!deploymentName, + }; +} diff --git a/apps/web/src/pages/history/hooks/useReleaseHistory.ts b/apps/web/src/pages/history/hooks/useReleaseHistory.ts new file mode 100644 index 0000000..7967dd2 --- /dev/null +++ b/apps/web/src/pages/history/hooks/useReleaseHistory.ts @@ -0,0 +1,89 @@ +import { api } from "@/lib/api"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import type { HistoryResponse, MergedItem, MetricEntry } from "../types"; + +const PAGE_SIZE = 20; + +// 릴리즈 이력·지표 조회와 페이지 상태 +export function useReleaseHistory( + appName: string, + deploymentName: string, + enabled: boolean, +) { + const queryClient = useQueryClient(); + const [page, setPage] = useState(1); + + useEffect( + function resetPageOnTargetChange() { + setPage(1); + }, + [appName, deploymentName], + ); + + const { + data: historyData, + isLoading, + isError, + isFetching, + } = useQuery({ + queryKey: ["history", appName, deploymentName, page], + queryFn: async () => { + const response = await api.appsAppNameDeploymentsDeploymentNameHistoryGet( + appName, + deploymentName, + page, + PAGE_SIZE, + ); + // types.ts 주석 참조: 서버 실제 응답 형태로 해석 + return response.data as unknown as HistoryResponse; + }, + enabled, + }); + + const { data: metricsData } = useQuery({ + queryKey: ["metrics", appName, deploymentName], + queryFn: async () => + ( + await api.appsAppNameDeploymentsDeploymentNameMetricsGet( + appName, + deploymentName, + ) + ).data, + enabled, + }); + + // 서버가 최신순 페이지를 내려주므로 정렬은 서버에 맡기고 지표만 병합한다. + const rows: MergedItem[] = useMemo(() => { + const history = historyData?.history ?? []; + const metrics = (metricsData?.metrics ?? {}) as Record; + return history.map((item) => ({ + ...item, + ...(item.label ? metrics[item.label] : {}), + })); + }, [historyData, metricsData]); + + const totalCount = historyData?.totalCount ?? 0; + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + + const refetch = () => { + queryClient.invalidateQueries({ + queryKey: ["history", appName, deploymentName], + }); + queryClient.invalidateQueries({ + queryKey: ["metrics", appName, deploymentName], + }); + }; + + return { + rows, + page, + setPage, + totalCount, + totalPages, + isLoading, + isError, + isFetching, + refetch, + }; +} diff --git a/apps/web/src/pages/history/hooks/useReleaseToggles.ts b/apps/web/src/pages/history/hooks/useReleaseToggles.ts new file mode 100644 index 0000000..4141bb4 --- /dev/null +++ b/apps/web/src/pages/history/hooks/useReleaseToggles.ts @@ -0,0 +1,90 @@ +import { api } from "@/lib/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { MergedItem } from "../types"; + +// disabled/mandatory 토글 액션 (confirm 포함) +export function useReleaseToggles(appName: string, deploymentName: string) { + const queryClient = useQueryClient(); + + const invalidateHistory = () => { + queryClient.invalidateQueries({ + queryKey: ["history", appName, deploymentName], + }); + }; + + const alertError = (error: unknown) => { + const message = + (error as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "릴리즈 업데이트에 실패했습니다."; + window.alert(message); + }; + + const disableMutation = useMutation({ + mutationFn: async ({ + label, + isDisabled, + }: { + label: string; + isDisabled: boolean; + }) => + api.appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch( + appName, + deploymentName, + { label, isDisabled }, + ), + onSuccess: invalidateHistory, + onError: alertError, + }); + + const mandatoryMutation = useMutation({ + mutationFn: async ({ + label, + isMandatory, + }: { + label: string; + isMandatory: boolean; + }) => + api.appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch( + appName, + deploymentName, + { label, isMandatory }, + ), + onSuccess: invalidateHistory, + onError: alertError, + }); + + const onToggleDisable = (item: MergedItem) => { + if (!item.label) return; + const willDisable = !item.isDisabled; + const confirmed = window.confirm( + `Label: ${item.label}\nDescription: ${item.description ?? "-"}\n\n이 버전을 ${ + willDisable ? "비활성화" : "활성화" + }하시겠습니까?`, + ); + if (!confirmed) return; + disableMutation.mutate({ label: item.label, isDisabled: willDisable }); + }; + + const onToggleMandatory = (item: MergedItem) => { + if (!item.label) return; + const willMandatory = !item.isMandatory; + const confirmed = window.confirm( + `Label: ${item.label}\nDescription: ${item.description ?? "-"}\n\n이 버전을 ${ + willMandatory ? "Mandatory로 설정" : "Optional로 해제" + }하시겠습니까?${ + willMandatory + ? "\n\n⚠️ Mandatory 설정 시 이 버전 이전을 실행 중인 기기들의 다음 업데이트가 강제 업데이트로 승격됩니다." + : "" + }`, + ); + if (!confirmed) return; + mandatoryMutation.mutate({ label: item.label, isMandatory: willMandatory }); + }; + + return { + onToggleDisable, + onToggleMandatory, + isDisablePending: disableMutation.isPending, + isMandatoryPending: mandatoryMutation.isPending, + }; +} diff --git a/apps/web/src/pages/history/types.ts b/apps/web/src/pages/history/types.ts new file mode 100644 index 0000000..f1a9600 --- /dev/null +++ b/apps/web/src/pages/history/types.ts @@ -0,0 +1,29 @@ +// history 라우트는 서버 OpenAPI 응답 스키마가 비어 있어 api-client가 void로 생성한다. +// 실제 응답은 { history, totalCount, page, pageSize } 이므로 여기서 형태를 명시한다. +export interface ReleaseHistoryItem { + label?: string; + appVersion?: string; + isMandatory?: boolean; + isDisabled?: boolean; + description?: string; + size?: number; + rollout?: number | null; + releaseMethod?: string; + uploadTime?: number; +} + +export interface HistoryResponse { + history: ReleaseHistoryItem[]; + totalCount: number; + page: number; + pageSize: number; +} + +export interface MetricEntry { + active?: number; + downloads?: number; + installed?: number; + failed?: number; +} + +export type MergedItem = ReleaseHistoryItem & MetricEntry; diff --git a/packages/api-client/src/api.ts b/packages/api-client/src/api.ts index bc9135a..2b2b355 100644 --- a/packages/api-client/src/api.ts +++ b/packages/api-client/src/api.ts @@ -295,6 +295,44 @@ export interface AppsAppNameDeploymentsDeploymentNameMetricsGet200ResponseMetric */ 'failed'?: number; } +/** + * + * @export + * @interface AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest + */ +export interface AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest { + /** + * + * @type {string} + * @memberof AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest + */ + 'label'?: string; + /** + * + * @type {boolean} + * @memberof AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest + */ + 'isDisabled': boolean; +} +/** + * + * @export + * @interface AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest + */ +export interface AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest { + /** + * + * @type {string} + * @memberof AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest + */ + 'label'?: string; + /** + * + * @type {boolean} + * @memberof AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest + */ + 'isMandatory': boolean; +} /** * * @export @@ -1909,6 +1947,88 @@ export const DefaultApiAxiosParamCreator = function (configuration?: Configurati options: localVarRequestOptions, }; }, + /** + * Update release disabled state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch: async (appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'appName' is not null or undefined + assertParamExists('appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch', 'appName', appName) + // verify required parameter 'deploymentName' is not null or undefined + assertParamExists('appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch', 'deploymentName', deploymentName) + const localVarPath = `/apps/{appName}/deployments/{deploymentName}/release/disabled` + .replace(`{${"appName"}}`, encodeURIComponent(String(appName))) + .replace(`{${"deploymentName"}}`, encodeURIComponent(String(deploymentName))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Update release mandatory state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch: async (appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'appName' is not null or undefined + assertParamExists('appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch', 'appName', appName) + // verify required parameter 'deploymentName' is not null or undefined + assertParamExists('appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch', 'deploymentName', deploymentName) + const localVarPath = `/apps/{appName}/deployments/{deploymentName}/release/mandatory` + .replace(`{${"appName"}}`, encodeURIComponent(String(appName))) + .replace(`{${"deploymentName"}}`, encodeURIComponent(String(deploymentName))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Update release * @param {string} appName @@ -2909,6 +3029,34 @@ export const DefaultApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['DefaultApi.appsAppNameDeploymentsDeploymentNamePatch']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Update release disabled state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch(appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch(appName, deploymentName, appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['DefaultApi.appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * Update release mandatory state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch(appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch(appName, deploymentName, appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['DefaultApi.appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Update release * @param {string} appName @@ -3343,6 +3491,28 @@ export const DefaultApiFactory = function (configuration?: Configuration, basePa appsAppNameDeploymentsDeploymentNamePatch(appName: string, deploymentName: string, appsAppNamePatchRequest?: AppsAppNamePatchRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.appsAppNameDeploymentsDeploymentNamePatch(appName, deploymentName, appsAppNamePatchRequest, options).then((request) => request(axios, basePath)); }, + /** + * Update release disabled state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch(appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch(appName, deploymentName, appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, options).then((request) => request(axios, basePath)); + }, + /** + * Update release mandatory state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch(appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch(appName, deploymentName, appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, options).then((request) => request(axios, basePath)); + }, /** * Update release * @param {string} appName @@ -3716,6 +3886,28 @@ export interface DefaultApiInterface { */ appsAppNameDeploymentsDeploymentNamePatch(appName: string, deploymentName: string, appsAppNamePatchRequest?: AppsAppNamePatchRequest, options?: RawAxiosRequestConfig): AxiosPromise; + /** + * Update release disabled state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApiInterface + */ + appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch(appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, options?: RawAxiosRequestConfig): AxiosPromise; + + /** + * Update release mandatory state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApiInterface + */ + appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch(appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, options?: RawAxiosRequestConfig): AxiosPromise; + /** * Update release * @param {string} appName @@ -4123,6 +4315,32 @@ export class DefaultApi extends BaseAPI implements DefaultApiInterface { return DefaultApiFp(this.configuration).appsAppNameDeploymentsDeploymentNamePatch(appName, deploymentName, appsAppNamePatchRequest, options).then((request) => request(this.axios, this.basePath)); } + /** + * Update release disabled state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch(appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, options?: RawAxiosRequestConfig) { + return DefaultApiFp(this.configuration).appsAppNameDeploymentsDeploymentNameReleaseDisabledPatch(appName, deploymentName, appsAppNameDeploymentsDeploymentNameReleaseDisabledPatchRequest, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Update release mandatory state + * @param {string} appName + * @param {string} deploymentName + * @param {AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest} [appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch(appName: string, deploymentName: string, appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest?: AppsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, options?: RawAxiosRequestConfig) { + return DefaultApiFp(this.configuration).appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatch(appName, deploymentName, appsAppNameDeploymentsDeploymentNameReleaseMandatoryPatchRequest, options).then((request) => request(this.axios, this.basePath)); + } + /** * Update release * @param {string} appName From 0e090575d8c469d9e19216d6d137d20fbaf5b84a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EC=84=B1=EC=9A=B0?= <82026688+devlasbe@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:27:34 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[FE-66]=20feat:=20Google=20OAuth=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=B6=94=EA=B0=80=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /auth/google/login, /auth/google/callback (허용 도메인 화이트리스트 게이트) - account.google_id 컬럼 마이그레이션 + linkedProviders GitHub/Google 조합 - getGoogleAccessToken/getGoogleUser 헬퍼 (userinfo 엔드포인트, 미검증 이메일 거부) - 웹 Continue with Google 버튼 + google_domain_not_allowed 에러, GitHub 버튼 검정 통일 - 테스트 4종: 로그인 리다이렉트/도메인 허용·거부/기존 계정 연결 Co-authored-by: Claude Fable 5 --- .gitignore | 1 + .../drizzle/migrations/0004_add_google_id.sql | 1 + .../migrations/meta/0004_snapshot.json | 669 ++++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + apps/server/src/db/schema.ts | 1 + apps/server/src/middleware/auth.ts | 70 ++ apps/server/src/routes/auth.ts | 187 +++++ apps/server/src/storage/d1.ts | 36 +- apps/server/src/types/env.ts | 4 + apps/server/src/types/schemas.ts | 1 + apps/server/test/routes/auth.test.ts | 100 +++ apps/server/vitest.config.ts | 3 + apps/server/wrangler.toml | 6 + apps/web/src/lib/config.ts | 1 + apps/web/src/pages/auth/LoginPage.tsx | 34 +- 15 files changed, 1099 insertions(+), 22 deletions(-) create mode 100644 apps/server/drizzle/migrations/0004_add_google_id.sql create mode 100644 apps/server/drizzle/migrations/meta/0004_snapshot.json diff --git a/.gitignore b/.gitignore index e70749f..855d5c4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ lerna-debug.log* .DS_Store _old/ +*.tsbuildinfo diff --git a/apps/server/drizzle/migrations/0004_add_google_id.sql b/apps/server/drizzle/migrations/0004_add_google_id.sql new file mode 100644 index 0000000..911f7d9 --- /dev/null +++ b/apps/server/drizzle/migrations/0004_add_google_id.sql @@ -0,0 +1 @@ +ALTER TABLE `account` ADD `google_id` text; \ No newline at end of file diff --git a/apps/server/drizzle/migrations/meta/0004_snapshot.json b/apps/server/drizzle/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..572727a --- /dev/null +++ b/apps/server/drizzle/migrations/meta/0004_snapshot.json @@ -0,0 +1,669 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a03f3943-8578-4cd9-bbc0-445b936c59f1", + "prevId": "e8fff387-69e6-4382-b4c3-a65bca2d9a9a", + "tables": { + "access_key": { + "name": "access_key", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "friendly_name": { + "name": "friendly_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_time": { + "name": "created_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_session": { + "name": "is_session", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "access_key_name_unique": { + "name": "access_key_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "access_key_account_id_account_id_fk": { + "name": "access_key_account_id_account_id_fk", + "tableFrom": "access_key", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_id": { + "name": "github_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "google_id": { + "name": "google_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_time": { + "name": "created_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_email_unique": { + "name": "account_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app": { + "name": "app", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_time": { + "name": "created_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "client_label": { + "name": "client_label", + "columns": { + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "client_label_client_id_deployment_id_pk": { + "columns": [ + "client_id", + "deployment_id" + ], + "name": "client_label_client_id_deployment_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collaborator": { + "name": "collaborator", + "columns": { + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "collaborator_app_id_app_id_fk": { + "name": "collaborator_app_id_app_id_fk", + "tableFrom": "collaborator", + "tableTo": "app", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "collaborator_account_id_account_id_fk": { + "name": "collaborator_account_id_account_id_fk", + "tableFrom": "collaborator", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "collaborator_app_id_account_id_pk": { + "columns": [ + "app_id", + "account_id" + ], + "name": "collaborator_app_id_account_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deployment": { + "name": "deployment", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_time": { + "name": "created_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "deployment_key_unique": { + "name": "deployment_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "deployment_app_id_app_id_fk": { + "name": "deployment_app_id_app_id_fk", + "tableFrom": "deployment", + "tableTo": "app", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "metric": { + "name": "metric", + "columns": { + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "metric_deployment_id_label_type_pk": { + "columns": [ + "deployment_id", + "label", + "type" + ], + "name": "metric_deployment_id_label_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "package_diff": { + "name": "package_diff", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "package_id": { + "name": "package_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_package_hash": { + "name": "source_package_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blob_path": { + "name": "blob_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "package_diff_package_id_source_package_hash_unique": { + "name": "package_diff_package_id_source_package_hash_unique", + "columns": [ + "package_id", + "source_package_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "package_diff_package_id_package_id_fk": { + "name": "package_diff_package_id_package_id_fk", + "tableFrom": "package_diff", + "tableTo": "package", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "package": { + "name": "package", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_mandatory": { + "name": "is_mandatory", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rollout": { + "name": "rollout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blob_path": { + "name": "blob_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest_blob_path": { + "name": "manifest_blob_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "package_hash": { + "name": "package_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_method": { + "name": "release_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_label": { + "name": "original_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_deployment": { + "name": "original_deployment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "released_by": { + "name": "released_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upload_time": { + "name": "upload_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "package_deployment_id_deployment_id_fk": { + "name": "package_deployment_id_deployment_id_fk", + "tableFrom": "package", + "tableTo": "deployment", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/migrations/meta/_journal.json b/apps/server/drizzle/migrations/meta/_journal.json index 379fb55..c744133 100644 --- a/apps/server/drizzle/migrations/meta/_journal.json +++ b/apps/server/drizzle/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1737298919485, "tag": "0003_striped_tomas", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1784008415419, + "tag": "0004_add_google_id", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index cb99c83..e4d806b 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -13,6 +13,7 @@ export const account = sqliteTable("account", { email: text("email").notNull().unique(), name: text("name").notNull(), githubId: text("github_id"), + googleId: text("google_id"), createdTime: integer("created_time").notNull(), deletedAt: integer("deleted_at"), }); diff --git a/apps/server/src/middleware/auth.ts b/apps/server/src/middleware/auth.ts index 803ec90..466b7b1 100644 --- a/apps/server/src/middleware/auth.ts +++ b/apps/server/src/middleware/auth.ts @@ -168,6 +168,76 @@ export async function isGitHubOrgMember( return membership.state === "active"; } +// Google OAuth helpers +export interface GoogleUser { + id: string; + email: string; + name: string; +} + +export async function getGoogleAccessToken( + code: string, + env: Env["Bindings"], +): Promise { + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: env.GOOGLE_CLIENT_ID ?? "", + client_secret: env.GOOGLE_CLIENT_SECRET ?? "", + redirect_uri: `${env.SERVER_URL}/auth/google/callback`, + }), + }); + + const data = (await response.json()) as { + access_token?: string; + error?: string; + error_description?: string; + }; + if (!data.access_token) { + throw new Error( + data.error_description || "Failed to get Google access token", + ); + } + + return data.access_token; +} + +// ID token 파싱 대신 userinfo 엔드포인트 사용 — Workers엔 Buffer가 없고, +// 토큰이 Google에서 TLS로 직접 온 것이라 서명 검증이 불필요하다. +export async function getGoogleUser(accessToken: string): Promise { + const response = await fetch( + "https://openidconnect.googleapis.com/v1/userinfo", + { + headers: { authorization: `Bearer ${accessToken}` }, + }, + ); + + if (!response.ok) { + throw new Error("Failed to get Google user data"); + } + + const data = await response.json<{ + sub: string; + email?: string; + email_verified?: boolean; + name?: string; + }>(); + + // 도메인 게이트가 이메일 기반이므로 미검증 이메일은 거부 + if (!data.email || data.email_verified === false) { + throw new Error("No verified email in Google userinfo"); + } + + return { + id: data.sub, + email: data.email, + name: data.name || data.email.split("@")[0], + }; +} + export async function getGitHubUser(accessToken: string): Promise { const [userResponse, emailsResponse] = await Promise.all([ fetch("https://api.github.com/user", { diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index c2ee4be..cf6bc4a 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -4,6 +4,8 @@ import { z } from "zod"; import { getGitHubAccessToken, getGitHubUser, + getGoogleAccessToken, + getGoogleUser, isGitHubOrgMember, } from "../middleware/auth"; import { getStorageProvider } from "../storage/factory"; @@ -53,6 +55,40 @@ const routes = { }, }), + googleLogin: createRoute({ + method: "get", + path: "/google/login", + responses: { + 302: { + description: "Redirect to Google OAuth", + }, + }, + }), + + googleCallback: createRoute({ + method: "get", + path: "/google/callback", + request: { + query: z.object({ + code: z.string(), + state: z.string().optional(), + }), + }, + responses: { + 302: { + description: "Redirect to dashboard with session", + }, + 400: { + content: { + "application/json": { + schema: LoginErrorResponse, + }, + }, + description: "Login error", + }, + }, + }), + logout: createRoute({ method: "get", path: "/logout", @@ -213,6 +249,157 @@ router.openapi(routes.callback, async (c) => { } }); +// Google OAuth login +router.openapi(routes.googleLogin, async (c) => { + if (!c.env.GOOGLE_CLIENT_ID) { + return c.json( + { + error: "google_not_configured", + error_description: "Google OAuth is not configured", + }, + 400, + ); + } + + const state = JSON.stringify({ + client: c.req.query("client") ?? "cli", + redirectTo: c.req.query("redirect_to") ?? "/", + }); + const params = new URLSearchParams({ + client_id: c.env.GOOGLE_CLIENT_ID, + redirect_uri: `${c.env.SERVER_URL}/auth/google/callback`, + response_type: "code", + scope: "openid email profile", + state, + }); + + return c.redirect( + `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`, + ); +}); + +// Google OAuth callback +router.openapi(routes.googleCallback, async (c) => { + try { + const { code, state } = c.req.valid("query"); + if (!code) { + return c.json( + { + error: "invalid_request", + error_description: "No code provided", + }, + 400, + ); + } + + let client = "cli"; + let redirectTo = "/"; + try { + const parsed = JSON.parse(state ?? "{}"); + client = parsed.client ?? "cli"; + redirectTo = parsed.redirectTo ?? "/"; + } catch {} + const loginErrorRedirect = (errorCode: string) => + client === "web" + ? c.redirect( + new URL(`/login?error=${errorCode}`, redirectTo).toString(), + ) + : c.redirect(`/login?error=${errorCode}`); + + const accessToken = await getGoogleAccessToken(code, c.env); + const googleUser = await getGoogleUser(accessToken); + + // 허용 도메인 게이트 — GOOGLE_ALLOWED_DOMAIN(콤마 구분) 미설정 시 전부 거부 + const emailDomain = googleUser.email.split("@")[1]?.toLowerCase() ?? ""; + const allowedDomains = (c.env.GOOGLE_ALLOWED_DOMAIN ?? "") + .split(",") + .map((domain) => domain.trim().toLowerCase()) + .filter(Boolean); + if (!allowedDomains.includes(emailDomain)) { + return loginErrorRedirect("google_domain_not_allowed"); + } + + const storage = getStorageProvider(c); + + // Find or create account + let accountId: string; + try { + const account = await storage.getAccountByEmail(googleUser.email); + + // Update Google ID if not set + if (!account.googleId) { + await storage.updateAccount(account.email, { + ...account, + googleId: googleUser.id, + }); + } + + accountId = account.id; + } catch { + // Create new account if registration is enabled + if (c.env.ENABLE_ACCOUNT_REGISTRATION !== "true") { + return loginErrorRedirect("registration_disabled"); + } + + accountId = await storage.addAccount({ + email: googleUser.email, + name: googleUser.name, + googleId: googleUser.id, + createdTime: Date.now(), + linkedProviders: ["Google"], + }); + } + + // Create access key for session + const accessKeyName = generateKey(); + await storage.addAccessKey(accountId, { + name: accessKeyName, + friendlyName: "Google OAuth Session", + createdBy: c.req.header("User-Agent") ?? "Unknown", + createdTime: Date.now(), + expires: Date.now() + 60 * 24 * 60 * 60 * 1000, // 60 days + isSession: true, + }); + // Create JWT token + const token = await sign( + { + sub: accountId, + email: googleUser.email, + }, + c.env.JWT_SECRET, + ); + + // Set session cookie + setCookie(c, "session", token, { + httpOnly: true, + secure: true, + sameSite: "Lax", + path: "/", + domain: c.env.COOKIE_DOMAIN || undefined, + maxAge: 60 * 60 * 24, // 1 day + }); + + // 웹: 세션 쿠키로 대시보드 리다이렉트 / CLI: 기존 JSON 토큰 유지 + if (client === "web") { + return c.redirect(redirectTo); + } + return c.json({ + accessKeyName, + token, + }); + } catch (error) { + console.error("Auth error:", error); + + return c.json( + { + error: "auth_failed", + error_description: "Authentication failed", + }, + 400, + ); + } +}); + // Logout router.openapi(routes.logout, async (c) => { // Clear session cookie diff --git a/apps/server/src/storage/d1.ts b/apps/server/src/storage/d1.ts index 4252c88..0b745b3 100644 --- a/apps/server/src/storage/d1.ts +++ b/apps/server/src/storage/d1.ts @@ -91,6 +91,7 @@ export class D1StorageProvider implements StorageProvider { email: account.email.toLowerCase(), name: account.name, githubId: account.gitHubId, + googleId: account.googleId, createdTime: account.createdTime, }); @@ -109,14 +110,7 @@ export class D1StorageProvider implements StorageProvider { throw createStorageError(ErrorCode.NotFound, "Account not found"); } - return { - id: account.id, - email: account.email, - name: account.name, - gitHubId: account.githubId ?? undefined, - createdTime: account.createdTime, - linkedProviders: account.githubId ? ["GitHub"] : [], - }; + return this.mapAccountFromDB(account); } async getAccounts(): Promise { @@ -124,14 +118,24 @@ export class D1StorageProvider implements StorageProvider { where: isNull(schema.account.deletedAt), }); - return accounts.map((account) => ({ + return accounts.map((account) => this.mapAccountFromDB(account)); + } + + private mapAccountFromDB( + account: typeof schema.account.$inferSelect, + ): Account { + return { id: account.id, email: account.email, name: account.name, gitHubId: account.githubId ?? undefined, + googleId: account.googleId ?? undefined, createdTime: account.createdTime, - linkedProviders: account.githubId ? ["GitHub"] : [], - })); + linkedProviders: [ + ...(account.githubId ? ["GitHub"] : []), + ...(account.googleId ? ["Google"] : []), + ], + }; } async getAccountByEmail(email: string): Promise { @@ -146,14 +150,7 @@ export class D1StorageProvider implements StorageProvider { throw createStorageError(ErrorCode.NotFound, "Account not found"); } - return { - id: account.id, - email: account.email, - name: account.name, - gitHubId: account.githubId ?? undefined, - createdTime: account.createdTime, - linkedProviders: account.githubId ? ["GitHub"] : [], - }; + return this.mapAccountFromDB(account); } async updateAccount(email: string, updates: Partial): Promise { @@ -172,6 +169,7 @@ export class D1StorageProvider implements StorageProvider { .update(schema.account) .set({ githubId: updates.gitHubId, + googleId: updates.googleId, name: updates.name, }) .where(eq(schema.account.id, account.id)); diff --git a/apps/server/src/types/env.ts b/apps/server/src/types/env.ts index 533189d..dabe7d9 100644 --- a/apps/server/src/types/env.ts +++ b/apps/server/src/types/env.ts @@ -12,6 +12,10 @@ export interface Env { CORS_ORIGINS: string; // 세션 쿠키를 서브도메인 간 공유하기 위한 상위 도메인(예: yplabs.workers.dev). 로컬은 미설정. COOKIE_DOMAIN?: string; + GOOGLE_CLIENT_ID?: string; + GOOGLE_CLIENT_SECRET?: string; + // Google 로그인 허용 이메일 도메인(콤마 구분, 예: "yplabs.kr"). 미설정 시 Google 로그인 전부 거부. + GOOGLE_ALLOWED_DOMAIN?: string; ACCOUNT_ID: string; R2_BUCKET_NAME: string; R2_ACCESS_KEY_ID: string; diff --git a/apps/server/src/types/schemas.ts b/apps/server/src/types/schemas.ts index cb04641..5090a3c 100644 --- a/apps/server/src/types/schemas.ts +++ b/apps/server/src/types/schemas.ts @@ -118,6 +118,7 @@ export const AccountSchema = z.object({ name: z.string(), linkedProviders: z.array(z.string()), gitHubId: z.string().optional(), + googleId: z.string().optional(), createdTime: z.number(), }); export type Account = z.infer; diff --git a/apps/server/test/routes/auth.test.ts b/apps/server/test/routes/auth.test.ts index 87e5490..5d5fe25 100644 --- a/apps/server/test/routes/auth.test.ts +++ b/apps/server/test/routes/auth.test.ts @@ -144,4 +144,104 @@ describe("Auth Routes", () => { expect(response.headers.get("Set-Cookie")).toBeNull(); }); }); + + describe("GET /auth/google/login", () => { + it("should redirect to Google OAuth", async () => { + const response = await SELF.fetch( + "https://example.com/auth/google/login", + { redirect: "manual" }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toMatch( + /^https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth/, + ); + }); + }); + + describe("GET /auth/google/callback - 도메인 게이트", () => { + const webState = encodeURIComponent( + JSON.stringify({ client: "web", redirectTo: "http://localhost:5173/" }), + ); + + // Google OAuth 왕복(토큰 교환 → userinfo)을 목킹한다. 이메일만 케이스별로 바꿔 + // 도메인 게이트(GOOGLE_ALLOWED_DOMAIN=test.dev, vitest.config.ts) 동작을 검증한다. + const mockGoogleFlow = (email: string) => { + fetchMock + .get("https://oauth2.googleapis.com") + .intercept({ path: "/token", method: "POST" }) + .reply(200, { access_token: "ya29_test" }); + fetchMock + .get("https://openidconnect.googleapis.com") + .intercept({ path: "/v1/userinfo", method: "GET" }) + .reply(200, { + sub: "108123456789", + email, + email_verified: true, + name: "Tester", + }); + }; + + afterEach(() => { + fetchMock.assertNoPendingInterceptors(); + }); + + it("허용 도메인 이메일이면 로그인 통과 후 웹 오리진으로 리다이렉트", async () => { + mockGoogleFlow("tester@test.dev"); + + const response = await SELF.fetch( + `https://example.com/auth/google/callback?code=testcode&state=${webState}`, + { redirect: "manual" }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("http://localhost:5173/"); + expect(response.headers.get("Set-Cookie")).toMatch(/^session=/); + }); + + it("허용 외 도메인이면 google_domain_not_allowed로 거부(세션 미발급)", async () => { + mockGoogleFlow("tester@gmail.com"); + + const response = await SELF.fetch( + `https://example.com/auth/google/callback?code=testcode&state=${webState}`, + { redirect: "manual" }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe( + "http://localhost:5173/login?error=google_domain_not_allowed", + ); + expect(response.headers.get("Set-Cookie")).toBeNull(); + }); + + it("기존 계정과 같은 이메일이면 새 계정을 만들지 않고 연결한다", async () => { + // 허용 도메인 이메일로 기존(GitHub 기반) 계정 생성 + const db = (await import("../utils/db")).getTestDb(); + const schema = await import("../../src/db/schema"); + await db.insert(schema.account).values({ + id: "existing-account-id", + email: "tester@test.dev", + name: "Existing", + githubId: "4242", + createdTime: Date.now(), + }); + + mockGoogleFlow("tester@test.dev"); + + const response = await SELF.fetch( + `https://example.com/auth/google/callback?code=testcode&state=${webState}`, + { redirect: "manual" }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Set-Cookie")).toMatch(/^session=/); + + // 계정이 늘지 않고 기존 계정에 googleId가 연결됨 + const accounts = await db.select().from(schema.account); + expect(accounts).toHaveLength(1); + expect(accounts[0].id).toBe("existing-account-id"); + expect(accounts[0].googleId).toBe("108123456789"); + expect(accounts[0].githubId).toBe("4242"); + }); + }); }); diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index e096abb..d398e4c 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -29,6 +29,9 @@ export default defineWorkersProject(async () => { GITHUB_CLIENT_ID: "xxx", GITHUB_CLIENT_SECRET: "xxx", GITHUB_ORG: "test-org", + GOOGLE_CLIENT_ID: "xxx", + GOOGLE_CLIENT_SECRET: "xxx", + GOOGLE_ALLOWED_DOMAIN: "test.dev", JWT_SECRET: "test", ACCOUNT_ID: "test", R2_BUCKET_NAME: "test", diff --git a/apps/server/wrangler.toml b/apps/server/wrangler.toml index 5d82081..34cc2a2 100644 --- a/apps/server/wrangler.toml +++ b/apps/server/wrangler.toml @@ -20,6 +20,9 @@ CORS_ORIGINS = "http://localhost:5173" ENABLE_PACKAGE_DIFFING = "true" GITHUB_CLIENT_ID="Ov23liuoVBhHRJ02UweV" GITHUB_ORG="yplabs-ltd" +# Google Cloud Console에서 로컬용 OAuth 클라이언트 생성 후 채울 것 (redirect URI: http://localhost:8787/auth/google/callback) +GOOGLE_CLIENT_ID="898879441077-3u83tsjeg5fis4614vc6t8juip4283gn.apps.googleusercontent.com" +GOOGLE_ALLOWED_DOMAIN="yplabs.kr" [env.production] @@ -38,6 +41,9 @@ COOKIE_DOMAIN = "yplabs.workers.dev" ENABLE_PACKAGE_DIFFING = "true" GITHUB_CLIENT_ID="Ov23liyNc4o1iJYk03YU" GITHUB_ORG="yplabs-ltd" +# Google Cloud Console에서 프로덕션용 OAuth 클라이언트 생성 후 채울 것. GOOGLE_CLIENT_SECRET은 wrangler secret put으로 등록. +GOOGLE_CLIENT_ID="898879441077-3u83tsjeg5fis4614vc6t8juip4283gn.apps.googleusercontent.com" +GOOGLE_ALLOWED_DOMAIN="yplabs.kr" R2_BUCKET_NAME="code-push-storage" ACCOUNT_ID="3ed9862a49fe75464126e11a462fd26e" AWS_REGION = "ap-northeast-2" diff --git a/apps/web/src/lib/config.ts b/apps/web/src/lib/config.ts index 9009769..74ab6c0 100644 --- a/apps/web/src/lib/config.ts +++ b/apps/web/src/lib/config.ts @@ -2,6 +2,7 @@ export const config = { apiUrl: import.meta.env.VITE_API_URL, // 콜백이 웹 오리진으로 되돌아오도록 redirect_to에 현재 오리진을 실어 보낸다. githubOAuthUrl: `${import.meta.env.VITE_API_URL}/auth/login?client=web&redirect_to=${encodeURIComponent(`${window.location.origin}/`)}`, + googleOAuthUrl: `${import.meta.env.VITE_API_URL}/auth/google/login?client=web&redirect_to=${encodeURIComponent(`${window.location.origin}/`)}`, // 로그아웃 후 웹 오리진의 /login으로 되돌아오도록 절대 URL을 redirect_to로 실어 보낸다. // (미지정 시 서버가 상대경로 "/login"으로 리다이렉트 → 서버 도메인에 갇힘) logoutUrl: `${import.meta.env.VITE_API_URL}/auth/logout?redirect_to=${encodeURIComponent(`${window.location.origin}/login`)}`, diff --git a/apps/web/src/pages/auth/LoginPage.tsx b/apps/web/src/pages/auth/LoginPage.tsx index 70ba2ad..0b84c60 100644 --- a/apps/web/src/pages/auth/LoginPage.tsx +++ b/apps/web/src/pages/auth/LoginPage.tsx @@ -30,7 +30,7 @@ export const LoginPage = () => { CodePush 로그인 - GitHub 계정으로 로그인하세요 + GitHub 또는 Google 계정으로 로그인하세요 {error && ( @@ -39,15 +39,43 @@ export const LoginPage = () => { ? "가입이 비활성화되어 있습니다." : error === "not_org_member" ? "기관(GitHub 조직) 소속 유저만 로그인할 수 있습니다." - : "인증 중 오류가 발생했습니다."} + : error === "google_domain_not_allowed" + ? "허용된 도메인의 Google 계정만 로그인할 수 있습니다." + : "인증 중 오류가 발생했습니다."} )} - +