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 cf9e4e1d..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
@@ -271,18 +273,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 +311,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 +341,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 70cb5f1f..c5713f5a 100644
--- a/conf/api.yaml
+++ b/conf/api.yaml
@@ -4,19 +4,19 @@
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:
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:
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:
@@ -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
@@ -1110,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
diff --git a/conf/webconsole_menu_resources.yaml b/conf/webconsole_menu_resources.yaml
index 269d700e..c32bef97 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
@@ -88,11 +96,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 +108,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: cspaccounts
parentid: cloudsps
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
new file mode 100644
index 00000000..cdc8d36d
--- /dev/null
+++ b/front/assets/js/common/api/services/cloudconnection_api.js
@@ -0,0 +1,228 @@
+// 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-manager/GetProviderList";
+ const response = await webconsolejs["common/api/http"].commonAPIPost(controller, {});
+ const data = unwrapResponse(response);
+ return (data && data.output) ? data.output : [];
+}
+
+// ─── 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 목록 조회
+ * 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';
+ 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++;
+ }
+ return Object.values(holderMap).map(h => ({
+ credentialHolder: h.credentialHolder,
+ providers: [...h.providers],
+ connectionCount: h.connectionCount,
+ verifiedConnectionCount: h.verifiedConnectionCount,
+ isDefault: h.credentialHolder === 'admin',
+ }));
+}
+
+/**
+ * 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 ─────────────────────────────────────────────────
+
+/**
+ * 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 = String(filters.filterVerified);
+ if (filters.filterRegionRepresentative !== undefined) queryParams.filterRegionRepresentative = String(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.connectionconfig))
+ ? (result.connConfig || result.connectionconfig)
+ : [];
+}
+
+/**
+ * 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)
+ // mc-infra-manager는 PKCS#1 형식(BEGIN RSA PUBLIC KEY)을 반환함.
+ // Web Crypto API는 SPKI 형식만 지원하므로 PKCS#1 → SPKI 변환 필요.
+ const pemBody = publicKeyPem
+ .replace(/-----[^-]+-----/g, '')
+ .replace(/\s/g, '');
+ 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',
+ spkiDer,
+ { 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/common/api/services/menus_api.js b/front/assets/js/common/api/services/menus_api.js
index 7209de69..3b4a56e7 100644
--- a/front/assets/js/common/api/services/menus_api.js
+++ b/front/assets/js/common/api/services/menus_api.js
@@ -41,3 +41,45 @@ 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);
+ } else {
+ // 부모가 응답에 없는 orphaned 노드 → 루트로 처리
+ rootMenus.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);
+
+ // 응답 자체가 빈 배열이면 기존 사이드바 유지 (실수로 localStorage 소거 방지)
+ if (menuList.length === 0) return;
+
+ webconsolejs["common/storage/localstorage"].setMenuLocalStorage(rootMenus);
+ document.dispatchEvent(new CustomEvent('refresh-sidebar'));
+}
diff --git a/front/assets/js/common/api/services/users_api.js b/front/assets/js/common/api/services/users_api.js
index 367c885c..99a4073d 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,75 @@ 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;
+}
+
+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 { 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) {
+ 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
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/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/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/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/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 = `
-
-
+
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 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Clear
+
+
+
+
+
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=
diff --git a/scripts/start-remote.sh b/scripts/start-remote.sh
new file mode 100755
index 00000000..30dd15d4
--- /dev/null
+++ b/scripts/start-remote.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(dirname "$SCRIPT_DIR")"
+LOGS_DIR="$SCRIPT_DIR/logs"
+mkdir -p "$LOGS_DIR"
+
+API_PORT="${MC_WEB_CONSOLE_API_PORT:-3007}"
+FRONT_PORT="${MC_WEB_CONSOLE_FRONT_PORT:-3017}"
+
+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=$FRONT_PORT \
+MC_WEB_CONSOLE_FRONT_ADDR=0.0.0.0 \
+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