diff --git a/KeeperSdk/src/folders/addFolder.ts b/KeeperSdk/src/folders/addFolder.ts index 57f56410..c90b724c 100644 --- a/KeeperSdk/src/folders/addFolder.ts +++ b/KeeperSdk/src/folders/addFolder.ts @@ -12,7 +12,7 @@ import { InMemoryStorage } from '../storage/InMemoryStorage' import { isBoolean, KeeperSdkError, extractErrorMessage } from '../utils' import { listFolder } from './listFolder' import { tryResolvePath, splitPathComponents, type VaultFolderSession } from './changeDirectory' -import { FolderKind, FolderResultStatus, ParentFolderKind } from './folderHelpers' +import { FolderKind, FolderResultStatus, ParentFolderKind, validateFolderName } from './folderHelpers' type NewFolderKind = FolderKind @@ -134,6 +134,7 @@ export async function addFolder(auth: Auth, storage: InMemoryStorage, input: Add if (!name) { throw new KeeperSdkError('Folder name cannot be empty.', 'folder_name_required') } + validateFolderName(name) const parentUid = input.parentUid === undefined || input.parentUid === '' ? null : input.parentUid const parent = resolveParentContext(storage, parentUid) diff --git a/KeeperSdk/src/folders/folderHelpers.ts b/KeeperSdk/src/folders/folderHelpers.ts index c3680d2f..618ad342 100644 --- a/KeeperSdk/src/folders/folderHelpers.ts +++ b/KeeperSdk/src/folders/folderHelpers.ts @@ -1,6 +1,14 @@ import type { DSharedFolder, DSharedFolderFolder, DUserFolder } from '@keeper-security/keeperapi' import type { InMemoryStorage } from '../storage/InMemoryStorage' -import { escapeRegExp } from '../utils' +import { escapeRegExp, KeeperSdkError } from '../utils' + +export const MAX_FOLDER_NAME_LENGTH = 255 + +export function validateFolderName(name: string): void { + if (name.length > MAX_FOLDER_NAME_LENGTH) { + throw new KeeperSdkError(`Folder name exceeds ${MAX_FOLDER_NAME_LENGTH} characters.`, 'folder_name_too_long') + } +} export enum FolderKind { UserFolder = 'user_folder', diff --git a/KeeperSdk/src/folders/updateFolder.ts b/KeeperSdk/src/folders/updateFolder.ts index d5daae71..d444c56f 100644 --- a/KeeperSdk/src/folders/updateFolder.ts +++ b/KeeperSdk/src/folders/updateFolder.ts @@ -9,7 +9,7 @@ import type { DSharedFolder, DSharedFolderFolder, DUserFolder } from '@keeper-se import { InMemoryStorage } from '../storage/InMemoryStorage' import { anyIsBoolean, isBoolean, isObject, KeeperSdkError, extractErrorMessage } from '../utils' import { resolveSingleFolder, type VaultFolderSession } from './changeDirectory' -import { FolderKind, FolderResultStatus } from './folderHelpers' +import { FolderKind, FolderResultStatus, validateFolderName } from './folderHelpers' export type UpdateFolderInput = { folderUid: string @@ -79,6 +79,7 @@ export async function updateFolder( } const trimmedName = input.folderName?.trim() || '' + if (trimmedName) validateFolderName(trimmedName) const hasPermissionUpdate = anyIsBoolean( input.manageUsers, diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index e1c5de04..3e1e5118 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -311,6 +311,15 @@ export { DeleteUserStatus, } from './users/deleteUser' +export { + actionUsers, + formatUserActionResult, + renderUserActionAsciiTable, + UserAction, + UserActionStatus, + UserActionSkipReason, +} from './users/actionUser' + export type { UserColumnInput, ListUsersOptions, @@ -336,11 +345,23 @@ export type { DeleteUserItemResult, DeleteUserResult, FormattedDeleteUserTable, + UserActionInput, + UserActionItemResult, + UserActionResult, + FormattedUserActionTable, + AliasUserInput, + AliasUserResult, + FormattedUserStatus, } from './users/userTypes' -export { UserManager } from './users/UserManager' +export { EnterpriseUserStatus } from './users/userTypes' -export { Auth, KeeperEnvironment, syncDown, Authentication } from '@keeper-security/keeperapi' +export { + aliasUser, + AliasOperation, +} from './users/aliasUser' + +export { UserManager } from './users/UserManager' export type { DRecord, @@ -357,4 +378,4 @@ export type { AuthUI3, KeeperError, LoginError, -} from '@keeper-security/keeperapi' +} from '@keeper-security/keeperapi' \ No newline at end of file diff --git a/KeeperSdk/src/users/UserManager.ts b/KeeperSdk/src/users/UserManager.ts index 4b5eea62..f7defb41 100644 --- a/KeeperSdk/src/users/UserManager.ts +++ b/KeeperSdk/src/users/UserManager.ts @@ -3,6 +3,8 @@ import { KeeperSdkError, ResultCodes } from '../utils' import { addUsers, formatAddUserResult, renderAddUserAsciiTable } from './addUser' import { updateUsers, formatUpdateUserResult, renderUpdateUserAsciiTable } from './updateUser' import { deleteUsers, formatDeleteUserResult, renderDeleteUserAsciiTable } from './deleteUser' +import { actionUsers, formatUserActionResult, renderUserActionAsciiTable } from './actionUser' +import { aliasUser } from './aliasUser' import { listUsers, formatUsersTable, renderUsersAsciiTable } from './listUsers' import { viewUser, formatUserView, userViewTable } from './viewUser' import type { @@ -24,6 +26,11 @@ import type { DeleteUserInput, DeleteUserResult, FormattedDeleteUserTable, + UserActionInput, + UserActionResult, + FormattedUserActionTable, + AliasUserInput, + AliasUserResult, } from './userTypes' export type AuthProvider = () => Auth @@ -95,6 +102,22 @@ export class UserManager { return renderDeleteUserAsciiTable(table) } + public async actionUsers(input: UserActionInput): Promise { + return actionUsers(this.requireAuth(), input) + } + + public formatUserActionResult(result: UserActionResult): FormattedUserActionTable { + return formatUserActionResult(result) + } + + public renderUserActionAsciiTable(table: FormattedUserActionTable): string { + return renderUserActionAsciiTable(table) + } + + public async aliasUser(input: AliasUserInput): Promise { + return aliasUser(this.requireAuth(), input) + } + private requireAuth(): Auth { const auth = this.authProvider() if (!auth) { @@ -102,4 +125,4 @@ export class UserManager { } return auth } -} +} \ No newline at end of file diff --git a/KeeperSdk/src/users/actionUser.ts b/KeeperSdk/src/users/actionUser.ts new file mode 100644 index 00000000..bacc22ae --- /dev/null +++ b/KeeperSdk/src/users/actionUser.ts @@ -0,0 +1,351 @@ +import { + Enterprise, + enterpriseUsersLockMessage, + type Auth, + type KeeperResponse, + type RestCommand, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, isNumber, KeeperSdkError, logger, ResultCodes } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseUser, +} from '../teams/enterpriseData' +import { + EnterpriseUserStatus, + normalizeEmailInputs, + UserAction, + UserActionStatus, + UserActionSkipReason, + type UserActionInput, + type UserActionItemResult, + type UserActionResult, + type FormattedUserActionTable, +} from './userTypes' + +export { UserAction, UserActionStatus, UserActionSkipReason } +export type { UserActionInput, UserActionItemResult, UserActionResult, FormattedUserActionTable } + +const EXPIRE_PASSWORD_COMMAND = 'set_master_password_expire' +const ALL_USERS_SENTINEL = '@all' +const ACTIONS_NOT_SUPPORTING_ALL = new Set([UserAction.Lock, UserAction.ExpirePassword]) +const LOCK_UNLOCK_BATCH_SIZE = 1000 +const SELF_ACTION_MESSAGE = 'This operation cannot be done on yourself.' + +const ACTION_USER_INCLUDES: EnterpriseDataInclude[] = [EnterpriseDataInclude.Users] + +const USER_ACTION_TABLE_HEADERS = ['#', 'Status', 'Email', 'User ID', 'Detail'] + +type ExpirePasswordPayload = { + email: string +} + +type ActionUserTarget = + | { kind: 'user'; user: EnterpriseUser } + | { kind: 'not_found'; identifier: string } + +export async function actionUsers(auth: Auth, input: UserActionInput): Promise { + const identifiers = normalizeEmailInputs(input.emails) + + if (identifiers.length === 0) { + throw new KeeperSdkError('No users provided.', ResultCodes.NO_USERS_TO_ACTION) + } + + const isAll = identifiers.includes(ALL_USERS_SENTINEL) + + if (isAll && ACTIONS_NOT_SUPPORTING_ALL.has(input.action)) { + throw new KeeperSdkError( + `The '${input.action}' action does not support @all.`, + ResultCodes.USER_ACTION_ALL_NOT_SUPPORTED + ) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const response = await enterpriseData.getData(ACTION_USER_INCLUDES) + const allUsers = response.users || [] + + const targets: ActionUserTarget[] = isAll + ? allUsers.map((user) => ({ kind: 'user' as const, user })) + : resolveActionUserTargets(allUsers, identifiers) + const callerEnterpriseUserId = resolveCallerEnterpriseUserId(auth, allUsers) + + const items: UserActionItemResult[] = [] + const batchTargets = new Map() + + for (const target of targets) { + if (target.kind === 'not_found') { + items.push({ + username: target.identifier, + status: UserActionStatus.Failed, + message: `User "${target.identifier}" does not exist.`, + }) + continue + } + + const user = target.user + const item: UserActionItemResult = { + username: user.username, + enterpriseUserId: user.enterprise_user_id, + status: UserActionStatus.Failed, + } + + if (user.status !== EnterpriseUserStatus.Active) { + const masked = maskEmail(user.username) + logger.warn(`User "${masked}" is not active and will be skipped.`) + item.status = UserActionStatus.Skipped + item.skipReason = UserActionSkipReason.Inactive + items.push(item) + continue + } + + items.push(item) + + if (input.action === UserAction.Lock || input.action === UserAction.Unlock) { + if (callerEnterpriseUserId !== null && user.enterprise_user_id === callerEnterpriseUserId) { + logger.warn(`User "${maskEmail(user.username)}" is the logged-in user and will be skipped for lock/unlock.`) + item.status = UserActionStatus.Failed + item.message = SELF_ACTION_MESSAGE + continue + } + batchTargets.set(user.enterprise_user_id, item) + continue + } + + try { + await sendExpirePassword(auth, user.username) + item.status = UserActionStatus.Success + } catch (err) { + item.message = extractErrorMessage(err) + } + } + + if (input.action === UserAction.Lock || input.action === UserAction.Unlock) { + await sendBatchLockUnlock(auth, input.action, batchTargets) + } + + return finalizeResult(items) +} + +async function sendBatchLockUnlock( + auth: Auth, + action: UserAction.Lock | UserAction.Unlock, + batchTargets: Map +): Promise { + if (batchTargets.size === 0) { + return + } + + const enterpriseUserIds = [...batchTargets.keys()] + + for (const chunk of chunkArray(enterpriseUserIds, LOCK_UNLOCK_BATCH_SIZE)) { + const response = await auth.executeRest( + enterpriseUsersLockMessage({ + lockEnterpriseUserIds: action === UserAction.Lock ? chunk : [], + disableEnterpriseUserIds: [], + unlockEnterpriseUserIds: action === UserAction.Unlock ? chunk : [], + deleteIfPending: false, + }) + ) + + const seen = new Set() + for (const lockResponse of response.response || []) { + const enterpriseUserId = toEnterpriseUserId(lockResponse.enterpriseUserId) + if (enterpriseUserId === null) { + continue + } + + seen.add(enterpriseUserId) + const item = batchTargets.get(enterpriseUserId) + if (!item) { + continue + } + + applyLockUserResponse(item, lockResponse, action) + } + + for (const enterpriseUserId of chunk) { + if (seen.has(enterpriseUserId)) { + continue + } + const item = batchTargets.get(enterpriseUserId) + if (!item) { + continue + } + item.status = UserActionStatus.Failed + item.message = 'No response received for user.' + } + } +} + +function applyLockUserResponse( + item: UserActionItemResult, + lockResponse: Enterprise.ILockUserResponse, + action: UserAction.Lock | UserAction.Unlock +): void { + const status = lockResponse.status ?? Enterprise.UserLockStatus.UNKNOWN_LOCK_STATUS + const errorMessage = lockResponse.errorMessage || '' + + switch (status) { + case Enterprise.UserLockStatus.LOCKED: + if (action === UserAction.Lock) { + item.status = UserActionStatus.Success + } else { + item.status = UserActionStatus.Failed + item.message = errorMessage || 'User is locked.' + } + break + case Enterprise.UserLockStatus.UNLOCKED: + if (action === UserAction.Unlock) { + item.status = UserActionStatus.Success + } else { + item.status = UserActionStatus.Failed + item.message = errorMessage || 'User is unlocked.' + } + break + case Enterprise.UserLockStatus.CANT_BE_PENDING: + item.status = UserActionStatus.Skipped + item.skipReason = UserActionSkipReason.Pending + item.message = errorMessage || 'Pending user was not modified.' + break + case Enterprise.UserLockStatus.DELETED: + item.status = UserActionStatus.Success + item.message = errorMessage || 'Pending user was deleted.' + break + case Enterprise.UserLockStatus.DISABLED: + item.status = UserActionStatus.Failed + item.message = errorMessage || 'User was disabled.' + break + default: + item.status = UserActionStatus.Failed + item.message = errorMessage || 'Lock operation failed.' + } +} + +async function sendExpirePassword(auth: Auth, email: string): Promise { + const command: RestCommand = { + baseRequest: { command: EXPIRE_PASSWORD_COMMAND }, + request: { email }, + authorization: {}, + } + const response = await auth.executeRestCommand(command) + const result = (response.result || '').toLowerCase() + if (result && result !== 'success') { + throw new KeeperSdkError( + response.message || + response.result_code || + `${EXPIRE_PASSWORD_COMMAND} failed for "${maskEmail(email)}"`, + response.result_code || ResultCodes.USER_ACTION_FAILED + ) + } +} + +function chunkArray(values: T[], size: number): T[][] { + const chunks: T[][] = [] + for (let index = 0; index < values.length; index += size) { + chunks.push(values.slice(index, index + size)) + } + return chunks +} + +function toEnterpriseUserId(value: unknown): number | null { + if (value == null) { + return null + } + const numericId = + typeof value === 'object' && 'toNumber' in value + ? (value as { toNumber(): number }).toNumber() + : Number(value) + return isNumber(numericId) && numericId > 0 ? numericId : null +} + +function maskEmail(email: string): string { + return email.includes('@') ? `***@${email.split('@')[1]}` : '***' +} + +function resolveActionUserTargets(allUsers: EnterpriseUser[], identifiers: string[]): ActionUserTarget[] { + const byEmail = new Map() + const byId = new Map() + for (const user of allUsers) { + if (user.username) byEmail.set(user.username.toLowerCase(), user) + byId.set(user.enterprise_user_id, user) + } + + const result: ActionUserTarget[] = [] + const seen = new Set() + + for (const identifier of identifiers) { + const trimmed = identifier.trim() + const numericId = Number(trimmed) + let user: EnterpriseUser | undefined + + if (Number.isInteger(numericId)) { + user = byId.get(numericId) + } + if (!user) { + user = byEmail.get(trimmed.toLowerCase()) + } + if (!user) { + result.push({ kind: 'not_found', identifier: trimmed }) + continue + } + if (!seen.has(user.enterprise_user_id)) { + seen.add(user.enterprise_user_id) + result.push({ kind: 'user', user }) + } + } + + return result +} + +function resolveCallerEnterpriseUserId(auth: Auth, allUsers: EnterpriseUser[]): number | null { + const username = auth.username.trim().toLowerCase() + if (!username) { + return null + } + const match = allUsers.find((user) => user.username?.trim().toLowerCase() === username) + return match?.enterprise_user_id ?? null +} + +function finalizeResult(items: UserActionItemResult[]): UserActionResult { + let succeeded = 0, skipped = 0, failed = 0 + for (const item of items) { + if (item.status === UserActionStatus.Success) succeeded++ + else if (item.status === UserActionStatus.Skipped) skipped++ + else failed++ + } + return { success: failed === 0 && succeeded > 0, items, succeeded, skipped, failed } +} + +export function formatUserActionResult(result: UserActionResult): FormattedUserActionTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.username, + item.enterpriseUserId != null ? String(item.enterpriseUserId) : '', + item.message || item.skipReason || '', + ]) + return { + headers: [...USER_ACTION_TABLE_HEADERS], + rows, + summary: `Succeeded: ${result.succeeded} Skipped: ${result.skipped} Failed: ${result.failed}`, + } +} + +export function renderUserActionAsciiTable(table: FormattedUserActionTable): string { + const { headers, rows } = table + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || '').length)) + ) + const padCell = (cell: string, columnIndex: number): string => + cell.padEnd(widths[columnIndex]) + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') + + const lines: string[] = [ + formatRow(headers), + formatRow(widths.map((w) => '-'.repeat(w))), + ...rows.map(formatRow), + table.summary, + ] + return lines.join('\n') +} diff --git a/KeeperSdk/src/users/addUser.ts b/KeeperSdk/src/users/addUser.ts index 5ee83637..f0816063 100644 --- a/KeeperSdk/src/users/addUser.ts +++ b/KeeperSdk/src/users/addUser.ts @@ -17,7 +17,9 @@ import { resolveParentNode, } from '../teams/teamUtils' import { + EnterpriseUserStatus, normalizeEmailInputs, + validateUserProfileFields, AddUserStatus, AddUserSkipReason, type AddUserInput, @@ -32,6 +34,7 @@ export type { AddUserInput, AddUserItemResult, AddUserResult, FormatAddUserResul const USER_ADD_COMMAND = 'enterprise_user_add' const ALLOCATE_IDS_COMMAND = 'enterprise_allocate_ids' +const REINVITE_COMMAND = 'resend_enterprise_invite' const ADD_USER_INCLUDES: EnterpriseDataInclude[] = [ EnterpriseDataInclude.Nodes, @@ -53,13 +56,12 @@ type AllocateIdsPayload = { number_requested: number } -type AllocateIdsResponse = KeeperResponse & { - base_id: number - number_allocated: number +type ReinvitePayload = { + enterprise_user_id: number } -type UserAddResponse = KeeperResponse & { - verification_code?: string +type AllocateIdsResponse = KeeperResponse & { + base_id: number } export async function addUsers(auth: Auth, input: AddUserInput): Promise { @@ -108,35 +110,48 @@ export async function addUsers(auth: Auth, input: AddUserInput): Promise { authorization: {}, } const response = await auth.executeRestCommand(command) - const result = (response.result || '').toLowerCase() - if (result && result !== 'success') { - throw new KeeperSdkError( - response.message || response.result_code || 'enterprise_allocate_ids failed', - response.result_code || ResultCodes.USER_ADD_FAILED - ) - } + assertCommandSuccess(response, 'enterprise_allocate_ids failed') if (!isNumber(response.base_id) || response.base_id === 0) { throw new KeeperSdkError('Failed to allocate enterprise user ID.', ResultCodes.USER_ADD_FAILED) } @@ -187,18 +190,30 @@ async function allocateEnterpriseId(auth: Auth): Promise { } async function sendUserAdd(auth: Auth, payload: UserAddPayload): Promise { - const command: RestCommand = { + const command: RestCommand = { baseRequest: { command: USER_ADD_COMMAND }, request: payload, authorization: {}, } const response = await auth.executeRestCommand(command) + assertCommandSuccess(response, `${USER_ADD_COMMAND} failed for "${payload.enterprise_user_username}"`) +} + +async function sendReinvite(auth: Auth, enterpriseUserId: number): Promise { + const command: RestCommand = { + baseRequest: { command: REINVITE_COMMAND }, + request: { enterprise_user_id: enterpriseUserId }, + authorization: {}, + } + const response = await auth.executeRestCommand(command) + assertCommandSuccess(response, `${REINVITE_COMMAND} failed for user_id=${enterpriseUserId}`) +} + +function assertCommandSuccess(response: KeeperResponse, fallbackMessage: string): void { const result = (response.result || '').toLowerCase() if (result && result !== 'success') { throw new KeeperSdkError( - response.message || - response.result_code || - `${USER_ADD_COMMAND} failed for "${payload.enterprise_user_username}"`, + response.message || response.result_code || fallbackMessage, response.result_code || ResultCodes.USER_ADD_FAILED ) } @@ -217,13 +232,14 @@ function finalizeResult( parentNodeId: number, parentNodeName: string ): AddUserResult { - let added = 0, skipped = 0, failed = 0 + let added = 0, reinvited = 0, skipped = 0, failed = 0 for (const item of items) { if (item.status === AddUserStatus.Added) added++ + else if (item.status === AddUserStatus.Reinvited) reinvited++ else if (item.status === AddUserStatus.Skipped) skipped++ else failed++ } - return { success: failed === 0 && added > 0, parentNodeId, parentNodeName, items, added, skipped, failed } + return { success: failed === 0 && (added > 0 || reinvited > 0), parentNodeId, parentNodeName, items, added, reinvited, skipped, failed } } export function formatAddUserResult( @@ -246,7 +262,7 @@ export function formatAddUserResult( headers: [...USER_TABLE_HEADERS], rows, parentNodeName: result.parentNodeName, - summary: `Added: ${result.added} Skipped: ${result.skipped} Failed: ${result.failed}`, + summary: `Added: ${result.added} Reinvited: ${result.reinvited} Skipped: ${result.skipped} Failed: ${result.failed}`, } } @@ -256,15 +272,16 @@ export function renderAddUserAsciiTable(table: FormattedAddUserTable): string { Math.max(header.length, ...rows.map((row) => (row[index] || '').length)) ) const padCell = (cell: string, columnIndex: number): string => - cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) + cell.padEnd(widths[columnIndex]) const formatRow = (cells: string[]): string => cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') - const lines: string[] = [] - lines.push(`Parent: ${table.parentNodeName}`) - lines.push(formatRow(headers)) - lines.push(formatRow(widths.map((width) => '-'.repeat(width)))) - for (const row of rows) lines.push(formatRow(row)) - lines.push(table.summary) + const lines: string[] = [ + `Parent: ${table.parentNodeName}`, + formatRow(headers), + formatRow(widths.map((w) => '-'.repeat(w))), + ...rows.map(formatRow), + table.summary, + ] return lines.join('\n') } diff --git a/KeeperSdk/src/users/aliasUser.ts b/KeeperSdk/src/users/aliasUser.ts new file mode 100644 index 00000000..90af0aa0 --- /dev/null +++ b/KeeperSdk/src/users/aliasUser.ts @@ -0,0 +1,122 @@ +import { + enterpriseUserAddAliasMessage, + enterpriseUserDeleteAliasMessage, + enterpriseUserSetPrimaryAliasMessage, + type Auth, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, logger, ResultCodes } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseUser, + type EnterpriseUserAliasLink, +} from '../teams/enterpriseData' +import { + resolveExistingUsers, + AliasOperation, + type AliasUserInput, + type AliasUserResult, +} from './userTypes' + +export { AliasOperation } +export type { AliasUserInput, AliasUserResult } + +const ALIAS_USER_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Users, + EnterpriseDataInclude.UserAliases, +] + +export async function aliasUser(auth: Auth, input: AliasUserInput): Promise { + const alias = input.alias.trim().toLowerCase() + + if (!alias) { + throw new KeeperSdkError('Alias is required.', ResultCodes.USER_ALIAS_FAILED) + } + + const identifier = input.email.trim() + if (!identifier) { + throw new KeeperSdkError('User email or ID is required.', ResultCodes.USER_NOT_FOUND) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const response = await enterpriseData.getData(ALIAS_USER_INCLUDES) + + const [user] = resolveExistingUsers(response.users || [], [identifier]) + const existingAliases = (response.user_aliases || []).filter( + (a) => a.enterprise_user_id === user.enterprise_user_id + ) + + if (input.operation === AliasOperation.Add) { + return addAlias(auth, user, existingAliases, alias) + } + return removeAlias(auth, user, existingAliases, alias) +} + +async function addAlias( + auth: Auth, + user: EnterpriseUser, + existingAliases: EnterpriseUserAliasLink[], + alias: string +): Promise { + const base = { username: user.username, enterpriseUserId: user.enterprise_user_id, alias, operation: AliasOperation.Add } + + if (user.username === alias) { + logger.info(`Alias "${alias}" is already the primary email for this user.`) + return { ...base, success: true, detail: 'Alias is already the primary email.' } + } + + const alreadyExists = existingAliases.some((a) => a.username === alias) + + try { + if (alreadyExists) { + await sendSetPrimaryAlias(auth, user.enterprise_user_id, alias) + return { ...base, success: true, detail: 'Alias promoted to primary.' } + } + + await sendAddAlias(auth, user.enterprise_user_id, alias) + return { ...base, success: true, detail: 'Alias added.' } + } catch (err) { + throw new KeeperSdkError(extractErrorMessage(err), ResultCodes.USER_ALIAS_FAILED) + } +} + +async function removeAlias( + auth: Auth, + user: EnterpriseUser, + existingAliases: EnterpriseUserAliasLink[], + alias: string +): Promise { + const base = { username: user.username, enterpriseUserId: user.enterprise_user_id, alias, operation: AliasOperation.Remove } + + const exists = alias === user.username || existingAliases.some((a) => a.username === alias) + + if (!exists) { + logger.info(`Alias "${alias}" does not exist for user.`) + return { ...base, success: false, detail: 'Alias does not exist.' } + } + try { + await sendDeleteAlias(auth, user.enterprise_user_id, alias) + return { ...base, success: true, detail: 'Alias removed.' } + } catch (err) { + throw new KeeperSdkError(extractErrorMessage(err), ResultCodes.USER_ALIAS_FAILED) + } +} + +async function sendAddAlias(auth: Auth, enterpriseUserId: number, alias: string): Promise { + const response = await auth.executeRest( + enterpriseUserAddAliasMessage({ enterpriseUserId, alias, primary: true }) + ) + for (const status of response.status || []) { + if (status.status && status.status !== 'success') { + throw new KeeperSdkError(`Add alias failed: ${status.status}`, ResultCodes.USER_ALIAS_FAILED) + } + } +} + +async function sendSetPrimaryAlias(auth: Auth, enterpriseUserId: number, alias: string): Promise { + await auth.executeRestAction(enterpriseUserSetPrimaryAliasMessage({ enterpriseUserId, alias })) +} + +async function sendDeleteAlias(auth: Auth, enterpriseUserId: number, alias: string): Promise { + await auth.executeRestAction(enterpriseUserDeleteAliasMessage({ enterpriseUserId, alias })) +} diff --git a/KeeperSdk/src/users/deleteUser.ts b/KeeperSdk/src/users/deleteUser.ts index 89900d38..327dd94e 100644 --- a/KeeperSdk/src/users/deleteUser.ts +++ b/KeeperSdk/src/users/deleteUser.ts @@ -44,21 +44,19 @@ export async function deleteUsers(auth: Auth, input: DeleteUserInput): Promise (row[index] || '').length)) ) const padCell = (cell: string, columnIndex: number): string => - cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) + cell.padEnd(widths[columnIndex]) const formatRow = (cells: string[]): string => cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') diff --git a/KeeperSdk/src/users/listUsers.ts b/KeeperSdk/src/users/listUsers.ts index d81139bc..6ec295cf 100644 --- a/KeeperSdk/src/users/listUsers.ts +++ b/KeeperSdk/src/users/listUsers.ts @@ -155,7 +155,7 @@ export function renderUsersAsciiTable( } } - const padCell = (cell: string, ci: number): string => cell + ' '.repeat(columnWidths[ci] - cell.length) + const padCell = (cell: string, ci: number): string => cell.padEnd(columnWidths[ci]) const formatPhysicalRow = (cells: string[]): string => cells.map((cell, ci) => padCell(cell, ci)).join(' ') diff --git a/KeeperSdk/src/users/updateUser.ts b/KeeperSdk/src/users/updateUser.ts index 3ba27a5c..4482d8f6 100644 --- a/KeeperSdk/src/users/updateUser.ts +++ b/KeeperSdk/src/users/updateUser.ts @@ -19,6 +19,7 @@ import { } from '../teams/teamUtils' import { normalizeEmailInputs, + validateUserProfileFields, resolveExistingUsers, UpdateUserStatus, type UpdateUserInput, @@ -111,11 +112,18 @@ export async function updateUsers(auth: Auth, input: UpdateUserInput): Promise (row[index] || '').length)) ) const padCell = (cell: string, columnIndex: number): string => - cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) + cell.padEnd(widths[columnIndex]) const formatRow = (cells: string[]): string => cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') diff --git a/KeeperSdk/src/users/userTypes.ts b/KeeperSdk/src/users/userTypes.ts index 6eff9ad9..6c3a49e5 100644 --- a/KeeperSdk/src/users/userTypes.ts +++ b/KeeperSdk/src/users/userTypes.ts @@ -1,6 +1,11 @@ import { KeeperSdkError, ResultCodes } from '../utils' import type { EnterpriseUser } from '../teams/enterpriseData' +export enum EnterpriseUserStatus { + Active = 'active', + Invited = 'invited', +} + export enum UserColumn { Name = 'name', Status = 'status', @@ -18,6 +23,7 @@ export enum UserColumn { export enum AddUserStatus { Added = 'added', + Reinvited = 'reinvited', Skipped = 'skipped', Failed = 'failed', } @@ -37,6 +43,23 @@ export enum DeleteUserStatus { Failed = 'failed', } +export enum UserAction { + Lock = 'lock', + Unlock = 'unlock', + ExpirePassword = 'expire_password', +} + +export enum UserActionStatus { + Success = 'success', + Skipped = 'skipped', + Failed = 'failed', +} + +export enum UserActionSkipReason { + Inactive = 'inactive', + Pending = 'pending', +} + export type UserColumnInput = UserColumn | `${UserColumn}` export type UserTeamInfo = { @@ -133,6 +156,7 @@ export type AddUserResult = { parentNodeName: string items: AddUserItemResult[] added: number + reinvited: number skipped: number failed: number } @@ -202,10 +226,75 @@ export type FormattedDeleteUserTable = { summary: string } +export type UserActionInput = { + emails: string[] + action: UserAction +} + +export type UserActionItemResult = { + username: string + enterpriseUserId?: number + status: UserActionStatus + skipReason?: UserActionSkipReason + message?: string +} + +export type UserActionResult = { + success: boolean + items: UserActionItemResult[] + succeeded: number + skipped: number + failed: number +} + +export type FormattedUserActionTable = { + headers: string[] + rows: string[][] + summary: string +} + +export enum AliasOperation { + Add = 'add', + Remove = 'remove', +} + +export type AliasUserInput = { + email: string + operation: AliasOperation + alias: string +} + +export type AliasUserResult = { + success: boolean + username: string + enterpriseUserId: number + alias: string + operation: AliasOperation + detail: string +} + +export const MAX_FULL_NAME_LENGTH = 255 +export const MAX_JOB_TITLE_LENGTH = 255 + export function normalizeEmailInputs(emails: string[] | undefined): string[] { return (emails || []).map((e) => e.trim()).filter((e) => e.length > 0) } +export function validateUserProfileFields(fullName: string | undefined, jobTitle: string | undefined): void { + if (fullName !== undefined && fullName.length > MAX_FULL_NAME_LENGTH) { + throw new KeeperSdkError( + `Full name exceeds ${MAX_FULL_NAME_LENGTH} characters.`, + ResultCodes.USER_UPDATE_FAILED + ) + } + if (jobTitle !== undefined && jobTitle.length > MAX_JOB_TITLE_LENGTH) { + throw new KeeperSdkError( + `Job title exceeds ${MAX_JOB_TITLE_LENGTH} characters.`, + ResultCodes.USER_UPDATE_FAILED + ) + } +} + export function resolveExistingUsers(users: EnterpriseUser[], identifiers: string[]): EnterpriseUser[] { const byEmail = new Map() const byId = new Map() @@ -240,8 +329,10 @@ export function resolveExistingUsers(users: EnterpriseUser[], identifiers: strin return result } -export function formatUserStatus(user: EnterpriseUser): string { - if (user.status === 'invited') return 'Invited' +export type FormattedUserStatus = 'Active' | 'Invited' | 'Locked' | 'Disabled' + +export function formatUserStatus(user: EnterpriseUser): FormattedUserStatus { + if (user.status === EnterpriseUserStatus.Invited) return 'Invited' if (user.lock === 1) return 'Locked' if ((user.lock ?? 0) > 1) return 'Disabled' return 'Active' @@ -253,4 +344,4 @@ export function formatTransferStatus(user: EnterpriseUser): string { return new Date(exp) < new Date() ? 'Blocked' : 'Pending Transfer' } return '' -} +} \ No newline at end of file diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index a64bc4ea..e8c4edff 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -60,6 +60,10 @@ export enum UserErrorCode { UserUpdateFailed = 'user_update_failed', UserDeleteFailed = 'user_delete_failed', RoleNotFound = 'role_not_found', + NoUsersToAction = 'no_users_to_action', + UserActionFailed = 'user_action_failed', + UserActionAllNotSupported = 'user_action_all_not_supported', + UserAliasFailed = 'user_alias_failed', } export const ResultCodes = { @@ -102,6 +106,10 @@ export const ResultCodes = { USER_UPDATE_FAILED: UserErrorCode.UserUpdateFailed, USER_DELETE_FAILED: UserErrorCode.UserDeleteFailed, ROLE_NOT_FOUND: UserErrorCode.RoleNotFound, + NO_USERS_TO_ACTION: UserErrorCode.NoUsersToAction, + USER_ACTION_FAILED: UserErrorCode.UserActionFailed, + USER_ACTION_ALL_NOT_SUPPORTED: UserErrorCode.UserActionAllNotSupported, + USER_ALIAS_FAILED: UserErrorCode.UserAliasFailed, } as const export const KEEPER_PUBLIC_HOSTS: Record = { @@ -111,4 +119,4 @@ export const KEEPER_PUBLIC_HOSTS: Record = { CA: 'keepersecurity.ca', JP: 'keepersecurity.jp', GOV: 'govcloud.keepersecurity.us', -} +} \ No newline at end of file diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 19ef5815..870b2729 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -77,6 +77,11 @@ import type { DeleteUserInput, DeleteUserResult, FormattedDeleteUserTable, + UserActionInput, + UserActionResult, + FormattedUserActionTable, + AliasUserInput, + AliasUserResult, } from '../users/userTypes' import { ConsoleLogger, LogLevel, KeeperSdkError, extractErrorMessage, SdkDefaults, ResultCodes } from '../utils' import type { ILogger } from '../utils' @@ -416,6 +421,18 @@ export class KeeperVault { return this.userManager.formatDeleteUserResult(result) } + public async actionUsers(input: UserActionInput): Promise { + return this.userManager.actionUsers(input) + } + + public formatUserActionResult(result: UserActionResult): FormattedUserActionTable { + return this.userManager.formatUserActionResult(result) + } + + public async aliasUser(input: AliasUserInput): Promise { + return this.userManager.aliasUser(input) + } + public async viewTeam(identifier: string): Promise { return this.teamManager.viewTeam(identifier) } diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index dd005444..4cc6dfdb 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -34,6 +34,8 @@ "users:add": "ts-node src/users/add_user.ts", "users:update": "ts-node src/users/update_user.ts", "users:delete": "ts-node src/users/delete_user.ts", + "users:action": "ts-node src/users/action_user.ts", + "users:alias": "ts-node src/users/alias_user.ts", "link-local": "cd ../../KeeperSdk && npm link ../keeperapi && cd ../examples/sdk_example && npm link ../../keeperapi", "types": "tsc --watch", "types:ci": "tsc" diff --git a/examples/sdk_example/src/users/action_user.ts b/examples/sdk_example/src/users/action_user.ts new file mode 100644 index 00000000..3fae82c8 --- /dev/null +++ b/examples/sdk_example/src/users/action_user.ts @@ -0,0 +1,93 @@ +import { + cleanup, + extractErrorMessage, + formatUserActionResult, + login, + logger, + prompt, + renderUserActionAsciiTable, + suppressLogs, + UserAction, +} from '@keeper-security/keeper-sdk-javascript' +import type { UserActionResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +const ACTION_CHOICES: Record = { + '1': UserAction.Lock, + '2': UserAction.Unlock, + '3': UserAction.ExpirePassword, +} + +function parseIdentifierList(raw: string): string[] { + const seen = new Set() + const out: string[] = [] + for (const token of raw.split(',')) { + const trimmed = token.trim() + if (!trimmed) continue + const key = trimmed.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(trimmed) + } + return out +} + +async function actionUserExample() { + const vault = await login() + + try { + logger.info('') + logger.info('Select an action:') + logger.info(' 1) Lock user') + logger.info(' 2) Unlock user (supports @all)') + logger.info(' 3) Expire master password') + logger.info('') + + const choice = (await prompt('Action [1-3]: ')).trim() + const action = ACTION_CHOICES[choice] + if (!action) { + logger.error('Invalid choice. Please enter 1, 2, or 3.') + process.exitCode = 1 + return + } + + const isUnlock = action === UserAction.Unlock + const hint = isUnlock ? 'User email(s), ID(s), or @all (comma-separated): ' : 'User email(s) or ID(s) (comma-separated): ' + const emailsRaw = (await prompt(hint)).trim() + const emails = parseIdentifierList(emailsRaw) + + if (emails.length === 0) { + logger.error('At least one user email, ID, or @all is required.') + process.exitCode = 1 + return + } + + const restore = suppressLogs() + let result: UserActionResult + try { + result = await vault.actionUsers({ emails, action }) + } finally { + restore() + } + + const table = formatUserActionResult(result) + logger.info('') + logger.info(renderUserActionAsciiTable(table)) + logger.info('') + logger.info( + `Result: ${result.success ? 'success' : 'partial/failed'} ` + + `(succeeded=${result.succeeded}, skipped=${result.skipped}, failed=${result.failed})` + ) + + if (result.failed > 0 || (!result.success && result.succeeded === 0)) { + process.exitCode = 1 + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(actionUserExample) diff --git a/examples/sdk_example/src/users/alias_user.ts b/examples/sdk_example/src/users/alias_user.ts new file mode 100644 index 00000000..2aa593ff --- /dev/null +++ b/examples/sdk_example/src/users/alias_user.ts @@ -0,0 +1,77 @@ +import { + AliasOperation, + aliasUser, + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { AliasUserResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +const OPERATION_CHOICES: Record = { + '1': AliasOperation.Add, + '2': AliasOperation.Remove, +} + +async function aliasUserExample() { + const vault = await login() + + try { + logger.info('') + logger.info('Select an operation:') + logger.info(' 1) Add alias (promotes to primary if alias already exists)') + logger.info(' 2) Remove alias') + logger.info('') + + const choice = (await prompt('Operation [1-2]: ')).trim() + const operation = OPERATION_CHOICES[choice] + if (!operation) { + logger.error('Invalid choice. Please enter 1 or 2.') + process.exitCode = 1 + return + } + + const email = (await prompt('User email or ID: ')).trim() + if (!email) { + logger.error('User email or ID is required.') + process.exitCode = 1 + return + } + + const alias = (await prompt('Alias email: ')).trim() + if (!alias) { + logger.error('Alias email is required.') + process.exitCode = 1 + return + } + + const restore = suppressLogs() + let result: AliasUserResult + try { + result = await vault.aliasUser({ email, operation, alias }) + } finally { + restore() + } + + logger.info('') + logger.info(`User: ${result.username} (ID: ${result.enterpriseUserId})`) + logger.info(`Alias: ${result.alias}`) + logger.info(`Operation: ${result.operation}`) + logger.info(`Result: ${result.success ? 'success' : 'no-op'} — ${result.detail}`) + logger.info('') + + if (!result.success) { + process.exitCode = 1 + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(aliasUserExample) \ No newline at end of file diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index dfbc372f..abf2d23e 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -567,6 +567,31 @@ export const setEnterpriseDataKeyMessage = ( ): RestInMessage => createInMessage(data, 'enterprise/set_enterprise_user_data_key', Enterprise.EnterpriseUserDataKey) +export const enterpriseUserAddAliasMessage = ( + data: Authentication.IEnterpriseUserAddAliasRequest +): RestMessage => + createMessage( + data, + 'enterprise/enterprise_user_add_alias', + Authentication.EnterpriseUserAddAliasRequest, + Authentication.EnterpriseUserAddAliasResponse + ) + +export const enterpriseUserSetPrimaryAliasMessage = ( + data: Authentication.IEnterpriseUserAliasRequest +): RestInMessage => + createInMessage(data, 'enterprise/enterprise_user_set_primary_alias', Authentication.EnterpriseUserAliasRequest) + +export const enterpriseUserDeleteAliasMessage = ( + data: Authentication.IEnterpriseUserAliasRequest +): RestInMessage => + createInMessage(data, 'enterprise/enterprise_user_delete_alias', Authentication.EnterpriseUserAliasRequest) + +export const enterpriseUsersLockMessage = ( + data: Enterprise.ILockUsersRequest +): RestMessage => + createMessage(data, 'enterprise/enterprise_users_lock', Enterprise.LockUsersRequest, Enterprise.LockUsersResponse) + export const setV2AlternatePasswordMessage = ( data: Authentication.IUserAuthRequest ): RestInMessage =>