import { sdk } from '../sdk' const { InputSpec, Value, Action } = sdk // Run a ten31portal CLI subcommand inside the service container against the data volume. async function runCli(effects: any, args: string[], taskName: string) { const sub = await sdk.SubContainer.of( effects, { imageId: 'main' }, sdk.Mounts.of().mountVolume({ volumeId: 'main', subpath: null, mountpoint: '/data', readonly: false, }), taskName, ) return sub.exec( ['python3', '-m', 'ten31portal.cli', ...args], { env: { TEN31_DB_PATH: '/data/portal.db' } }, 30000, ) } const errorResult = (message: string) => ({ version: '1' as const, title: 'Error', message, result: null }) // ============================================ // Action: Create User // ============================================ const createUserInputSpec = InputSpec.of({ name: Value.text({ name: 'Full Name', description: 'Display name for the user', default: '', required: true, placeholder: 'Jane Smith', }), email: Value.text({ name: 'Email', description: 'Login email address', default: '', required: true, placeholder: 'jane@ten31.com', patterns: [ { regex: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$', description: 'Must be a valid email address', }, ], }), username: Value.text({ name: 'Username', description: 'Login username', default: '', required: true, placeholder: 'jsmith', }), password: Value.text({ name: 'Password', description: 'Initial password (user should change after first login)', default: '', required: true, placeholder: 'minimum 4 characters', }), role: Value.select({ name: 'Role', description: 'Managing Partner: full access including valuation sign-off. Operations: full access except final sign-off. Fund Admin: edit holdings/NAV and submit, no user or document admin.', default: 'operations', values: { approver: 'Managing Partner', operations: 'Operations', fund_admin: 'Fund Admin', }, }), }) const createUserAction = Action.withInput( 'create-user', { name: 'Create User', description: 'Add a new staff user account with a role', warning: null, allowedStatuses: 'only-running', group: null, visibility: 'enabled', }, createUserInputSpec, async () => ({ name: '', email: '', username: '', password: '', role: 'operations' as const, }), async ({ input, effects }) => { try { const result = await runCli( effects, [ 'create-user', '--name', input.name, '--username', input.username, '--email', input.email, '--role', input.role, '--password', input.password, ], 'create-user-task', ) if (result.exitCode !== 0) { return errorResult(`Failed to create user: ${result.stderr?.toString() || 'Unknown error'}`) } return { version: '1' as const, title: 'User Created', message: `Created ${input.name} (${input.username}) with role: ${input.role}`, result: null, } } catch (e: any) { return errorResult(`Failed to create user: ${e.message || e}`) } }, ) // ============================================ // Action: Reset Password // ============================================ const resetPasswordInputSpec = InputSpec.of({ username: Value.text({ name: 'Username', description: 'Username of the account to reset (e.g. the Admin / Service Admin)', default: '', required: true, placeholder: 'admin', }), password: Value.text({ name: 'New Password', description: 'The new password for this account', default: '', required: true, placeholder: 'minimum 4 characters', }), }) const resetPasswordAction = Action.withInput( 'reset-password', { name: 'Reset Password', description: "Reset any user's password (including the Admin / Service Admin)", warning: null, allowedStatuses: 'only-running', group: null, visibility: 'enabled', }, resetPasswordInputSpec, async () => ({ username: '', password: '' }), async ({ input, effects }) => { try { const result = await runCli( effects, ['reset-password', '--username', input.username, '--password', input.password], 'reset-password-task', ) if (result.exitCode !== 0) { return errorResult(result.stderr?.toString() || 'Failed to reset password') } return { version: '1' as const, title: 'Password Reset', message: `Password reset for ${input.username}. Their login is enabled.`, result: null, } } catch (e: any) { return errorResult(`Failed to reset password: ${e.message || e}`) } }, ) // ============================================ // Action: List Users // ============================================ const listUsersAction = Action.withoutInput( 'list-users', { name: 'List Users', description: 'Show every user account and role', warning: null, allowedStatuses: 'only-running', group: null, visibility: 'enabled', }, async ({ effects }) => { try { const result = await runCli(effects, ['list-users'], 'list-users-task') if (result.exitCode !== 0) { return errorResult(result.stderr?.toString() || 'Failed to list users') } return { version: '1' as const, title: 'Users', message: 'The Service Admin cannot be deleted.', result: { type: 'single' as const, value: result.stdout?.toString() || 'No users.', copyable: true, qr: false, masked: false, }, } } catch (e: any) { return errorResult(`Failed to list users: ${e.message || e}`) } }, ) // ============================================ // Action: Show Initial Admin Password // ============================================ const showAdminPasswordAction = Action.withoutInput( 'show-admin-password', { name: 'Show Initial Admin Password', description: 'Reveal the randomly-generated admin password created on first boot. Sign in with it, then change your password — after which this no longer shows it.', warning: null, allowedStatuses: 'only-running', group: null, visibility: 'enabled', }, async ({ effects }) => { try { const result = await runCli(effects, ['show-admin-password'], 'show-admin-password-task') if (result.exitCode !== 0) { return errorResult(result.stderr?.toString() || 'Failed to read the admin password') } return { version: '1' as const, title: 'Initial Admin Password', message: 'Sign in as "admin" with this password, then change it from the portal.', result: { type: 'single' as const, value: (result.stdout?.toString() || '').trim() || 'No stored password.', copyable: true, qr: false, masked: true, }, } } catch (e: any) { return errorResult(`Failed to read the admin password: ${e.message || e}`) } }, ) // ============================================ // Action: Delete User // ============================================ const deleteUserInputSpec = InputSpec.of({ username: Value.text({ name: 'Username', description: 'Username of the account to delete (use List Users to see them)', default: '', required: true, placeholder: 'jsmith', }), }) const deleteUserAction = Action.withInput( 'delete-user', { name: 'Delete User', description: 'Permanently delete a user account', warning: 'This permanently deletes the user and their data (access grants, capital statements, private documents). The Service Admin cannot be deleted.', allowedStatuses: 'only-running', group: null, visibility: 'enabled', }, deleteUserInputSpec, async () => ({ username: '' }), async ({ input, effects }) => { try { const result = await runCli( effects, ['delete-user', '--username', input.username], 'delete-user-task', ) if (result.exitCode !== 0) { return errorResult(result.stderr?.toString() || 'Failed to delete user') } return { version: '1' as const, title: 'User Deleted', message: result.stdout?.toString() || `Deleted ${input.username}.`, result: null, } } catch (e: any) { return errorResult(`Failed to delete user: ${e.message || e}`) } }, ) // ============================================ // Action: Fix Duplicate Holdings // ============================================ const dedupeAction = Action.withoutInput( 'dedupe-holdings', { name: 'Fix Duplicate Holdings', description: 'Remove duplicate holdings/positions left by repeated imports on older versions, which inflated the Invested totals on the Entities view. Safe to run anytime.', warning: null, allowedStatuses: 'only-running', group: null, visibility: 'enabled', }, async ({ effects }) => { try { const result = await runCli(effects, ['dedupe-holdings'], 'dedupe-holdings-task') if (result.exitCode !== 0) { return errorResult(result.stderr?.toString() || 'Failed to clean up duplicates') } return { version: '1' as const, title: 'Duplicates Cleaned Up', message: result.stdout?.toString() || 'Done.', result: null, } } catch (e: any) { return errorResult(`Failed to clean up duplicates: ${e.message || e}`) } }, ) // ============================================ // Action: List Funds // ============================================ const listFundsAction = Action.withoutInput( 'list-funds', { name: 'List Funds', description: 'Show every fund/SPV and its exact name (for Reset Fund Holdings)', warning: null, allowedStatuses: 'only-running', group: null, visibility: 'enabled', }, async ({ effects }) => { try { const result = await runCli(effects, ['list-funds'], 'list-funds-task') if (result.exitCode !== 0) { return errorResult(result.stderr?.toString() || 'Failed to list funds') } return { version: '1' as const, title: 'Funds', message: null, result: { type: 'single' as const, value: result.stdout?.toString() || 'No funds.', copyable: true, qr: false, masked: false, }, } } catch (e: any) { return errorResult(`Failed to list funds: ${e.message || e}`) } }, ) // ============================================ // Action: Reset Fund Holdings // ============================================ const resetHoldingsInputSpec = InputSpec.of({ name: Value.text({ name: 'Fund Name', description: 'Exact name of the fund to clear (see List Funds)', default: '', required: true, placeholder: 'Low Time Preference Fund III, LP', }), }) const resetHoldingsAction = Action.withInput( 'reset-holdings', { name: 'Reset Fund Holdings', description: 'Clear a fund\'s holdings, positions, and valuation rounds so it can be re-imported from scratch. Use after switching source workbooks (e.g. Carta → eNAV) renamed the positions. Investor capital accounts are NOT affected.', warning: 'This permanently deletes the fund\'s holdings, positions, and valuation history. Re-import the fund\'s NAV afterward to repopulate it. Investor capital accounts are kept.', allowedStatuses: 'only-running', group: null, visibility: 'enabled', }, resetHoldingsInputSpec, async () => ({ name: '' }), async ({ input, effects }) => { try { const result = await runCli(effects, ['reset-holdings', '--name', input.name], 'reset-holdings-task') if (result.exitCode !== 0) { return errorResult(result.stderr?.toString() || 'Failed to reset fund') } return { version: '1' as const, title: 'Fund Cleared', message: result.stdout?.toString() || `Cleared ${input.name}.`, result: null, } } catch (e: any) { return errorResult(`Failed to reset fund: ${e.message || e}`) } }, ) // ============================================ // Action: Reset Fund Partners // ============================================ const resetPartnersInputSpec = InputSpec.of({ name: Value.text({ name: 'Fund Name', description: 'Exact name of the fund whose partners to clear (see List Funds)', default: '', required: true, placeholder: 'Low Time Preference Fund III, LP', }), }) const resetPartnersAction = Action.withInput( 'reset-partners', { name: 'Reset Fund Partners', description: "Remove every partner from a fund — deletes its investor capital-account statements and their access grants to it. Use to undo a wrong members import (e.g. another fund's roster loaded into this one). Investor accounts themselves are kept, and holdings/NAV are not affected (use Reset Fund Holdings for those).", warning: "This permanently deletes this fund's capital-account statements and removes investors' access to it. Investor accounts are kept. Re-import the correct roster afterward to repopulate.", allowedStatuses: 'only-running', group: null, visibility: 'enabled', }, resetPartnersInputSpec, async () => ({ name: '' }), async ({ input, effects }) => { try { const result = await runCli(effects, ['reset-partners', '--name', input.name], 'reset-partners-task') if (result.exitCode !== 0) { return errorResult(result.stderr?.toString() || 'Failed to clear partners') } return { version: '1' as const, title: 'Partners Cleared', message: result.stdout?.toString() || `Cleared partners from ${input.name}.`, result: null, } } catch (e: any) { return errorResult(`Failed to clear partners: ${e.message || e}`) } }, ) // ============================================ // Action: Enable Investor Logins // ============================================ const enableInvestorLoginsAction = Action.withoutInput( 'enable-investor-logins', { name: 'Enable Investor Logins', description: 'Give every investor account that has no login yet the default password (Ten31Portal) and enable sign-in. Accounts that can already sign in are not touched; investors change their own password in the portal.', warning: 'Every converted account gets the same well-known default password until the investor changes it.', allowedStatuses: 'only-running', group: null, visibility: 'enabled', }, async ({ effects }) => { try { const result = await runCli(effects, ['enable-investor-logins'], 'enable-investor-logins-task') if (result.exitCode !== 0) { return errorResult(result.stderr?.toString() || 'Failed to enable investor logins') } return { version: '1' as const, title: 'Investor Logins Enabled', message: 'Send each investor their username; they sign in with the default password and change it.', result: { type: 'single' as const, value: result.stdout?.toString() || 'Nothing to do.', copyable: true, qr: false, masked: false, }, } } catch (e: any) { return errorResult(`Failed to enable investor logins: ${e.message || e}`) } }, ) export const actions = sdk.Actions.of() .addAction(createUserAction) .addAction(resetPasswordAction) .addAction(showAdminPasswordAction) .addAction(listUsersAction) .addAction(enableInvestorLoginsAction) .addAction(deleteUserAction) .addAction(dedupeAction) .addAction(listFundsAction) .addAction(resetHoldingsAction) .addAction(resetPartnersAction)