diff --git a/packages/cli/src/cli/commands/auth/index.ts b/packages/cli/src/cli/commands/auth/index.ts new file mode 100644 index 00000000..349fa050 --- /dev/null +++ b/packages/cli/src/cli/commands/auth/index.ts @@ -0,0 +1,12 @@ +import { Command } from "commander"; +import { getPasswordLoginCommand } from "./password-login.js"; +import { getAuthPullCommand } from "./pull.js"; +import { getAuthPushCommand } from "./push.js"; + +export function getAuthCommand(): Command { + return new Command("auth") + .description("Manage app authentication settings") + .addCommand(getPasswordLoginCommand()) + .addCommand(getAuthPullCommand()) + .addCommand(getAuthPushCommand()); +} diff --git a/packages/cli/src/cli/commands/auth/password-login.ts b/packages/cli/src/cli/commands/auth/password-login.ts new file mode 100644 index 00000000..9fd6818f --- /dev/null +++ b/packages/cli/src/cli/commands/auth/password-login.ts @@ -0,0 +1,87 @@ +import { dirname, join } from "node:path"; +import { log } from "@clack/prompts"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask } from "@/cli/utils/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { readProjectConfig } from "@/core/project/index.js"; +import { + hasAnyLoginMethod, + updateAuthConfigFile, +} from "@/core/resources/auth-config/index.js"; + +interface PasswordLoginOptions { + enable?: true; + disable?: true; +} + +function validateOptions(options: PasswordLoginOptions): void { + const errors: string[] = []; + + if (!options.enable && !options.disable) { + errors.push("Missing required flag: specify --enable or --disable"); + } + if (options.enable && options.disable) { + errors.push( + "Conflicting flags: --enable and --disable cannot be used together", + ); + } + + if (errors.length > 0) { + throw new InvalidInputError(errors.join("\n"), { + hints: [ + { + message: "Enable password auth: base44 auth password-login --enable", + command: "base44 auth password-login --enable", + }, + { + message: + "Disable password auth: base44 auth password-login --disable", + command: "base44 auth password-login --disable", + }, + ], + }); + } +} + +async function passwordLoginAction( + options: PasswordLoginOptions, +): Promise { + validateOptions(options); + + const shouldEnable = !!options.enable; + const { project } = await readProjectConfig(); + + const configDir = dirname(project.configPath); + const authDir = join(configDir, project.authDir); + + const updated = await runTask( + `${shouldEnable ? "Enabling" : "Disabling"} username & password authentication`, + async () => { + return await updateAuthConfigFile(authDir, { + enableUsernamePassword: shouldEnable, + }); + }, + ); + + if (!shouldEnable && !hasAnyLoginMethod(updated)) { + log.warn( + "Disabling password auth will leave no login methods enabled. Users will be locked out.", + ); + } + + const newStatus = shouldEnable ? "enabled" : "disabled"; + return { + outroMessage: `Username & password authentication ${newStatus} in local config. Run \`base44 auth push\` or \`base44 deploy\` to apply.`, + }; +} + +export function getPasswordLoginCommand(): Command { + return new Base44Command("password-login") + .description("Enable or disable username & password authentication") + .option("--enable", "Enable password authentication") + .option("--disable", "Disable password authentication") + .action(async (options: PasswordLoginOptions) => { + return passwordLoginAction(options); + }); +} diff --git a/packages/cli/src/cli/commands/auth/pull.ts b/packages/cli/src/cli/commands/auth/pull.ts new file mode 100644 index 00000000..6af58e58 --- /dev/null +++ b/packages/cli/src/cli/commands/auth/pull.ts @@ -0,0 +1,55 @@ +import { dirname, join } from "node:path"; +import { log } from "@clack/prompts"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask } from "@/cli/utils/index.js"; +import { readProjectConfig } from "@/core/project/index.js"; +import { + pullAuthConfig, + writeAuthConfig, +} from "@/core/resources/auth-config/index.js"; + +async function pullAuthAction(): Promise { + const { project } = await readProjectConfig(); + + const configDir = dirname(project.configPath); + const authDir = join(configDir, project.authDir); + + const remoteConfig = await runTask( + "Fetching auth config from Base44", + async () => { + return await pullAuthConfig(); + }, + { + successMessage: "Auth config fetched successfully", + errorMessage: "Failed to fetch auth config", + }, + ); + + const { written } = await runTask( + "Syncing auth config file", + async () => { + return await writeAuthConfig(authDir, remoteConfig); + }, + { + successMessage: "Auth config file synced successfully", + errorMessage: "Failed to sync auth config file", + }, + ); + + if (written) { + log.success("Auth config written to local file"); + } else { + log.info("Auth config is already up to date"); + } + + return { + outroMessage: `Pulled auth config to ${authDir}`, + }; +} + +export function getAuthPullCommand(): Command { + return new Base44Command("pull") + .description("Pull auth config from Base44 to local file") + .action(pullAuthAction); +} diff --git a/packages/cli/src/cli/commands/auth/push.ts b/packages/cli/src/cli/commands/auth/push.ts new file mode 100644 index 00000000..fb16f976 --- /dev/null +++ b/packages/cli/src/cli/commands/auth/push.ts @@ -0,0 +1,39 @@ +import { log } from "@clack/prompts"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask } from "@/cli/utils/index.js"; +import { readProjectConfig } from "@/core/project/index.js"; +import { pushAuthConfig } from "@/core/resources/auth-config/index.js"; + +async function pushAuthAction(): Promise { + const { authConfig } = await readProjectConfig(); + + if (authConfig.length === 0) { + log.info("No local auth config found"); + return { + outroMessage: + "No auth config to push. Run `base44 auth pull` to fetch the remote config first.", + }; + } + + await runTask( + "Pushing auth config to Base44", + async () => { + return await pushAuthConfig(authConfig); + }, + { + successMessage: "Auth config pushed successfully", + errorMessage: "Failed to push auth config", + }, + ); + + return { + outroMessage: "Auth config pushed to Base44", + }; +} + +export function getAuthPushCommand(): Command { + return new Base44Command("push") + .description("Push local auth config to Base44") + .action(pushAuthAction); +} diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index cbef0592..fc8e4d12 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -40,7 +40,8 @@ export async function deployAction( }; } - const { project, entities, functions, agents, connectors } = projectData; + const { project, entities, functions, agents, connectors, authConfig } = + projectData; // Build summary of what will be deployed const summaryLines: string[] = []; @@ -64,6 +65,9 @@ export async function deployAction( ` - ${connectors.length} ${connectors.length === 1 ? "connector" : "connectors"}`, ); } + if (authConfig.length > 0) { + summaryLines.push(" - Auth config"); + } if (project.site?.outputDirectory) { summaryLines.push(` - Site from ${project.site.outputDirectory}`); } diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 1e1a2f2c..c5f8c3ff 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { getAgentsCommand } from "@/cli/commands/agents/index.js"; +import { getAuthCommand } from "@/cli/commands/auth/index.js"; import { getLoginCommand } from "@/cli/commands/auth/login.js"; import { getLogoutCommand } from "@/cli/commands/auth/logout.js"; import { getWhoamiCommand } from "@/cli/commands/auth/whoami.js"; @@ -69,6 +70,9 @@ export function createProgram(context: CLIContext): Command { // Register secrets commands program.addCommand(getSecretsCommand()); + // Register auth config commands + program.addCommand(getAuthCommand()); + // Register site commands program.addCommand(getSiteCommand()); diff --git a/packages/cli/src/core/project/config.ts b/packages/cli/src/core/project/config.ts index 4ebe801e..7bd09b26 100644 --- a/packages/cli/src/core/project/config.ts +++ b/packages/cli/src/core/project/config.ts @@ -5,6 +5,7 @@ import { ConfigNotFoundError, SchemaValidationError } from "@/core/errors.js"; import { ProjectConfigSchema } from "@/core/project/schema.js"; import type { ProjectData, ProjectRoot } from "@/core/project/types.js"; import { agentResource } from "@/core/resources/agent/index.js"; +import { authConfigResource } from "@/core/resources/auth-config/index.js"; import { connectorResource } from "@/core/resources/connector/index.js"; import { entityResource } from "@/core/resources/entity/index.js"; import { functionResource } from "@/core/resources/function/index.js"; @@ -92,12 +93,14 @@ export async function readProjectConfig( const project = result.data; const configDir = dirname(configPath); - const [entities, functions, agents, connectors] = await Promise.all([ - entityResource.readAll(join(configDir, project.entitiesDir)), - functionResource.readAll(join(configDir, project.functionsDir)), - agentResource.readAll(join(configDir, project.agentsDir)), - connectorResource.readAll(join(configDir, project.connectorsDir)), - ]); + const [entities, functions, agents, connectors, authConfig] = + await Promise.all([ + entityResource.readAll(join(configDir, project.entitiesDir)), + functionResource.readAll(join(configDir, project.functionsDir)), + agentResource.readAll(join(configDir, project.agentsDir)), + connectorResource.readAll(join(configDir, project.connectorsDir)), + authConfigResource.readAll(join(configDir, project.authDir)), + ]); return { project: { ...project, root, configPath }, @@ -105,5 +108,6 @@ export async function readProjectConfig( functions, agents, connectors, + authConfig, }; } diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index adcb6f5e..d7a151ff 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import type { ProjectData } from "@/core/project/types.js"; import { agentResource } from "@/core/resources/agent/index.js"; +import { pushAuthConfig } from "@/core/resources/auth-config/index.js"; import { type ConnectorSyncResult, pushConnectors, @@ -19,14 +20,23 @@ import { deploySite } from "@/core/site/index.js"; * @returns true if there are entities, functions, agents, connectors, or a configured site to deploy */ export function hasResourcesToDeploy(projectData: ProjectData): boolean { - const { project, entities, functions, agents, connectors } = projectData; + const { project, entities, functions, agents, connectors, authConfig } = + projectData; const hasSite = Boolean(project.site?.outputDirectory); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; const hasAgents = agents.length > 0; const hasConnectors = connectors.length > 0; + const hasAuthConfig = authConfig.length > 0; - return hasEntities || hasFunctions || hasAgents || hasConnectors || hasSite; + return ( + hasEntities || + hasFunctions || + hasAgents || + hasConnectors || + hasAuthConfig || + hasSite + ); } /** @@ -59,7 +69,8 @@ export async function deployAll( projectData: ProjectData, options?: DeployAllOptions, ): Promise { - const { project, entities, functions, agents, connectors } = projectData; + const { project, entities, functions, agents, connectors, authConfig } = + projectData; await entityResource.push(entities); await deployFunctionsSequentially(functions, { @@ -67,6 +78,7 @@ export async function deployAll( onResult: options?.onFunctionResult, }); await agentResource.push(agents); + await pushAuthConfig(authConfig); const { results: connectorResults } = await pushConnectors(connectors); if (project.site?.outputDirectory) { diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 60bf4a01..00ea6b1b 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -31,6 +31,7 @@ export const ProjectConfigSchema = z.object({ functionsDir: z.string().optional().default("functions"), agentsDir: z.string().optional().default("agents"), connectorsDir: z.string().optional().default("connectors"), + authDir: z.string().optional().default("auth"), }); export type ProjectConfig = z.infer; diff --git a/packages/cli/src/core/project/types.ts b/packages/cli/src/core/project/types.ts index f1e8fa5c..7b581b3c 100644 --- a/packages/cli/src/core/project/types.ts +++ b/packages/cli/src/core/project/types.ts @@ -1,5 +1,6 @@ import type { ProjectConfig } from "@/core/project/schema.js"; import type { AgentConfig } from "@/core/resources/agent/index.js"; +import type { AuthConfig } from "@/core/resources/auth-config/index.js"; import type { ConnectorResource } from "@/core/resources/connector/index.js"; import type { Entity } from "@/core/resources/entity/index.js"; import type { BackendFunction } from "@/core/resources/function/index.js"; @@ -20,4 +21,5 @@ export interface ProjectData { functions: BackendFunction[]; agents: AgentConfig[]; connectors: ConnectorResource[]; + authConfig: AuthConfig[]; } diff --git a/packages/cli/src/core/resources/auth-config/api.ts b/packages/cli/src/core/resources/auth-config/api.ts new file mode 100644 index 00000000..00b773bb --- /dev/null +++ b/packages/cli/src/core/resources/auth-config/api.ts @@ -0,0 +1,74 @@ +import type { KyResponse } from "ky"; +import { base44Client } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import { getAppConfig } from "@/core/project/index.js"; +import type { AuthConfig } from "./schema.js"; +import { AppAuthConfigResponseSchema, toAuthConfigPayload } from "./schema.js"; + +/** + * Fetches the current auth config for the app. + */ +export async function getAuthConfig(): Promise { + const { id } = getAppConfig(); + + let response: KyResponse; + try { + response = await base44Client.get(`api/apps/${id}`); + } catch (error) { + throw await ApiError.fromHttpError(error, "fetching auth config"); + } + + const result = AppAuthConfigResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + + return result.data.authConfig; +} + +/** + * Pushes the full auth config to the API. + */ +export async function pushAuthConfigToApi( + config: AuthConfig, +): Promise { + const { id } = getAppConfig(); + + let response: KyResponse; + try { + response = await base44Client.put(`api/apps/${id}`, { + json: { auth_config: toAuthConfigPayload(config) }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "updating auth config"); + } + + const result = AppAuthConfigResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + + return result.data.authConfig; +} + +/** + * Returns true if at least one login method is enabled in the given config. + */ +export function hasAnyLoginMethod(config: AuthConfig): boolean { + return ( + config.enableUsernamePassword || + config.enableGoogleLogin || + config.enableMicrosoftLogin || + config.enableFacebookLogin || + config.enableAppleLogin || + config.enableSSOLogin + ); +} diff --git a/packages/cli/src/core/resources/auth-config/config.ts b/packages/cli/src/core/resources/auth-config/config.ts new file mode 100644 index 00000000..75f20e47 --- /dev/null +++ b/packages/cli/src/core/resources/auth-config/config.ts @@ -0,0 +1,91 @@ +import { join } from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { SchemaValidationError } from "@/core/errors.js"; +import { CONFIG_FILE_EXTENSION } from "../../consts.js"; +import { pathExists, readJsonFile, writeJsonFile } from "../../utils/fs.js"; +import type { AuthConfig } from "./schema.js"; +import { AuthConfigFileSchema } from "./schema.js"; + +const AUTH_CONFIG_FILENAME = `config.${CONFIG_FILE_EXTENSION}`; + +const DEFAULT_AUTH_CONFIG: AuthConfig = { + enableUsernamePassword: false, + enableGoogleLogin: false, + enableMicrosoftLogin: false, + enableFacebookLogin: false, + enableAppleLogin: false, + ssoProviderName: null, + enableSSOLogin: false, + googleOAuthMode: "default", + googleOAuthClientId: null, + useWorkspaceSSO: false, +}; + +function getAuthConfigPath(authDir: string): string { + return join(authDir, AUTH_CONFIG_FILENAME); +} + +/** + * Reads the auth config file from the given directory. + * Returns [config] if the file exists, or [] if the directory or file doesn't exist. + */ +export async function readAuthConfig(authDir: string): Promise { + const filePath = getAuthConfigPath(authDir); + + if (!(await pathExists(filePath))) { + return []; + } + + const parsed = await readJsonFile(filePath); + const result = AuthConfigFileSchema.safeParse(parsed); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid auth config file", + result.error, + filePath, + ); + } + + return [result.data]; +} + +/** + * Writes the auth config to a file. Skips if the file content is unchanged. + */ +export async function writeAuthConfig( + authDir: string, + config: AuthConfig, +): Promise<{ written: boolean }> { + const filePath = getAuthConfigPath(authDir); + + if (await pathExists(filePath)) { + const existing = await readJsonFile(filePath); + const existingResult = AuthConfigFileSchema.safeParse(existing); + if ( + existingResult.success && + isDeepStrictEqual(existingResult.data, config) + ) { + return { written: false }; + } + } + + await writeJsonFile(filePath, config); + return { written: true }; +} + +/** + * Updates the auth config file with a partial update. + * If the file doesn't exist, creates it with default values and applies the updates. + */ +export async function updateAuthConfigFile( + authDir: string, + updates: Partial, +): Promise { + const existing = await readAuthConfig(authDir); + const current = existing[0] ?? DEFAULT_AUTH_CONFIG; + const merged: AuthConfig = { ...current, ...updates }; + + await writeJsonFile(getAuthConfigPath(authDir), merged); + return merged; +} diff --git a/packages/cli/src/core/resources/auth-config/index.ts b/packages/cli/src/core/resources/auth-config/index.ts new file mode 100644 index 00000000..1419e93b --- /dev/null +++ b/packages/cli/src/core/resources/auth-config/index.ts @@ -0,0 +1,6 @@ +export * from "./api.js"; +export * from "./config.js"; +export * from "./pull.js"; +export * from "./push.js"; +export * from "./resource.js"; +export * from "./schema.js"; diff --git a/packages/cli/src/core/resources/auth-config/pull.ts b/packages/cli/src/core/resources/auth-config/pull.ts new file mode 100644 index 00000000..6f28073a --- /dev/null +++ b/packages/cli/src/core/resources/auth-config/pull.ts @@ -0,0 +1,9 @@ +import { getAuthConfig } from "./api.js"; +import type { AuthConfig } from "./schema.js"; + +/** + * Pulls the auth config from the remote API. + */ +export async function pullAuthConfig(): Promise { + return await getAuthConfig(); +} diff --git a/packages/cli/src/core/resources/auth-config/push.ts b/packages/cli/src/core/resources/auth-config/push.ts new file mode 100644 index 00000000..465fe9a2 --- /dev/null +++ b/packages/cli/src/core/resources/auth-config/push.ts @@ -0,0 +1,14 @@ +import { pushAuthConfigToApi } from "./api.js"; +import type { AuthConfig } from "./schema.js"; + +/** + * Pushes the auth config to the remote API. + * If the array is empty, does nothing. + */ +export async function pushAuthConfig(configs: AuthConfig[]): Promise { + if (configs.length === 0) { + return; + } + + await pushAuthConfigToApi(configs[0]); +} diff --git a/packages/cli/src/core/resources/auth-config/resource.ts b/packages/cli/src/core/resources/auth-config/resource.ts new file mode 100644 index 00000000..72c65b7f --- /dev/null +++ b/packages/cli/src/core/resources/auth-config/resource.ts @@ -0,0 +1,9 @@ +import type { Resource } from "../types.js"; +import { readAuthConfig } from "./config.js"; +import { pushAuthConfig } from "./push.js"; +import type { AuthConfig } from "./schema.js"; + +export const authConfigResource: Resource = { + readAll: readAuthConfig, + push: pushAuthConfig, +}; diff --git a/packages/cli/src/core/resources/auth-config/schema.ts b/packages/cli/src/core/resources/auth-config/schema.ts new file mode 100644 index 00000000..11fe3fcf --- /dev/null +++ b/packages/cli/src/core/resources/auth-config/schema.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +const GoogleOAuthMode = z.enum(["default", "custom"]); + +/** + * Schema for the auth_config object returned by the API (snake_case). + * Transforms to camelCase for internal use. + */ +export const AuthConfigSchema = z + .object({ + enable_username_password: z.boolean(), + enable_google_login: z.boolean(), + enable_microsoft_login: z.boolean(), + enable_facebook_login: z.boolean(), + enable_apple_login: z.boolean(), + sso_provider_name: z.string().nullable(), + enable_sso_login: z.boolean(), + google_oauth_mode: GoogleOAuthMode, + google_oauth_client_id: z.string().nullable(), + use_workspace_sso: z.boolean(), + }) + .transform((data) => ({ + enableUsernamePassword: data.enable_username_password, + enableGoogleLogin: data.enable_google_login, + enableMicrosoftLogin: data.enable_microsoft_login, + enableFacebookLogin: data.enable_facebook_login, + enableAppleLogin: data.enable_apple_login, + ssoProviderName: data.sso_provider_name, + enableSSOLogin: data.enable_sso_login, + googleOAuthMode: data.google_oauth_mode, + googleOAuthClientId: data.google_oauth_client_id, + useWorkspaceSSO: data.use_workspace_sso, + })); + +export type AuthConfig = z.infer; + +/** + * Schema for validating local auth config files (camelCase, no transform). + */ +export const AuthConfigFileSchema = z.object({ + enableUsernamePassword: z.boolean(), + enableGoogleLogin: z.boolean(), + enableMicrosoftLogin: z.boolean(), + enableFacebookLogin: z.boolean(), + enableAppleLogin: z.boolean(), + ssoProviderName: z.string().nullable(), + enableSSOLogin: z.boolean(), + googleOAuthMode: GoogleOAuthMode, + googleOAuthClientId: z.string().nullable(), + useWorkspaceSSO: z.boolean(), +}); + +/** + * Schema for the app response — we only care about auth_config. + */ +export const AppAuthConfigResponseSchema = z + .object({ + auth_config: AuthConfigSchema, + }) + .transform((data) => ({ + authConfig: data.auth_config, + })); + +/** + * Converts camelCase AuthConfig back to snake_case for the API request body. + */ +export function toAuthConfigPayload( + config: AuthConfig, +): Record { + return { + enable_username_password: config.enableUsernamePassword, + enable_google_login: config.enableGoogleLogin, + enable_microsoft_login: config.enableMicrosoftLogin, + enable_facebook_login: config.enableFacebookLogin, + enable_apple_login: config.enableAppleLogin, + sso_provider_name: config.ssoProviderName, + enable_sso_login: config.enableSSOLogin, + google_oauth_mode: config.googleOAuthMode, + google_oauth_client_id: config.googleOAuthClientId, + use_workspace_sso: config.useWorkspaceSSO, + }; +} diff --git a/packages/cli/src/core/resources/index.ts b/packages/cli/src/core/resources/index.ts index 6da1204e..a8b80eaf 100644 --- a/packages/cli/src/core/resources/index.ts +++ b/packages/cli/src/core/resources/index.ts @@ -1,4 +1,5 @@ export * from "./agent/index.js"; +export * from "./auth-config/index.js"; export * from "./connector/index.js"; export * from "./entity/index.js"; export * from "./function/index.js"; diff --git a/packages/cli/tests/cli/auth_password_setup.spec.ts b/packages/cli/tests/cli/auth_password_setup.spec.ts new file mode 100644 index 00000000..26dae86f --- /dev/null +++ b/packages/cli/tests/cli/auth_password_setup.spec.ts @@ -0,0 +1,64 @@ +import { describe, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("auth password-login command", () => { + const t = setupCLITests(); + + it("fails when no flag is provided", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("auth", "password-login"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Missing required flag"); + t.expectResult(result).toContain("--enable"); + t.expectResult(result).toContain("--disable"); + }); + + it("fails when both --enable and --disable are provided", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run( + "auth", + "password-login", + "--enable", + "--disable", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Conflicting flags"); + }); + + it("fails when not in a project directory", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + const result = await t.run("auth", "password-login", "--enable"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No Base44 project found"); + }); + + it("shows help with --help flag", async () => { + const result = await t.run("auth", "password-login", "--help"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain( + "Enable or disable username & password authentication", + ); + }); + + it("shows password-login in auth subcommands", async () => { + const result = await t.run("auth", "--help"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("password-login"); + }); + + it("shows pull and push in auth subcommands", async () => { + const result = await t.run("auth", "--help"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("pull"); + t.expectResult(result).toContain("push"); + }); +}); diff --git a/packages/cli/tests/core/auth-password.spec.ts b/packages/cli/tests/core/auth-password.spec.ts new file mode 100644 index 00000000..edc580dc --- /dev/null +++ b/packages/cli/tests/core/auth-password.spec.ts @@ -0,0 +1,293 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock project config +vi.mock("../../src/core/project/index.js", () => ({ + getAppConfig: () => ({ id: "test-app-id" }), + initAppConfig: () => ({ id: "test-app-id" }), +})); + +// Mock HTTP client +const mockGet = vi.fn(); +const mockPut = vi.fn(); +vi.mock("../../src/core/clients/index.js", () => ({ + base44Client: { + get: (...args: unknown[]) => mockGet(...args), + put: (...args: unknown[]) => mockPut(...args), + }, +})); + +import { + hasAnyLoginMethod, + pushAuthConfigToApi, +} from "../../src/core/resources/auth-config/api.js"; +import { + readAuthConfig, + updateAuthConfigFile, + writeAuthConfig, +} from "../../src/core/resources/auth-config/config.js"; +import { pullAuthConfig } from "../../src/core/resources/auth-config/pull.js"; +import { pushAuthConfig } from "../../src/core/resources/auth-config/push.js"; +import type { AuthConfig } from "../../src/core/resources/auth-config/schema.js"; + +const DEFAULT_API_AUTH_CONFIG = { + enable_username_password: true, + enable_google_login: false, + enable_microsoft_login: false, + enable_facebook_login: false, + enable_apple_login: false, + sso_provider_name: null, + enable_sso_login: false, + google_oauth_mode: "default", + google_oauth_client_id: null, + use_workspace_sso: false, +}; + +function mockAppResponse(overrides: Record = {}) { + return { + json: () => + Promise.resolve({ + auth_config: { ...DEFAULT_API_AUTH_CONFIG, ...overrides }, + }), + }; +} + +const ALL_DISABLED: AuthConfig = { + enableUsernamePassword: false, + enableGoogleLogin: false, + enableMicrosoftLogin: false, + enableFacebookLogin: false, + enableAppleLogin: false, + ssoProviderName: null, + enableSSOLogin: false, + googleOAuthMode: "default", + googleOAuthClientId: null, + useWorkspaceSSO: false, +}; + +describe("pushAuthConfigToApi", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("sends the full config as snake_case payload", async () => { + const config: AuthConfig = { + ...ALL_DISABLED, + enableUsernamePassword: true, + }; + mockPut.mockResolvedValue( + mockAppResponse({ enable_username_password: true }), + ); + + const result = await pushAuthConfigToApi(config); + + expect(result.enableUsernamePassword).toBe(true); + const putCall = mockPut.mock.calls[0]; + const payload = putCall[1].json.auth_config; + expect(payload.enable_username_password).toBe(true); + }); + + it("returns parsed AuthConfig from response", async () => { + const config: AuthConfig = { + ...ALL_DISABLED, + enableUsernamePassword: true, + }; + mockPut.mockResolvedValue( + mockAppResponse({ + enable_username_password: true, + enable_google_login: true, + }), + ); + + const result = await pushAuthConfigToApi(config); + + expect(result.enableUsernamePassword).toBe(true); + expect(result.enableGoogleLogin).toBe(true); + expect(result.ssoProviderName).toBeNull(); + }); + + it("throws on HTTP failure", async () => { + const config: AuthConfig = { ...ALL_DISABLED }; + mockPut.mockRejectedValue(new Error("Server error")); + + await expect(pushAuthConfigToApi(config)).rejects.toThrow(); + }); +}); + +describe("hasAnyLoginMethod", () => { + it("returns true when only password is enabled", () => { + expect( + hasAnyLoginMethod({ ...ALL_DISABLED, enableUsernamePassword: true }), + ).toBe(true); + }); + + it("returns true when only a social provider is enabled", () => { + expect( + hasAnyLoginMethod({ ...ALL_DISABLED, enableGoogleLogin: true }), + ).toBe(true); + }); + + it("returns true when only SSO is enabled", () => { + expect(hasAnyLoginMethod({ ...ALL_DISABLED, enableSSOLogin: true })).toBe( + true, + ); + }); + + it("returns false when all methods are disabled", () => { + expect(hasAnyLoginMethod(ALL_DISABLED)).toBe(false); + }); +}); + +describe("readAuthConfig", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "auth-config-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("returns empty array when file does not exist", async () => { + const result = await readAuthConfig(tempDir); + expect(result).toEqual([]); + }); + + it("returns [config] when file exists with valid content", async () => { + const { writeFile } = await import("node:fs/promises"); + await writeFile( + join(tempDir, "config.jsonc"), + JSON.stringify(ALL_DISABLED), + ); + + const result = await readAuthConfig(tempDir); + expect(result).toEqual([ALL_DISABLED]); + }); + + it("throws on invalid file content", async () => { + const { writeFile } = await import("node:fs/promises"); + await writeFile( + join(tempDir, "config.jsonc"), + JSON.stringify({ invalid: true }), + ); + + await expect(readAuthConfig(tempDir)).rejects.toThrow(); + }); +}); + +describe("writeAuthConfig", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "auth-config-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("writes config file and returns written: true", async () => { + const result = await writeAuthConfig(tempDir, ALL_DISABLED); + expect(result.written).toBe(true); + + const configs = await readAuthConfig(tempDir); + expect(configs).toEqual([ALL_DISABLED]); + }); + + it("skips write when content is unchanged", async () => { + await writeAuthConfig(tempDir, ALL_DISABLED); + const result = await writeAuthConfig(tempDir, ALL_DISABLED); + expect(result.written).toBe(false); + }); + + it("writes when content has changed", async () => { + await writeAuthConfig(tempDir, ALL_DISABLED); + const updated = { ...ALL_DISABLED, enableUsernamePassword: true }; + const result = await writeAuthConfig(tempDir, updated); + expect(result.written).toBe(true); + + const configs = await readAuthConfig(tempDir); + expect(configs[0].enableUsernamePassword).toBe(true); + }); +}); + +describe("updateAuthConfigFile", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "auth-config-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("creates file with defaults when it does not exist", async () => { + const result = await updateAuthConfigFile(tempDir, { + enableUsernamePassword: true, + }); + + expect(result.enableUsernamePassword).toBe(true); + expect(result.enableGoogleLogin).toBe(false); + + const configs = await readAuthConfig(tempDir); + expect(configs[0].enableUsernamePassword).toBe(true); + }); + + it("merges updates into existing file", async () => { + await writeAuthConfig(tempDir, { + ...ALL_DISABLED, + enableGoogleLogin: true, + }); + + const result = await updateAuthConfigFile(tempDir, { + enableUsernamePassword: true, + }); + + expect(result.enableUsernamePassword).toBe(true); + expect(result.enableGoogleLogin).toBe(true); + }); +}); + +describe("pullAuthConfig", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("fetches auth config from API", async () => { + mockGet.mockResolvedValue(mockAppResponse()); + + const result = await pullAuthConfig(); + + expect(result.enableUsernamePassword).toBe(true); + expect(mockGet).toHaveBeenCalledWith("api/apps/test-app-id"); + }); +}); + +describe("pushAuthConfig", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("does nothing when configs array is empty", async () => { + await pushAuthConfig([]); + expect(mockPut).not.toHaveBeenCalled(); + }); + + it("pushes config to API when configs array has one item", async () => { + const config: AuthConfig = { + ...ALL_DISABLED, + enableUsernamePassword: true, + }; + mockPut.mockResolvedValue( + mockAppResponse({ enable_username_password: true }), + ); + + await pushAuthConfig([config]); + + expect(mockPut).toHaveBeenCalledTimes(1); + }); +});