diff --git a/.gitignore b/.gitignore
index 49eba67df9..903df63bf4 100755
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,7 @@ dev/*.env
user.json
.vercel
.env*.local
+
+# Claude
+.claude/*
+CLAUDE.md
\ No newline at end of file
diff --git a/apps/web/src/services/asset-inventory/components/ServiceAccountAutoSync.vue b/apps/web/src/services/asset-inventory/components/ServiceAccountAutoSync.vue
index 935566cda4..7c25b6b824 100644
--- a/apps/web/src/services/asset-inventory/components/ServiceAccountAutoSync.vue
+++ b/apps/web/src/services/asset-inventory/components/ServiceAccountAutoSync.vue
@@ -75,8 +75,10 @@ const handleClickSaveButton = async () => {
hours: serviceAccountPageFormState.scheduleHours,
},
sync_options: {
- skip_project_group: serviceAccountPageFormState.skipProjectGroup,
+ project_group_mapping_type: serviceAccountPageFormState.projectGroupMappingType,
single_workspace_id: serviceAccountPageFormState.selectedSingleWorkspace ?? undefined,
+ custom_depth: serviceAccountPageFormState.customDepth ?? 1,
+ workspace_mapping_type: serviceAccountPageFormState.workspaceMappingType,
},
plugin_options: serviceAccountPageFormState.additionalOptions,
});
diff --git a/apps/web/src/services/asset-inventory/components/ServiceAccountAutoSyncForm.vue b/apps/web/src/services/asset-inventory/components/ServiceAccountAutoSyncForm.vue
index 57439ec3a4..3a4b526940 100644
--- a/apps/web/src/services/asset-inventory/components/ServiceAccountAutoSyncForm.vue
+++ b/apps/web/src/services/asset-inventory/components/ServiceAccountAutoSyncForm.vue
@@ -40,9 +40,12 @@ const state = reactive({
additionalOptions: {},
isScheduleHoursValid: computed(() => ((state.isAutoSyncEnabled) ? !!serviceAccountPageFormState.scheduleHours.length : true)),
isAdditionalOptionsValid: false,
+ isMappingMethodValid: false,
isAllValid: computed(() => {
if (!state.isAutoSyncEnabled) return true;
- return state.isScheduleHoursValid && (serviceAccountPageStore.getters.autoSyncAdditionalOptionsSchema ? state.isAdditionalOptionsValid : true);
+ return state.isScheduleHoursValid
+ && (serviceAccountPageStore.getters.autoSyncAdditionalOptionsSchema ? state.isAdditionalOptionsValid : true)
+ && state.isMappingMethodValid;
}),
});
@@ -76,11 +79,18 @@ const handleAdditionalOptionsValidate = (isValid:boolean) => {
state.isAdditionalOptionsValid = state.isAutoSyncEnabled ? isValid : true;
};
+const handleMappingMethodValidate = (isValid:boolean) => {
+ state.isMappingMethodValid = state.isAutoSyncEnabled ? isValid : true;
+};
const handleChangeToggle = (e:boolean) => {
serviceAccountPageStore.$patch((_state) => {
_state.formState.isAutoSyncEnabled = e;
});
+ // Auto Sync가 비활성화되면 mapping method validation을 즉시 true로 설정
+ if (!e) {
+ state.isMappingMethodValid = true;
+ }
};
watch(() => state.additionalOptions, (additionalOptions) => {
@@ -91,7 +101,7 @@ watch(() => state.isAllValid, (isAllValid) => {
serviceAccountPageStore.$patch((_state) => {
_state.formState.isAutoSyncFormValid = isAllValid;
});
-});
+}, { immediate: true });
@@ -108,7 +118,9 @@ watch(() => state.isAllValid, (isAllValid) => {
-
+
(), {
mode: 'UPDATE',
});
+const emit = defineEmits<{(event: 'update:is-valid', value: boolean): void;
+}>();
+
const serviceAccountPageStore = useServiceAccountPageStore();
const serviceAccountPageState = serviceAccountPageStore.state;
const appContextStore = useAppContextStore();
const userWorkspaceStore = useUserWorkspaceStore();
const state = reactive({
- selectedWorkspace: computed(() => serviceAccountPageStore.formState.selectedSingleWorkspace ?? ''),
+ selectedWorkspace: computed({
+ get: () => serviceAccountPageStore.formState.selectedSingleWorkspace ?? '',
+ set: (value: string) => {
+ serviceAccountPageStore.$patch((_state) => {
+ _state.formState.selectedSingleWorkspace = value;
+ });
+ },
+ }),
organizationTerms: computed<{ name: string; group: string }>(() => CSP_ORGANIZATION_TERMS[serviceAccountPageState.selectedProvider] ?? {}),
workspaceMapping: WORKSPACE_MAPPING_TYPE.ALL_GROUPS_SINGLE_WORKSPACE as WorkspaceMappingType,
projectGroupMapping: PROJECT_GROUP_MAPPING_TYPE.SKIP as ProjectGroupMappingType,
@@ -93,21 +103,36 @@ const state = reactive({
selectedWorkspaceMappingOptionLabel: computed(() => {
const option = WORKSPACE_MAPPING_OPTIONS.find((opt) => opt.value === state.workspaceMapping);
if (!option) return '';
- return option.target ? `${option.name} → ${option.target}` : option.name;
+ return option.target ? `${option.name} ➔ ${option.target}` : option.name;
}),
selectedProjectGroupMappingOptionLabel: computed(() => {
const option = PROJECT_GROUP_MAPPING_OPTIONS.find((opt) => opt.value === state.projectGroupMapping);
if (!option) return '';
- return option.target ? `${option.name} → ${option.target}` : option.name;
+ return option.target ? `${option.name} ➔ ${option.target}` : option.name;
}),
customDepthMaxDepth: computed(() => CUSTOM_DEPTH_MAX_DEPTH[serviceAccountPageState.selectedProvider]),
customDepthInvalid: computed(() => state.customDepth !== null && (state.customDepth < 1 || state.customDepth > state.customDepthMaxDepth)),
+ // Validation states
+ isWorkspaceMappingValid: computed(() => {
+ if (state.workspaceMapping === WORKSPACE_MAPPING_TYPE.ALL_GROUPS_SINGLE_WORKSPACE) {
+ return !!state.selectedWorkspace;
+ }
+ return true;
+ }),
+ isProjectGroupMappingValid: computed(() => {
+ if (state.workspaceMapping === WORKSPACE_MAPPING_TYPE.CUSTOM_DEPTH_GROUPS) {
+ return !state.customDepthInvalid && state.customDepth !== null;
+ }
+ return true;
+ }),
+ isMappingMethodValid: computed(() => {
+ if (!state.isDomainForm) return true;
+ return state.isWorkspaceMappingValid && state.isProjectGroupMappingValid;
+ }),
});
const handleUpdateWorkspace = (workspaceId:string) => {
- serviceAccountPageStore.$patch((_state) => {
- _state.formState.selectedSingleWorkspace = workspaceId;
- });
+ state.selectedWorkspace = workspaceId;
};
watch(() => state.formData, (formData) => {
@@ -116,14 +141,40 @@ watch(() => state.formData, (formData) => {
});
});
+// Initialize from store formState (for CREATE mode) or originServiceAccountItem (for UPDATE mode)
watch(() => serviceAccountPageState.originServiceAccountItem, (item) => {
if (item) {
state.workspaceMapping = item.sync_options?.workspace_mapping_type ?? WORKSPACE_MAPPING_TYPE.ALL_GROUPS_SINGLE_WORKSPACE;
state.projectGroupMapping = item.sync_options?.project_group_mapping_type ?? PROJECT_GROUP_MAPPING_TYPE.NESTED_SUB_GROUPS;
- state.customDepth = item.sync_options?.custom_depth ?? 1;
+ state.customDepth = item.sync_options?.custom_depth ?? null;
+ }
+}, { immediate: true });
+
+// Sync workspaceMapping from formState on mount (for cases where store is already populated)
+watch(() => serviceAccountPageStore.formState.workspaceMappingType, (newType) => {
+ if (newType && newType !== state.workspaceMapping) {
+ state.workspaceMapping = newType;
}
}, { immediate: true });
+// Sync projectGroupMapping from formState on mount
+watch(() => serviceAccountPageStore.formState.projectGroupMappingType, (newType) => {
+ if (newType && newType !== state.projectGroupMapping) {
+ state.projectGroupMapping = newType;
+ }
+}, { immediate: true });
+
+// Sync customDepth from formState on mount
+watch(() => serviceAccountPageStore.formState.customDepth, (newDepth) => {
+ if (newDepth !== undefined && newDepth !== state.customDepth) {
+ state.customDepth = newDepth;
+ }
+}, { immediate: true });
+
+// Emit validation state whenever any validation-related state changes
+watch(() => state.isMappingMethodValid, (isValid) => {
+ emit('update:is-valid', isValid);
+}, { immediate: true });
@@ -205,19 +256,23 @@ watch(() => serviceAccountPageState.originServiceAccountItem, (item) => {
-
{{ state.selectedWorkspaceMappingOptionLabel }} ➔
-
+
+
+ All Groups ➔
{{ state.selectedWorkspaceItem?.name }}
-
-
- Multiple workspaces
-
+
+
+
+ Groups of {{ state.customDepth }} depth ➔ Multiple workspaces
+
+
+
+ {{ state.selectedWorkspaceMappingOptionLabel }}
+
diff --git a/apps/web/src/services/asset-inventory/components/ServiceAccountBaseInformationForm.vue b/apps/web/src/services/asset-inventory/components/ServiceAccountBaseInformationForm.vue
index 401b11e7f0..7ab5ec1cd1 100644
--- a/apps/web/src/services/asset-inventory/components/ServiceAccountBaseInformationForm.vue
+++ b/apps/web/src/services/asset-inventory/components/ServiceAccountBaseInformationForm.vue
@@ -12,11 +12,13 @@ import type { ListResponse } from '@/schema/_common/api-verbs/list';
import type { ServiceAccountListParameters } from '@/schema/identity/service-account/api-verbs/list';
import { ACCOUNT_TYPE } from '@/schema/identity/service-account/constant';
import type { ServiceAccountModel } from '@/schema/identity/service-account/model';
+import type { TrustedAccountListParameters } from '@/schema/identity/trusted-account/api-verbs/list';
import type { TrustedAccountModel } from '@/schema/identity/trusted-account/model';
import { i18n } from '@/translations';
import type { Tag } from '@/common/components/forms/tags-input-group/type';
import TagsInput from '@/common/components/inputs/TagsInput.vue';
+import ErrorHandler from '@/common/composables/error/errorHandler';
import { useFormValidator } from '@/common/composables/form-validator';
import ServiceAccountProjectForm from '@/services/asset-inventory/components/ServiceAccountProjectForm.vue';
@@ -48,7 +50,7 @@ const {
serviceAccountName: (val: string) => {
if (val?.length < 2) {
return i18n.t('IDENTITY.SERVICE_ACCOUNT.ADD.NAME_INVALID');
- } if (state.serviceAccountNames.includes(val)) {
+ } if (state.allAccountNames.includes(val)) {
if (state.originForm?.accountName === val) return true;
return i18n.t('IDENTITY.SERVICE_ACCOUNT.ADD.NAME_DUPLICATED');
}
@@ -68,6 +70,9 @@ const state = reactive({
}),
})),
serviceAccountNames: [] as string[],
+ trustedAccountNames: [] as string[],
+ allAccountNames: computed(() => [...state.serviceAccountNames, ...state.trustedAccountNames]),
+ isLoadingNames: false,
customSchemaForm: {},
isCustomSchemaFormValid: undefined,
tags: {},
@@ -80,10 +85,25 @@ const state = reactive({
projectForm: state.projectForm,
tags: state.tags,
})),
- isAllValid: computed(() => ((invalidState.serviceAccountName === false)
- && (state.isTrustedAccount ? true : state.isProjectFormValid)
- && state.isTagsValid
- && (isEmpty(props.schema) ? true : state.isCustomSchemaFormValid))),
+ isAllValid: computed(() => {
+ // Name validation: must be valid (false means valid in invalidState)
+ const isNameValid = invalidState.serviceAccountName === false;
+
+ // Account name must have a value (not empty)
+ const hasName = !!serviceAccountName.value;
+
+ // Project form validation (only for non-trusted accounts)
+ const isProjectValid = state.isTrustedAccount ? true : state.isProjectFormValid;
+
+ // Tags validation
+ const isTagsValidValue = state.isTagsValid;
+
+ // Custom schema validation (account_id etc.)
+ // When schema exists, validation must be true (not undefined or false)
+ const isSchemaValid = isEmpty(props.schema) ? true : state.isCustomSchemaFormValid === true;
+
+ return isNameValid && hasName && isProjectValid && isTagsValidValue && isSchemaValid;
+ }),
isChanged: false,
});
@@ -99,12 +119,43 @@ const initFormData = (originForm: Partial) => {
/* Api */
const listServiceAccounts = async () => {
- const { results } = await SpaceConnector.clientV2.identity.serviceAccount.list>({
- query: {
- only: ['name'],
- },
- });
- state.serviceAccountNames = (results ?? []).map((v) => v.name);
+ try {
+ const { results } = await SpaceConnector.clientV2.identity.serviceAccount.list>({
+ query: {
+ only: ['name'],
+ },
+ });
+ state.serviceAccountNames = (results ?? []).map((v) => v.name);
+ } catch (e) {
+ ErrorHandler.handleError(e);
+ state.serviceAccountNames = [];
+ }
+};
+
+const listTrustedAccounts = async () => {
+ try {
+ const { results } = await SpaceConnector.clientV2.identity.trustedAccount.list>({
+ query: {
+ only: ['name'],
+ },
+ });
+ state.trustedAccountNames = (results ?? []).map((v) => v.name);
+ } catch (e) {
+ ErrorHandler.handleError(e);
+ state.trustedAccountNames = [];
+ }
+};
+
+const fetchAllAccountNames = async () => {
+ state.isLoadingNames = true;
+ try {
+ await Promise.all([
+ listServiceAccounts(),
+ listTrustedAccounts(),
+ ]);
+ } finally {
+ state.isLoadingNames = false;
+ }
};
/* Event */
@@ -131,21 +182,23 @@ const handleChangeProjectForm = (projectForm) => {
/* Init */
(async () => {
- await listServiceAccounts();
+ await fetchAllAccountNames();
})();
/* Watcher */
watch([() => state.isAllValid, () => state.isChanged], ([isAllValid, isChanged]) => {
- if (props.mode === 'UPDATE' && !isChanged) {
+ // UPDATE 모드: validation + changed 모두 체크
+ if (props.mode === 'UPDATE') {
serviceAccountPageStore.$patch((_state) => {
- _state.formState.isBaseInformationFormValid = false;
+ _state.formState.isBaseInformationFormValid = isAllValid && isChanged;
});
return;
}
+ // CREATE 모드: validation만 체크
serviceAccountPageStore.$patch((_state) => {
_state.formState.isBaseInformationFormValid = isAllValid;
});
-});
+}, { immediate: true });
watch(() => state.formData, (formData) => {
serviceAccountPageStore.$patch((_state) => {
_state.formState.baseInformation = formData;
diff --git a/apps/web/src/services/asset-inventory/components/ServiceAccountCredentialsForm.vue b/apps/web/src/services/asset-inventory/components/ServiceAccountCredentialsForm.vue
index 5a8740e26b..0447103f0b 100644
--- a/apps/web/src/services/asset-inventory/components/ServiceAccountCredentialsForm.vue
+++ b/apps/web/src/services/asset-inventory/components/ServiceAccountCredentialsForm.vue
@@ -235,7 +235,7 @@ watch(() => formState.isAllValid, (isAllValid) => {
serviceAccountPageStore.$patch((_state) => {
_state.formState.isCredentialFormValid = isAllValid;
});
-});
+}, { immediate: true });
const schemaApiQueryHelper = new ApiQueryHelper();
const getSecretSchema = async (isTrustingSchema:boolean) => {
const trustedAccountRelatedSchemas = isTrustingSchema ? serviceAccountSchemaStore.getters.trustingSecretSchemaList : (storeState.trustedAccountSchema?.related_schemas ?? []);
diff --git a/apps/web/src/services/asset-inventory/components/WorkspaceDropdown.vue b/apps/web/src/services/asset-inventory/components/WorkspaceDropdown.vue
index 804f15c44f..252ff64cde 100644
--- a/apps/web/src/services/asset-inventory/components/WorkspaceDropdown.vue
+++ b/apps/web/src/services/asset-inventory/components/WorkspaceDropdown.vue
@@ -63,6 +63,11 @@ const state = reactive({
pageLimit: PAGE_SIZE,
});
+// Watch for prop changes to sync internal state
+watch(() => props.selected, (newSelected) => {
+ state.selected = newSelected;
+});
+
const fetchWorkspace = async (searchText?:string) => {
try {
state.loading = true;
diff --git a/apps/web/src/services/asset-inventory/pages/ServiceAccountAddPage.vue b/apps/web/src/services/asset-inventory/pages/ServiceAccountAddPage.vue
index 677ca1089c..66e6053282 100644
--- a/apps/web/src/services/asset-inventory/pages/ServiceAccountAddPage.vue
+++ b/apps/web/src/services/asset-inventory/pages/ServiceAccountAddPage.vue
@@ -133,8 +133,10 @@ const createAccount = async (): Promise => {
hours: serviceAccountPageFormState.scheduleHours,
},
sync_options: {
- skip_project_group: serviceAccountPageFormState.skipProjectGroup,
+ project_group_mapping_type: serviceAccountPageFormState.projectGroupMappingType,
single_workspace_id: serviceAccountPageFormState.selectedSingleWorkspace ?? undefined,
+ custom_depth: serviceAccountPageFormState.customDepth ?? 1,
+ workspace_mapping_type: serviceAccountPageFormState.workspaceMappingType,
},
plugin_options: serviceAccountPageFormState.additionalOptions,
});
diff --git a/apps/web/src/services/asset-inventory/stores/service-account-page-store.ts b/apps/web/src/services/asset-inventory/stores/service-account-page-store.ts
index 5b90ed06ca..1ee9d8b60a 100644
--- a/apps/web/src/services/asset-inventory/stores/service-account-page-store.ts
+++ b/apps/web/src/services/asset-inventory/stores/service-account-page-store.ts
@@ -92,13 +92,13 @@ export const useServiceAccountPageStore = defineStore('page-service-account', ()
const formState = reactive({
// baseInformation
- isBaseInformationFormValid: true,
+ isBaseInformationFormValid: false,
baseInformation: {},
// credential
- isCredentialFormValid: true,
+ isCredentialFormValid: false,
credential: {},
// autoSync
- isAutoSyncFormValid: true,
+ isAutoSyncFormValid: false,
isAutoSyncEnabled: false,
additionalOptions: {},
selectedSingleWorkspace: '',
@@ -135,8 +135,10 @@ export const useServiceAccountPageStore = defineStore('page-service-account', ()
state.selectedProvider = '';
state.originServiceAccountItem = {};
formState.baseInformation = {};
- formState.isBaseInformationFormValid = true;
- formState.isAutoSyncFormValid = true;
+ formState.isBaseInformationFormValid = false;
+ formState.isCredentialFormValid = false;
+ formState.credential = {};
+ formState.isAutoSyncFormValid = false;
formState.isAutoSyncEnabled = false;
formState.additionalOptions = {};
formState.selectedSingleWorkspace = '';
@@ -152,7 +154,6 @@ export const useServiceAccountPageStore = defineStore('page-service-account', ()
formState.isAutoSyncEnabled = state.originServiceAccountItem?.schedule?.state === 'ENABLED';
formState.scheduleHours = state.originServiceAccountItem?.schedule?.hours ?? [];
formState.selectedSingleWorkspace = state.originServiceAccountItem?.sync_options?.single_workspace_id ?? '';
- formState.skipProjectGroup = state.originServiceAccountItem?.sync_options?.skip_project_group ?? false;
formState.additionalOptions = state.originServiceAccountItem?.plugin_options ?? {};
formState.workspaceMappingType = state.originServiceAccountItem?.sync_options?.workspace_mapping_type ?? WORKSPACE_MAPPING_TYPE.ALL_GROUPS_SINGLE_WORKSPACE;
formState.projectGroupMappingType = state.originServiceAccountItem?.sync_options?.project_group_mapping_type ?? PROJECT_GROUP_MAPPING_TYPE.NESTED_SUB_GROUPS;