Per-user opt-in 2FA: enroll from the Two-factor option next to Change password (QR + confirm code + 8 one-time recovery codes), login becomes two-step for enrolled users, disable requires the account password. Escape hatch for lost phones: reset-2fa CLI + Reset Two-Factor StartOS action. Second-factor guesses share the login rate limiter; the pending login window expires after 5 minutes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
560 lines
17 KiB
TypeScript
560 lines
17 KiB
TypeScript
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: Reset Two-Factor
|
|
// ============================================
|
|
const resetTwoFactorInputSpec = InputSpec.of({
|
|
username: Value.text({
|
|
name: 'Username',
|
|
description: 'Username of the account whose two-factor should be cleared (lost phone)',
|
|
default: '',
|
|
required: true,
|
|
placeholder: 'admin',
|
|
}),
|
|
})
|
|
|
|
const resetTwoFactorAction = Action.withInput(
|
|
'reset-2fa',
|
|
{
|
|
name: 'Reset Two-Factor',
|
|
description:
|
|
"Clear a user's two-factor enrollment so they can sign in with just their password (e.g. after losing their authenticator)",
|
|
warning: null,
|
|
allowedStatuses: 'only-running',
|
|
group: null,
|
|
visibility: 'enabled',
|
|
},
|
|
resetTwoFactorInputSpec,
|
|
async () => ({ username: '' }),
|
|
async ({ input, effects }) => {
|
|
try {
|
|
const result = await runCli(
|
|
effects,
|
|
['reset-2fa', '--username', input.username],
|
|
'reset-2fa-task',
|
|
)
|
|
if (result.exitCode !== 0) {
|
|
return errorResult(result.stderr?.toString() || 'Failed to reset two-factor')
|
|
}
|
|
return {
|
|
version: '1' as const,
|
|
title: 'Two-Factor Reset',
|
|
message: `Two-factor cleared for ${input.username}. They can sign in with their password and re-enroll from the app.`,
|
|
result: null,
|
|
}
|
|
} catch (e: any) {
|
|
return errorResult(`Failed to reset two-factor: ${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(resetTwoFactorAction)
|
|
.addAction(showAdminPasswordAction)
|
|
.addAction(listUsersAction)
|
|
.addAction(enableInvestorLoginsAction)
|
|
.addAction(deleteUserAction)
|
|
.addAction(dedupeAction)
|
|
.addAction(listFundsAction)
|
|
.addAction(resetHoldingsAction)
|
|
.addAction(resetPartnersAction)
|