Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -818,7 +818,9 @@ const EnvironmentVariables: React.FC<EnvironmentVariablesPropsOptional> = ({
const displayValue =
masked && Object.hasOwn(revealedValues, env.key)
? revealedValues[env.key]
: env.value;
: masked && !onReveal
? ""
: env.value;
const canToggleValue = !masked || Boolean(onReveal);
return (
<div key={index} data-env-index={index} className="space-y-1.5">
Expand All @@ -844,7 +846,7 @@ const EnvironmentVariables: React.FC<EnvironmentVariablesPropsOptional> = ({
type={showAsText ? "text" : "password"}
value={displayValue}
onChange={(e) => handleValueChange(index, e.target.value)}
placeholder={ev.valuePlaceholder}
placeholder={masked && !onReveal ? ENV_MASK : ev.valuePlaceholder}
readOnly={!isEditingMode}
className={`w-full px-3.5 py-2.5 pe-9 border border-border/50 rounded-lg text-sm font-mono text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all ${
!isEditingMode ? 'cursor-default bg-muted/20' : 'bg-muted/30'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,12 @@ describe("EnvironmentVariables reveal affordance", () => {

it("omits it when nothing can resolve the sentinel", () => {
// Deliberate: the toggle could only ever display the dots themselves as text.
expect(eyeCount(render({ envVars: MASKED }))).toBe(0);
const html = render({ envVars: MASKED });
expect(eyeCount(html)).toBe(0);
// Keep the sentinel in state for safe diffing, but render it as a placeholder
// so typing starts a replacement value instead of appending to the dots.
expect(html).toContain('value=""');
expect(html).toContain('placeholder="••••••••"');
});

it("gives a plaintext row an eye with or without a source", () => {
Expand Down
4 changes: 4 additions & 0 deletions apps/dashboard/src/components/import-project/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@ export interface RepoData {
}

export interface EnvironmentVariable {
/** Persisted project-env row id. Absent for a variable added in the wizard. */
sourceId?: string;
key: string;
value: string;
visible: boolean;
/** Preserve the server-side secret classification while editing an existing project. */
isSecret?: boolean;
}

export type StartCommand = string;
Expand Down
26 changes: 26 additions & 0 deletions apps/dashboard/src/context/deployment/project-env-wiring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";

const source = readFileSync(new URL("./useDeploymentBuild.tsx", import.meta.url), "utf8");

describe("project env persistence wiring", () => {
it("persists the env diff before reporting a config-only save as successful", () => {
const start = source.indexOf("if (saveConfigOnly)");
const end = source.indexOf("const isServiceDeployment", start);
const saveOnlyBranch = source.slice(start, end);

const mergeIndex = saveOnlyBranch.indexOf("projectsApi.mergeEnv");
const successIndex = saveOnlyBranch.indexOf('showToast("Configuration saved"');
expect(mergeIndex).toBeGreaterThan(-1);
expect(successIndex).toBeGreaterThan(mergeIndex);
});

it("only gives build/access env values from the new-project plan", () => {
const start = source.indexOf("const data = await deployApi.buildAccess");
const end = source.indexOf("if (!data.success", start);
const buildAccessCall = source.slice(start, end);

expect(buildAccessCall).toContain("envVars: envPlan.deployEnvVars");
expect(buildAccessCall).not.toContain("ENV_MASK");
});
});
109 changes: 109 additions & 0 deletions apps/dashboard/src/context/deployment/project-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { ENV_MASK } from "@repo/core";
import { describe, expect, it } from "vitest";
import type { EnvironmentVariable } from "@/components/import-project/types";
import { planProjectEnvPersistence, type PersistedProjectEnvVar } from "./project-env";

const saved = (
id: string,
key: string,
value: string,
isSecret = false,
): PersistedProjectEnvVar => ({ id, key, value, isSecret });

const row = (
key: string,
value: string,
options: Partial<EnvironmentVariable> = {},
): EnvironmentVariable => ({ key, value, visible: true, ...options });

function existingPlan(rows: EnvironmentVariable[], persisted: PersistedProjectEnvVar[]) {
const result = planProjectEnvPersistence(rows, persisted, true);
if (!result.ok) throw new Error(result.error);
return result;
}

describe("planProjectEnvPersistence", () => {
it("never sends or overwrites an untouched masked secret", () => {
const persisted = [saved("env-1", "API_KEY", ENV_MASK, true)];
const plan = existingPlan(
[row("API_KEY", ENV_MASK, { sourceId: "env-1", isSecret: true })],
persisted,
);

expect(plan.merge).toEqual({ upserts: [], deletes: [] });
expect(plan.deployEnvVars).toBeUndefined();
});

it("turns an edited secret into exactly one secret upsert", () => {
const persisted = [saved("env-1", "API_KEY", ENV_MASK, true)];
const plan = existingPlan(
[row("API_KEY", "replacement", { sourceId: "env-1", isSecret: true })],
persisted,
);

expect(plan.merge).toEqual({
upserts: [{ key: "API_KEY", value: "replacement", isSecret: true }],
deletes: [],
});
});

it("deletes a persisted variable when its row is removed", () => {
const plan = existingPlan([], [saved("env-1", "OLD_KEY", "old")]);
expect(plan.merge).toEqual({ upserts: [], deletes: ["OLD_KEY"] });
});

it("renames a persisted variable with an upsert and delete", () => {
const plan = existingPlan(
[row("NEW_KEY", "value", { sourceId: "env-1", isSecret: false })],
[saved("env-1", "OLD_KEY", "value")],
);
expect(plan.merge).toEqual({
upserts: [{ key: "NEW_KEY", value: "value", isSecret: false }],
deletes: ["OLD_KEY"],
});
});

it("omits envVars from an existing-project deploy after planning its merge", () => {
const plan = existingPlan(
[row("PUBLIC_URL", "https://new.example", { sourceId: "env-1", isSecret: false })],
[saved("env-1", "PUBLIC_URL", "https://old.example")],
);

expect(plan.merge?.upserts).toEqual([
{ key: "PUBLIC_URL", value: "https://new.example", isSecret: false },
]);
expect(plan.deployEnvVars).toBeUndefined();
});

it("still sends entered values when deploying a brand-new project", () => {
const result = planProjectEnvPersistence(
[row("PUBLIC_URL", "https://example.com"), row("API_SECRET", "secret")],
[],
false,
);
if (!result.ok) throw new Error(result.error);

expect(result.merge).toBeNull();
expect(result.deployEnvVars).toEqual({
PUBLIC_URL: "https://example.com",
API_SECRET: "secret",
});
});

it("infers secret status for a new row added to an existing project", () => {
const plan = existingPlan([row("DATABASE_PASSWORD", "secret")], []);
expect(plan.merge?.upserts).toEqual([
{ key: "DATABASE_PASSWORD", value: "secret", isSecret: true },
]);
});

it("rejects renaming a masked secret without entering a replacement", () => {
const result = planProjectEnvPersistence(
[row("RENAMED", ENV_MASK, { sourceId: "env-1", isSecret: true })],
[saved("env-1", "API_KEY", ENV_MASK, true)],
true,
);

expect(result).toMatchObject({ ok: false });
});
});
120 changes: 120 additions & 0 deletions apps/dashboard/src/context/deployment/project-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { isMaskedValue, looksLikeSecretKey } from "@repo/core";
import type { EnvironmentVariable } from "@/components/import-project/types";

export interface PersistedProjectEnvVar {
id: string;
key: string;
value: string;
isSecret: boolean;
}

export interface ProjectEnvMerge {
upserts: Array<{ key: string; value: string; isSecret: boolean }>;
deletes: string[];
}

export type ProjectEnvPersistencePlan =
| { ok: false; error: string }
| {
ok: true;
/** Existing projects persist this diff before save/deploy. */
merge: ProjectEnvMerge | null;
/** Only a brand-new project sends env through build/access. */
deployEnvVars: Record<string, string> | undefined;
};

/**
* Plan project-level env persistence for the deployment wizard.
*
* Existing projects use PATCH /env and omit envVars from build/access. That
* keeps the stored env authoritative and, crucially, means an untouched masked
* secret can never be serialized as dots or an empty placeholder. New projects
* have no stored env to merge into, so their entered values still travel in the
* initial build/access request.
*/
export function planProjectEnvPersistence(
rows: EnvironmentVariable[],
persisted: PersistedProjectEnvVar[],
existingProject: boolean,
): ProjectEnvPersistencePlan {
const normalized = rows.map((row) => ({ ...row, key: row.key.trim() }));
const seenKeys = new Set<string>();

for (const row of normalized) {
if (!row.key && !row.value && !row.sourceId) continue;
if (!row.key) return { ok: false, error: "Every environment variable needs a name" };
if (seenKeys.has(row.key)) {
return { ok: false, error: `Duplicate environment variable "${row.key}"` };
}
seenKeys.add(row.key);
}

if (!existingProject) {
const deployEnvVars: Record<string, string> = {};
for (const row of normalized) {
if (!row.key && !row.value && !row.sourceId) continue;
if (isMaskedValue(row.value)) {
return { ok: false, error: `Enter a value for "${row.key}"` };
}
deployEnvVars[row.key] = row.value;
}
return {
ok: true,
merge: null,
deployEnvVars: Object.keys(deployEnvVars).length > 0 ? deployEnvVars : undefined,
};
}

const persistedById = new Map(persisted.map((row) => [row.id, row]));
const keptIds = new Set<string>();
const upserts: ProjectEnvMerge["upserts"] = [];

for (const row of normalized) {
if (!row.key && !row.value && !row.sourceId) continue;

const original = row.sourceId ? persistedById.get(row.sourceId) : undefined;
if (row.sourceId && !original) {
return { ok: false, error: `Reload the page before changing "${row.key}"` };
}

if (!original) {
if (isMaskedValue(row.value)) {
return { ok: false, error: `Enter a value for "${row.key}"` };
}
upserts.push({
key: row.key,
value: row.value,
isSecret: row.isSecret ?? looksLikeSecretKey(row.key),
});
continue;
}

const isSecret = row.isSecret ?? original.isSecret;
const renamed = row.key !== original.key;
if (!renamed) keptIds.add(original.id);

if (original.isSecret && isMaskedValue(row.value)) {
if (renamed || isSecret !== original.isSecret) {
return {
ok: false,
error: `Enter a new value for "${original.key}" before renaming it`,
};
}
continue;
}

if (renamed || row.value !== original.value || isSecret !== original.isSecret) {
upserts.push({ key: row.key, value: row.value, isSecret });
}
}

const upsertKeys = new Set(upserts.map((row) => row.key));
const deletes = persisted
.filter((row) => !keptIds.has(row.id))
.map((row) => row.key)
// Removing and re-adding the same key is represented by its upsert only;
// the merge endpoint deliberately rejects a key in both arrays.
.filter((key) => !upsertKeys.has(key));

return { ok: true, merge: { upserts, deletes }, deployEnvVars: undefined };
}
8 changes: 8 additions & 0 deletions apps/dashboard/src/context/deployment/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,13 @@ export interface DeploymentConfig {
buildImage: string;
publicEndpoints: PublicEndpoint[];
envVars: EnvironmentVariable[];
/** Snapshot used to diff project env edits without ever re-sending masked secrets. */
persistedEnvVars: Array<{
id: string;
key: string;
value: string;
isSecret: boolean;
}>;
/** Root .env values detected during prepare; user must import before they apply. */
rootEnvVars: EnvironmentVariable[];
branch: string;
Expand Down Expand Up @@ -489,6 +496,7 @@ export const DEFAULT_CONFIG: DeploymentConfig = {
workloadType: "web",
},
envVars: [],
persistedEnvVars: [],
rootEnvVars: [],
};

Expand Down
Loading