From 786a190f8e0afc9915afd1d89cb5c2e700ec5631 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Fri, 29 May 2026 11:34:27 +0530 Subject: [PATCH 1/3] User team (add, update and remove) implementation --- KeeperSdk/src/index.ts | 14 + KeeperSdk/src/teams/enterpriseData.ts | 2 + KeeperSdk/src/teams/teamUtils.ts | 19 +- KeeperSdk/src/users/UserManager.ts | 21 + KeeperSdk/src/users/teamUser.ts | 555 ++++++++++++++++++++ KeeperSdk/src/users/updateUser.ts | 18 +- KeeperSdk/src/users/userTypes.ts | 52 +- KeeperSdk/src/utils/constants.ts | 6 + KeeperSdk/src/vault/KeeperVault.ts | 16 + examples/sdk_example/package.json | 1 + examples/sdk_example/src/users/team_user.ts | 135 +++++ keeperapi/src/commands.ts | 13 + keeperapi/src/restMessages.ts | 2 +- 13 files changed, 837 insertions(+), 17 deletions(-) create mode 100644 KeeperSdk/src/users/teamUser.ts create mode 100644 examples/sdk_example/src/users/team_user.ts diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 3e1e5118..b5f27501 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -352,6 +352,11 @@ export type { AliasUserInput, AliasUserResult, FormattedUserStatus, + AddUsersToTeamsInput, + RemoveUsersFromTeamsInput, + TeamUserItemResult, + TeamUserResult, + FormattedTeamUserTable, } from './users/userTypes' export { EnterpriseUserStatus } from './users/userTypes' @@ -361,6 +366,15 @@ export { AliasOperation, } from './users/aliasUser' +export { + addUsersToTeams, + removeUsersFromTeams, + formatTeamUserResult, + renderTeamUserAsciiTable, + TeamUserStatus, + TeamUserSkipReason, +} from './users/teamUser' + export { UserManager } from './users/UserManager' export type { diff --git a/KeeperSdk/src/teams/enterpriseData.ts b/KeeperSdk/src/teams/enterpriseData.ts index 35a1d9e5..2fb55fd6 100644 --- a/KeeperSdk/src/teams/enterpriseData.ts +++ b/KeeperSdk/src/teams/enterpriseData.ts @@ -76,6 +76,7 @@ export type EnterpriseTeamRecord = { restrict_share?: boolean restrict_sharing?: boolean encrypted_data?: string + encrypted_team_key?: string } export type EnterpriseTeamUserLink = { @@ -458,6 +459,7 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { restrict_share: message.restrictShare === true, } if (message.encryptedData) team.encrypted_data = message.encryptedData + if (message.encryptedTeamKey) team.encrypted_team_key = message.encryptedTeamKey return team } diff --git a/KeeperSdk/src/teams/teamUtils.ts b/KeeperSdk/src/teams/teamUtils.ts index 230d3db9..35cb89d9 100644 --- a/KeeperSdk/src/teams/teamUtils.ts +++ b/KeeperSdk/src/teams/teamUtils.ts @@ -16,18 +16,33 @@ export function validateTeamName(name: string): void { export function resolveExistingTeams( teams: EnterpriseTeamRecord[], - identifiers: string[] + identifiers: string[], + queuedTeams: EnterpriseTeamRecord[] = [] ): EnterpriseTeamRecord[] { const byUid = new Map() const byLowerName = new Map() for (const team of teams) { - if (team.team_uid) byUid.set(team.team_uid, team) + if (!team.team_uid) continue + byUid.set(team.team_uid, team) const key = (team.name || '').trim().toLowerCase() if (!key) continue const existing = byLowerName.get(key) if (existing) existing.push(team) else byLowerName.set(key, [team]) } + for (const team of queuedTeams) { + if (!team.team_uid || byUid.has(team.team_uid)) continue + const resolved: EnterpriseTeamRecord = { + team_uid: team.team_uid, + name: team.name, + node_id: team.node_id ?? 0, + ...(team.encrypted_team_key ? { encrypted_team_key: team.encrypted_team_key } : {}), + } + byUid.set(team.team_uid, resolved) + const key = (team.name || '').trim().toLowerCase() + if (!key || byLowerName.has(key)) continue + byLowerName.set(key, [resolved]) + } const found = new Map() const missing: string[] = [] diff --git a/KeeperSdk/src/users/UserManager.ts b/KeeperSdk/src/users/UserManager.ts index f7defb41..6db91cc2 100644 --- a/KeeperSdk/src/users/UserManager.ts +++ b/KeeperSdk/src/users/UserManager.ts @@ -5,6 +5,7 @@ import { updateUsers, formatUpdateUserResult, renderUpdateUserAsciiTable } from import { deleteUsers, formatDeleteUserResult, renderDeleteUserAsciiTable } from './deleteUser' import { actionUsers, formatUserActionResult, renderUserActionAsciiTable } from './actionUser' import { aliasUser } from './aliasUser' +import { addUsersToTeams, removeUsersFromTeams, formatTeamUserResult, renderTeamUserAsciiTable } from './teamUser' import { listUsers, formatUsersTable, renderUsersAsciiTable } from './listUsers' import { viewUser, formatUserView, userViewTable } from './viewUser' import type { @@ -31,6 +32,10 @@ import type { FormattedUserActionTable, AliasUserInput, AliasUserResult, + AddUsersToTeamsInput, + RemoveUsersFromTeamsInput, + TeamUserResult, + FormattedTeamUserTable, } from './userTypes' export type AuthProvider = () => Auth @@ -118,6 +123,22 @@ export class UserManager { return aliasUser(this.requireAuth(), input) } + public async addUsersToTeams(input: AddUsersToTeamsInput): Promise { + return addUsersToTeams(this.requireAuth(), input) + } + + public async removeUsersFromTeams(input: RemoveUsersFromTeamsInput): Promise { + return removeUsersFromTeams(this.requireAuth(), input) + } + + public formatTeamUserResult(result: TeamUserResult): FormattedTeamUserTable { + return formatTeamUserResult(result) + } + + public renderTeamUserAsciiTable(table: FormattedTeamUserTable): string { + return renderTeamUserAsciiTable(table) + } + private requireAuth(): Auth { const auth = this.authProvider() if (!auth) { diff --git a/KeeperSdk/src/users/teamUser.ts b/KeeperSdk/src/users/teamUser.ts new file mode 100644 index 00000000..5bcfd181 --- /dev/null +++ b/KeeperSdk/src/users/teamUser.ts @@ -0,0 +1,555 @@ +import { + type Auth, + type Authentication, + Enterprise, + type KeeperResponse, + type RestCommand, + decryptKey, + getPublicKeysMessage, + normal64Bytes, + platform, + teamEnterpriseUserRemoveCommand, + teamQueueUserCommand, + teamsEnterpriseUsersAdd, + webSafe64FromBytes, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, logger, ResultCodes } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseQueuedTeamRecord, + type EnterpriseTeamRecord, + type EnterpriseUser, + type EnterpriseTeamUserLink, +} from '../teams/enterpriseData' +import { resolveExistingTeams } from '../teams/teamUtils' +import { + normalizeEmailInputs, + resolveExistingUsers, + EnterpriseUserStatus, + TeamUserStatus, + TeamUserSkipReason, + type AddUsersToTeamsInput, + type RemoveUsersFromTeamsInput, + type TeamUserItemResult, + type TeamUserResult, + type FormattedTeamUserTable, +} from './userTypes' + +export { TeamUserStatus, TeamUserSkipReason } +export type { + AddUsersToTeamsInput, + RemoveUsersFromTeamsInput, + TeamUserItemResult, + TeamUserResult, + FormattedTeamUserTable, +} + +const SUCCESS_RESULT = 'success' + +const TEAM_USER_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Users, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.QueuedTeams, + EnterpriseDataInclude.TeamUsers, + EnterpriseDataInclude.QueuedTeamUsers, +] + +const TEAM_USER_TABLE_HEADERS = ['#', 'Status', 'User Email', 'User ID', 'Team Name', 'Team UID', 'Detail'] + +type ResolvedTeam = Pick + +type UserPublicKeys = { + rsaPublicKey: Uint8Array | null + eccPublicKey: Uint8Array | null + errorCode?: string + message?: string +} + +type EncryptedTeamKey = { + key: Uint8Array + keyType: Enterprise.EncryptedKeyType +} + +type PreparedBatchUser = { + user: EnterpriseUser + encryptedKey: EncryptedTeamKey +} + +type PreparedBatchTeam = { + team: ResolvedTeam + prepared: PreparedBatchUser[] +} + +type TeamUserContext = { + enterpriseData: EnterpriseDataManager + teams: ResolvedTeam[] + users: EnterpriseUser[] + membership: Set +} + +type InvitedQueueEntry = { user: EnterpriseUser; team: ResolvedTeam } + +type AddBatchPreparation = { + items: TeamUserItemResult[] + batchTeams: PreparedBatchTeam[] + invitedQueue: InvitedQueueEntry[] +} + +type TeamUserItemBase = Omit + +const membershipKey = (userId: number, teamUid: string): string => `${userId}:${teamUid}` + +const buildItemBase = (user: EnterpriseUser, team: ResolvedTeam): TeamUserItemBase => ({ + username: user.username, + enterpriseUserId: user.enterprise_user_id, + teamUid: team.team_uid, + teamName: team.name, +}) + +const toResolvedTeam = (team: EnterpriseTeamRecord): ResolvedTeam => ({ + team_uid: team.team_uid, + name: team.name, + encrypted_team_key: team.encrypted_team_key, +}) + +const toQueuedTeamRecord = (team: EnterpriseQueuedTeamRecord): EnterpriseTeamRecord => ({ + team_uid: team.team_uid, + name: team.name, + node_id: team.node_id, +}) + +const toPublicKeyBytes = (value: Uint8Array | null | undefined): Uint8Array | null => + value && value.length > 0 ? value : null + +const normalizeTeamIdentifiers = (teams: string[] | undefined): string[] => + (teams || []).map((t) => t.trim()).filter((t) => t.length > 0) + +function buildMembershipSet( + teamUsers: EnterpriseTeamUserLink[] | undefined, + queuedTeamUsers: EnterpriseTeamUserLink[] | undefined +): Set { + const membership = new Set() + for (const link of [...(teamUsers || []), ...(queuedTeamUsers || [])]) { + membership.add(membershipKey(link.enterprise_user_id, link.team_uid)) + } + return membership +} + +async function fetchUserPublicKeys(auth: Auth, emails: string[]): Promise> { + const result = new Map() + if (emails.length === 0) return result + + let response: Authentication.IGetPublicKeysResponse + try { + response = await auth.executeRest(getPublicKeysMessage({ usernames: emails })) + } catch (err) { + throw new KeeperSdkError( + `Failed to fetch public keys: ${extractErrorMessage(err)}`, + ResultCodes.TEAM_USER_ADD_FAILED + ) + } + + for (const entry of response.keyResponses || []) { + const username = (entry.username || '').toLowerCase() + if (!username) continue + result.set(username, { + rsaPublicKey: toPublicKeyBytes(entry.publicKey ?? undefined), + eccPublicKey: toPublicKeyBytes(entry.publicEccKey ?? undefined), + errorCode: entry.errorCode || undefined, + message: entry.message || undefined, + }) + } + return result +} + +async function encryptTeamKeyForUser( + teamKey: Uint8Array, + publicKeys: UserPublicKeys +): Promise { + if (publicKeys.rsaPublicKey) { + return { + key: platform.publicEncrypt(teamKey, platform.bytesToBase64(publicKeys.rsaPublicKey)), + keyType: Enterprise.EncryptedKeyType.KT_ENCRYPTED_BY_PUBLIC_KEY, + } + } + return { + key: await platform.publicEncryptEC(teamKey, publicKeys.eccPublicKey as Uint8Array), + keyType: Enterprise.EncryptedKeyType.KT_ENCRYPTED_BY_PUBLIC_KEY_ECC, + } +} + +async function getDecryptedTeamKey( + team: ResolvedTeam, + treeKey: Uint8Array, + cache: Map +): Promise { + if (cache.has(team.team_uid)) return cache.get(team.team_uid) ?? null + if (!team.encrypted_team_key) { + cache.set(team.team_uid, null) + return null + } + try { + const key = await decryptKey(team.encrypted_team_key, treeKey) + cache.set(team.team_uid, key) + return key + } catch (err) { + logger.warn(`Could not decrypt key for team "${team.name}": ${extractErrorMessage(err)}`) + cache.set(team.team_uid, null) + return null + } +} + +async function loadTeamUserContext( + auth: Auth, + rawUsers: string[], + rawTeams: string[] +): Promise { + const emails = normalizeEmailInputs(rawUsers) + if (emails.length === 0) { + throw new KeeperSdkError('No users provided.', ResultCodes.NO_USERS_TO_UPDATE) + } + const teamIdentifiers = normalizeTeamIdentifiers(rawTeams) + if (teamIdentifiers.length === 0) { + throw new KeeperSdkError('No teams provided.', ResultCodes.NO_TEAMS_FOR_USER_OP) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const response = await enterpriseData.getData(TEAM_USER_INCLUDES) + const queuedTeams = (response.queued_teams || []).map(toQueuedTeamRecord) + + return { + enterpriseData, + teams: resolveExistingTeams(response.teams || [], teamIdentifiers, queuedTeams).map(toResolvedTeam), + users: resolveExistingUsers(response.users || [], emails), + membership: buildMembershipSet(response.team_users, response.queued_team_users), + } +} + +function determineUserType(hideSharedFolders: boolean | undefined): Enterprise.TeamUserType | undefined { + if (hideSharedFolders === true) return Enterprise.TeamUserType.USER + if (hideSharedFolders === false) return Enterprise.TeamUserType.ADMIN_ONLY + return undefined +} + +function pickPublicKeyError(publicKeys: UserPublicKeys | undefined, username: string): string { + return publicKeys?.message || publicKeys?.errorCode || `No public key for "${username}"` +} + +function hasUsablePublicKey(publicKeys: UserPublicKeys | undefined): publicKeys is UserPublicKeys { + return !!publicKeys && !publicKeys.errorCode && !!(publicKeys.eccPublicKey || publicKeys.rsaPublicKey) +} + +function pickResponseError( + message?: string | null, + resultCode?: string | null, + additionalInfo?: string | null, + fallback?: string +): string | undefined { + return message || resultCode || additionalInfo || fallback || undefined +} + +async function prepareAddBatches( + teams: ResolvedTeam[], + users: EnterpriseUser[], + membership: Set, + publicKeyMap: Map, + treeKey: Uint8Array +): Promise { + const items: TeamUserItemResult[] = [] + const batchTeams: PreparedBatchTeam[] = [] + const invitedQueue: InvitedQueueEntry[] = [] + const teamKeyCache = new Map() + + for (const team of teams) { + const prepared: PreparedBatchUser[] = [] + + for (const user of users) { + const base = buildItemBase(user, team) + + if (membership.has(membershipKey(user.enterprise_user_id, team.team_uid))) { + items.push({ ...base, status: TeamUserStatus.Skipped, skipReason: TeamUserSkipReason.AlreadyMember }) + continue + } + + if (user.status !== EnterpriseUserStatus.Active) { + invitedQueue.push({ user, team }) + continue + } + + const publicKeys = publicKeyMap.get(user.username.toLowerCase()) + if (!hasUsablePublicKey(publicKeys)) { + items.push({ + ...base, + status: TeamUserStatus.MissingPublicKey, + message: pickPublicKeyError(publicKeys, user.username), + }) + continue + } + + const teamKey = await getDecryptedTeamKey(team, treeKey, teamKeyCache) + if (!teamKey) { + items.push({ + ...base, + status: TeamUserStatus.Failed, + message: `Team key for "${team.name}" is unavailable.`, + }) + continue + } + + try { + prepared.push({ user, encryptedKey: await encryptTeamKeyForUser(teamKey, publicKeys) }) + } catch (err) { + items.push({ ...base, status: TeamUserStatus.Failed, message: extractErrorMessage(err) }) + } + } + + if (prepared.length > 0) batchTeams.push({ team, prepared }) + } + + return { items, batchTeams, invitedQueue } +} + +function buildAddTeamRequests( + batchTeams: PreparedBatchTeam[], + userType: Enterprise.TeamUserType | undefined +): Enterprise.ITeamsEnterpriseUsersAddTeamRequest[] { + return batchTeams.map(({ team, prepared }) => ({ + teamUid: normal64Bytes(team.team_uid), + users: prepared.map(({ user, encryptedKey }) => { + const userReq: Enterprise.ITeamsEnterpriseUsersAddUserRequest = { + enterpriseUserId: user.enterprise_user_id, + typedTeamKey: { key: encryptedKey.key, keyType: encryptedKey.keyType }, + } + if (userType !== undefined) userReq.userType = userType + return userReq + }), + })) +} + +function markAllBatchUsersFailed(batchTeams: PreparedBatchTeam[], message: string): TeamUserItemResult[] { + return batchTeams.flatMap(({ team, prepared }) => + prepared.map(({ user }) => ({ ...buildItemBase(user, team), status: TeamUserStatus.Failed, message })) + ) +} + +function mergeTeamResponse( + prepTeam: PreparedBatchTeam, + teamResp: Enterprise.ITeamsEnterpriseUsersAddTeamResponse +): TeamUserItemResult[] { + const userMap = new Map(prepTeam.prepared.map((p) => [p.user.enterprise_user_id, p.user])) + const teamFailureMessage = + teamResp.success === false + ? pickResponseError(teamResp.message, teamResp.resultCode, teamResp.additionalInfo, 'Team add failed') + : undefined + + const items: TeamUserItemResult[] = [] + for (const userResp of teamResp.users || []) { + const enterpriseUserId = Number(userResp.enterpriseUserId) + const user = userMap.get(enterpriseUserId) + if (!user) continue + const success = userResp.success === true + items.push({ + ...buildItemBase(user, prepTeam.team), + status: success ? TeamUserStatus.Added : TeamUserStatus.Failed, + message: success + ? undefined + : pickResponseError( + userResp.message, + userResp.resultCode, + userResp.additionalInfo, + teamFailureMessage + ), + }) + userMap.delete(enterpriseUserId) + } + + for (const user of userMap.values()) { + items.push({ + ...buildItemBase(user, prepTeam.team), + status: teamFailureMessage ? TeamUserStatus.Failed : TeamUserStatus.Added, + message: teamFailureMessage, + }) + } + return items +} + +async function sendTeamsEnterpriseUsersAdd( + auth: Auth, + batchTeams: PreparedBatchTeam[], + userType: Enterprise.TeamUserType | undefined +): Promise { + let response: Enterprise.ITeamsEnterpriseUsersAddResponse + try { + response = await auth.executeRest( + teamsEnterpriseUsersAdd({ teams: buildAddTeamRequests(batchTeams, userType) }) + ) + } catch (err) { + return markAllBatchUsersFailed(batchTeams, extractErrorMessage(err)) + } + + const items: TeamUserItemResult[] = [] + const teamMap = new Map(batchTeams.map((p) => [p.team.team_uid, p])) + + for (const teamResp of response.teams || []) { + const teamUid = teamResp.teamUid ? webSafe64FromBytes(teamResp.teamUid as Uint8Array) : '' + const prepTeam = teamMap.get(teamUid) + if (!prepTeam) continue + items.push(...mergeTeamResponse(prepTeam, teamResp)) + teamMap.delete(teamUid) + } + + for (const prepTeam of teamMap.values()) { + for (const { user } of prepTeam.prepared) { + items.push({ ...buildItemBase(user, prepTeam.team), status: TeamUserStatus.Added }) + } + } + return items +} + +async function executeTeamUserCommand( + auth: Auth, + command: RestCommand<{ enterprise_user_id: number; team_uid: string }, KeeperResponse>, + fallbackErrorCode: string +): Promise { + const response = await auth.executeRestCommand(command) + const result = (response.result || '').toLowerCase() + if (result && result !== SUCCESS_RESULT) { + const { enterprise_user_id: userId, team_uid: teamUid } = command.request + throw new KeeperSdkError( + response.message || + response.result_code || + `${command.baseRequest.command} failed for user=${userId}, team=${teamUid}`, + response.result_code || fallbackErrorCode + ) + } +} + +async function processInvitedQueue(auth: Auth, invitedQueue: InvitedQueueEntry[]): Promise { + const items: TeamUserItemResult[] = [] + for (const { user, team } of invitedQueue) { + const base = buildItemBase(user, team) + try { + await executeTeamUserCommand( + auth, + teamQueueUserCommand({ enterprise_user_id: user.enterprise_user_id, team_uid: team.team_uid }), + ResultCodes.TEAM_USER_ADD_FAILED + ) + items.push({ ...base, status: TeamUserStatus.Added }) + } catch (err) { + items.push({ ...base, status: TeamUserStatus.Failed, message: extractErrorMessage(err) }) + } + } + return items +} + +export async function addUsersToTeams(auth: Auth, input: AddUsersToTeamsInput): Promise { + const ctx = await loadTeamUserContext(auth, input.users, input.teams || []) + + const treeKey = await ctx.enterpriseData.getTreeKey() + if (!treeKey) { + throw new KeeperSdkError('Enterprise tree key is unavailable.', ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE) + } + + const publicKeyMap = await fetchUserPublicKeys( + auth, + ctx.users.filter((u) => u.status === EnterpriseUserStatus.Active).map((u) => u.username) + ) + + const { items, batchTeams, invitedQueue } = await prepareAddBatches( + ctx.teams, + ctx.users, + ctx.membership, + publicKeyMap, + treeKey + ) + + if (batchTeams.length > 0) { + items.push(...(await sendTeamsEnterpriseUsersAdd(auth, batchTeams, determineUserType(input.hideSharedFolders)))) + } + items.push(...(await processInvitedQueue(auth, invitedQueue))) + + return finalizeResult(items) +} + +export async function removeUsersFromTeams( + auth: Auth, + input: RemoveUsersFromTeamsInput +): Promise { + const ctx = await loadTeamUserContext(auth, input.users, input.teams || []) + const items: TeamUserItemResult[] = [] + + for (const team of ctx.teams) { + for (const user of ctx.users) { + const base = buildItemBase(user, team) + + if (!ctx.membership.has(membershipKey(user.enterprise_user_id, team.team_uid))) { + items.push({ ...base, status: TeamUserStatus.Skipped, skipReason: TeamUserSkipReason.NotMember }) + continue + } + + try { + await executeTeamUserCommand( + auth, + teamEnterpriseUserRemoveCommand({ + enterprise_user_id: user.enterprise_user_id, + team_uid: team.team_uid, + }), + ResultCodes.TEAM_USER_REMOVE_FAILED + ) + items.push({ ...base, status: TeamUserStatus.Removed }) + } catch (err) { + items.push({ ...base, status: TeamUserStatus.Failed, message: extractErrorMessage(err) }) + } + } + } + + return finalizeResult(items) +} + +function finalizeResult(items: TeamUserItemResult[]): TeamUserResult { + let succeeded = 0 + let skipped = 0 + let failed = 0 + for (const item of items) { + if (item.status === TeamUserStatus.Added || item.status === TeamUserStatus.Removed) succeeded++ + else if (item.status === TeamUserStatus.Skipped) skipped++ + else failed++ + } + return { success: failed === 0 && succeeded > 0, items, succeeded, skipped, failed } +} + +export function formatTeamUserResult(result: TeamUserResult): FormattedTeamUserTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.username, + String(item.enterpriseUserId), + item.teamName, + item.teamUid, + item.message || item.skipReason || '', + ]) + return { + headers: [...TEAM_USER_TABLE_HEADERS], + rows, + summary: `Succeeded: ${result.succeeded} Skipped: ${result.skipped} Failed: ${result.failed}`, + } +} + +export function renderTeamUserAsciiTable(table: FormattedTeamUserTable): string { + const { headers, rows } = table + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...rows.map((row) => (row[columnIndex] || '').length)) + ) + const padCell = (cell: string, columnIndex: number): string => + cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') + + return [ + formatRow(headers), + formatRow(widths.map((w) => '-'.repeat(w))), + ...rows.map(formatRow), + table.summary, + ].join('\n') +} diff --git a/KeeperSdk/src/users/updateUser.ts b/KeeperSdk/src/users/updateUser.ts index 4482d8f6..feb1932a 100644 --- a/KeeperSdk/src/users/updateUser.ts +++ b/KeeperSdk/src/users/updateUser.ts @@ -3,6 +3,7 @@ import { type Auth, type KeeperResponse, type RestCommand, + teamEnterpriseUserRemoveCommand, } from '@keeper-security/keeperapi' import { extractErrorMessage, isNumber, KeeperSdkError, ResultCodes } from '../utils' import { @@ -32,7 +33,6 @@ export { UpdateUserStatus } export type { UpdateUserInput, UpdateUserItemResult, UpdateUserResult, FormattedUpdateUserTable } const USER_UPDATE_COMMAND = 'enterprise_user_update' -const TEAM_USER_REMOVE_COMMAND = 'team_enterprise_user_remove' const ROLE_USER_REMOVE_COMMAND = 'role_user_remove' const UPDATE_USER_INCLUDES: EnterpriseDataInclude[] = [ @@ -57,11 +57,6 @@ type UserUpdatePayload = { job_title?: string } -type TeamUserRemovePayload = { - enterprise_user_id: number - team_uid: string -} - type RoleUserRemovePayload = { role_id: number enterprise_user_id: number @@ -178,18 +173,17 @@ async function sendUserUpdate(auth: Auth, payload: UserUpdatePayload): Promise { - const command: RestCommand = { - baseRequest: { command: TEAM_USER_REMOVE_COMMAND }, - request: { enterprise_user_id: enterpriseUserId, team_uid: teamUid }, - authorization: {}, - } + const command = teamEnterpriseUserRemoveCommand({ + enterprise_user_id: enterpriseUserId, + team_uid: teamUid, + }) const response = await auth.executeRestCommand(command) const result = (response.result || '').toLowerCase() if (result && result !== 'success') { throw new KeeperSdkError( response.message || response.result_code || - `${TEAM_USER_REMOVE_COMMAND} failed for user=${enterpriseUserId}, team=${teamUid}`, + `team_enterprise_user_remove failed for user=${enterpriseUserId}, team=${teamUid}`, response.result_code || ResultCodes.USER_UPDATE_FAILED ) } diff --git a/KeeperSdk/src/users/userTypes.ts b/KeeperSdk/src/users/userTypes.ts index 6c3a49e5..760486f9 100644 --- a/KeeperSdk/src/users/userTypes.ts +++ b/KeeperSdk/src/users/userTypes.ts @@ -1,6 +1,9 @@ import { KeeperSdkError, ResultCodes } from '../utils' import type { EnterpriseUser } from '../teams/enterpriseData' +export const MAX_FULL_NAME_LENGTH = 255 +export const MAX_JOB_TITLE_LENGTH = 255 + export enum EnterpriseUserStatus { Active = 'active', Invited = 'invited', @@ -273,8 +276,53 @@ export type AliasUserResult = { detail: string } -export const MAX_FULL_NAME_LENGTH = 255 -export const MAX_JOB_TITLE_LENGTH = 255 +export enum TeamUserStatus { + Added = 'added', + Removed = 'removed', + Skipped = 'skipped', + Failed = 'failed', + MissingPublicKey = 'missing_public_key', +} + +export enum TeamUserSkipReason { + AlreadyMember = 'already_member', + NotMember = 'not_member', +} + +export type AddUsersToTeamsInput = { + users: string[] + teams: string[] + hideSharedFolders?: boolean +} + +export type RemoveUsersFromTeamsInput = { + users: string[] + teams: string[] +} + +export type TeamUserItemResult = { + username: string + enterpriseUserId: number + teamUid: string + teamName: string + status: TeamUserStatus + skipReason?: TeamUserSkipReason + message?: string +} + +export type TeamUserResult = { + success: boolean + items: TeamUserItemResult[] + succeeded: number + skipped: number + failed: number +} + +export type FormattedTeamUserTable = { + headers: string[] + rows: string[][] + summary: string +} export function normalizeEmailInputs(emails: string[] | undefined): string[] { return (emails || []).map((e) => e.trim()).filter((e) => e.length > 0) diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index e8c4edff..742a54d3 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -64,6 +64,9 @@ export enum UserErrorCode { UserActionFailed = 'user_action_failed', UserActionAllNotSupported = 'user_action_all_not_supported', UserAliasFailed = 'user_alias_failed', + NoTeamsForUserOp = 'no_teams_for_user_op', + TeamUserAddFailed = 'team_user_add_failed', + TeamUserRemoveFailed = 'team_user_remove_failed', } export const ResultCodes = { @@ -110,6 +113,9 @@ export const ResultCodes = { USER_ACTION_FAILED: UserErrorCode.UserActionFailed, USER_ACTION_ALL_NOT_SUPPORTED: UserErrorCode.UserActionAllNotSupported, USER_ALIAS_FAILED: UserErrorCode.UserAliasFailed, + NO_TEAMS_FOR_USER_OP: UserErrorCode.NoTeamsForUserOp, + TEAM_USER_ADD_FAILED: UserErrorCode.TeamUserAddFailed, + TEAM_USER_REMOVE_FAILED: UserErrorCode.TeamUserRemoveFailed, } as const export const KEEPER_PUBLIC_HOSTS: Record = { diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 870b2729..070b5d78 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -82,6 +82,10 @@ import type { FormattedUserActionTable, AliasUserInput, AliasUserResult, + AddUsersToTeamsInput, + RemoveUsersFromTeamsInput, + TeamUserResult, + FormattedTeamUserTable, } from '../users/userTypes' import { ConsoleLogger, LogLevel, KeeperSdkError, extractErrorMessage, SdkDefaults, ResultCodes } from '../utils' import type { ILogger } from '../utils' @@ -433,6 +437,18 @@ export class KeeperVault { return this.userManager.aliasUser(input) } + public async addUsersToTeams(input: AddUsersToTeamsInput): Promise { + return this.userManager.addUsersToTeams(input) + } + + public async removeUsersFromTeams(input: RemoveUsersFromTeamsInput): Promise { + return this.userManager.removeUsersFromTeams(input) + } + + public formatTeamUserResult(result: TeamUserResult): FormattedTeamUserTable { + return this.userManager.formatTeamUserResult(result) + } + 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 4cc6dfdb..93463834 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -36,6 +36,7 @@ "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", + "users:user-team": "ts-node src/users/team_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/team_user.ts b/examples/sdk_example/src/users/team_user.ts new file mode 100644 index 00000000..cc638009 --- /dev/null +++ b/examples/sdk_example/src/users/team_user.ts @@ -0,0 +1,135 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + renderTeamUserAsciiTable, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { TeamUserResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +type VaultHandle = Awaited> + +const OP_ADD = '1' +const OP_REMOVE = '2' + +const OPERATION_PROMPT = [ + '', + 'Select an operation:', + ` ${OP_ADD}) Add users to team(s)`, + ` ${OP_REMOVE}) Remove users from team(s)`, + '', +].join('\n') + +const USERS_PROMPT = 'User email(s) or ID(s) (comma-separated): ' +const TEAMS_PROMPT = 'Team name(s) or UID(s) (comma-separated): ' +const HIDE_SHARED_FOLDERS_PROMPT = 'Hide shared folders? [on/off/skip]: ' +const OPERATION_INPUT_PROMPT = `Operation [${OP_ADD}-${OP_REMOVE}]: ` + +type AddInput = { kind: 'add'; users: string[]; teams: string[]; hideSharedFolders: boolean | undefined } +type RemoveInput = { kind: 'remove'; users: string[]; teams: string[] } +type OperationInput = AddInput | RemoveInput + +function parseList(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 +} + +function parseHideSharedFolders(raw: string): boolean | undefined { + const value = raw.trim().toLowerCase() + if (value === 'on') return true + if (value === 'off') return false + return undefined +} + +function failWith(message: string): null { + logger.error(message) + process.exitCode = 1 + return null +} + +async function promptList(label: string, prompt_: string): Promise { + const items = parseList((await prompt(prompt_)).trim()) + if (items.length === 0) return failWith(`At least one ${label} is required.`) + return items +} + +async function gatherOperationInput(): Promise { + logger.info(OPERATION_PROMPT) + + const choice = (await prompt(OPERATION_INPUT_PROMPT)).trim() + if (choice !== OP_ADD && choice !== OP_REMOVE) { + return failWith(`Invalid choice. Please enter ${OP_ADD} or ${OP_REMOVE}.`) + } + + const users = await promptList('user email or ID', USERS_PROMPT) + if (!users) return null + + const teams = await promptList('team name or UID', TEAMS_PROMPT) + if (!teams) return null + + if (choice === OP_ADD) { + const hideSharedFolders = parseHideSharedFolders(await prompt(HIDE_SHARED_FOLDERS_PROMPT)) + return { kind: 'add', users, teams, hideSharedFolders } + } + return { kind: 'remove', users, teams } +} + +async function executeOperation(vault: VaultHandle, input: OperationInput): Promise { + const restore = suppressLogs() + try { + return input.kind === 'add' + ? await vault.addUsersToTeams({ + users: input.users, + teams: input.teams, + hideSharedFolders: input.hideSharedFolders, + }) + : await vault.removeUsersFromTeams({ users: input.users, teams: input.teams }) + } finally { + restore() + } +} + +function reportResult(vault: VaultHandle, result: TeamUserResult): void { + const table = vault.formatTeamUserResult(result) + logger.info('') + logger.info(renderTeamUserAsciiTable(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 + } +} + +async function teamUserExample() { + const vault = await login() + try { + const input = await gatherOperationInput() + if (!input) return + + const result = await executeOperation(vault, input) + reportResult(vault, result) + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(teamUserExample) diff --git a/keeperapi/src/commands.ts b/keeperapi/src/commands.ts index b7a3631b..011c1d33 100644 --- a/keeperapi/src/commands.ts +++ b/keeperapi/src/commands.ts @@ -323,6 +323,19 @@ export type FolderUpdateRequest = { export const folderUpdateCommand = (request: FolderUpdateRequest): RestCommand => createCommand(request, 'folder_update') +export type TeamUserCommandRequest = { + enterprise_user_id: number + team_uid: string +} + +export const teamQueueUserCommand = ( + request: TeamUserCommandRequest +): RestCommand => createCommand(request, 'team_queue_user') + +export const teamEnterpriseUserRemoveCommand = ( + request: TeamUserCommandRequest +): RestCommand => createCommand(request, 'team_enterprise_user_remove') + export type GetRecordHistoryRequest = { record_uid: string client_time: number diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index abf2d23e..84dc07b1 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -524,7 +524,7 @@ export const teamsEnterpriseUsersAdd = ( ): RestMessage => createMessage( data, - 'enterprise/teams_enterprise_users_add', + 'teams/teams_enterprise_users_add', Enterprise.TeamsEnterpriseUsersAddRequest, Enterprise.TeamsEnterpriseUsersAddResponse ) From b115cc3710f361f1dca1f7bad17bc65b72bc41d8 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Mon, 1 Jun 2026 11:26:14 +0530 Subject: [PATCH 2/3] Improvment as per PR comment suggestion --- examples/sdk_example/src/users/team_user.ts | 90 ++++++++++++--------- 1 file changed, 52 insertions(+), 38 deletions(-) diff --git a/examples/sdk_example/src/users/team_user.ts b/examples/sdk_example/src/users/team_user.ts index cc638009..1686a687 100644 --- a/examples/sdk_example/src/users/team_user.ts +++ b/examples/sdk_example/src/users/team_user.ts @@ -12,24 +12,23 @@ import { runExample } from '../utils/runner' type VaultHandle = Awaited> -const OP_ADD = '1' -const OP_REMOVE = '2' - -const OPERATION_PROMPT = [ - '', - 'Select an operation:', - ` ${OP_ADD}) Add users to team(s)`, - ` ${OP_REMOVE}) Remove users from team(s)`, - '', -].join('\n') - -const USERS_PROMPT = 'User email(s) or ID(s) (comma-separated): ' -const TEAMS_PROMPT = 'Team name(s) or UID(s) (comma-separated): ' -const HIDE_SHARED_FOLDERS_PROMPT = 'Hide shared folders? [on/off/skip]: ' -const OPERATION_INPUT_PROMPT = `Operation [${OP_ADD}-${OP_REMOVE}]: ` - -type AddInput = { kind: 'add'; users: string[]; teams: string[]; hideSharedFolders: boolean | undefined } -type RemoveInput = { kind: 'remove'; users: string[]; teams: string[] } +enum TeamUserMenuChoice { + Add = '1', + Remove = '2', +} + +enum TeamUserOperation { + Add = 'add', + Remove = 'remove', +} + +type AddInput = { + kind: TeamUserOperation.Add + users: string[] + teams: string[] + hideSharedFolders: boolean | undefined +} +type RemoveInput = { kind: TeamUserOperation.Remove; users: string[]; teams: string[] } type OperationInput = AddInput | RemoveInput function parseList(raw: string): string[] { @@ -53,49 +52,64 @@ function parseHideSharedFolders(raw: string): boolean | undefined { return undefined } +function formatRunStatus(result: TeamUserResult): string { + if (result.failed > 0 && result.succeeded === 0) return 'failed' + if (result.failed > 0) return 'partial' + return result.success ? 'success' : 'failed' +} + function failWith(message: string): null { logger.error(message) process.exitCode = 1 return null } -async function promptList(label: string, prompt_: string): Promise { - const items = parseList((await prompt(prompt_)).trim()) +async function promptList(label: string, message: string): Promise { + const items = parseList((await prompt(message)).trim()) if (items.length === 0) return failWith(`At least one ${label} is required.`) return items } async function gatherOperationInput(): Promise { - logger.info(OPERATION_PROMPT) + logger.info('') + logger.info('Select an operation:') + logger.info(` ${TeamUserMenuChoice.Add}) Add users to team(s)`) + logger.info(` ${TeamUserMenuChoice.Remove}) Remove users from team(s)`) + logger.info('') - const choice = (await prompt(OPERATION_INPUT_PROMPT)).trim() - if (choice !== OP_ADD && choice !== OP_REMOVE) { - return failWith(`Invalid choice. Please enter ${OP_ADD} or ${OP_REMOVE}.`) + const choice = (await prompt(`Operation [${TeamUserMenuChoice.Add}-${TeamUserMenuChoice.Remove}]: `)).trim() + if (choice !== TeamUserMenuChoice.Add && choice !== TeamUserMenuChoice.Remove) { + return failWith(`Invalid choice. Please enter ${TeamUserMenuChoice.Add} or ${TeamUserMenuChoice.Remove}.`) } - const users = await promptList('user email or ID', USERS_PROMPT) + const users = await promptList('user email or ID', 'User email(s) or ID(s) (comma-separated): ') if (!users) return null - const teams = await promptList('team name or UID', TEAMS_PROMPT) + const teams = await promptList('team name or UID', 'Team name(s) or UID(s) (comma-separated): ') if (!teams) return null - if (choice === OP_ADD) { - const hideSharedFolders = parseHideSharedFolders(await prompt(HIDE_SHARED_FOLDERS_PROMPT)) - return { kind: 'add', users, teams, hideSharedFolders } + if (choice === TeamUserMenuChoice.Add) { + const hideSharedFolders = parseHideSharedFolders( + await prompt('Hide shared folders? [on/off/skip]: ') + ) + return { kind: TeamUserOperation.Add, users, teams, hideSharedFolders } } - return { kind: 'remove', users, teams } + return { kind: TeamUserOperation.Remove, users, teams } } async function executeOperation(vault: VaultHandle, input: OperationInput): Promise { const restore = suppressLogs() try { - return input.kind === 'add' - ? await vault.addUsersToTeams({ - users: input.users, - teams: input.teams, - hideSharedFolders: input.hideSharedFolders, - }) - : await vault.removeUsersFromTeams({ users: input.users, teams: input.teams }) + switch (input.kind) { + case TeamUserOperation.Add: + return await vault.addUsersToTeams({ + users: input.users, + teams: input.teams, + hideSharedFolders: input.hideSharedFolders, + }) + case TeamUserOperation.Remove: + return await vault.removeUsersFromTeams({ users: input.users, teams: input.teams }) + } } finally { restore() } @@ -107,7 +121,7 @@ function reportResult(vault: VaultHandle, result: TeamUserResult): void { logger.info(renderTeamUserAsciiTable(table)) logger.info('') logger.info( - `Result: ${result.success ? 'success' : 'partial/failed'} ` + + `Result: ${formatRunStatus(result)} ` + `(succeeded=${result.succeeded}, skipped=${result.skipped}, failed=${result.failed})` ) From cd6872e676abb1f42f491a18658e9fb50b5f790a Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Mon, 1 Jun 2026 16:20:46 +0530 Subject: [PATCH 3/3] Improve team-user safety: batch limits, API response checks, and CLI input validation --- KeeperSdk/src/users/teamUser.ts | 70 +++++++++++++++++++-- examples/sdk_example/src/users/team_user.ts | 13 +++- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/KeeperSdk/src/users/teamUser.ts b/KeeperSdk/src/users/teamUser.ts index 5bcfd181..47b362f2 100644 --- a/KeeperSdk/src/users/teamUser.ts +++ b/KeeperSdk/src/users/teamUser.ts @@ -13,7 +13,7 @@ import { teamsEnterpriseUsersAdd, webSafe64FromBytes, } from '@keeper-security/keeperapi' -import { extractErrorMessage, KeeperSdkError, logger, ResultCodes } from '../utils' +import { extractErrorMessage, isNumber, KeeperSdkError, logger, ResultCodes } from '../utils' import { EnterpriseDataInclude, EnterpriseDataManager, @@ -46,6 +46,12 @@ export type { } const SUCCESS_RESULT = 'success' +const MAX_TEAM_USERS_PER_REQUEST = 1000 +const MAX_USERS_PER_OPERATION = MAX_TEAM_USERS_PER_REQUEST +const MAX_TEAMS_PER_OPERATION = 100 +const MAX_RESPONSE_MESSAGE_LENGTH = 500 +const UNEXPECTED_TEAM_RESPONSE_MESSAGE = 'Unexpected API response: no team results returned' +const MISSING_TEAM_RESPONSE_MESSAGE = 'Team add response missing for this team' const TEAM_USER_INCLUDES: EnterpriseDataInclude[] = [ EnterpriseDataInclude.Users, @@ -213,6 +219,7 @@ async function loadTeamUserContext( if (teamIdentifiers.length === 0) { throw new KeeperSdkError('No teams provided.', ResultCodes.NO_TEAMS_FOR_USER_OP) } + validateOperationLimits(emails.length, teamIdentifiers.length) const enterpriseData = new EnterpriseDataManager(auth) const response = await enterpriseData.getData(TEAM_USER_INCLUDES) @@ -240,13 +247,52 @@ function hasUsablePublicKey(publicKeys: UserPublicKeys | undefined): publicKeys return !!publicKeys && !publicKeys.errorCode && !!(publicKeys.eccPublicKey || publicKeys.rsaPublicKey) } +function sanitizeResponseMessage(value: string): string { + return value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').slice(0, MAX_RESPONSE_MESSAGE_LENGTH) +} + function pickResponseError( message?: string | null, resultCode?: string | null, additionalInfo?: string | null, fallback?: string ): string | undefined { - return message || resultCode || additionalInfo || fallback || undefined + for (const candidate of [message, resultCode, additionalInfo, fallback]) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return sanitizeResponseMessage(candidate.trim()) + } + } + return undefined +} + +function toEnterpriseUserId(value: unknown): number | null { + const id = typeof value === 'number' ? value : Number(value) + return isNumber(id) && id > 0 ? Math.trunc(id) : null +} + +function validateOperationLimits(userCount: number, teamCount: number): void { + if (userCount > MAX_USERS_PER_OPERATION) { + throw new KeeperSdkError( + `Cannot process more than ${MAX_USERS_PER_OPERATION} users at once.`, + ResultCodes.NO_USERS_TO_UPDATE + ) + } + if (teamCount > MAX_TEAMS_PER_OPERATION) { + throw new KeeperSdkError( + `Cannot process more than ${MAX_TEAMS_PER_OPERATION} teams at once.`, + ResultCodes.NO_TEAMS_FOR_USER_OP + ) + } +} + +function validateBatchUserCount(batchTeams: PreparedBatchTeam[]): void { + const totalUsers = batchTeams.reduce((count, batch) => count + batch.prepared.length, 0) + if (totalUsers > MAX_TEAM_USERS_PER_REQUEST) { + throw new KeeperSdkError( + `Cannot add more than ${MAX_TEAM_USERS_PER_REQUEST} users in one batch request.`, + ResultCodes.TEAM_USER_ADD_FAILED + ) + } } async function prepareAddBatches( @@ -345,7 +391,8 @@ function mergeTeamResponse( const items: TeamUserItemResult[] = [] for (const userResp of teamResp.users || []) { - const enterpriseUserId = Number(userResp.enterpriseUserId) + const enterpriseUserId = toEnterpriseUserId(userResp.enterpriseUserId) + if (enterpriseUserId === null) continue const user = userMap.get(enterpriseUserId) if (!user) continue const success = userResp.success === true @@ -379,6 +426,10 @@ async function sendTeamsEnterpriseUsersAdd( batchTeams: PreparedBatchTeam[], userType: Enterprise.TeamUserType | undefined ): Promise { + if (batchTeams.length === 0) return [] + + validateBatchUserCount(batchTeams) + let response: Enterprise.ITeamsEnterpriseUsersAddResponse try { response = await auth.executeRest( @@ -388,10 +439,15 @@ async function sendTeamsEnterpriseUsersAdd( return markAllBatchUsersFailed(batchTeams, extractErrorMessage(err)) } + const teamResponses = response.teams ?? [] + if (teamResponses.length === 0) { + return markAllBatchUsersFailed(batchTeams, UNEXPECTED_TEAM_RESPONSE_MESSAGE) + } + const items: TeamUserItemResult[] = [] const teamMap = new Map(batchTeams.map((p) => [p.team.team_uid, p])) - for (const teamResp of response.teams || []) { + for (const teamResp of teamResponses) { const teamUid = teamResp.teamUid ? webSafe64FromBytes(teamResp.teamUid as Uint8Array) : '' const prepTeam = teamMap.get(teamUid) if (!prepTeam) continue @@ -401,7 +457,11 @@ async function sendTeamsEnterpriseUsersAdd( for (const prepTeam of teamMap.values()) { for (const { user } of prepTeam.prepared) { - items.push({ ...buildItemBase(user, prepTeam.team), status: TeamUserStatus.Added }) + items.push({ + ...buildItemBase(user, prepTeam.team), + status: TeamUserStatus.Failed, + message: MISSING_TEAM_RESPONSE_MESSAGE, + }) } } return items diff --git a/examples/sdk_example/src/users/team_user.ts b/examples/sdk_example/src/users/team_user.ts index 1686a687..3b7014c6 100644 --- a/examples/sdk_example/src/users/team_user.ts +++ b/examples/sdk_example/src/users/team_user.ts @@ -31,6 +31,8 @@ type AddInput = { type RemoveInput = { kind: TeamUserOperation.Remove; users: string[]; teams: string[] } type OperationInput = AddInput | RemoveInput +const MAX_LIST_ITEMS = 100 + function parseList(raw: string): string[] { const seen = new Set() const out: string[] = [] @@ -45,11 +47,12 @@ function parseList(raw: string): string[] { return out } -function parseHideSharedFolders(raw: string): boolean | undefined { +function parseHideSharedFolders(raw: string): boolean | undefined | null { const value = raw.trim().toLowerCase() + if (value === '' || value === 'skip') return undefined if (value === 'on') return true if (value === 'off') return false - return undefined + return null } function formatRunStatus(result: TeamUserResult): string { @@ -67,6 +70,9 @@ function failWith(message: string): null { async function promptList(label: string, message: string): Promise { const items = parseList((await prompt(message)).trim()) if (items.length === 0) return failWith(`At least one ${label} is required.`) + if (items.length > MAX_LIST_ITEMS) { + return failWith(`At most ${MAX_LIST_ITEMS} ${label}s are allowed per operation.`) + } return items } @@ -92,6 +98,9 @@ async function gatherOperationInput(): Promise { const hideSharedFolders = parseHideSharedFolders( await prompt('Hide shared folders? [on/off/skip]: ') ) + if (hideSharedFolders === null) { + return failWith('Invalid hide shared folders value. Use on, off, or skip.') + } return { kind: TeamUserOperation.Add, users, teams, hideSharedFolders } } return { kind: TeamUserOperation.Remove, users, teams }