Skip to content
Merged
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
28 changes: 12 additions & 16 deletions apps/dashboard/src/api/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,25 +34,21 @@ export async function getWorkspaceEnvironment(
return http.get<WorkspaceEnvironmentSettings>(workspaceEnvironmentKey(entryId, environment));
}

export interface EnvironmentInitialization extends WorkspaceEnvironmentSettings {
binding?: {
project_id: string;
project_name: string;
created: boolean;
requested_name?: string;
};
export interface WorkspaceBinding {
project_id: string;
project_name?: string;
created: boolean;
requested_name?: string;
environments: string[];
}

export async function initializeWorkspaceEnvironmentBackend(
export function bindWorkspaceEnvironment(
entryId: string | undefined,
environment: string,
project?: string,
): Promise<EnvironmentInitialization> {
const search = new URLSearchParams({ env: environment });
if (project) search.set("project", project);
return http.post<EnvironmentInitialization>(
`${workspaceBasePath(entryId)}/environment/backend/initialize?${search.toString()}`,
);
input: { revision: string; create: boolean; projectId?: string },
): Promise<WorkspaceBinding> {
return http.post<WorkspaceBinding>(`${workspaceBasePath(entryId)}/environment/bind`, input, {
timeout: 120000,
});
}

function projectBasePath(project: string, entryId?: string): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,23 +110,40 @@ describe("global credential browsing", () => {
});
});

async function openBinding(user: ReturnType<typeof userEvent.setup>, existing = false) {
await user.click(
await screen.findByRole("button", {
name: i18n.t(existing ? "binding.change" : "binding.globalTitle"),
}),
);
return within(await screen.findByRole("dialog", { name: i18n.t("binding.globalTitle") }));
}
async function chooseExisting(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("combobox", { name: i18n.t("binding.method") }));
await user.click(await screen.findByRole("option", { name: i18n.t("binding.existing") }));
}
describe("shared credential setup", () => {
it("initializes the default location only on click and then opens the credential list", async () => {
vi.mocked(api.getLocation).mockResolvedValue({ location: null });
vi.mocked(api.initializeGlobalLocation).mockImplementation(async () => {
vi.mocked(api.getLocation).mockResolvedValue({ location });
return { location };
});
mount();
const user = userEvent.setup();
await screen.findByRole("button", { name: "Initialize default location" });
expect(api.initializeGlobalLocation).not.toHaveBeenCalled();
expect(api.getGlobalListing).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Initialize default location" }));
await screen.findByText("OSS_AK");
expect(api.initializeGlobalLocation).toHaveBeenCalledTimes(1);
});
it("creates and selects a project without changing storage until Save location", async () => {
it.each(["en-US", "zh-CN"])(
"binds the default storage only after confirmation in %s",
async (locale) => {
await i18n.changeLanguage(locale);
vi.mocked(api.getLocation).mockResolvedValue({ location: null });
vi.mocked(api.initializeGlobalLocation).mockImplementation(async () => {
vi.mocked(api.getLocation).mockResolvedValue({ location });
return { location };
});
mount();
const user = userEvent.setup();
const dialog = await openBinding(user);
expect(api.initializeGlobalLocation).not.toHaveBeenCalled();
expect(api.getGlobalListing).not.toHaveBeenCalled();
await user.click(dialog.getByRole("button", { name: i18n.t("binding.prepareAndBind") }));
await screen.findByText("OSS_AK");
expect(api.initializeGlobalLocation).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
},
);
it("creates and selects a custom project, then binds only on confirmation", async () => {
vi.mocked(api.getLocation).mockResolvedValue({ location: null });
const created = {
id: "team",
Expand All @@ -142,48 +159,49 @@ describe("shared credential setup", () => {
});
mount();
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "New project" }));
await openBinding(user);
await chooseExisting(user);
await user.click(screen.getByRole("button", { name: "New project" }));
await user.type(screen.getByLabelText("Project name"), "Team");
await user.click(screen.getByRole("button", { name: "Create and select" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.queryByRole("dialog", { name: "New project" })).toBeNull());
expect(api.createRemoteProject).toHaveBeenCalledWith("Team");
expect(screen.getByRole("combobox", { name: "Storage project" }).textContent).toContain("Team");
expect(
screen.getByRole("combobox", { name: "Default browsing environment" }).textContent,
).toContain("dev");
expect(api.bindLocation).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Save location" }));
await waitFor(() => expect(api.bindLocation).toHaveBeenCalledWith("team", "dev"));
await user.click(screen.getByRole("button", { name: i18n.t("binding.bindExisting") }));
await screen.findByText("OSS_AK");
expect(api.bindLocation).toHaveBeenCalledWith("team", "dev");
});
it("keeps a failed creation editable and leaves the existing location alone", async () => {
vi.mocked(api.createRemoteProject).mockRejectedValue(
new Error("No permission to create projects"),
);
mount();
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Default storage location" }));
expect(screen.queryByRole("button", { name: "Initialize default location" })).toBeNull();
await openBinding(user, true);
await user.click(screen.getByRole("button", { name: "New project" }));
await user.type(screen.getByLabelText("Project name"), "Team");
await user.click(screen.getByRole("button", { name: "Create and select" }));
await screen.findByText("No permission to create projects");
expect(screen.getByRole("dialog")).toBeTruthy();
expect((screen.getByLabelText("Project name") as HTMLInputElement).value).toBe("Team");
expect(api.bindLocation).not.toHaveBeenCalled();
expect(api.initializeGlobalLocation).not.toHaveBeenCalled();
});
it("shows default setup failure without falling through to an empty credential list", async () => {
it("retains default setup failures and retries without an empty credential list", async () => {
vi.mocked(api.getLocation).mockResolvedValue({ location: null });
vi.mocked(api.initializeGlobalLocation).mockRejectedValue(
new Error("Default environment is missing"),
);
vi.mocked(api.initializeGlobalLocation)
.mockRejectedValueOnce(new Error("Default environment is missing"))
.mockImplementationOnce(async () => {
vi.mocked(api.getLocation).mockResolvedValue({ location });
return { location };
});
mount();
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Initialize default location" }));
await screen.findByText("Default environment is missing");
const dialog = await openBinding(user);
await user.click(dialog.getByRole("button", { name: i18n.t("binding.prepareAndBind") }));
await dialog.findByText("Default environment is missing");
expect(api.getGlobalListing).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "New project" })).toBeTruthy();
await user.click(dialog.getByRole("button", { name: i18n.t("binding.prepareAndBind") }));
await screen.findByText("OSS_AK");
});
});

Expand Down
33 changes: 20 additions & 13 deletions apps/dashboard/src/features/global-variables/GlobalVariables.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
sessionKey,
type GlobalLocation,
} from "@/api/session";
import { LocationPicker } from "./LocationPicker";
import { InfisicalBindingDialog } from "@/features/infisical-binding/InfisicalBindingDialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
Expand Down Expand Up @@ -113,10 +113,10 @@ export function GlobalVariables() {
title={t("global.title")}
description={t("global.description")}
actions={
signedIn && current && !configure && !mismatched ? (
signedIn && current ? (
<Button variant="outline" onClick={() => setConfigure(true)}>
<Settings2 />
{t("global.location")}
{t("binding.change")}
</Button>
) : undefined
}
Expand Down Expand Up @@ -159,22 +159,29 @@ export function GlobalVariables() {
) : (
<>
{mismatched && <ErrorNotice>{t("global.mismatch")}</ErrorNotice>}
{!current || configure || mismatched ? (
<LocationPicker
key={`${session.data?.session.siteUrl}:${session.data?.session.userId}:${session.data?.session.organizationId}`}
initial={current ?? undefined}
onSaved={async () => {
await location.mutate();
setConfigure(false);
}}
onCancel={current && !mismatched ? () => setConfigure(false) : undefined}
/>
{!current || mismatched ? (
<Card>
<StatePanel
icon={KeyRound}
title={t("binding.unbound")}
description={t("binding.globalHint")}
>
<Button onClick={() => setConfigure(true)}>{t("binding.globalTitle")}</Button>
</StatePanel>
</Card>
) : (
<VariableBrowser
key={`${session.data?.session.userId}:${current.siteUrl}:${current.projectId}`}
location={current}
/>
)}
<InfisicalBindingDialog
key={`${session.data?.session.siteUrl}:${session.data?.session.userId}:${session.data?.session.organizationId}`}
open={configure}
onOpenChange={setConfigure}
scope="global"
initial={current ?? undefined}
/>
</>
)}
</div>
Expand Down
22 changes: 15 additions & 7 deletions apps/dashboard/src/features/global-variables/LocationPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { DiscardDialog } from "@/components/ui/discard-dialog";
import { Database, FolderPlus, RefreshCw, Save } from "lucide-react";
import { ErrorNotice, SectionHeading } from "@/components/ui/page-layout";
import { Spinner } from "@/components/ui/spinner";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR, { useSWRConfig } from "swr";
import {
Expand Down Expand Up @@ -39,10 +39,12 @@ export function LocationPicker({
initial,
onSaved,
onCancel,
onBusyChange,
}: {
initial?: GlobalLocation;
onSaved: () => Promise<void>;
onSaved: (location?: GlobalLocation) => Promise<void>;
onCancel?: () => void;
onBusyChange?: (busy: boolean) => void;
}) {
const { t } = useTranslation();
const { mutate } = useSWRConfig();
Expand All @@ -58,6 +60,9 @@ export function LocationPicker({
const [name, setName] = useState("");
const [createError, setCreateError] = useState("");
const [discard, setDiscard] = useState(false);
useEffect(() => {
onBusyChange?.(busy);
}, [busy, onBusyChange]);
function closeCreation() {
if (busy) return;
if (name.trim()) setDiscard(true);
Expand All @@ -69,9 +74,10 @@ export function LocationPicker({
setBusy(true);
setError("");
try {
if (useDefault) await initializeGlobalLocation();
else await bindLocation(project, environment);
await onSaved();
const result = useDefault
? await initializeGlobalLocation()
: await bindLocation(project, environment);
await onSaved(result.location);
} catch (e) {
setError(message(e));
// Setup may have created the remote project before a later step failed.
Expand Down Expand Up @@ -114,7 +120,7 @@ export function LocationPicker({
title={t("global.location")}
description={t("global.locationHint")}
/>
{!initial ? (
{!initial && !onBusyChange ? (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border bg-muted/40 p-4">
<div className="space-y-1">
<h3 className="font-medium">{t("global.defaultLocation")}</h3>
Expand Down Expand Up @@ -214,7 +220,9 @@ export function LocationPicker({
onClick={() => void save(false)}
>
{busy ? <Spinner /> : <Save />}
{busy ? t("global.saving") : t("global.saveLocation")}
{busy
? t("global.saving")
: t(onBusyChange ? "binding.bindExisting" : "global.saveLocation")}
</Button>
{onCancel ? (
<Button variant="outline" disabled={busy} onClick={onCancel}>
Expand Down
Loading
Loading