Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@ dev/*.env
user.json
.vercel
.env*.local

# Claude
.claude/*
CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copilot AI Nov 11, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The custom_depth field is sent with a default value of 1 when it's null or undefined (?? 1). However, this may not be appropriate for all workspace mapping types. When workspace_mapping_type is not CUSTOM_DEPTH_GROUPS, sending a custom_depth value could be misleading or incorrect.

Consider only including custom_depth in the payload when the workspace mapping type is CUSTOM_DEPTH_GROUPS, or document why a default of 1 is always appropriate.

Suggested change
custom_depth: serviceAccountPageFormState.customDepth ?? 1,
...(serviceAccountPageFormState.workspaceMappingType === 'CUSTOM_DEPTH_GROUPS'
? { custom_depth: serviceAccountPageFormState.customDepth ?? 1 }
: {}),

Copilot uses AI. Check for mistakes.
workspace_mapping_type: serviceAccountPageFormState.workspaceMappingType,
},
plugin_options: serviceAccountPageFormState.additionalOptions,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}),
});

Expand Down Expand Up @@ -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로 설정
Comment thread
seungyeoneeee marked this conversation as resolved.
if (!e) {
state.isMappingMethodValid = true;
}
};

watch(() => state.additionalOptions, (additionalOptions) => {
Expand All @@ -91,7 +101,7 @@ watch(() => state.isAllValid, (isAllValid) => {
serviceAccountPageStore.$patch((_state) => {
_state.formState.isAutoSyncFormValid = isAllValid;
});
});
}, { immediate: true });
</script>

<template>
Expand All @@ -108,7 +118,9 @@ watch(() => state.isAllValid, (isAllValid) => {
</p>
</div>
<div v-if="state.isAutoSyncEnabled">
<service-account-auto-sync-mapping-method mode="UPDATE" />
<service-account-auto-sync-mapping-method mode="UPDATE"
@update:is-valid="handleMappingMethodValidate"
/>
<div v-if="serviceAccountPageStore.getters.autoSyncAdditionalOptionsSchema">
<p-field-title label="Additional Options"
size="lg"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,23 @@ const props = withDefaults(defineProps<{mode:'UPDATE'|'READ'}>(), {
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,
Expand Down Expand Up @@ -93,21 +103,36 @@ const state = reactive({
selectedWorkspaceMappingOptionLabel: computed<string>(() => {
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<string>(() => {
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<number>(() => CUSTOM_DEPTH_MAX_DEPTH[serviceAccountPageState.selectedProvider]),
customDepthInvalid: computed<boolean>(() => state.customDepth !== null && (state.customDepth < 1 || state.customDepth > state.customDepthMaxDepth)),
// Validation states
isWorkspaceMappingValid: computed<boolean>(() => {
if (state.workspaceMapping === WORKSPACE_MAPPING_TYPE.ALL_GROUPS_SINGLE_WORKSPACE) {
return !!state.selectedWorkspace;
}
return true;
}),
isProjectGroupMappingValid: computed<boolean>(() => {
if (state.workspaceMapping === WORKSPACE_MAPPING_TYPE.CUSTOM_DEPTH_GROUPS) {
return !state.customDepthInvalid && state.customDepth !== null;
}
return true;
}),
isMappingMethodValid: computed<boolean>(() => {
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) => {
Expand All @@ -116,14 +141,58 @@ 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 });

Comment thread
seungyeoneeee marked this conversation as resolved.
// Emit validation state whenever any validation-related state changes
watch(() => state.isMappingMethodValid, (isValid) => {
emit('update:is-valid', isValid);
}, { immediate: true });

// Watch individual validation dependencies to ensure reactivity
watch(() => state.selectedWorkspace, () => {
emit('update:is-valid', state.isMappingMethodValid);
});

watch(() => state.workspaceMapping, () => {
emit('update:is-valid', state.isMappingMethodValid);
});

watch(() => state.customDepth, () => {
emit('update:is-valid', state.isMappingMethodValid);
});

watch(() => state.projectGroupMapping, () => {
emit('update:is-valid', state.isMappingMethodValid);
});

Comment thread
seungyeoneeee marked this conversation as resolved.
Outdated
</script>

<template>
Expand Down Expand Up @@ -205,19 +274,23 @@ watch(() => serviceAccountPageState.originServiceAccountItem, (item) => {
</div>
<div v-else>
<div class="flex gap-1 flex-wrap items-center">
<span>{{ state.selectedWorkspaceMappingOptionLabel }} ➔</span>
<div v-if="state.selectedWorkspace"
class="flex gap-1"
>
<!-- All Groups → workspace name -->
<template v-if="state.workspaceMapping === WORKSPACE_MAPPING_TYPE.ALL_GROUPS_SINGLE_WORKSPACE && state.selectedWorkspace">
<span>All Groups ➔</span>
<workspace-logo-icon :text="state.selectedWorkspaceItem?.name || ''"
:theme="state.selectedWorkspaceItem?.tags?.theme"
size="xs"
/>
<span class="workspace-name">{{ state.selectedWorkspaceItem?.name }}</span>
</div>
<div v-else>
Multiple workspaces
</div>
</template>
<!-- Custom Depth Groups → Multiple workspaces -->
<template v-else-if="state.workspaceMapping === WORKSPACE_MAPPING_TYPE.CUSTOM_DEPTH_GROUPS && state.customDepth">
<span>Groups of {{ state.customDepth }} depth ➔ Multiple workspaces</span>
</template>
<!-- Other cases: use label as is -->
<template v-else>
<span>{{ state.selectedWorkspaceMappingOptionLabel }}</span>
</template>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
}
Expand All @@ -68,6 +70,9 @@ const state = reactive({
}),
})),
serviceAccountNames: [] as string[],
trustedAccountNames: [] as string[],
allAccountNames: computed<string[]>(() => [...state.serviceAccountNames, ...state.trustedAccountNames]),
isLoadingNames: false,
customSchemaForm: {},
isCustomSchemaFormValid: undefined,
tags: {},
Expand All @@ -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,
});

Expand All @@ -99,12 +119,43 @@ const initFormData = (originForm: Partial<BaseInformationForm>) => {

/* Api */
const listServiceAccounts = async () => {
const { results } = await SpaceConnector.clientV2.identity.serviceAccount.list<ServiceAccountListParameters, ListResponse<ServiceAccountModel>>({
query: {
only: ['name'],
},
});
state.serviceAccountNames = (results ?? []).map((v) => v.name);
try {
const { results } = await SpaceConnector.clientV2.identity.serviceAccount.list<ServiceAccountListParameters, ListResponse<ServiceAccountModel>>({
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<TrustedAccountListParameters, ListResponse<TrustedAccountModel>>({
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 */
Expand All @@ -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 모두 체크
Comment thread
seungyeoneeee marked this conversation as resolved.
if (props.mode === 'UPDATE') {
serviceAccountPageStore.$patch((_state) => {
_state.formState.isBaseInformationFormValid = false;
_state.formState.isBaseInformationFormValid = isAllValid && isChanged;
});
return;
}
// CREATE 모드: validation만 체크
Comment thread
seungyeoneeee marked this conversation as resolved.
serviceAccountPageStore.$patch((_state) => {
_state.formState.isBaseInformationFormValid = isAllValid;
});
});
}, { immediate: true });
watch(() => state.formData, (formData) => {
serviceAccountPageStore.$patch((_state) => {
_state.formState.baseInformation = formData;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? []);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading