fix(auth): improve org context handling and export invitation flow (#1472)

Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
This commit is contained in:
Eyal Toledano
2025-12-01 18:26:45 -05:00
committed by GitHub
parent 62d3cd30e4
commit edeeef4d92
4 changed files with 215 additions and 82 deletions

View File

@@ -17,6 +17,7 @@ import {
selectBriefFromInput,
selectBriefInteractive
} from '../utils/brief-selection.js';
import { ensureOrgSelected } from '../utils/org-selection.js';
import * as ui from '../utils/ui.js';
/**
@@ -180,7 +181,7 @@ Note: Briefs must be created through the Hamster Studio web interface.
}
// Ensure org is selected - prompt if not
const orgId = await this.ensureOrgSelected();
const orgId = await this.ensureOrgSelectedLocal();
if (!orgId) {
process.exit(1);
}
@@ -248,63 +249,11 @@ Note: Briefs must be created through the Hamster Studio web interface.
/**
* Ensure an organization is selected, prompting if necessary
* Uses the shared org-selection utility
*/
private async ensureOrgSelected(): Promise<string | null> {
const context = this.authManager.getContext();
// If org is already selected, return it
if (context?.orgId) {
return context.orgId;
}
// No org selected - check if we can auto-select
const orgs = await this.authManager.getOrganizations();
if (orgs.length === 0) {
ui.displayError(
'No organizations available. Please create or join an organization first.'
);
return null;
}
if (orgs.length === 1) {
// Auto-select the only org
await this.authManager.updateContext({
orgId: orgs[0].id,
orgName: orgs[0].name,
orgSlug: orgs[0].slug
});
console.log(chalk.gray(` Auto-selected organization: ${orgs[0].name}`));
return orgs[0].id;
}
// Multiple orgs - prompt for selection
console.log(chalk.yellow('No organization selected.'));
const response = await inquirer.prompt<{ orgId: string }>([
{
type: 'list',
name: 'orgId',
message: 'Select an organization:',
choices: orgs.map((org) => ({
name: org.name,
value: org.id
}))
}
]);
const selectedOrg = orgs.find((o) => o.id === response.orgId);
if (selectedOrg) {
await this.authManager.updateContext({
orgId: selectedOrg.id,
orgName: selectedOrg.name,
orgSlug: selectedOrg.slug
});
ui.displaySuccess(`Selected organization: ${selectedOrg.name}`);
return selectedOrg.id;
}
return null;
private async ensureOrgSelectedLocal(): Promise<string | null> {
const result = await ensureOrgSelected(this.authManager);
return result.success ? result.orgId || null : null;
}
/**

View File

@@ -19,6 +19,7 @@ import {
selectBriefFromInput,
selectBriefInteractive
} from '../utils/brief-selection.js';
import { ensureOrgSelected } from '../utils/org-selection.js';
import * as ui from '../utils/ui.js';
/**
@@ -622,7 +623,8 @@ export class ContextCommand extends Command {
/**
* Interactive context setup (for post-auth flow)
* Prompts user to select org and brief
* Organization selection is MANDATORY - you cannot proceed without an org.
* Brief selection is optional.
*/
async setupContextInteractive(): Promise<{
success: boolean;
@@ -630,30 +632,35 @@ export class ContextCommand extends Command {
briefSelected: boolean;
}> {
try {
// Ask if user wants to set up workspace context
const { setupContext } = await inquirer.prompt([
// Organization selection is REQUIRED - use the shared utility
// It will auto-select if only one org, or prompt if multiple
const orgResult = await ensureOrgSelected(this.authManager, {
promptMessage: 'Select an organization:'
});
if (!orgResult.success || !orgResult.orgId) {
// This should rarely happen (only if user has no orgs)
return { success: false, orgSelected: false, briefSelected: false };
}
// Brief selection is optional - ask if they want to select one
const { selectBrief } = await inquirer.prompt([
{
type: 'confirm',
name: 'setupContext',
message: 'Would you like to set up your workspace context now?',
name: 'selectBrief',
message: 'Would you like to select a brief now?',
default: true
}
]);
if (!setupContext) {
return { success: true, orgSelected: false, briefSelected: false };
}
// Select organization
const orgResult = await this.selectOrganization();
if (!orgResult.success || !orgResult.context?.orgId) {
return { success: false, orgSelected: false, briefSelected: false };
if (!selectBrief) {
return { success: true, orgSelected: true, briefSelected: false };
}
// Select brief using shared utility
const briefResult = await selectBriefInteractive(
this.authManager,
orgResult.context.orgId
orgResult.orgId
);
return {
success: true,
@@ -663,7 +670,7 @@ export class ContextCommand extends Command {
} catch (error) {
console.error(
chalk.yellow(
'\nContext setup skipped due to error. You can set it up later with "tm context"'
'\nContext setup encountered an error. You can set it up later with "tm context"'
)
);
return { success: false, orgSelected: false, briefSelected: false };

View File

@@ -416,6 +416,9 @@ export class ExportCommand extends Command {
spinner.succeed('Export complete');
this.displaySuccessResult(result);
// Auto-set context to the new brief FIRST (needed for invitations)
await this.setContextToBrief(result.brief.url);
// Send invitations separately if user provided emails
if (inviteEmails.length > 0) {
await this.sendInvitationsForBrief(result.brief.url, inviteEmails);
@@ -424,9 +427,6 @@ export class ExportCommand extends Command {
// Always show the invite URL
this.showInviteUrl(result.brief.url);
// Auto-set context to the new brief
await this.setContextToBrief(result.brief.url);
// Track exported tag for future reference
const exportedTag = options?.tag || 'master';
await this.trackExportedTag(
@@ -599,6 +599,9 @@ export class ExportCommand extends Command {
spinner.succeed('Export complete');
this.displaySuccessResult(result);
// Auto-set context to the new brief FIRST (needed for invitations)
await this.setContextToBrief(result.brief.url);
// Send invitations separately if user provided emails
if (inviteEmails.length > 0) {
await this.sendInvitationsForBrief(result.brief.url, inviteEmails);
@@ -607,9 +610,6 @@ export class ExportCommand extends Command {
// Always show the invite URL (whether they invited or not)
this.showInviteUrl(result.brief.url);
// Auto-set context to the new brief
await this.setContextToBrief(result.brief.url);
// Track exported tag for future reference
const exportedTag = options?.tag || 'master';
await this.trackExportedTag(
@@ -785,6 +785,12 @@ export class ExportCommand extends Command {
console.log('');
}
// Set context to first successful brief BEFORE sending invitations
// (invitations need org context to work)
if (successful.length > 0 && successful[0].brief) {
await this.setContextToBrief(successful[0].brief.url);
}
// Send invitations separately after all exports (if user provided emails)
if (inviteEmails.length > 0 && successful.length > 0) {
// Send invitations for the first successful brief (they all share the same org)
@@ -794,10 +800,8 @@ export class ExportCommand extends Command {
);
}
// If only one successful, auto-set context to it
if (successful.length === 1 && successful[0].brief) {
await this.setContextToBrief(successful[0].brief.url);
} else if (successful.length > 1) {
// If multiple successful, allow user to change context to a different brief
if (successful.length > 1) {
// For multiple successful exports, show option to set context
const briefChoices = successful.map((r) => ({
name: `${r.tag} - ${r.brief?.title}`,

View File

@@ -0,0 +1,173 @@
/**
* @fileoverview Shared Organization Selection Utility
* Provides reusable org selection flow for commands that require org context.
*/
import { AuthManager } from '@tm/core';
import chalk from 'chalk';
import inquirer from 'inquirer';
import * as ui from './ui.js';
/**
* Result of org selection
*/
export interface OrgSelectionResult {
success: boolean;
orgId?: string;
orgName?: string;
orgSlug?: string;
message?: string;
}
/**
* Options for ensureOrgSelected
*/
export interface EnsureOrgOptions {
/** If true, suppress informational messages */
silent?: boolean;
/** Custom message to show when prompting */
promptMessage?: string;
}
/**
* Ensure an organization is selected, prompting if necessary.
*
* This is a shared utility that can be used by any command that requires
* an organization context. It will:
* 1. Check if org is already selected in context
* 2. If not, fetch orgs and auto-select if only one
* 3. If multiple, prompt user to select
*
* @param authManager - The AuthManager instance
* @param options - Optional configuration
* @returns OrgSelectionResult with orgId if successful
*
* @example
* ```typescript
* const result = await ensureOrgSelected(authManager);
* if (!result.success) {
* process.exit(1);
* }
* // Now we have result.orgId
* ```
*/
export async function ensureOrgSelected(
authManager: AuthManager,
options: EnsureOrgOptions = {}
): Promise<OrgSelectionResult> {
const { silent = false, promptMessage } = options;
try {
const context = authManager.getContext();
// If org is already selected, return it
if (context?.orgId) {
return {
success: true,
orgId: context.orgId,
orgName: context.orgName,
orgSlug: context.orgSlug
};
}
// No org selected - check if we can auto-select
const orgs = await authManager.getOrganizations();
if (orgs.length === 0) {
ui.displayError(
'No organizations available. Please create or join an organization first.'
);
return {
success: false,
message: 'No organizations available'
};
}
if (orgs.length === 1) {
// Auto-select the only org
await authManager.updateContext({
orgId: orgs[0].id,
orgName: orgs[0].name,
orgSlug: orgs[0].slug
});
if (!silent) {
console.log(
chalk.gray(` Auto-selected organization: ${orgs[0].name}`)
);
}
return {
success: true,
orgId: orgs[0].id,
orgName: orgs[0].name,
orgSlug: orgs[0].slug
};
}
// Multiple orgs - prompt for selection
if (!silent) {
console.log(chalk.yellow('No organization selected.'));
}
const response = await inquirer.prompt<{ orgId: string }>([
{
type: 'list',
name: 'orgId',
message: promptMessage || 'Select an organization:',
choices: orgs.map((org) => ({
name: org.name,
value: org.id
}))
}
]);
const selectedOrg = orgs.find((o) => o.id === response.orgId);
if (selectedOrg) {
await authManager.updateContext({
orgId: selectedOrg.id,
orgName: selectedOrg.name,
orgSlug: selectedOrg.slug
});
ui.displaySuccess(`Selected organization: ${selectedOrg.name}`);
return {
success: true,
orgId: selectedOrg.id,
orgName: selectedOrg.name,
orgSlug: selectedOrg.slug
};
}
return {
success: false,
message: 'Failed to select organization'
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
ui.displayError(`Failed to select organization: ${errorMessage}`);
return {
success: false,
message: errorMessage
};
}
}
/**
* Check if org is selected, returning the current org info without prompting.
* Use this for non-interactive checks.
*/
export function getSelectedOrg(authManager: AuthManager): OrgSelectionResult {
const context = authManager.getContext();
if (context?.orgId) {
return {
success: true,
orgId: context.orgId,
orgName: context.orgName,
orgSlug: context.orgSlug
};
}
return {
success: false,
message: 'No organization selected'
};
}