diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index 016577d..740b72f 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -38,6 +38,12 @@ "nsf:record-add": "ts-node src/nestedShareFolders/add_nsf_record.ts", "nsf:mkdir": "ts-node src/nestedShareFolders/mkdir_nsf.ts", "nsf:rmdir": "ts-node src/nestedShareFolders/rmdir_nsf.ts", + "nsf:share-folder": "ts-node src/nestedShareFolders/share_nsf_folder.ts", + "nsf:share-record": "ts-node src/nestedShareFolders/share_nsf_record.ts", + "nsf:record-permission": "ts-node src/nestedShareFolders/nsf_record_permission.ts", + "nsf:shortcut": "ts-node src/nestedShareFolders/shortcut_nsf.ts", + "nsf:transfer-record": "ts-node src/nestedShareFolders/transfer_nsf_record.ts", + "nsf:rndir": "ts-node src/nestedShareFolders/rndir_nsf.ts", "teams:list": "ts-node src/teams/list_teams.ts", "teams:view": "ts-node src/teams/view_team.ts", "teams:add": "ts-node src/teams/add_team.ts", diff --git a/examples/sdk_example/src/nestedShareFolders/nsf_record_permission.ts b/examples/sdk_example/src/nestedShareFolders/nsf_record_permission.ts new file mode 100644 index 0000000..e3b1572 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/nsf_record_permission.ts @@ -0,0 +1,133 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NsfRecordPermissionAction, + NSF_RECORD_PERMISSION_ROLES, + prompt, + suppressLogs, + type UpdateNsfRecordPermissionInput, + type UpdateNsfRecordPermissionResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +type Vault = Awaited> + +const ACTION_BY_INPUT: Record = { + '': NsfRecordPermissionAction.Grant, + '1': NsfRecordPermissionAction.Grant, + '2': NsfRecordPermissionAction.Revoke, + grant: NsfRecordPermissionAction.Grant, + revoke: NsfRecordPermissionAction.Revoke, +} + +function parseAction(input: string): NsfRecordPermissionAction { + return ACTION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRecordPermissionAction.Grant +} + +async function runPermissionUpdate( + vault: Vault, + input: UpdateNsfRecordPermissionInput +): Promise { + const restore = suppressLogs() + try { + return await vault.updateNestedShareRecordPermissions(input) + } finally { + restore() + } +} + +function printPlan(vault: Vault, result: UpdateNsfRecordPermissionResult): void { + logger.info('') + logger.info(vault.formatNsfRecordPermissionPlan(result)) + logger.info('') +} + +function printFailures(vault: Vault, result: UpdateNsfRecordPermissionResult): void { + const grantFailures = vault.formatNsfRecordPermissionFailures(result.grantFailures, 'GRANT') + const revokeFailures = vault.formatNsfRecordPermissionFailures(result.revokeFailures, 'REVOKE') + if (grantFailures) { + logger.info('') + logger.info(grantFailures) + logger.info('') + } + if (revokeFailures) { + logger.info('') + logger.info(revokeFailures) + logger.info('') + } +} + +async function nsfRecordPermission() { + const vault = await login() + + try { + logger.info('Action: 1) grant 2) revoke') + const action = parseAction(await prompt('Choose [1]: ')) + const folder = (await prompt('Folder path or UID (Enter for root-level records): ')).trim() + const recursive = isYes(await prompt('Include sub-folders? [y/N]: ')) + + let role: string | undefined + if (action === NsfRecordPermissionAction.Grant) { + logger.info(`Roles: ${NSF_RECORD_PERMISSION_ROLES.join(', ')}`) + role = (await prompt('Role to grant: ')).trim() + if (!role) { + logger.info('Role is required for grant action.') + return + } + } else { + role = (await prompt('Role filter for revoke (Enter for all roles): ')).trim() || undefined + } + + const dryRun = isYes(await prompt('Dry run (preview only)? [y/N]: ')) + const force = dryRun ? false : isYes(await prompt('Force confirm without prompt? [y/N]: ')) + + const baseInput: UpdateNsfRecordPermissionInput = { + folder: folder || undefined, + action, + role, + recursive, + } + + if (dryRun) { + const result = await runPermissionUpdate(vault, { ...baseInput, dryRun: true }) + printPlan(vault, result) + if (result.message) logger.info(result.message) + logger.info('[Dry-run] No permission changes were applied.') + return + } + + if (force) { + const result = await runPermissionUpdate(vault, { ...baseInput, force: true }) + printPlan(vault, result) + printFailures(vault, result) + if (result.message) logger.info(result.message) + return + } + + const preview = await runPermissionUpdate(vault, { ...baseInput, force: false }) + printPlan(vault, preview) + if (preview.message && preview.plan.grants.length + preview.plan.revokes.length === 0) { + logger.info(preview.message) + return + } + + if (!isYes(await prompt('Do you want to proceed with these permission changes? [y/n]: '))) { + logger.info('Permission update cancelled.') + return + } + + const result = await runPermissionUpdate(vault, { ...baseInput, force: true }) + printFailures(vault, result) + if (result.message) logger.info(result.message) + } catch (err) { + logger.error(`Record permission update failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(nsfRecordPermission) diff --git a/examples/sdk_example/src/nestedShareFolders/rndir_nsf.ts b/examples/sdk_example/src/nestedShareFolders/rndir_nsf.ts new file mode 100644 index 0000000..217b6a9 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/rndir_nsf.ts @@ -0,0 +1,52 @@ +import { + NSF_FOLDER_COLORS, + cleanup, + extractErrorMessage, + login, + logger, + prompt, + type NsfFolderColorInput, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes, withSuppressedLogs } from '../utils/format' + +async function rndirNsf() { + const vault = await login() + + try { + const folder = (await prompt('Folder path or UID: ')).trim() + if (!folder) { + logger.info('Enter the path or UID of an existing folder.') + return + } + + const name = (await prompt('New name (optional): ')).trim() + const colorInput = (await prompt(`New color (${NSF_FOLDER_COLORS.join(', ')}, optional): `)).trim() + const quiet = isYes(await prompt('Quiet mode? (y/N): ')) + + if (!name && !colorInput) { + logger.info('New folder name and/or color parameters are required.') + return + } + + const result = await withSuppressedLogs(() => + vault.updateNestedShareFolder({ + folder, + name: name || undefined, + color: colorInput ? (colorInput as NsfFolderColorInput) : undefined, + quiet, + }) + ) + + if (result.message) { + logger.info(result.message) + } + } catch (err) { + logger.error(`Folder update failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(rndirNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/share_nsf_folder.ts b/examples/sdk_example/src/nestedShareFolders/share_nsf_folder.ts new file mode 100644 index 0000000..c867fb2 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/share_nsf_folder.ts @@ -0,0 +1,99 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NsfFolderShareAction, + NSF_RECORD_PERMISSION_ROLES, + prompt, + suppressLogs, + type ShareNestedShareFolderInput, + type ShareNestedShareFolderResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { promptShareExpiration, splitCommaSeparated } from '../utils/format' + +type Vault = Awaited> + +const ACTION_BY_INPUT: Record = { + '': NsfFolderShareAction.Grant, + '1': NsfFolderShareAction.Grant, + '2': NsfFolderShareAction.Remove, + grant: NsfFolderShareAction.Grant, + remove: NsfFolderShareAction.Remove, +} + +function parseAction(input: string): NsfFolderShareAction { + return ACTION_BY_INPUT[input.trim().toLowerCase()] ?? NsfFolderShareAction.Grant +} + +async function runFolderShare( + vault: Vault, + input: ShareNestedShareFolderInput +): Promise { + const restore = suppressLogs() + try { + return await vault.shareNestedShareFolder(input) + } finally { + restore() + } +} + +function printResults(vault: Vault, result: ShareNestedShareFolderResult): void { + logger.info('') + logger.info(vault.formatNsfFolderShareResults(result.results)) + logger.info('') +} + +async function shareNsfFolder() { + const vault = await login() + + try { + const folders = splitCommaSeparated( + await prompt('Folder path(s) or UID(s), comma-separated: ') + ) + if (folders.length === 0) { + logger.info('Folder path or UID is required.') + return + } + + const recipients = splitCommaSeparated( + await prompt('Recipient(s) — email, team name/UID, or @existing, comma-separated: ') + ) + if (recipients.length === 0) { + logger.info('At least one recipient is required.') + return + } + + logger.info('Action: 1) grant 2) remove') + const action = parseAction(await prompt('Choose [1]: ')) + + const role = + action === NsfFolderShareAction.Grant + ? (await prompt(`Role (${NSF_RECORD_PERMISSION_ROLES.join(', ')}) [viewer]: `)).trim() || + 'viewer' + : undefined + + const expiration = + action === NsfFolderShareAction.Grant + ? await promptShareExpiration('nsf-share-folder') + : {} + + const result = await runFolderShare(vault, { + folders, + recipients, + action, + role, + ...expiration, + }) + + printResults(vault, result) + } catch (err) { + logger.error(`Folder share failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(shareNsfFolder) diff --git a/examples/sdk_example/src/nestedShareFolders/share_nsf_record.ts b/examples/sdk_example/src/nestedShareFolders/share_nsf_record.ts new file mode 100644 index 0000000..62af8f8 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/share_nsf_record.ts @@ -0,0 +1,114 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NsfRecordShareAction, + NSF_RECORD_PERMISSION_ROLES, + prompt, + suppressLogs, + type ShareNestedShareRecordInput, + type ShareNestedShareRecordResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes, promptShareExpiration, splitCommaSeparated } from '../utils/format' + +type Vault = Awaited> + +const ACTION_BY_INPUT: Record = { + '': NsfRecordShareAction.Grant, + '1': NsfRecordShareAction.Grant, + '2': NsfRecordShareAction.Revoke, + '3': NsfRecordShareAction.Owner, + grant: NsfRecordShareAction.Grant, + revoke: NsfRecordShareAction.Revoke, + owner: NsfRecordShareAction.Owner, +} + +function parseAction(input: string): NsfRecordShareAction { + return ACTION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRecordShareAction.Grant +} + +async function runRecordShare( + vault: Vault, + input: ShareNestedShareRecordInput +): Promise { + const restore = suppressLogs() + try { + return await vault.shareNestedShareRecord(input) + } finally { + restore() + } +} + +function printPlan(vault: Vault, result: ShareNestedShareRecordResult): void { + logger.info('') + logger.info(vault.formatNsfRecordSharePlan(result)) + logger.info('') +} + +function printResults(vault: Vault, result: ShareNestedShareRecordResult): void { + if (result.dryRun || result.results.length === 0) return + logger.info('') + logger.info(vault.formatNsfRecordShareResults(result.results)) + logger.info('') +} + +async function shareNsfRecord() { + const vault = await login() + + try { + const record = (await prompt('Record UID, title, or folder path: ')).trim() + if (!record) { + logger.info('Record path or UID is required.') + return + } + + const emails = splitCommaSeparated(await prompt('Recipient email(s), comma-separated: ')) + if (emails.length === 0) { + logger.info('At least one recipient email is required.') + return + } + + logger.info('Action: 1) grant 2) revoke 3) owner') + const action = parseAction(await prompt('Choose [1]: ')) + + const role = + action === NsfRecordShareAction.Grant + ? (await prompt(`Role (${NSF_RECORD_PERMISSION_ROLES.join(', ')}) [viewer]: `)).trim() || + 'viewer' + : undefined + + const expiration = + action === NsfRecordShareAction.Grant + ? await promptShareExpiration('nsf-share-record') + : {} + + const recursive = isYes(await prompt('Recursive when record is a folder? (y/N): ')) + const dryRun = isYes(await prompt('Dry run? (y/N): ')) + + const result = await runRecordShare(vault, { + record, + emails, + action, + role, + recursive, + dryRun, + ...expiration, + }) + + printPlan(vault, result) + if (result.dryRun) { + logger.info('[Dry-run] No record share changes were applied.') + return + } + printResults(vault, result) + } catch (err) { + logger.error(`Record share failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(shareNsfRecord) diff --git a/examples/sdk_example/src/nestedShareFolders/shortcut_nsf.ts b/examples/sdk_example/src/nestedShareFolders/shortcut_nsf.ts new file mode 100644 index 0000000..ad597cc --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/shortcut_nsf.ts @@ -0,0 +1,126 @@ +import fs from 'fs/promises' +import { + cleanup, + extractErrorMessage, + ListNsfFormat, + login, + logger, + prompt, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes, withSuppressedLogs } from '../utils/format' + +type Vault = Awaited> + +const ACTION_BY_INPUT: Record = { + '': 'list', + '1': 'list', + '2': 'keep', + list: 'list', + keep: 'keep', +} + +function parseAction(input: string): 'list' | 'keep' { + return ACTION_BY_INPUT[input.trim().toLowerCase()] ?? 'list' +} + +async function listShortcuts(vault: Vault): Promise { + const target = (await prompt('Filter by record or folder (Enter for all): ')).trim() + const asJson = isYes(await prompt('Output as JSON? [y/N]: ')) + const asCsv = !asJson && isYes(await prompt('Output as CSV? [y/N]: ')) + const outputPath = (await prompt('Output file path (Enter for stdout): ')).trim() + + const format = asJson ? ListNsfFormat.JSON : asCsv ? ListNsfFormat.CSV : ListNsfFormat.Table + const rows = vault.listNsfShortcuts({ target: target || undefined }) + + if (rows.length === 0) { + logger.info('No multi-folder records found.') + return + } + + const output = vault.formatNsfShortcutOutput(rows, format) + if (outputPath && format !== ListNsfFormat.Table) { + await fs.writeFile(outputPath, output, 'utf-8') + logger.info(`Wrote ${rows.length} row(s) to ${outputPath}`) + return + } + + logger.info('') + logger.info(output) + logger.info('') + logger.info(`Total: ${rows.length} shortcut record${rows.length === 1 ? '' : 's'}`) +} + +async function keepShortcut(vault: Vault): Promise { + const record = (await prompt('Record UID or title: ')).trim() + if (!record) { + logger.info('Record UID or title is required.') + return + } + + const folder = (await prompt('Folder to keep record in (Enter for current NSF folder): ')).trim() + const dryRun = isYes(await prompt('Dry run? [y/N]: ')) + + if (!dryRun) { + const preview = await withSuppressedLogs(() => + vault.keepNsfShortcut({ record, folder: folder || undefined, dryRun: true }) + ) + logger.info('') + logger.info(vault.formatKeepNsfShortcutPlan(preview)) + if (preview.nothingToDo) { + logger.info('') + logger.info('Nothing to do — record is already in only one folder.') + return + } + if (!isYes(await prompt('Proceed? [y/N]: '))) { + logger.info('Aborted.') + return + } + } + + const result = await withSuppressedLogs(() => + vault.keepNsfShortcut({ + record, + folder: folder || undefined, + dryRun, + }) + ) + + logger.info('') + logger.info(vault.formatKeepNsfShortcutPlan(result)) + if (result.nothingToDo) { + logger.info('') + logger.info('Nothing to do — record is already in only one folder.') + return + } + if (!result.dryRun) { + logger.info('') + for (const item of result.results) { + logger.info( + `${item.folderUid} ${item.success ? 'success' : 'failed'} ${item.message}` + ) + } + } +} + +async function nsfShortcut() { + const vault = await login() + + try { + logger.info('Action: 1) list 2) keep') + const action = parseAction(await prompt('Choose [1]: ')) + + if (action === 'keep') { + await keepShortcut(vault) + } else { + await listShortcuts(vault) + } + } catch (err) { + logger.error(`NSF shortcut failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(nsfShortcut) diff --git a/examples/sdk_example/src/nestedShareFolders/transfer_nsf_record.ts b/examples/sdk_example/src/nestedShareFolders/transfer_nsf_record.ts new file mode 100644 index 0000000..3df0770 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/transfer_nsf_record.ts @@ -0,0 +1,50 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated, withSuppressedLogs } from '../utils/format' + +async function transferNsfRecord() { + const vault = await login() + + try { + const records = splitCommaSeparated( + await prompt('Record UID(s) or title(s), comma-separated: ') + ) + if (records.length === 0) { + logger.info('At least one record is required.') + return + } + + const newOwnerEmail = (await prompt('New owner email: ')).trim() + if (!newOwnerEmail) { + logger.info('New owner email is required.') + return + } + + const result = await withSuppressedLogs(() => + vault.transferNestedShareRecords({ + records, + newOwnerEmail, + }) + ) + + logger.info('') + logger.info(vault.formatTransferNestedShareRecordResults(result.results)) + if (result.success) { + logger.info('') + logger.info('Warning: You will no longer have access to transferred record(s).') + } + } catch (err) { + logger.error(`Record transfer failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(transferNsfRecord) diff --git a/examples/sdk_example/src/utils/format.ts b/examples/sdk_example/src/utils/format.ts index 9d332b0..a807a8b 100644 --- a/examples/sdk_example/src/utils/format.ts +++ b/examples/sdk_example/src/utils/format.ts @@ -2,6 +2,9 @@ import { EMAIL_LIST_SEPARATOR_PATTERN, EMAIL_PATTERN, isValidEmail, + logger, + parseShareExpirationValue, + prompt, suppressLogs, } from '@keeper-security/keeper-sdk-javascript' @@ -57,6 +60,26 @@ export function splitCommaSeparated(input: string): string[] { .filter((item) => item.length > 0) } +const SHARE_EXPIRE_IN_RE = + /^(\d+)\s*(mi(?:nutes?)?|h(?:ours?)?|d(?:ays?)?|mo(?:nths?)?|y(?:ears?)?)$/i + +export async function promptShareExpiration( + cmdName: 'nsf-share-folder' | 'nsf-share-record' +): Promise<{ expireAt?: string; expireIn?: string }> { + logger.info( + 'Expiration: Enter to skip, never, ISO datetime (2027-01-01T00:00:00Z), or period (30d, 6mo, 1y, 24h, 30mi)' + ) + const value = (await prompt('Share expiration: ')).trim() + if (!value) return {} + + if (SHARE_EXPIRE_IN_RE.test(value)) { + return { expireIn: value } + } + + parseShareExpirationValue(value, cmdName) + return { expireAt: value } +} + export function parseEmails(raw: string): { emails: string[]; invalid: string[] } { const tokens = raw .split(EMAIL_LIST_SEPARATOR_PATTERN)