From 33b0ac0ea1177cc9d2ed2f2f5f9d82a3dac6dcce Mon Sep 17 00:00:00 2001 From: yh-noh Date: Tue, 14 Apr 2026 16:19:03 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat(cloud-env-001):=20credential/connect?= =?UTF-8?q?ion=20=ED=99=94=EB=A9=B4=20=EA=B0=9C=EB=B0=9C=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EC=A0=84=ED=99=98=20(cb-spider=20=E2=86=92=20mc-in?= =?UTF-8?q?fra-manager)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - conf: clouddrivers/regions 메뉴 제거, credentials/connections isaction true로 활성화 - 신규: cloudconnection_api.js (RSA+AES 암호화 credential 등록, connConfig 조회) - 신규: credentials.html/js (Credential Holder 목록, 3-Step 암호화 등록 폼) - 신규: connections.html/js (ConnConfig 조회, 필터, 상세 패널) --- conf/webconsole_menu_resources.yaml | 24 +- .../api/services/cloudconnection_api.js | 181 +++++++++++++ .../environment/cloudsps/connections.js | 176 +++++++++++++ .../environment/cloudsps/credentials.js | 238 ++++++++++++++++++ .../environment/cloudsps/connections.html | 114 +++++++++ .../environment/cloudsps/credentials.html | 75 +++++- 6 files changed, 787 insertions(+), 21 deletions(-) create mode 100644 front/assets/js/common/api/services/cloudconnection_api.js create mode 100644 front/assets/js/pages/settings/environment/cloudsps/connections.js create mode 100644 front/assets/js/pages/settings/environment/cloudsps/credentials.js create mode 100644 front/templates/pages/settings/environment/cloudsps/connections.html diff --git a/conf/webconsole_menu_resources.yaml b/conf/webconsole_menu_resources.yaml index 60019f9b..cdad44d2 100644 --- a/conf/webconsole_menu_resources.yaml +++ b/conf/webconsole_menu_resources.yaml @@ -88,11 +88,11 @@ menus: priority: 2 menunumber: 1310 - - id: regions + - id: credentials parentid: cloudsps - displayname: Regions + displayname: Credentials restype: menu - isaction: false + isaction: true priority: 2 menunumber: 1320 @@ -100,25 +100,9 @@ menus: parentid: cloudsps displayname: Connections restype: menu - isaction: false - priority: 2 - menunumber: 1330 - - - id: clouddrivers - parentid: cloudsps - displayname: Cloud Drivers - restype: menu isaction: true priority: 2 - menunumber: 1340 - - - id: credentials - parentid: cloudsps - displayname: Credentials - restype: menu - isaction: false - priority: 2 - menunumber: 1350 + menunumber: 1330 - id: cloudresources parentid: environment diff --git a/front/assets/js/common/api/services/cloudconnection_api.js b/front/assets/js/common/api/services/cloudconnection_api.js new file mode 100644 index 00000000..246bf314 --- /dev/null +++ b/front/assets/js/common/api/services/cloudconnection_api.js @@ -0,0 +1,181 @@ +// Cloud Connection API 서비스 (mc-infra-manager / cb-tumblebug) +// Credential은 RSA+AES 암호화 방식으로 등록, Connection Config는 자동 생성됨 + +function unwrapResponse(response) { + if (!response) throw new Error('Invalid response from server'); + if (response.status === 204) return null; + if (!response.data) throw new Error('Invalid response from server'); + if (response.status >= 400) { + const msg = (response.data.status && response.data.status.message) + || response.data.message + || response.data.error + || 'Request failed'; + const err = new Error(msg); + err.response = response; + throw err; + } + return response.data.responseData; +} + +// ─── CloudOS ────────────────────────────────────────────────────────── + +export async function listCloudOS() { + const controller = "/api/mc-infra-connector/List-Cloudos"; + const response = await webconsolejs["common/api/http"].commonAPIPost(controller, {}); + const data = unwrapResponse(response); + return (data && data.cloudos) ? data.cloudos : []; +} + +// ─── Credential ─────────────────────────────────────────────────────── + +/** + * Credential 등록 Step 1: RSA 공개키 발급 + * @returns {{ tokenId: string, publicKey: string }} + */ +export async function getPublicKeyForCredential() { + const controller = "/api/mc-infra-manager/GetPublicKeyForCredentialEncryption"; + const response = await webconsolejs["common/api/http"].commonAPIPost(controller, {}); + return unwrapResponse(response); +} + +/** + * Credential 등록 Step 3: 암호화된 payload 전송 + * @param {object} payload - { providerName, credentialHolder, publicKeyTokenId, encryptedClientAesKeyByPublicKey, credentialKeyValueList } + * @returns {object} CredentialInfo (자동 생성된 ConnectionConfig 목록 포함) + */ +export async function registerCredential(payload, credentialHolder) { + const controller = "/api/mc-infra-manager/RegisterCredential"; + const data = { + request: payload, + headers: { 'X-Credential-Holder': credentialHolder || 'admin' }, + }; + const response = await webconsolejs["common/api/http"].commonAPIPost(controller, data); + return unwrapResponse(response); +} + +/** + * Credential Holder 목록 조회 + * @returns {Array} CredentialHolderInfo[] + */ +export async function listCredentialHolders(credentialHolder) { + const controller = "/api/mc-infra-manager/GetCredentialHolderList"; + const data = { + headers: { 'X-Credential-Holder': credentialHolder || 'admin' }, + }; + const response = await webconsolejs["common/api/http"].commonAPIPost(controller, data); + const result = unwrapResponse(response); + return (result && result.credentialHolderInfos) ? result.credentialHolderInfos : []; +} + +/** + * Credential Holder 상세 조회 + * @param {string} holderId + * @returns {object} CredentialHolderInfo + */ +export async function getCredentialHolder(holderId, credentialHolder) { + const controller = "/api/mc-infra-manager/GetCredentialHolder"; + const data = { + pathParams: { holderId }, + headers: { 'X-Credential-Holder': credentialHolder || 'admin' }, + }; + const response = await webconsolejs["common/api/http"].commonAPIPost(controller, data); + return unwrapResponse(response); +} + +// ─── Connection Config ───────────────────────────────────────────────── + +/** + * Connection Config 목록 조회 + * @param {object} filters - { filterCredentialHolder, filterVerified, filterRegionRepresentative } + * @returns {Array} ConnConfig[] + */ +export async function listConnConfigs(filters = {}, credentialHolder) { + const controller = "/api/mc-infra-manager/GetConnConfigList"; + const queryParams = {}; + if (filters.filterCredentialHolder) queryParams.filterCredentialHolder = filters.filterCredentialHolder; + if (filters.filterVerified !== undefined) queryParams.filterVerified = filters.filterVerified; + if (filters.filterRegionRepresentative !== undefined) queryParams.filterRegionRepresentative = filters.filterRegionRepresentative; + const data = { + queryParams, + headers: { 'X-Credential-Holder': credentialHolder || 'admin' }, + }; + const response = await webconsolejs["common/api/http"].commonAPIPost(controller, data); + const result = unwrapResponse(response); + return (result && result.connConfig) ? result.connConfig : []; +} + +/** + * Connection Config 상세 조회 + * @param {string} connConfigName + * @returns {object} ConnConfig + */ +export async function getConnConfig(connConfigName, credentialHolder) { + const controller = "/api/mc-infra-manager/GetConnConfig"; + const data = { + pathParams: { connConfigName }, + headers: { 'X-Credential-Holder': credentialHolder || 'admin' }, + }; + const response = await webconsolejs["common/api/http"].commonAPIPost(controller, data); + return unwrapResponse(response); +} + +// ─── 암호화 유틸 ─────────────────────────────────────────────────────── + +/** + * 평문 credential 값을 RSA 공개키로 암호화하여 RegisterCredential payload 생성 + * Web Crypto API(SubtleCrypto) 사용 — 브라우저 환경에서만 동작 + * + * @param {string} providerName - CSP 이름 (소문자, e.g. "aws") + * @param {string} credentialHolder - Holder 이름 (e.g. "admin") + * @param {string} tokenId - getPublicKeyForCredential() 응답의 tokenId + * @param {string} publicKeyPem - PEM 형식 RSA 공개키 + * @param {Array} keyValueList - [{ key: "ClientId", value: "AKIA..." }, ...] + * @returns {object} RegisterCredential API body + */ +export async function buildEncryptedCredentialPayload(providerName, credentialHolder, tokenId, publicKeyPem, keyValueList) { + // 1. AES-256 키 생성 + const aesKey = await crypto.subtle.generateKey( + { name: 'AES-CBC', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + const rawAesKey = await crypto.subtle.exportKey('raw', aesKey); + + // 2. 각 credential 값을 AES-CBC로 암호화 + const credentialKeyValueList = await Promise.all(keyValueList.map(async ({ key, value }) => { + const iv = crypto.getRandomValues(new Uint8Array(16)); + const encoded = new TextEncoder().encode(value); + const encrypted = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, aesKey, encoded); + // IV(16) + ciphertext 를 Base64 + const combined = new Uint8Array(iv.byteLength + encrypted.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(encrypted), iv.byteLength); + return { key, encryptedValue: btoa(String.fromCharCode(...combined)) }; + })); + + // 3. PEM → CryptoKey (RSA-OAEP SHA-256) + const pemBody = publicKeyPem + .replace(/-----BEGIN PUBLIC KEY-----/, '') + .replace(/-----END PUBLIC KEY-----/, '') + .replace(/\s/g, ''); + const derBuffer = Uint8Array.from(atob(pemBody), c => c.charCodeAt(0)); + const rsaKey = await crypto.subtle.importKey( + 'spki', + derBuffer, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['encrypt'] + ); + + // 4. AES 키를 RSA-OAEP로 암호화 + const encryptedAesKey = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, rsaKey, rawAesKey); + const encryptedClientAesKeyByPublicKey = btoa(String.fromCharCode(...new Uint8Array(encryptedAesKey))); + + return { + providerName, + credentialHolder, + publicKeyTokenId: tokenId, + encryptedClientAesKeyByPublicKey, + credentialKeyValueList, + }; +} diff --git a/front/assets/js/pages/settings/environment/cloudsps/connections.js b/front/assets/js/pages/settings/environment/cloudsps/connections.js new file mode 100644 index 00000000..70482609 --- /dev/null +++ b/front/assets/js/pages/settings/environment/cloudsps/connections.js @@ -0,0 +1,176 @@ +import { TabulatorFull as Tabulator } from "tabulator-tables"; + +const PROVIDER_BADGE_MAP = { + aws: 'bg-orange-lt', gcp: 'bg-blue-lt', azure: 'bg-indigo-lt', + alibaba: 'bg-red-lt', tencent: 'bg-cyan-lt', ibm: 'bg-dark-lt', +}; + +function providerBadge(name) { + if (!name) return '-'; + const lower = name.toLowerCase(); + const cls = PROVIDER_BADGE_MAP[lower] || 'bg-secondary-lt'; + return `${name.toUpperCase()}`; +} + +const AppState = { + connTable: null, + holders: [], +}; + +// ─── Detail Panel ────────────────────────────────────────────────────── + +const DetailPanel = { + show(conn) { + document.getElementById('conn-detail-name').textContent = `[${conn.configName}]`; + document.getElementById('conn-detail-configname').textContent = conn.configName || '-'; + document.getElementById('conn-detail-provider').innerHTML = providerBadge(conn.providerName); + document.getElementById('conn-detail-holder').textContent = conn.credentialHolder || '-'; + document.getElementById('conn-detail-region').textContent = conn.regionZoneInfo?.assignedRegion || '-'; + document.getElementById('conn-detail-zone').textContent = conn.regionZoneInfo?.assignedZone || '-'; + document.getElementById('conn-detail-verified').innerHTML = conn.verified + ? 'Verified' + : 'Unverified'; + document.getElementById('conn-detail-representative').innerHTML = conn.regionRepresentative + ? 'Yes' + : 'No'; + const panel = document.getElementById('conn-detail-panel'); + bootstrap.Collapse.getOrCreateInstance(panel).show(); + }, + + hide() { + bootstrap.Collapse.getOrCreateInstance(document.getElementById('conn-detail-panel')).hide(); + }, +}; + +// ─── Table ───────────────────────────────────────────────────────────── + +const TableManager = { + init(data) { + if (AppState.connTable) { + AppState.connTable.replaceData(data); + return; + } + AppState.connTable = new Tabulator("#connection-table", { + data, + layout: "fitColumns", + height: "400px", + placeholder: "조건에 맞는 Connection Config가 없습니다.", + columns: [ + { + title: "Config Name", + field: "configName", + headerSort: true, + }, + { + title: "Provider", + field: "providerName", + width: 120, + formatter: cell => providerBadge(cell.getValue()), + }, + { + title: "Holder", + field: "credentialHolder", + width: 130, + }, + { + title: "Region", + field: "regionZoneInfo", + formatter: cell => { + const v = cell.getValue(); + return v?.assignedRegion || '-'; + }, + }, + { + title: "Zone", + field: "regionZoneInfo", + width: 160, + formatter: cell => { + const v = cell.getValue(); + return v?.assignedZone || '-'; + }, + }, + { + title: "Verified", + field: "verified", + width: 100, + hozAlign: "center", + formatter: cell => cell.getValue() + ? 'OK' + : 'Fail', + }, + { + title: "Rep.", + field: "regionRepresentative", + width: 70, + hozAlign: "center", + formatter: cell => cell.getValue() + ? '' + : '', + headerTooltip: "Region Representative", + }, + ], + }); + + AppState.connTable.on("rowClick", (e, row) => { + DetailPanel.show(row.getData()); + }); + }, +}; + +// ─── Load & Filter ───────────────────────────────────────────────────── + +async function loadConnections() { + const holder = document.getElementById('filter-holder')?.value || ''; + const verified = document.getElementById('filter-verified')?.checked; + const rep = document.getElementById('filter-representative')?.checked; + + const filters = {}; + if (holder) filters.filterCredentialHolder = holder; + if (verified !== undefined) filters.filterVerified = verified; + if (rep !== undefined) filters.filterRegionRepresentative = rep; + + try { + const conns = await webconsolejs["common/api/services/cloudconnection_api"].listConnConfigs(filters); + TableManager.init(conns); + DetailPanel.hide(); + } catch (e) { + console.error('Connection Config 조회 실패:', e); + webconsolejs["partials/layout/toast"].showToast('Connection 목록을 불러오지 못했습니다.', 'error'); + TableManager.init([]); + } +} + +async function loadHolderFilter() { + try { + const holders = await webconsolejs["common/api/services/cloudconnection_api"].listCredentialHolders(); + AppState.holders = holders; + const select = document.getElementById('filter-holder'); + if (!select) return; + select.innerHTML = ''; + holders.forEach(h => { + const opt = document.createElement('option'); + opt.value = h.credentialHolder; + opt.textContent = h.credentialHolder; + select.appendChild(opt); + }); + } catch (e) { + console.error('Holder 필터 로드 실패:', e); + } +} + +// ─── Export ──────────────────────────────────────────────────────────── + +export async function applyFilters() { + await loadConnections(); +} + +// ─── DOMContentLoaded ────────────────────────────────────────────────── + +document.addEventListener("DOMContentLoaded", async function () { + // 필터 변경 시 자동 새로고침 + document.getElementById('filter-verified')?.addEventListener('change', loadConnections); + document.getElementById('filter-representative')?.addEventListener('change', loadConnections); + + await loadHolderFilter(); + await loadConnections(); +}); diff --git a/front/assets/js/pages/settings/environment/cloudsps/credentials.js b/front/assets/js/pages/settings/environment/cloudsps/credentials.js new file mode 100644 index 00000000..6af5ed73 --- /dev/null +++ b/front/assets/js/pages/settings/environment/cloudsps/credentials.js @@ -0,0 +1,238 @@ +import { TabulatorFull as Tabulator } from "tabulator-tables"; + +// Provider별 필요한 Credential Key 목록 +const PROVIDER_CREDENTIAL_KEYS = { + AWS: [{ key: 'ClientId', label: 'Access Key ID' }, { key: 'ClientSecret', label: 'Secret Access Key' }], + GCP: [{ key: 'PrivateKey', label: 'Private Key (JSON)' }, { key: 'ClientEmail', label: 'Client Email' }, { key: 'ProjectID', label: 'Project ID' }], + AZURE: [{ key: 'ClientId', label: 'Client ID' }, { key: 'ClientSecret', label: 'Client Secret' }, { key: 'TenantId', label: 'Tenant ID' }, { key: 'SubscriptionId', label: 'Subscription ID' }], + ALIBABA: [{ key: 'ClientId', label: 'Access Key ID' }, { key: 'ClientSecret', label: 'Access Key Secret' }], + TENCENT: [{ key: 'ClientId', label: 'Secret ID' }, { key: 'ClientSecret', label: 'Secret Key' }], + IBM: [{ key: 'ApiKey', label: 'API Key' }, { key: 'IamToken', label: 'IAM Token' }], + NCP: [{ key: 'ClientId', label: 'Access Key' }, { key: 'ClientSecret', label: 'Secret Key' }], + NHN: [{ key: 'ClientId', label: 'Tenant ID' }, { key: 'ClientSecret', label: 'Password' }, { key: 'Username', label: 'Username' }], + OPENSTACK: [{ key: 'ClientId', label: 'Tenant ID' }, { key: 'ClientSecret', label: 'Password' }, { key: 'Username', label: 'Username' }, { key: 'AuthURL', label: 'Auth URL' }], +}; + +const PROVIDER_BADGE_MAP = { + aws: 'bg-orange-lt', gcp: 'bg-blue-lt', azure: 'bg-indigo-lt', + alibaba: 'bg-red-lt', tencent: 'bg-cyan-lt', ibm: 'bg-dark-lt', +}; + +function providerBadge(name) { + if (!name) return '-'; + const lower = name.toLowerCase(); + const cls = PROVIDER_BADGE_MAP[lower] || 'bg-secondary-lt'; + return `${name.toUpperCase()}`; +} + +const AppState = { + holders: [], + holderTable: null, +}; + +// ─── CredentialHolder 테이블 ────────────────────────────────────────── + +const TableManager = { + init(data) { + if (AppState.holderTable) { + AppState.holderTable.replaceData(data); + return; + } + AppState.holderTable = new Tabulator("#credential-holder-table", { + data, + layout: "fitColumns", + height: "350px", + placeholder: "등록된 Credential이 없습니다.", + columns: [ + { title: "Holder", field: "credentialHolder", headerSort: true }, + { + title: "Providers", + field: "providers", + formatter: cell => { + const providers = cell.getValue() || []; + return providers.map(p => providerBadge(p)).join(' '); + }, + }, + { + title: "Connections", + field: "connectionCount", + width: 130, + hozAlign: "center", + formatter: cell => { + const total = cell.getValue() || 0; + const row = cell.getRow().getData(); + const verified = row.verifiedConnectionCount || 0; + return `${verified} / ${total}`; + }, + headerTooltip: "Verified / Total", + }, + { + title: "Status", + field: "verifiedConnectionCount", + width: 100, + hozAlign: "center", + formatter: (cell, _, row) => { + const data = row.getData ? row.getData() : cell.getRow().getData(); + const total = data.connectionCount || 0; + const verified = data.verifiedConnectionCount || 0; + if (total === 0) return '-'; + if (verified === total) return 'OK'; + if (verified > 0) return 'Partial'; + return 'Failed'; + }, + }, + { + title: "Default", + field: "isDefault", + width: 90, + hozAlign: "center", + formatter: cell => cell.getValue() + ? 'Default' + : '', + }, + ], + }); + }, +}; + +// ─── Credential 등록 ────────────────────────────────────────────────── + +const CredentialForm = { + /** Provider 선택 시 Key 입력 필드 동적 렌더링 */ + updateKeyFields(provider) { + const container = document.getElementById('cred-key-fields'); + if (!container) return; + const keys = PROVIDER_CREDENTIAL_KEYS[provider?.toUpperCase()] || []; + if (!keys.length) { + container.innerHTML = ''; + return; + } + container.innerHTML = keys.map(({ key, label }) => ` +
+ + +
+ `).join(''); + }, + + validate() { + const provider = document.getElementById('cred-provider')?.value?.trim(); + const holder = document.getElementById('cred-holder')?.value?.trim(); + if (!provider) { + webconsolejs["partials/layout/toast"].showToast('Provider를 선택해 주세요.', 'warning'); + return false; + } + if (!holder || !/^[a-z0-9_]+$/.test(holder)) { + webconsolejs["partials/layout/toast"].showToast('Credential Holder는 소문자·숫자·언더스코어만 사용 가능합니다.', 'warning'); + return false; + } + const keys = PROVIDER_CREDENTIAL_KEYS[provider.toUpperCase()] || []; + for (const { key, label } of keys) { + const val = document.getElementById(`cred-key-${key}`)?.value?.trim(); + if (!val) { + webconsolejs["partials/layout/toast"].showToast(`${label}을(를) 입력해 주세요.`, 'warning'); + return false; + } + } + return true; + }, + + collectKeyValues(provider) { + const keys = PROVIDER_CREDENTIAL_KEYS[provider.toUpperCase()] || []; + return keys.map(({ key }) => ({ + key, + value: document.getElementById(`cred-key-${key}`)?.value?.trim() || '', + })); + }, +}; + +async function doSubmitCredential() { + if (!CredentialForm.validate()) return; + + const provider = document.getElementById('cred-provider').value.trim(); + const holder = document.getElementById('cred-holder').value.trim(); + const keyValueList = CredentialForm.collectKeyValues(provider); + + try { + webconsolejs["partials/layout/toast"].showToast('공개키를 발급 중입니다...', 'info'); + + // Step 1: 공개키 발급 + const { tokenId, publicKey } = await webconsolejs["common/api/services/cloudconnection_api"].getPublicKeyForCredential(); + + // Step 2 + 3: 암호화 및 등록 + const payload = await webconsolejs["common/api/services/cloudconnection_api"].buildEncryptedCredentialPayload( + provider.toLowerCase(), holder, tokenId, publicKey, keyValueList + ); + await webconsolejs["common/api/services/cloudconnection_api"].registerCredential(payload, holder); + + webconsolejs["partials/layout/toast"].showToast('Credential이 등록되었습니다. 모든 리전에 Connection이 자동 생성됩니다.', 'success'); + bootstrap.Collapse.getOrCreateInstance(document.getElementById('credential-create-section')).hide(); + await loadHolders(); + } catch (e) { + console.error('Credential 등록 실패:', e); + webconsolejs["partials/layout/toast"].showToast(e.message || 'Credential 등록에 실패했습니다.', 'error'); + } +} + +async function loadHolders() { + try { + const holders = await webconsolejs["common/api/services/cloudconnection_api"].listCredentialHolders(); + AppState.holders = holders; + TableManager.init(holders); + } catch (e) { + console.error('Credential Holder 목록 조회 실패:', e); + webconsolejs["partials/layout/toast"].showToast('Credential 목록을 불러오지 못했습니다.', 'error'); + TableManager.init([]); + } +} + +async function loadCloudOS() { + try { + const cloudos = await webconsolejs["common/api/services/cloudconnection_api"].listCloudOS(); + const select = document.getElementById('cred-provider'); + if (!select) return; + select.innerHTML = ''; + cloudos.forEach(os => { + const opt = document.createElement('option'); + opt.value = os; + opt.textContent = os; + select.appendChild(opt); + }); + select.addEventListener('change', e => CredentialForm.updateKeyFields(e.target.value)); + } catch (e) { + console.error('CloudOS 목록 조회 실패:', e); + } +} + +// ─── Export ─────────────────────────────────────────────────────────── + +export async function refreshHolderList() { + await loadHolders(); +} + +export async function submitCredential() { + await doSubmitCredential(); +} + +// ─── DOMContentLoaded ───────────────────────────────────────────────── + +document.addEventListener("DOMContentLoaded", function () { + const btnList = document.getElementById('page-header-btn-list'); + if (btnList) { + btnList.innerHTML = ` + `; + } + + loadHolders(); + loadCloudOS(); +}); diff --git a/front/templates/pages/settings/environment/cloudsps/connections.html b/front/templates/pages/settings/environment/cloudsps/connections.html new file mode 100644 index 00000000..09dc6d46 --- /dev/null +++ b/front/templates/pages/settings/environment/cloudsps/connections.html @@ -0,0 +1,114 @@ +
+
+ + +
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+
+ + +
+
+
+
+

Connection Configs

+
+ Credential 등록 시 자동 생성됩니다. +
+
+
+
+
+
+
+
+ + +
+
+
+

+ Connection Detail + +

+
+
+
+
+
+
Config Name
+
+
+
+
Provider
+
+
+
+
Credential Holder
+
+
+
+
Region
+
+
+
+
Zone
+
+
+
+
Verified
+
+
+
+
Region Representative
+
+
+
+
+
+
+
+ +
+
+ +{{ javascriptTag("common/api/services/cloudconnection_api.js") | raw }} +{{ javascriptTag("pages/settings/environment/cloudsps/connections.js") | raw }} diff --git a/front/templates/pages/settings/environment/cloudsps/credentials.html b/front/templates/pages/settings/environment/cloudsps/credentials.html index 2a3b585c..03ab3189 100644 --- a/front/templates/pages/settings/environment/cloudsps/credentials.html +++ b/front/templates/pages/settings/environment/cloudsps/credentials.html @@ -1 +1,74 @@ -{{ partial("underdevelop.html") | raw }} \ No newline at end of file +
+
+ + +
+
+
+
+

Registered Credentials

+ +
+
+
+
+
+
+
+ + +
+
+
+

Register Credential

+
+
+
+
+ +
+ + +
+ +
+ + + 영문 소문자·숫자·언더스코어만 허용. 하이픈 불가. +
+ + +
+ +
+
+
+ +
+
+ +
+
+ +{{ javascriptTag("common/api/services/cloudconnection_api.js") | raw }} +{{ javascriptTag("pages/settings/environment/cloudsps/credentials.js") | raw }} From 5382ae85311140ce6f4c7ea5c91d59b24070a75a Mon Sep 17 00:00:00 2001 From: yh-noh Date: Tue, 14 Apr 2026 16:38:26 +0900 Subject: [PATCH 02/14] =?UTF-8?q?fix(cloud-env-001):=20credentialHolder=20?= =?UTF-8?q?API=20=EB=AF=B8=EB=B0=B0=ED=8F=AC=20=EB=8C=80=EC=9D=91=20-=20co?= =?UTF-8?q?nnConfig=EC=97=90=EC=84=9C=20holder=20=EC=A7=91=EA=B3=84?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - listCredentialHolders(): GetCredentialHolderList 대신 GetConnConfigList 결과에서 holder 집계 - queryParams boolean → String() 변환 (map[string]string 호환) --- .../api/services/cloudconnection_api.js | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/front/assets/js/common/api/services/cloudconnection_api.js b/front/assets/js/common/api/services/cloudconnection_api.js index 246bf314..59e6f5d2 100644 --- a/front/assets/js/common/api/services/cloudconnection_api.js +++ b/front/assets/js/common/api/services/cloudconnection_api.js @@ -55,31 +55,31 @@ export async function registerCredential(payload, credentialHolder) { /** * Credential Holder 목록 조회 - * @returns {Array} CredentialHolderInfo[] + * credentialHolder API 미배포 버전 대응: connConfig 목록에서 holder를 추출하여 집계 + * @returns {Array} [{ credentialHolder, providers, connectionCount, verifiedConnectionCount, isDefault }] */ -export async function listCredentialHolders(credentialHolder) { - const controller = "/api/mc-infra-manager/GetCredentialHolderList"; - const data = { - headers: { 'X-Credential-Holder': credentialHolder || 'admin' }, - }; - const response = await webconsolejs["common/api/http"].commonAPIPost(controller, data); - const result = unwrapResponse(response); - return (result && result.credentialHolderInfos) ? result.credentialHolderInfos : []; -} +export async function listCredentialHolders() { + const conns = await listConnConfigs({ filterVerified: false }); + if (!conns.length) return []; + + const holderMap = {}; + for (const conn of conns) { + const holder = conn.credentialHolder || 'admin'; + if (!holderMap[holder]) { + holderMap[holder] = { credentialHolder: holder, providers: new Set(), connectionCount: 0, verifiedConnectionCount: 0 }; + } + holderMap[holder].providers.add((conn.providerName || '').toLowerCase()); + holderMap[holder].connectionCount++; + if (conn.verified) holderMap[holder].verifiedConnectionCount++; + } -/** - * Credential Holder 상세 조회 - * @param {string} holderId - * @returns {object} CredentialHolderInfo - */ -export async function getCredentialHolder(holderId, credentialHolder) { - const controller = "/api/mc-infra-manager/GetCredentialHolder"; - const data = { - pathParams: { holderId }, - headers: { 'X-Credential-Holder': credentialHolder || 'admin' }, - }; - const response = await webconsolejs["common/api/http"].commonAPIPost(controller, data); - return unwrapResponse(response); + return Object.values(holderMap).map(h => ({ + credentialHolder: h.credentialHolder, + providers: [...h.providers], + connectionCount: h.connectionCount, + verifiedConnectionCount: h.verifiedConnectionCount, + isDefault: h.credentialHolder === 'admin', + })); } // ─── Connection Config ───────────────────────────────────────────────── @@ -93,8 +93,8 @@ export async function listConnConfigs(filters = {}, credentialHolder) { const controller = "/api/mc-infra-manager/GetConnConfigList"; const queryParams = {}; if (filters.filterCredentialHolder) queryParams.filterCredentialHolder = filters.filterCredentialHolder; - if (filters.filterVerified !== undefined) queryParams.filterVerified = filters.filterVerified; - if (filters.filterRegionRepresentative !== undefined) queryParams.filterRegionRepresentative = filters.filterRegionRepresentative; + if (filters.filterVerified !== undefined) queryParams.filterVerified = String(filters.filterVerified); + if (filters.filterRegionRepresentative !== undefined) queryParams.filterRegionRepresentative = String(filters.filterRegionRepresentative); const data = { queryParams, headers: { 'X-Credential-Holder': credentialHolder || 'admin' }, From 8eaeae18c4f59fcd68457fc7f3868ec06803e62c Mon Sep 17 00:00:00 2001 From: yh-noh Date: Wed, 15 Apr 2026 10:15:38 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat(cloud-env-001):=20listCloudOS=20?= =?UTF-8?q?=E2=86=92=20GetProviderList=20=EA=B5=90=EC=B2=B4=20=EB=B0=8F=20?= =?UTF-8?q?Provider=20Name=20=EB=9D=BC=EB=B2=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front/assets/js/common/api/services/cloudconnection_api.js | 4 ++-- front/assets/js/common/api/services/clouddriver_api.js | 4 ++-- .../pages/settings/environment/cloudsps/clouddrivers.html | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/front/assets/js/common/api/services/cloudconnection_api.js b/front/assets/js/common/api/services/cloudconnection_api.js index 59e6f5d2..a5505b7c 100644 --- a/front/assets/js/common/api/services/cloudconnection_api.js +++ b/front/assets/js/common/api/services/cloudconnection_api.js @@ -20,10 +20,10 @@ function unwrapResponse(response) { // ─── CloudOS ────────────────────────────────────────────────────────── export async function listCloudOS() { - const controller = "/api/mc-infra-connector/List-Cloudos"; + const controller = "/api/mc-infra-manager/GetProviderList"; const response = await webconsolejs["common/api/http"].commonAPIPost(controller, {}); const data = unwrapResponse(response); - return (data && data.cloudos) ? data.cloudos : []; + return (data && data.output) ? data.output : []; } // ─── Credential ─────────────────────────────────────────────────────── diff --git a/front/assets/js/common/api/services/clouddriver_api.js b/front/assets/js/common/api/services/clouddriver_api.js index d50989b3..d489b613 100644 --- a/front/assets/js/common/api/services/clouddriver_api.js +++ b/front/assets/js/common/api/services/clouddriver_api.js @@ -30,10 +30,10 @@ export async function listCloudDrivers() { } export async function listCloudOS() { - const controller = "/api/mc-infra-manager/List-Cloudos"; + const controller = "/api/mc-infra-manager/GetProviderList"; const response = await webconsolejs["common/api/http"].commonAPIPost(controller, {}); const data = unwrapResponse(response); - return (data && data.cloudos) ? data.cloudos : []; + return (data && data.output) ? data.output : []; } export async function registerCloudDriver(driverData) { diff --git a/front/templates/pages/settings/environment/cloudsps/clouddrivers.html b/front/templates/pages/settings/environment/cloudsps/clouddrivers.html index 83b9acf5..f2804120 100644 --- a/front/templates/pages/settings/environment/cloudsps/clouddrivers.html +++ b/front/templates/pages/settings/environment/cloudsps/clouddrivers.html @@ -106,7 +106,7 @@

Add Cloud Driver

- + From 44f7bb95f9e8fbfabc2b3e1864b27683a460c5d3 Mon Sep 17 00:00:00 2001 From: yh-noh Date: Wed, 15 Apr 2026 12:33:57 +0900 Subject: [PATCH 04/14] =?UTF-8?q?fix(cloud-env-001):=20api.yaml=20GetProvi?= =?UTF-8?q?derList=20=EC=A4=91=EB=B3=B5=20=ED=82=A4=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20(develop=20=EB=A8=B8=EC=A7=80=20=EC=B6=A9=EB=8F=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- conf/api.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/conf/api.yaml b/conf/api.yaml index 9092f3d4..4efc5fcd 100644 --- a/conf/api.yaml +++ b/conf/api.yaml @@ -4807,10 +4807,6 @@ serviceActions: method: delete resourcePath: /ns/{nsId}/resources/spec/{specId} description: "Delete spec" - GetProviderList: - method: get - resourcePath: /provider - description: "List all registered cloud providers (aws, azure, gcp, ...)" SetBastionNodesWithMci: method: put resourcePath: /ns/{nsId}/mci/{mciId}/vm/{targetVmId}/bastion/{bastionMciId}/{bastionVmId} From f51f111dfec834a50f608b74196dac9d2aa7daec Mon Sep 17 00:00:00 2001 From: yh-noh Date: Tue, 26 May 2026 13:29:43 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat(cloud-env-001):=20=EC=9B=90=EA=B2=A9?= =?UTF-8?q?=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=97=B0=EA=B2=B0=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20(mciam.onecloudcon.com=20HTTPS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MC_WEB_CONSOLE_API_SCHEME 환경변수 추가 (env.go) - proxy.go: http:// 하드코딩 → API_SCHEME 동적 처리 - scripts/start-remote.sh: Front 서버 단독 기동 스크립트 추가 --- front/actions/env.go | 2 + front/actions/proxy.go | 2 +- .../api/services/cloudconnection_api.js | 63 ++++++++++++++++--- scripts/start-remote.sh | 26 ++++++++ 4 files changed, 84 insertions(+), 9 deletions(-) create mode 100755 scripts/start-remote.sh diff --git a/front/actions/env.go b/front/actions/env.go index 3e3794b4..1200f45b 100644 --- a/front/actions/env.go +++ b/front/actions/env.go @@ -7,6 +7,7 @@ import ( var FRONT_ADDR string var FRONT_PORT string +var API_SCHEME string var API_ADDR string var API_PORT string var SESSION_SECRET string @@ -15,6 +16,7 @@ func init() { // Get environment variables with defaults FRONT_ADDR = getEnvOrDefault("MC_WEB_CONSOLE_FRONT_ADDR", "0.0.0.0") FRONT_PORT = getEnvOrDefault("MC_WEB_CONSOLE_FRONT_PORT", "3001") + API_SCHEME = getEnvOrDefault("MC_WEB_CONSOLE_API_SCHEME", "http") API_ADDR = getEnvOrDefault("MC_WEB_CONSOLE_API_ADDR", "localhost") API_PORT = getEnvOrDefault("MC_WEB_CONSOLE_API_PORT", "3000") SESSION_SECRET = getEnvOrDefault("MC_WEB_CONSOLE_SESSION_SECRET", "mc-web-console-secret-key") diff --git a/front/actions/proxy.go b/front/actions/proxy.go index 10b6b755..f8a78ad8 100644 --- a/front/actions/proxy.go +++ b/front/actions/proxy.go @@ -11,7 +11,7 @@ var proxy *httputil.ReverseProxy func init() { var err error - ApiBaseHost, err = url.Parse("http://" + API_ADDR + ":" + API_PORT) + ApiBaseHost, err = url.Parse(API_SCHEME + "://" + API_ADDR + ":" + API_PORT) if err != nil { panic(err) } diff --git a/front/assets/js/common/api/services/cloudconnection_api.js b/front/assets/js/common/api/services/cloudconnection_api.js index a5505b7c..cdc8d36d 100644 --- a/front/assets/js/common/api/services/cloudconnection_api.js +++ b/front/assets/js/common/api/services/cloudconnection_api.js @@ -55,13 +55,23 @@ export async function registerCredential(payload, credentialHolder) { /** * Credential Holder 목록 조회 - * credentialHolder API 미배포 버전 대응: connConfig 목록에서 holder를 추출하여 집계 + * GetCredentialHolderList 우선 호출, 404(미배포) 시 connConfig 집계로 fallback * @returns {Array} [{ credentialHolder, providers, connectionCount, verifiedConnectionCount, isDefault }] */ export async function listCredentialHolders() { + try { + const controller = "/api/mc-infra-manager/GetCredentialHolderList"; + const response = await webconsolejs["common/api/http"].commonAPIPost(controller, {}); + const data = unwrapResponse(response); + if (data && data.credentialHolderList) return data.credentialHolderList; + } catch (e) { + // 404 등 미배포 환경: connConfig 집계로 fallback + console.warn('[listCredentialHolders] GetCredentialHolderList 미지원, connConfig 집계로 대체:', e.message); + } + + // fallback: connConfig 목록에서 holder 집계 const conns = await listConnConfigs({ filterVerified: false }); if (!conns.length) return []; - const holderMap = {}; for (const conn of conns) { const holder = conn.credentialHolder || 'admin'; @@ -72,7 +82,6 @@ export async function listCredentialHolders() { holderMap[holder].connectionCount++; if (conn.verified) holderMap[holder].verifiedConnectionCount++; } - return Object.values(holderMap).map(h => ({ credentialHolder: h.credentialHolder, providers: [...h.providers], @@ -82,6 +91,18 @@ export async function listCredentialHolders() { })); } +/** + * Credential Holder 상세 조회 + * @param {string} holderId + * @returns {object} CredentialHolderInfo + */ +export async function getCredentialHolder(holderId) { + const controller = "/api/mc-infra-manager/GetCredentialHolder"; + const data = { pathParams: { holderId } }; + const response = await webconsolejs["common/api/http"].commonAPIPost(controller, data); + return unwrapResponse(response); +} + // ─── Connection Config ───────────────────────────────────────────────── /** @@ -101,7 +122,9 @@ export async function listConnConfigs(filters = {}, credentialHolder) { }; const response = await webconsolejs["common/api/http"].commonAPIPost(controller, data); const result = unwrapResponse(response); - return (result && result.connConfig) ? result.connConfig : []; + return (result && (result.connConfig || result.connectionconfig)) + ? (result.connConfig || result.connectionconfig) + : []; } /** @@ -154,14 +177,38 @@ export async function buildEncryptedCredentialPayload(providerName, credentialHo })); // 3. PEM → CryptoKey (RSA-OAEP SHA-256) + // mc-infra-manager는 PKCS#1 형식(BEGIN RSA PUBLIC KEY)을 반환함. + // Web Crypto API는 SPKI 형식만 지원하므로 PKCS#1 → SPKI 변환 필요. const pemBody = publicKeyPem - .replace(/-----BEGIN PUBLIC KEY-----/, '') - .replace(/-----END PUBLIC KEY-----/, '') + .replace(/-----[^-]+-----/g, '') .replace(/\s/g, ''); - const derBuffer = Uint8Array.from(atob(pemBody), c => c.charCodeAt(0)); + const pkcs1Der = Uint8Array.from(atob(pemBody), c => c.charCodeAt(0)); + + function derLenBytes(n) { + if (n < 0x80) return [n]; + if (n < 0x100) return [0x81, n]; + return [0x82, (n >> 8) & 0xff, n & 0xff]; + } + + let spkiDer; + if (publicKeyPem.includes('BEGIN RSA PUBLIC KEY')) { + // PKCS#1 → SPKI(SubjectPublicKeyInfo) 래핑 + const algIdSeq = new Uint8Array([ + 0x30, 0x0d, + 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, + 0x05, 0x00, + ]); + const bsPayload = new Uint8Array([0x00, ...pkcs1Der]); + const bitString = new Uint8Array([0x03, ...derLenBytes(bsPayload.length), ...bsPayload]); + const seqInner = new Uint8Array([...algIdSeq, ...bitString]); + spkiDer = new Uint8Array([0x30, ...derLenBytes(seqInner.length), ...seqInner]); + } else { + spkiDer = pkcs1Der; + } + const rsaKey = await crypto.subtle.importKey( 'spki', - derBuffer, + spkiDer, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt'] diff --git a/scripts/start-remote.sh b/scripts/start-remote.sh new file mode 100755 index 00000000..2d44d3a3 --- /dev/null +++ b/scripts/start-remote.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +MC_WEB_CONSOLE_FRONT_PORT="${MC_WEB_CONSOLE_FRONT_PORT:-3017}" +MC_WEB_CONSOLE_API_SCHEME="${MC_WEB_CONSOLE_API_SCHEME:-https}" +MC_WEB_CONSOLE_API_ADDR="${MC_WEB_CONSOLE_API_ADDR:-mciam.onecloudcon.com}" +MC_WEB_CONSOLE_API_PORT="${MC_WEB_CONSOLE_API_PORT:-3000}" + +echo "[remote] API backend : $MC_WEB_CONSOLE_API_SCHEME://$MC_WEB_CONSOLE_API_ADDR:$MC_WEB_CONSOLE_API_PORT" +echo "[remote] Front server: http://0.0.0.0:$MC_WEB_CONSOLE_FRONT_PORT" + +if [ "${SKIP_BUILD:-false}" != "true" ]; then + echo "[remote] Building frontend..." + cd "$ROOT_DIR/front" && npm run build +fi + +cd "$ROOT_DIR/front" +MC_WEB_CONSOLE_FRONT_PORT=$MC_WEB_CONSOLE_FRONT_PORT \ +MC_WEB_CONSOLE_FRONT_ADDR=0.0.0.0 \ +MC_WEB_CONSOLE_API_SCHEME=$MC_WEB_CONSOLE_API_SCHEME \ +MC_WEB_CONSOLE_API_ADDR=$MC_WEB_CONSOLE_API_ADDR \ +MC_WEB_CONSOLE_API_PORT=$MC_WEB_CONSOLE_API_PORT \ +go run cmd/app/main.go From fdebd5b981a140fd2757f77733d59a3e7292dabe Mon Sep 17 00:00:00 2001 From: yh-noh Date: Tue, 26 May 2026 15:04:54 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat(cloud-env-001):=20=EC=9B=90=EA=B2=A9?= =?UTF-8?q?=20Framework=20=EC=84=9C=EB=B9=84=EC=8A=A4=20URL=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(mciam.onecloudcon.com)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - conf/api.yaml: 11개 서비스 baseurl을 mciam.onecloudcon.com으로 일괄 변경 - mc-infra-connector: spider_url → mciam.onecloudcon.com:1024 - mc-infra-manager: 52.79.163.111 → mciam.onecloudcon.com:1323 - mc-observability, mc-application-manager, mc-workflow-manager 등 placeholder 제거 - scripts/start-remote.sh: API + Front 서버 양쪽 로컬 기동으로 변경 --- conf/api.yaml | 22 +++++++++++----------- scripts/start-remote.sh | 32 ++++++++++++++++++++++---------- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/conf/api.yaml b/conf/api.yaml index 70cb5f1f..1e08228f 100644 --- a/conf/api.yaml +++ b/conf/api.yaml @@ -4,7 +4,7 @@ services: mc-infra-connector: version: 0.9.4 - baseurl: http://spider_url:1024/spider + baseurl: http://mciam.onecloudcon.com:1024/spider auth: type: basic username: @@ -16,7 +16,7 @@ services: type: bearer mc-infra-manager: version: 0.12.9 - baseurl: http://52.79.163.111:1323/tumblebug + baseurl: http://mciam.onecloudcon.com:1323/tumblebug auth: type: basic username: default @@ -28,39 +28,39 @@ services: type: bearer mc-observability: version: main - baseurl: http://observability_url:18080 + baseurl: http://mciam.onecloudcon.com:18080 auth: mc-application-manager: version: main - baseurl: http://localhost:18084 + baseurl: http://mciam.onecloudcon.com:18084 auth: mc-application-manager-fe: version: main - baseurl: http://application_manager_fe_url:18084 + baseurl: http://mciam.onecloudcon.com:18084 auth: mc-workflow-manager: version: main - baseurl: http://workflow_url:18083 + baseurl: http://mciam.onecloudcon.com:18083 auth: mc-workflow-manager-fe: version: main - baseurl: http://workflow_manager_fe_url:18083 + baseurl: http://mciam.onecloudcon.com:18083 auth: mc-cost-optimizer: version: main - baseurl: http://cost_optimizer_url:18082 + baseurl: http://mciam.onecloudcon.com:18082 auth: null mc-cost-optimizer-fe: version: main - baseurl: http://cost_optimizer_fe_url:7780 + baseurl: http://mciam.onecloudcon.com:7780 auth: null mc-data-manager: version: main:20240923 - baseurl: http://localhost:3300 + baseurl: http://mciam.onecloudcon.com:3300 auth: mc-data-manager-fe: version: main - baseurl: http://data_manager_fe_url:3300 + baseurl: http://mciam.onecloudcon.com:3300 auth: serviceActions: mc-infra-connector: diff --git a/scripts/start-remote.sh b/scripts/start-remote.sh index 2d44d3a3..30dd15d4 100755 --- a/scripts/start-remote.sh +++ b/scripts/start-remote.sh @@ -3,24 +3,36 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(dirname "$SCRIPT_DIR")" +LOGS_DIR="$SCRIPT_DIR/logs" +mkdir -p "$LOGS_DIR" -MC_WEB_CONSOLE_FRONT_PORT="${MC_WEB_CONSOLE_FRONT_PORT:-3017}" -MC_WEB_CONSOLE_API_SCHEME="${MC_WEB_CONSOLE_API_SCHEME:-https}" -MC_WEB_CONSOLE_API_ADDR="${MC_WEB_CONSOLE_API_ADDR:-mciam.onecloudcon.com}" -MC_WEB_CONSOLE_API_PORT="${MC_WEB_CONSOLE_API_PORT:-3000}" +API_PORT="${MC_WEB_CONSOLE_API_PORT:-3007}" +FRONT_PORT="${MC_WEB_CONSOLE_FRONT_PORT:-3017}" -echo "[remote] API backend : $MC_WEB_CONSOLE_API_SCHEME://$MC_WEB_CONSOLE_API_ADDR:$MC_WEB_CONSOLE_API_PORT" -echo "[remote] Front server: http://0.0.0.0:$MC_WEB_CONSOLE_FRONT_PORT" +echo "[remote] API server : http://localhost:$API_PORT" +echo "[remote] Front server: http://0.0.0.0:$FRONT_PORT" +echo "[remote] Framework : mciam.onecloudcon.com" +# webpack 빌드 (SKIP_BUILD=true 로 건너뛸 수 있음) if [ "${SKIP_BUILD:-false}" != "true" ]; then echo "[remote] Building frontend..." cd "$ROOT_DIR/front" && npm run build fi +# API 서버 기동 (백그라운드) +cd "$ROOT_DIR/api" +MC_WEB_CONSOLE_API_ADDR=0.0.0.0 \ +MC_WEB_CONSOLE_API_PORT=$API_PORT \ +go run cmd/main.go > "$LOGS_DIR/api.log" 2>&1 & +echo $! > "$LOGS_DIR/api.pid" +echo "[remote] API server started (PID: $(cat $LOGS_DIR/api.pid)), log: $LOGS_DIR/api.log" +sleep 2 + +# Front 서버 기동 (포그라운드 — Ctrl+C 로 종료) cd "$ROOT_DIR/front" -MC_WEB_CONSOLE_FRONT_PORT=$MC_WEB_CONSOLE_FRONT_PORT \ +MC_WEB_CONSOLE_FRONT_PORT=$FRONT_PORT \ MC_WEB_CONSOLE_FRONT_ADDR=0.0.0.0 \ -MC_WEB_CONSOLE_API_SCHEME=$MC_WEB_CONSOLE_API_SCHEME \ -MC_WEB_CONSOLE_API_ADDR=$MC_WEB_CONSOLE_API_ADDR \ -MC_WEB_CONSOLE_API_PORT=$MC_WEB_CONSOLE_API_PORT \ +MC_WEB_CONSOLE_API_SCHEME=http \ +MC_WEB_CONSOLE_API_ADDR=localhost \ +MC_WEB_CONSOLE_API_PORT=$API_PORT \ go run cmd/app/main.go From 278b8bdc2eec6afb9ff4bfb9c1b38cdf77f27434 Mon Sep 17 00:00:00 2001 From: yh-noh Date: Tue, 26 May 2026 15:19:05 +0900 Subject: [PATCH 07/14] =?UTF-8?q?fix(cloud-env-001):=20cb-tumblebug=20Cred?= =?UTF-8?q?ential=20=EC=95=94=ED=98=B8=ED=99=94=20=ED=98=B8=ED=99=98?= =?UTF-8?q?=EC=84=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RSA 공개키 파싱: PKCS#1(BEGIN RSA PUBLIC KEY) + PKIX 양쪽 지원 - AES 암호화: GCM → CBC (IV(16B)+ciphertext, PKCS7 패딩) - mc-iam-manager 포트: 5006 → 5000 --- api/internal/handler/proxy.go | 53 ++++++++++++++++++++++------------- conf/api.yaml | 2 +- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/api/internal/handler/proxy.go b/api/internal/handler/proxy.go index cf9e4e1d..cb6ccb50 100644 --- a/api/internal/handler/proxy.go +++ b/api/internal/handler/proxy.go @@ -271,18 +271,30 @@ func encryptCredentialBody(plainBody []byte, baseURL string, service *config.Ser return nil, fmt.Errorf("empty publicKey from mc-infra-manager") } - // 2. RSA 공개키 파싱 + // 2. RSA 공개키 파싱 (cb-tumblebug은 PKCS#1 형식 "BEGIN RSA PUBLIC KEY" 반환) block, _ := pem.Decode([]byte(pkData.PublicKey)) if block == nil { return nil, fmt.Errorf("failed to decode PEM publicKey") } - pubInterface, err := x509.ParsePKIXPublicKey(block.Bytes) - if err != nil { - return nil, fmt.Errorf("parse RSA publicKey: %w", err) - } - rsaPub, ok := pubInterface.(*rsa.PublicKey) - if !ok { - return nil, fmt.Errorf("publicKey is not RSA") + var rsaPub *rsa.PublicKey + switch block.Type { + case "RSA PUBLIC KEY": + // PKCS#1 형식 + rsaPub, err = x509.ParsePKCS1PublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse PKCS1 RSA publicKey: %w", err) + } + default: + // PKIX/SPKI 형식 (BEGIN PUBLIC KEY) + pubInterface, pkixErr := x509.ParsePKIXPublicKey(block.Bytes) + if pkixErr != nil { + return nil, fmt.Errorf("parse PKIX RSA publicKey: %w", pkixErr) + } + var ok bool + rsaPub, ok = pubInterface.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("publicKey is not RSA") + } } // 3. 평문 요청 파싱 @@ -297,10 +309,10 @@ func encryptCredentialBody(plainBody []byte, baseURL string, service *config.Ser return nil, fmt.Errorf("generate AES key: %w", err) } - // 5. credentialKeyValueList[].value AES-256-GCM 암호화 + // 5. credentialKeyValueList[].value AES-256-CBC 암호화 (cb-tumblebug 호환) encryptedKVList := make([]credentialKV, len(plain.CredentialKeyValueList)) for i, kv := range plain.CredentialKeyValueList { - cipherText, err := aesGCMEncrypt(aesKey, []byte(kv.Value)) + cipherText, err := aesCBCEncrypt(aesKey, []byte(kv.Value)) if err != nil { return nil, fmt.Errorf("encrypt credential value [%s]: %w", kv.Key, err) } @@ -327,22 +339,23 @@ func encryptCredentialBody(plainBody []byte, baseURL string, service *config.Ser return json.Marshal(encrypted) } -// aesGCMEncrypt AES-256-GCM 암호화. 반환값: nonce(12B) + ciphertext -func aesGCMEncrypt(key, plaintext []byte) ([]byte, error) { +// aesCBCEncrypt AES-256-CBC 암호화 (cb-tumblebug 호환) +// 반환값: IV(16B) + ciphertext (PKCS7 패딩, 블록 배수) +func aesCBCEncrypt(key, plaintext []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err - } - nonce := make([]byte, gcm.NonceSize()) - if _, err := rand.Read(nonce); err != nil { + blockSize := block.BlockSize() + padding := blockSize - len(plaintext)%blockSize + padded := append(plaintext, bytes.Repeat([]byte{byte(padding)}, padding)...) + iv := make([]byte, blockSize) + if _, err := rand.Read(iv); err != nil { return nil, err } - ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) - return ciphertext, nil + ciphertext := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, padded) + return append(iv, ciphertext...), nil } // buildAuthHeader api.yaml의 auth 타입에 따라 Authorization 헤더 값 반환 diff --git a/conf/api.yaml b/conf/api.yaml index 1e08228f..ce2c6aa9 100644 --- a/conf/api.yaml +++ b/conf/api.yaml @@ -11,7 +11,7 @@ services: password: mc-iam-manager: version: 0.2.11 - baseurl: http://mciam.onecloudcon.com:5006 + baseurl: http://mciam.onecloudcon.com:5000 auth: type: bearer mc-infra-manager: From 09a6f8c4a068b672312b30f754807d5adf6c90d0 Mon Sep 17 00:00:00 2001 From: yh-noh Date: Tue, 26 May 2026 16:18:50 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat(cloud-env-001):=20Credentials/Connec?= =?UTF-8?q?tions=20=ED=99=94=EB=A9=B4=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API: MC_WEB_CONSOLE_USE_REGISTRY_URL 환경변수 추가 - false로 설정 시 MCIAM 레지스트리 BaseURL 덮어쓰기 비활성화 - 로컬 개발 환경에서 api.yaml 설정이 항상 유효하게 동작 - Credentials: Tabulator rowClick 이벤트 리스너 방식으로 변경 (v5 호환) - DOM 모듈 로드 시점 null 참조 수정 (document.getElementById 직접 호출) - Provider 선택 시 cb-spider 기대 키 이름 자동 입력 (PROVIDER_KEYS) - Connections: Tabulator rowClick 이벤트 리스너 방식으로 변경 (v5 호환) - DOM 모듈 로드 시점 null 참조 수정 - height: 400px 설정으로 테이블 내부 스크롤 활성화 - 외부 컨테이너 overflow:hidden 제거로 페이지네이션 컨트롤 표시 - 필터 패널 추가 (Field/Type/Value/Clear, networks.html 패턴 적용) --- api/internal/config/config.go | 10 ++-- api/internal/handler/proxy.go | 8 +-- .../environment/cloudsps/connections.js | 51 ++++++++++++++++--- .../environment/cloudsps/credentials.js | 46 ++++++++++++++--- .../environment/cloudsps/connections.html | 42 ++++++++++++++- 5 files changed, 135 insertions(+), 22 deletions(-) diff --git a/api/internal/config/config.go b/api/internal/config/config.go index 38c28874..055488d2 100644 --- a/api/internal/config/config.go +++ b/api/internal/config/config.go @@ -58,8 +58,9 @@ type DatabaseConfig struct { // MCIAMConfig MC-IAM 설정 type MCIAMConfig struct { - Use bool - TicketUse bool + Use bool + TicketUse bool + UseRegistryURL bool } // Load 설정 로드 @@ -82,8 +83,9 @@ func Load() (*Config, error) { SSLMode: getEnv("MC_WEB_CONSOLE_POSTGRES_SSLMODE", "disable"), }, MCIAM: MCIAMConfig{ - Use: getEnv("MC_WEB_CONSOLE_USE_IAM", "false") == "true", - TicketUse: getEnv("MC_WEB_CONSOLE_USE_TICKET_VALID", "false") == "true", + Use: getEnv("MC_WEB_CONSOLE_USE_IAM", "false") == "true", + TicketUse: getEnv("MC_WEB_CONSOLE_USE_TICKET_VALID", "false") == "true", + UseRegistryURL: getEnv("MC_WEB_CONSOLE_USE_REGISTRY_URL", "true") == "true", }, SetupYaml: SetupYamlConfig{ McWebconsoleMenuYaml: getEnv("MC_WEB_CONSOLE_MENUYAML", ""), diff --git a/api/internal/handler/proxy.go b/api/internal/handler/proxy.go index cb6ccb50..a06bdec9 100644 --- a/api/internal/handler/proxy.go +++ b/api/internal/handler/proxy.go @@ -86,9 +86,11 @@ func SubsystemAnyController(c echo.Context) error { // ActionSpec: 캐시 우선 → 없으면 api.yaml ActionSpec effectiveActionSpec := actionSpec if cfg.RegistryCache != nil { - if dynamicURL := cfg.RegistryCache.GetBaseURL(subsystemName, operationId); dynamicURL != "" { - log.Printf("[RegistryCache] BaseURL override for %s: %s", subsystemName, dynamicURL) - effectiveBaseURL = dynamicURL + if cfg.MCIAM.UseRegistryURL { + if dynamicURL := cfg.RegistryCache.GetBaseURL(subsystemName, operationId); dynamicURL != "" { + log.Printf("[RegistryCache] BaseURL override for %s: %s", subsystemName, dynamicURL) + effectiveBaseURL = dynamicURL + } } if cachedSpec := cfg.RegistryCache.GetActionSpec(subsystemName, operationId); cachedSpec != nil { effectiveActionSpec = cachedSpec diff --git a/front/assets/js/pages/settings/environment/cloudsps/connections.js b/front/assets/js/pages/settings/environment/cloudsps/connections.js index 21b43457..4c70f4db 100644 --- a/front/assets/js/pages/settings/environment/cloudsps/connections.js +++ b/front/assets/js/pages/settings/environment/cloudsps/connections.js @@ -53,6 +53,7 @@ const TableManager = { paginationSize: 15, paginationSizeSelector: [15, 30, 50], paginationCounter: 'rows', + height: '400px', columns: [ { formatter: 'rowSelection', @@ -79,9 +80,9 @@ const TableManager = { width: 110, }, ], - rowClick(e, row) { - ConnectionManager.loadDetail(row.getData().configName); - }, + }); + AppState.tables.connectionTable.on('rowClick', (e, row) => { + ConnectionManager.loadDetail(row.getData().configName); }); }, }; @@ -92,8 +93,12 @@ const UIManager = { showDetail(conn) { AppState.selectedConnection = conn; - if (DOM.detailNameLabel) DOM.detailNameLabel.style.display = ''; - if (DOM.detailNameText) DOM.detailNameText.textContent = conn.configName || '-'; + const detailPanel = document.getElementById('connection-detail-panel'); + const detailNameLabel = document.getElementById('connection-detail-name-label'); + const detailNameText = document.getElementById('connection-detail-name-text'); + + if (detailNameLabel) detailNameLabel.style.display = ''; + if (detailNameText) detailNameText.textContent = conn.configName || '-'; document.getElementById('detail-config-name').textContent = conn.configName || '-'; document.getElementById('detail-credential-holder').textContent = conn.credentialHolder || '-'; @@ -116,12 +121,13 @@ const UIManager = { ? 'Yes' : 'No'; - if (DOM.detailPanel) DOM.detailPanel.style.display = ''; + if (detailPanel) detailPanel.style.display = ''; }, hideDetail() { AppState.selectedConnection = null; - if (DOM.detailPanel) DOM.detailPanel.style.display = 'none'; + const detailPanel = document.getElementById('connection-detail-panel'); + if (detailPanel) detailPanel.style.display = 'none'; }, }; @@ -169,6 +175,36 @@ const ConnectionManager = { }, }; +// ─── FilterManager ────────────────────────────────────────────────── + +const FilterManager = { + init() { + const fieldEl = document.getElementById('connection-filter-field'); + const typeEl = document.getElementById('connection-filter-type'); + const valueEl = document.getElementById('connection-filter-value'); + if (!fieldEl || !typeEl || !valueEl) return; + + function updateFilter() { + const field = fieldEl.value; + const type = typeEl.value; + if (field && AppState.tables.connectionTable) { + AppState.tables.connectionTable.setFilter(field, type, valueEl.value); + } + } + + fieldEl.addEventListener('change', updateFilter); + typeEl.addEventListener('change', updateFilter); + valueEl.addEventListener('keyup', updateFilter); + + document.getElementById('connection-filter-clear')?.addEventListener('click', () => { + fieldEl.value = ''; + typeEl.value = 'like'; + valueEl.value = ''; + if (AppState.tables.connectionTable) AppState.tables.connectionTable.clearFilter(); + }); + }, +}; + // ─── Public exports ────────────────────────────────────────────────── export function refreshConnectionList() { @@ -183,6 +219,7 @@ export function hideDetail() { // ─── Init ────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', async () => { + FilterManager.init(); await ConnectionManager.populateHolderFilter(); await ConnectionManager.loadConnections(''); diff --git a/front/assets/js/pages/settings/environment/cloudsps/credentials.js b/front/assets/js/pages/settings/environment/cloudsps/credentials.js index 87570438..3427cb76 100644 --- a/front/assets/js/pages/settings/environment/cloudsps/credentials.js +++ b/front/assets/js/pages/settings/environment/cloudsps/credentials.js @@ -14,6 +14,17 @@ const AppState = { tables: { holderTable: null }, }; +// cb-spider가 각 Provider별로 기대하는 Credential key 이름 목록 +const PROVIDER_KEYS = { + aws: ['ClientId', 'ClientSecret'], + gcp: ['ClientEmail', 'PrivateKey', 'ProjectID'], + azure: ['ClientId', 'ClientSecret', 'TenantId', 'SubscriptionId'], + alibaba: ['ClientId', 'ClientSecret'], + ncp: ['ClientId', 'ClientSecret'], + nhncloud: ['ClientId', 'ClientSecret', 'TenantId'], + ktcloud: ['ClientId', 'ClientSecret'], +}; + const PROVIDER_BADGE = { aws: 'AWS', gcp: 'GCP', @@ -121,9 +132,9 @@ const TableManager = { width: 100, }, ], - rowClick(e, row) { - UIManager.showDetail(row.getData()); - }, + }); + AppState.tables.holderTable.on('rowClick', (e, row) => { + UIManager.showDetail(row.getData()); }); }, }; @@ -134,8 +145,12 @@ const UIManager = { showDetail(holder) { AppState.selectedHolder = holder; - if (DOM.detailNameLabel) DOM.detailNameLabel.style.display = ''; - if (DOM.detailNameText) DOM.detailNameText.textContent = holder.credentialHolder || '-'; + const detailPanel = document.getElementById('credential-detail-panel'); + const detailNameLabel = document.getElementById('credential-detail-name-label'); + const detailNameText = document.getElementById('credential-detail-name-text'); + + if (detailNameLabel) detailNameLabel.style.display = ''; + if (detailNameText) detailNameText.textContent = holder.credentialHolder || '-'; document.getElementById('detail-holder-id').textContent = holder.credentialHolder || '-'; document.getElementById('detail-holder-provider').innerHTML = getProvidersBadges(holder.providers); @@ -143,12 +158,13 @@ const UIManager = { document.getElementById('detail-holder-verified').textContent = holder.verifiedConnectionCount ?? '-'; document.getElementById('detail-holder-default').innerHTML = getDefaultBadge(holder.isDefault); - if (DOM.detailPanel) DOM.detailPanel.style.display = ''; + if (detailPanel) detailPanel.style.display = ''; }, hideDetail() { AppState.selectedHolder = null; - if (DOM.detailPanel) DOM.detailPanel.style.display = 'none'; + const detailPanel = document.getElementById('credential-detail-panel'); + if (detailPanel) detailPanel.style.display = 'none'; }, }; @@ -226,4 +242,20 @@ export function hideDetail() { document.addEventListener('DOMContentLoaded', () => { KVManager.reset(); CredentialHolderManager.loadHolders(); + + // Provider 선택 시 cb-spider 기대 key 이름으로 KV 행 자동 채우기 + const providerSelect = document.getElementById('create-holder-provider'); + if (providerSelect) { + providerSelect.addEventListener('change', () => { + const keys = PROVIDER_KEYS[providerSelect.value]; + const container = document.getElementById('kv-list-container'); + if (!container) return; + container.innerHTML = ''; + if (keys && keys.length > 0) { + keys.forEach(key => KVManager.addRow(key, '')); + } else { + KVManager.addRow(); + } + }); + } }); diff --git a/front/templates/pages/settings/environment/cloudsps/connections.html b/front/templates/pages/settings/environment/cloudsps/connections.html index 31e0690a..f7611bdb 100644 --- a/front/templates/pages/settings/environment/cloudsps/connections.html +++ b/front/templates/pages/settings/environment/cloudsps/connections.html @@ -29,6 +29,14 @@ -
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
From 020e592eaa9dfca75abf91eae97d2450bc2890f8 Mon Sep 17 00:00:00 2001 From: yh-noh Date: Fri, 29 May 2026 13:00:52 +0900 Subject: [PATCH 09/14] =?UTF-8?q?fix:=20=EC=97=AD=ED=95=A0=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EA=B6=8C=ED=95=9C=20=EC=A0=80=EC=9E=A5=20=ED=9B=84?= =?UTF-8?q?=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20=EC=A6=89=EC=8B=9C=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사이드바 메뉴가 로그인 시 localStorage에 캐시된 후 갱신되지 않아 역할 메뉴 권한 변경 후 재로그인해야 반영되는 문제 수정. - menus_api.js: refreshAvailableMenus() 추가 GetAllAvailableMenus 재조회 → localStorage 갱신 → refresh-sidebar 이벤트 dispatch - sidebar.js: refresh-sidebar CustomEvent 리스너 추가 → updatemenu() 재호출 - organizations/menus.js: create/update/delete 성공 시 refreshAvailableMenus() 호출 - roles.js: saveRole/updateRole 성공 시 refreshAvailableMenus() 호출 Fixes: WEB-BUG-016 --- .../js/common/api/services/menus_api.js | 34 +++++++++++++++++++ .../js/pages/operation/workspace/roles.js | 6 ++++ .../accountnaccess/organizations/menus.js | 3 ++ front/assets/js/partials/layout/sidebar.js | 4 +++ 4 files changed, 47 insertions(+) diff --git a/front/assets/js/common/api/services/menus_api.js b/front/assets/js/common/api/services/menus_api.js index 7209de69..3f78d813 100644 --- a/front/assets/js/common/api/services/menus_api.js +++ b/front/assets/js/common/api/services/menus_api.js @@ -41,3 +41,37 @@ export async function listRoles() { const response = await webconsolejs["common/api/http"].commonAPIPost(controller, {}); return response.data.responseData; } + +// 역할 메뉴 권한 변경 후 사이드바를 즉시 갱신하기 위해 호출. +// GetAllAvailableMenus를 재조회하여 localStorage를 갱신하고 +// refresh-sidebar 이벤트를 dispatch하면 sidebar.js가 updatemenu()를 재호출한다. +export async function refreshAvailableMenus() { + const response = await webconsolejs["common/api/http"].commonAPIPost( + "/api/mc-iam-manager/GetAllAvailableMenus" + ); + const menuList = response?.data?.responseData; + if (!menuList) return; + + const menuMap = new Map(); + menuList.forEach(menu => menuMap.set(menu.id, { ...menu, menus: [] })); + const rootMenus = []; + menuList.forEach(menu => { + const node = menuMap.get(menu.id); + if (menu.parentId === 'home' || !menu.parentId) { + rootMenus.push(node); + } else { + const parent = menuMap.get(menu.parentId); + if (parent) parent.menus.push(node); + } + }); + const sortMenus = (menus) => { + menus.sort((a, b) => + a.priority !== b.priority ? a.priority - b.priority : a.menuNumber - b.menuNumber + ); + menus.forEach(m => { if (m.menus?.length > 0) sortMenus(m.menus); }); + }; + sortMenus(rootMenus); + + webconsolejs["common/storage/localstorage"].setMenuLocalStorage(rootMenus); + document.dispatchEvent(new CustomEvent('refresh-sidebar')); +} diff --git a/front/assets/js/pages/operation/workspace/roles.js b/front/assets/js/pages/operation/workspace/roles.js index a4af80b4..c81c9401 100644 --- a/front/assets/js/pages/operation/workspace/roles.js +++ b/front/assets/js/pages/operation/workspace/roles.js @@ -2819,6 +2819,9 @@ async function saveRole() { // 헤더 클릭 이벤트 리스너 재설정 (DOM 재생성 후 이벤트 연결) setupHeaderClickEvents(); + // 사이드바 메뉴 즉시 갱신 (역할 메뉴 권한 변경 반영) + await webconsolejs["common/api/services/menus_api"].refreshAvailableMenus(); + // 페이지를 맨 위로 스크롤 window.scrollTo({ top: 0, behavior: 'smooth' }); } @@ -3221,6 +3224,9 @@ async function updateRole() { // 헤더 클릭 이벤트 리스너 재설정 (DOM 재생성 후 이벤트 연결) setupHeaderClickEvents(); + // 사이드바 메뉴 즉시 갱신 (역할 메뉴 권한 변경 반영) + await webconsolejs["common/api/services/menus_api"].refreshAvailableMenus(); + // 페이지를 맨 위로 스크롤 window.scrollTo({ top: 0, behavior: 'smooth' }); } diff --git a/front/assets/js/pages/settings/accountnaccess/organizations/menus.js b/front/assets/js/pages/settings/accountnaccess/organizations/menus.js index e67e29ea..1f930b68 100644 --- a/front/assets/js/pages/settings/accountnaccess/organizations/menus.js +++ b/front/assets/js/pages/settings/accountnaccess/organizations/menus.js @@ -71,6 +71,7 @@ const MenuManager = { AppState.menus.selectedMenu = null; UIManager.hideDetailPanel(); await this.loadTree(); + await webconsolejs["common/api/services/menus_api"].refreshAvailableMenus(); } catch (error) { console.error("Error creating menu:", error); alert("Failed to create menu: " + (error.message || error)); @@ -86,6 +87,7 @@ const MenuManager = { AppState.menus.selectedMenu = updated; UIManager.showDetailPanel(updated); } + await webconsolejs["common/api/services/menus_api"].refreshAvailableMenus(); } catch (error) { console.error("Error updating menu:", error); alert("Failed to update menu: " + (error.message || error)); @@ -98,6 +100,7 @@ const MenuManager = { AppState.menus.selectedMenu = null; UIManager.hideDetailPanel(); await this.loadTree(); + await webconsolejs["common/api/services/menus_api"].refreshAvailableMenus(); } catch (error) { console.error("Error deleting menu:", error); alert("Failed to delete menu: " + (error.message || error)); diff --git a/front/assets/js/partials/layout/sidebar.js b/front/assets/js/partials/layout/sidebar.js index 5034d156..8c803530 100644 --- a/front/assets/js/partials/layout/sidebar.js +++ b/front/assets/js/partials/layout/sidebar.js @@ -22,6 +22,10 @@ document.addEventListener("DOMContentLoaded", function () { }); setActiveMenu(); + document.addEventListener("refresh-sidebar", function () { + updatemenu(); + }); + }); function updatemenu(){ From e3079c452b288e44c11a990ab9bb6c6f598e6c38 Mon Sep 17 00:00:00 2001 From: yh-noh Date: Fri, 29 May 2026 16:26:55 +0900 Subject: [PATCH 10/14] =?UTF-8?q?fix(WEB-BUG-015,=20WEB-TECH-008):=20?= =?UTF-8?q?=EC=A1=B0=EC=A7=81=20=EA=B4=80=EB=A0=A8=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EB=B0=8F=20companyinfo=20=ED=99=94=EB=A9=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WEB-BUG-015: webconsole_menu_resources.yaml에 groups 메뉴 항목 누락 추가 - WEB-TECH-008: companyinfo 화면 구현 (HTML 템플릿 + JS) - 최상위 조직 정보 조회 및 표시 - Edit/Cancel 모드 전환 기능 - Users 화면 스타일 기준으로 Edit 버튼 통일 - fix: mc-iam-manager baseurl 포트 5006 → 5000 수정 --- conf/api.yaml | 2 +- conf/webconsole_menu_resources.yaml | 8 ++ .../organizations/companyinfo.js | 76 ++++++++++++++++ .../organizations/companyinfo.html | 86 +++++++++++++++++++ go.work.sum | 16 ++++ 5 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 front/assets/js/pages/settings/accountnaccess/organizations/companyinfo.js create mode 100644 front/templates/pages/settings/accountnaccess/organizations/companyinfo.html diff --git a/conf/api.yaml b/conf/api.yaml index 70cb5f1f..a31eac9f 100644 --- a/conf/api.yaml +++ b/conf/api.yaml @@ -11,7 +11,7 @@ services: password: mc-iam-manager: version: 0.2.11 - baseurl: http://mciam.onecloudcon.com:5006 + baseurl: http://mciam.onecloudcon.com:5000 auth: type: bearer mc-infra-manager: diff --git a/conf/webconsole_menu_resources.yaml b/conf/webconsole_menu_resources.yaml index 269d700e..0ec48574 100644 --- a/conf/webconsole_menu_resources.yaml +++ b/conf/webconsole_menu_resources.yaml @@ -40,6 +40,14 @@ menus: priority: 2 menunumber: 1220 + - id: groups + parentid: organizations + displayname: Groups + restype: menu + isaction: true + priority: 2 + menunumber: 1225 + - id: approvals parentid: organizations displayname: Approvals diff --git a/front/assets/js/pages/settings/accountnaccess/organizations/companyinfo.js b/front/assets/js/pages/settings/accountnaccess/organizations/companyinfo.js new file mode 100644 index 00000000..111f81be --- /dev/null +++ b/front/assets/js/pages/settings/accountnaccess/organizations/companyinfo.js @@ -0,0 +1,76 @@ +if (typeof webconsolejs === 'undefined') window.webconsolejs = {}; +if (typeof webconsolejs['pages/settings/accountnaccess/organizations/companyinfo'] === 'undefined') { + webconsolejs['pages/settings/accountnaccess/organizations/companyinfo'] = {}; +} + +const AppState = { + org: null +}; + +const groupsApi = () => webconsolejs['common/api/services/groups_api']; + +const companyInfoPage = { + async load() { + try { + const list = await groupsApi().getGroupList(); + const orgs = Array.isArray(list) ? list : []; + if (orgs.length === 0) throw new Error('No organization found'); + // 최상위 조직: level이 가장 낮은 항목 + const root = orgs.reduce((min, o) => (o.level < min.level ? o : min), orgs[0]); + AppState.org = root; + this._render(root); + } catch (e) { + console.error('Failed to load organization info:', e); + document.getElementById('companyinfo-loading').innerHTML = + '
Failed to load organization info.
'; + } + }, + + _render(org) { + document.getElementById('ci-name').textContent = org.name || '-'; + document.getElementById('ci-code').textContent = org.organizationCode || org.code || '-'; + document.getElementById('ci-level').textContent = org.level ?? '-'; + document.getElementById('ci-path').textContent = org.path || '-'; + document.getElementById('ci-description').textContent = org.description || '-'; + document.getElementById('ci-user-count').textContent = org.userCount ?? '-'; + document.getElementById('companyinfo-loading').style.display = 'none'; + document.getElementById('companyinfo-data').style.display = ''; + }, + + enterEditMode() { + if (!AppState.org) return; + document.getElementById('ci-edit-name').value = AppState.org.name || ''; + document.getElementById('ci-edit-description').value = AppState.org.description || ''; + document.getElementById('companyinfo-view-card').style.display = 'none'; + document.getElementById('companyinfo-edit-card').style.display = ''; + }, + + cancelEdit() { + document.getElementById('companyinfo-edit-card').style.display = 'none'; + document.getElementById('companyinfo-view-card').style.display = ''; + }, + + async saveEdit() { + const name = document.getElementById('ci-edit-name').value.trim(); + if (!name) { + alert('Name is required.'); + return; + } + const description = document.getElementById('ci-edit-description').value.trim(); + try { + const updated = await groupsApi().updateGroup(AppState.org.id, { name, description }); + AppState.org = { ...AppState.org, name, description, ...updated }; + this._render(AppState.org); + this.cancelEdit(); + } catch (e) { + console.error('Failed to update organization:', e); + alert('Failed to save. Please try again.'); + } + } +}; + +window.companyInfoPage = companyInfoPage; + +document.addEventListener('DOMContentLoaded', () => { + companyInfoPage.load(); +}); diff --git a/front/templates/pages/settings/accountnaccess/organizations/companyinfo.html b/front/templates/pages/settings/accountnaccess/organizations/companyinfo.html new file mode 100644 index 00000000..5369e5d9 --- /dev/null +++ b/front/templates/pages/settings/accountnaccess/organizations/companyinfo.html @@ -0,0 +1,86 @@ +
+
+
+
+ + +
+
+

Organization Info

+
+ +
+
+
+
+
+
+ +
+
+ + + + +
+
+
+
+ +{{ partial("partials/layout/pageloader.html") | raw }} + +{{ javascriptTag("common/api/services/groups_api.js") | raw }} +{{ javascriptTag("pages/settings/accountnaccess/organizations/companyinfo.js") | raw }} diff --git a/go.work.sum b/go.work.sum index 5e504d46..3f3bfd57 100644 --- a/go.work.sum +++ b/go.work.sum @@ -50,6 +50,7 @@ github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5il github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/go-co-op/gocron v1.28.1 h1:z2+Y094RkGriilTVdGq6IeNbTkZbGMTQSxK4zITogo0= @@ -110,6 +111,7 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= @@ -162,8 +164,10 @@ github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQs github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lestrrat-go/jwx v1.2.29/go.mod h1:hU8k2l6WF0ncx20uQdOmik/Gjg6E3/wIRtXSNFeZuB8= github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= @@ -178,6 +182,7 @@ github.com/markbates/refresh v1.12.0/go.mod h1:Vpwi1+q+2U1VxE7C0Ilj6r2/+TigRzQcL github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microcosm-cc/bluemonday v1.0.16/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= @@ -192,10 +197,12 @@ github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDm github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/onsi/ginkgo/v2 v2.15.0/go.mod h1:HlxMHtYF57y6Dpf+mc5529KKmSq9h2FpCF+/ZkwUxKM= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/openzipkin/zipkin-go v0.4.2 h1:zjqfqHjUpPmB3c1GlCvvgsM1G4LkvqQbBDueDOCg/jA= github.com/openzipkin/zipkin-go v0.4.2/go.mod h1:ZeVkFjuuBiSy13y8vpSDCjMi9GoI3hPpCJSBx/EYFhY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= @@ -210,6 +217,7 @@ github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43Z github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc= github.com/rs/zerolog v1.15.0 h1:uPRuwkWF4J6fGsJ2R0Gn2jB1EQiav9k3S6CSdygQJXY= @@ -226,7 +234,9 @@ github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJ github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= github.com/spf13/viper v1.2.1 h1:bIcUwXqLseLF3BDAZduuNfekWG87ibtFxi59Bq+oI9M= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= @@ -277,6 +287,7 @@ golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -307,9 +318,11 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= +golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 h1:E2/AqCUMZGgd73TQkxUMcMla25GB9i/5HOdLr+uH7Vo= golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= @@ -321,6 +334,7 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= @@ -339,11 +353,13 @@ google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec h1:RlWgLqCMMIYYEVcAR5MDsuHlVkaIPDAF+5Dehzg8L5A= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= From 760a784462a30f9acf68bcc8a4868bd8fa2d9a61 Mon Sep 17 00:00:00 2001 From: dogfootman Date: Wed, 3 Jun 2026 03:03:36 +0000 Subject: [PATCH 11/14] =?UTF-8?q?feat(iam):=20Users=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20API=20=EB=93=B1=EB=A1=9D=20=EB=B0=8F=20users=5Fapi.js=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=ED=95=A8=EC=88=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - conf/api.yaml: removePlatformRole 신규 등록 (DELETE /api/roles/unassign/platform-role) - conf/api.yaml: Deleteuser/Updateuser resourcePath 오타 수정 (/api/user/ → /api/users/, userid → userId) - users_api.js: updateUser, deleteUser, addUserRole, removeUserRole 함수 추가 --- conf/api.yaml | 8 ++- .../js/common/api/services/users_api.js | 49 ++++++++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/conf/api.yaml b/conf/api.yaml index ce2c6aa9..90302e01 100644 --- a/conf/api.yaml +++ b/conf/api.yaml @@ -879,11 +879,11 @@ serviceActions: description: 해당 프레임워크 역할에 할당된 메뉴 리스트를 반환합니다. Deleteuser: method: delete - resourcePath: /api/user/id/{userid} + resourcePath: /api/users/id/{userId} description: 사용자를 삭제합니다. Updateuser: method: put - resourcePath: /api/user/id/{userid} + resourcePath: /api/users/id/{userId} description: 사용자 정보를 업데이트 합니다. Createworkspace: method: post @@ -1058,6 +1058,10 @@ serviceActions: method: post resourcePath: /api/roles/assign/platform-role description: Platform Role 할당 + removePlatformRole: + method: delete + resourcePath: /api/roles/unassign/platform-role + description: 사용자에게서 Platform Role 제거 UpdateUserStatus: method: post resourcePath: /api/users/id/{userId}/status diff --git a/front/assets/js/common/api/services/users_api.js b/front/assets/js/common/api/services/users_api.js index 367c885c..9ec593d9 100644 --- a/front/assets/js/common/api/services/users_api.js +++ b/front/assets/js/common/api/services/users_api.js @@ -62,8 +62,6 @@ export async function updateUserStatus(userId, status) { export async function getUserWorkspacesByUserID(userId) { const controller = "/api/mc-iam-manager/Getuserworkspacesbyuserid"; - // var controller = "/api/" + "/mc-iam-manager/" + "Getuserworkspacesbyuserid"; - const data = { pathParams: { "userId": userId.toString() @@ -71,4 +69,51 @@ export async function getUserWorkspacesByUserID(userId) { } const response = await webconsolejs["common/api/http"].commonAPIPost(controller, data); return response.data.responseData; +} + +export async function updateUser(userId, userData) { + const controller = "/api/mc-iam-manager/Updateuser"; + const data = { + pathParams: { "userId": userId.toString() }, + request: userData + }; + const response = await webconsolejs["common/api/http"].commonAPIPost(controller, data); + return response; +} + +export async function deleteUser(userId) { + const controller = "/api/mc-iam-manager/Deleteuser"; + const data = { + pathParams: { "userId": userId.toString() } + }; + const response = await webconsolejs["common/api/http"].commonAPIPost(controller, data); + return response; +} + +export async function addUserRole(userId, roleData) { + const controller = "/api/mc-iam-manager/assignPlatformRole"; + const data = { + request: { + userId: userId.toString(), + roleId: roleData.roleId ? roleData.roleId.toString() : undefined, + roleName: roleData.roleName, + roleType: roleData.roleType || "platform", + workspaceId: roleData.workspaceId ? roleData.workspaceId.toString() : "1" + } + }; + const response = await webconsolejs["common/api/http"].commonAPIPost(controller, data); + return response; +} + +export async function removeUserRole(userId, roleId) { + const controller = "/api/mc-iam-manager/removePlatformRole"; + const data = { + request: { + userId: userId.toString(), + roleId: roleId.toString(), + roleType: "platform" + } + }; + const response = await webconsolejs["common/api/http"].commonAPIPost(controller, data); + return response; } \ No newline at end of file From cb93a7a5ac67fdbc53f94f3ac793e69ae86c6db1 Mon Sep 17 00:00:00 2001 From: yh-noh Date: Thu, 4 Jun 2026 13:47:05 +0900 Subject: [PATCH 12/14] fix(roles): load menus_api.js on roles page to enable sidebar refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WEB-BUG-016 근본 원인: roles.html 템플릿에 menus_api.js 번들이 로드되지 않아 webconsolejs["common/api/services/menus_api"]가 undefined였음. 역할 저장 후 refreshAvailableMenus() 호출 시 TypeError가 발생했으나 outer catch에서 조용히 무시되어 사이드바가 갱신되지 않았음. - roles.html: menus_api.js script tag 추가 (roles_api.js 뒤에 로드) - menus_api.js: orphaned 노드 루트 처리 추가, 빈 배열 방어 로직 추가 - login.js: orphaned 메뉴 노드 루트 처리 (refreshAvailableMenus와 동일 패턴) --- front/assets/js/common/api/services/menus_api.js | 10 +++++++++- front/assets/js/pages/auth/login.js | 6 ++++-- .../pages/operations/manage/workspaces/roles.html | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/front/assets/js/common/api/services/menus_api.js b/front/assets/js/common/api/services/menus_api.js index 3f78d813..3b4a56e7 100644 --- a/front/assets/js/common/api/services/menus_api.js +++ b/front/assets/js/common/api/services/menus_api.js @@ -61,7 +61,12 @@ export async function refreshAvailableMenus() { rootMenus.push(node); } else { const parent = menuMap.get(menu.parentId); - if (parent) parent.menus.push(node); + if (parent) { + parent.menus.push(node); + } else { + // 부모가 응답에 없는 orphaned 노드 → 루트로 처리 + rootMenus.push(node); + } } }); const sortMenus = (menus) => { @@ -72,6 +77,9 @@ export async function refreshAvailableMenus() { }; sortMenus(rootMenus); + // 응답 자체가 빈 배열이면 기존 사이드바 유지 (실수로 localStorage 소거 방지) + if (menuList.length === 0) return; + webconsolejs["common/storage/localstorage"].setMenuLocalStorage(rootMenus); document.dispatchEvent(new CustomEvent('refresh-sidebar')); } diff --git a/front/assets/js/pages/auth/login.js b/front/assets/js/pages/auth/login.js index ae5ec5cd..76a46132 100644 --- a/front/assets/js/pages/auth/login.js +++ b/front/assets/js/pages/auth/login.js @@ -76,15 +76,17 @@ function convertToMenuTree(menuList) { const rootMenus = []; menuList.forEach(menu => { const menuNode = menuMap.get(menu.id); - + if (menu.parentId === 'home' || !menu.parentId) { // 최상위 메뉴 rootMenus.push(menuNode); } else { - // 하위 메뉴 + // 하위 메뉴 — 부모가 응답에 없으면(orphaned) 루트로 처리 const parentMenu = menuMap.get(menu.parentId); if (parentMenu) { parentMenu.menus.push(menuNode); + } else { + rootMenus.push(menuNode); } } }); diff --git a/front/templates/pages/operations/manage/workspaces/roles.html b/front/templates/pages/operations/manage/workspaces/roles.html index ac5d9bac..7ec15898 100644 --- a/front/templates/pages/operations/manage/workspaces/roles.html +++ b/front/templates/pages/operations/manage/workspaces/roles.html @@ -725,6 +725,6 @@ {{ partial("partials/layout/pageloader.html") | raw }} -{{ javascriptTag("common/api/services/roles_api.js") | raw }} {{ javascriptTag("pages/operation/workspace/roles.js") | raw }} +{{ javascriptTag("common/api/services/roles_api.js") | raw }} {{ javascriptTag("common/api/services/menus_api.js") | raw }} {{ javascriptTag("pages/operation/workspace/roles.js") | raw }} {{ javascriptTag("common/api/services/users_api.js") | raw }} {{ javascriptTag("common/api/services/csproles_api.js") | raw }} From 5dbf583480e6ddf38fff97f44eeb61b6e2815e4b Mon Sep 17 00:00:00 2001 From: yh-noh Date: Fri, 5 Jun 2026 09:16:33 +0900 Subject: [PATCH 13/14] feat: add assignGroupPlatformRole and getGroupPlatformRoles to mc-iam-manager in api.yaml --- conf/api.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/conf/api.yaml b/conf/api.yaml index 90302e01..c5713f5a 100644 --- a/conf/api.yaml +++ b/conf/api.yaml @@ -1114,6 +1114,14 @@ serviceActions: method: delete resourcePath: /api/users/id/{userId}/organizations/{organizationId} description: 사용자 그룹 제거 + assignGroupPlatformRole: + method: post + resourcePath: /api/groups/id/{groupId}/platform-roles + description: 그룹에 Platform Role 할당 + getGroupPlatformRoles: + method: get + resourcePath: /api/groups/id/{groupId}/platform-roles + description: 그룹의 Platform Role 목록 조회 listCspAccounts: method: post resourcePath: /api/csp-accounts/list From 74e85283ddc3ed1886dc01445982746f00e80957 Mon Sep 17 00:00:00 2001 From: yh-noh Date: Fri, 5 Jun 2026 09:19:38 +0900 Subject: [PATCH 14/14] =?UTF-8?q?merge:=20resolve=20conflict=20in=20users?= =?UTF-8?q?=5Fapi.js=20=E2=80=94=20keep=20updateUser/deleteUser=20from=20u?= =?UTF-8?q?pstream=20and=20improved=20addUserRole=20from=20stash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../js/common/api/services/users_api.js | 48 +++-- .../accountnaccess/organizations/users.js | 175 ++++++++++++------ .../accountnaccess/organizations/users.html | 29 ++- 3 files changed, 178 insertions(+), 74 deletions(-) diff --git a/front/assets/js/common/api/services/users_api.js b/front/assets/js/common/api/services/users_api.js index 9ec593d9..99a4073d 100644 --- a/front/assets/js/common/api/services/users_api.js +++ b/front/assets/js/common/api/services/users_api.js @@ -90,19 +90,43 @@ export async function deleteUser(userId) { return response; } +function resolveWorkspaceId(workspaceId) { + if (workspaceId) { + return workspaceId.toString(); + } + + const workspaceApi = webconsolejs['common/api/services/workspace_api']; + const currentWorkspace = workspaceApi?.getCurrentWorkspace?.(); + const sessionWorkspaceId = currentWorkspace?.Id ?? currentWorkspace?.id; + return sessionWorkspaceId ? sessionWorkspaceId.toString() : '1'; +} + export async function addUserRole(userId, roleData) { - const controller = "/api/mc-iam-manager/assignPlatformRole"; - const data = { - request: { - userId: userId.toString(), - roleId: roleData.roleId ? roleData.roleId.toString() : undefined, - roleName: roleData.roleName, - roleType: roleData.roleType || "platform", - workspaceId: roleData.workspaceId ? roleData.workspaceId.toString() : "1" - } - }; - const response = await webconsolejs["common/api/http"].commonAPIPost(controller, data); - return response; + const { roleType, roleId, workspaceId } = roleData; + + if (!roleType || !roleId) { + throw new Error('Role type and role are required.'); + } + + if (roleType === 'platform') { + return webconsolejs['common/api/services/roles_api'].assignUserToRole(roleId, userId); + } + + if (roleType === 'workspace') { + return webconsolejs['common/api/services/workspace_api'].createWorkspaceUserRoleMappingByName( + resolveWorkspaceId(workspaceId), + roleId, + userId + ); + } + + if (roleType === 'csp') { + throw new Error( + 'Direct CSP role assignment to users is not supported. Configure CSP mappings on the role instead.' + ); + } + + throw new Error(`Unsupported role type: ${roleType}`); } export async function removeUserRole(userId, roleId) { diff --git a/front/assets/js/pages/settings/accountnaccess/organizations/users.js b/front/assets/js/pages/settings/accountnaccess/organizations/users.js index 44008b35..a3ae77ae 100644 --- a/front/assets/js/pages/settings/accountnaccess/organizations/users.js +++ b/front/assets/js/pages/settings/accountnaccess/organizations/users.js @@ -120,24 +120,41 @@ const UserManager = { // 폼 데이터 수집 collectFormData() { - const email = document.getElementById('create-user-email').value; - return { - username: email, // email을 username으로 사용 - email: email, + const username = document.getElementById('create-user-username').value.trim(); + const email = document.getElementById('create-user-email').value.trim(); + // const password = document.getElementById('create-user-password').value; + const data = { + username, + email, firstName: document.getElementById('create-user-firstname').value, lastName: document.getElementById('create-user-lastname').value, enabled: document.getElementById('create-user-enabled').checked, emailVerified: false // 고정값 }; + // if (password && password.trim() !== '') { + // data.password = password.trim(); + // } + return data; }, // 유효성 검증 validateUserData(userData) { const errors = []; - - if (!userData.email || userData.email.trim() === '') { + + const username = (userData.username || '').trim(); + const email = (userData.email || '').trim(); + + if (!username) { + errors.push('User ID is required'); + } else if (username.includes('@')) { + errors.push('User ID must not be an email address'); + } else if (email && username === email) { + errors.push('User ID must differ from email address'); + } + + if (!email) { errors.push('Email is required'); - } else if (!this.isValidEmail(userData.email)) { + } else if (!this.isValidEmail(email)) { errors.push('Invalid email format'); } @@ -148,7 +165,11 @@ const UserManager = { if (!userData.lastName || userData.lastName.trim() === '') { errors.push('Last name is required'); } - + + // if (userData.password && userData.password.length < 8) { + // errors.push('Password must be at least 8 characters'); + // } + return errors; }, @@ -353,7 +374,7 @@ const UIManager = { descriptionsElement.innerHTML = '-'; actionsElement.innerHTML = `
-
+
+ + + 로그인 ID — email 과 별도 (예: abc / abc@abc.com) +
Create User required />
+
+ @@ -910,4 +925,8 @@ -{{ javascriptTag("common/api/services/users_api.js") | raw }} {{ javascriptTag("common/api/services/groups_api.js") | raw }} {{ javascriptTag("pages/settings/accountnaccess/organizations/users.js") | raw }} +{{ javascriptTag("common/api/services/users_api.js") | raw }} +{{ javascriptTag("common/api/services/groups_api.js") | raw }} +{{ javascriptTag("common/api/services/roles_api.js") | raw }} +{{ javascriptTag("common/api/services/workspace_api.js") | raw }} +{{ javascriptTag("pages/settings/accountnaccess/organizations/users.js") | raw }}