From 0c402b7552918cfefeda5ccdd05236d1fd6b5bd0 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Wed, 3 Jun 2026 14:56:33 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Config=20Sync=20page=20=E2=80=94=20?= =?UTF-8?q?promote=20platform=20config=20across=20environments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Config Sync screen (ADR 0003) to move definitional config (permissions, roles, menus) from one environment to another: - Export: snapshot this server's config as a code-keyed JSON bundle (view / download / copy). - Import: paste or upload a bundle, preview the diff (dry-run, the default), then apply. Apply is gated on a fresh dry-run reflecting the current text — any edit clears the preview. - merge / mirror modes. mirror deletes target entities absent from the bundle (needs a kit version that supports it; warned in the UI). - Route + sidebar nav entry under Platform; ko/en i18n. Talks to GET /config/export and POST /config/import (kit config-sync, off by default, refused under a production profile). --- src/api/configSync.ts | 44 +++++ src/i18n/locales/en.ts | 57 +++++++ src/i18n/locales/ko.ts | 57 +++++++ src/layout/AppLayout.vue | 1 + src/router/index.ts | 5 + src/views/ConfigSyncView.vue | 309 +++++++++++++++++++++++++++++++++++ 6 files changed, 473 insertions(+) create mode 100644 src/api/configSync.ts create mode 100644 src/views/ConfigSyncView.vue diff --git a/src/api/configSync.ts b/src/api/configSync.ts new file mode 100644 index 0000000..7a4b648 --- /dev/null +++ b/src/api/configSync.ts @@ -0,0 +1,44 @@ +import { client } from './client' + +export interface ConfigBundle { + version: number + tenantId: string + permissions: Array<{ code: string; description: string | null }> + roles: Array<{ code: string; name: string; permissionCodes: string[] }> + menus: Array<{ + code: string + parentCode: string | null + label: string + path: string | null + icon: string | null + requiredPermissionCode: string | null + displayOrder: number + }> +} + +export interface ImportSection { + created: string[] + updated: string[] +} + +export interface ImportResult { + dryRun: boolean + mode: string + permissions: ImportSection + roles: ImportSection + menus: ImportSection +} + +const base = '/config' + +export const configSyncApi = { + export(tenantId: string) { + return client.get(`${base}/export`, { params: { tenantId } }).then((r) => r.data) + }, + // `import` is awkward as a method name; the backend endpoint is POST /config/import. + apply(bundle: ConfigBundle, dryRun: boolean, mode = 'merge') { + return client + .post(`${base}/import`, bundle, { params: { mode, dryRun } }) + .then((r) => r.data) + }, +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 8b25a28..2333075 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -30,6 +30,63 @@ export default { dashboard: 'Dashboard', diagnostics: 'Diagnostics', auditLogs: 'Audit Logs', + configSync: 'Config Sync', + }, + configSync: { + title: 'Config Sync', + intro: + 'Promote definitional config (permissions, roles, menus) from this environment to another. Export a code-keyed bundle here, then import it on the target. Requires devslab.kit.config-sync.enabled=true on both servers; it is refused under a production profile.', + counts: { + permissions: '{n} permissions', + roles: '{n} roles', + menus: '{n} menus', + }, + export: { + title: 'Export', + desc: "Snapshot this environment's permissions, roles and menus as a portable, code-keyed bundle.", + run: 'Export', + download: 'Download JSON', + copy: 'Copy', + empty: 'No bundle yet — click Export to generate one.', + }, + import: { + title: 'Import', + desc: 'Paste or upload a bundle, preview the changes (dry-run), then apply.', + placeholder: 'Paste a config bundle JSON here…', + pickFile: 'Choose file', + mode: 'Mode', + modes: { + merge: 'Merge (add & update)', + mirror: 'Mirror (delete extras)', + }, + mirrorWarn: + 'Mirror deletes target entities that are absent from the bundle. Always review the dry-run before applying. Requires a kit version that supports mirror.', + dryRun: 'Preview (dry-run)', + apply: 'Apply', + invalidJson: 'Invalid JSON — could not parse the bundle.', + needDryRun: 'Run a preview first; apply unlocks once the dry-run reflects the current bundle.', + }, + result: { + dryRunTitle: 'Preview (dry-run) — nothing was written', + appliedTitle: 'Applied', + created: 'Created', + updated: 'Updated', + none: 'None', + section: { + permissions: 'Permissions', + roles: 'Roles', + menus: 'Menus', + }, + }, + toasts: { + exported: 'Config exported', + exportFailed: 'Export failed', + dryRunDone: 'Preview ready', + applied: 'Config applied', + importFailed: 'Import failed', + copied: 'Copied to clipboard', + copyFailed: 'Copy failed', + }, }, login: { tenant: 'Tenant', diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index aac581e..67524ab 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -30,6 +30,63 @@ export default { dashboard: '대시보드', diagnostics: '진단', auditLogs: '감사 로그', + configSync: '설정 동기화', + }, + configSync: { + title: '설정 동기화', + intro: + '정의성 설정(권한·역할·메뉴)을 이 환경에서 다른 환경으로 배포합니다. 여기서 코드 기준 번들을 내보낸 뒤 대상 서버에서 가져오면 됩니다. 양쪽 모두 devslab.kit.config-sync.enabled=true 가 필요하며, 운영(prod) 프로파일에서는 거부됩니다.', + counts: { + permissions: '권한 {n}개', + roles: '역할 {n}개', + menus: '메뉴 {n}개', + }, + export: { + title: '내보내기', + desc: '이 환경의 권한·역할·메뉴를 코드 기준의 이식 가능한 번들로 스냅샷합니다.', + run: '내보내기', + download: 'JSON 다운로드', + copy: '복사', + empty: '아직 번들이 없습니다 — 내보내기를 눌러 생성하세요.', + }, + import: { + title: '가져오기', + desc: '번들을 붙여넣거나 업로드하고, 변경 내용을 미리보기(dry-run)한 뒤 적용합니다.', + placeholder: '설정 번들 JSON 을 여기에 붙여넣으세요…', + pickFile: '파일 선택', + mode: '모드', + modes: { + merge: '병합 (추가·수정)', + mirror: '미러 (없는 항목 삭제)', + }, + mirrorWarn: + '미러 모드는 번들에 없는 대상 항목을 삭제합니다. 적용 전 반드시 미리보기를 확인하세요. 미러를 지원하는 kit 버전이 필요합니다.', + dryRun: '미리보기 (dry-run)', + apply: '적용', + invalidJson: '잘못된 JSON — 번들을 파싱할 수 없습니다.', + needDryRun: '먼저 미리보기를 실행하세요. 미리보기가 현재 번들과 일치하면 적용이 활성화됩니다.', + }, + result: { + dryRunTitle: '미리보기 (dry-run) — 아무것도 기록되지 않았습니다', + appliedTitle: '적용 완료', + created: '생성', + updated: '수정', + none: '없음', + section: { + permissions: '권한', + roles: '역할', + menus: '메뉴', + }, + }, + toasts: { + exported: '설정을 내보냈습니다', + exportFailed: '내보내기 실패', + dryRunDone: '미리보기 준비됨', + applied: '설정을 적용했습니다', + importFailed: '가져오기 실패', + copied: '클립보드에 복사됨', + copyFailed: '복사 실패', + }, }, login: { tenant: '테넌트', diff --git a/src/layout/AppLayout.vue b/src/layout/AppLayout.vue index 3d63b92..9f0295a 100644 --- a/src/layout/AppLayout.vue +++ b/src/layout/AppLayout.vue @@ -51,6 +51,7 @@ const navGroups = computed(() => [ { label: t('nav.tenants'), icon: 'pi pi-building', route: 'tenants' }, { label: t('nav.policies'), icon: 'pi pi-shield', route: 'policies' }, { label: t('nav.settings'), icon: 'pi pi-cog', route: 'settings' }, + { label: t('nav.configSync'), icon: 'pi pi-sync', route: 'config-sync' }, ], }, { diff --git a/src/router/index.ts b/src/router/index.ts index 8fee041..3d928b3 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -76,6 +76,11 @@ const routes: RouteRecordRaw[] = [ name: 'settings', component: () => import('@/views/SettingsView.vue'), }, + { + path: 'config-sync', + name: 'config-sync', + component: () => import('@/views/ConfigSyncView.vue'), + }, { // Voluntary self-service password change, inside the layout. The same // ChangePasswordView also serves the forced standalone route above; it diff --git a/src/views/ConfigSyncView.vue b/src/views/ConfigSyncView.vue new file mode 100644 index 0000000..60b624b --- /dev/null +++ b/src/views/ConfigSyncView.vue @@ -0,0 +1,309 @@ + + +