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
7 changes: 6 additions & 1 deletion pkg/public/setup_owner.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type SetupOwnerRequest struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Password string `json:"password"`
OrganizationName string `json:"organization_name"`
SMTPEnabled bool `json:"smtp_enabled"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
Expand Down Expand Up @@ -62,6 +63,10 @@ func (s *Server) setupOwner(w http.ResponseWriter, r *http.Request) {
return
}

if req.OrganizationName == "" {
req.OrganizationName = "Demo"
}

if req.SMTPEnabled {
if req.SMTPHost == "" || req.SMTPPort == 0 || req.SMTPFromEmail == "" {
http.Error(w, "SMTP host, port, and from email are required", http.StatusBadRequest)
Expand Down Expand Up @@ -110,7 +115,7 @@ func (s *Server) setupOwner(w http.ResponseWriter, r *http.Request) {
return err
}

organization, err = models.CreateOrganizationInTransaction(tx, "Demo", "")
organization, err = models.CreateOrganizationInTransaction(tx, req.OrganizationName, "")
if err != nil {
return err
}
Expand Down
1 change: 1 addition & 0 deletions pkg/public/setup_owner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func TestSetupOwnerPersistsInstallationNetworkSettings(t *testing.T) {
FirstName: "Owner",
LastName: "User",
Password: "Password1",
OrganizationName: "Test Org",
AllowPrivateNetworkAccess: true,
})
require.NoError(t, err)
Expand Down
5 changes: 3 additions & 2 deletions test/e2e/owner_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ func (s *ownerSetupSteps) finishOwnerSetupWithoutSMTP() {
}

func (s *ownerSetupSteps) fillInOwnerDetails(email, firstName, lastName, password string) {
s.session.FillIn(q.Locator(`input[placeholder="Acme Inc."]`), "Test Org")
s.session.FillIn(q.Locator(`input[type="email"]`), email)
s.session.FillIn(q.Locator(`input[placeholder="First name"]`), firstName)
s.session.FillIn(q.Locator(`input[placeholder="Last name"]`), lastName)
Expand Down Expand Up @@ -227,8 +228,8 @@ func (s *ownerSetupSteps) assertOwnerAndOrganizationCreated() {
assert.NoError(s.t, err, "count organizations")
assert.Equal(s.t, int64(1), accountsCount, "expected exactly one account to be created")

org, err := models.FindOrganizationByName("Demo")
assert.NoError(s.t, err, "find organization Demo")
org, err := models.FindOrganizationByName("Test Org")
assert.NoError(s.t, err, "find organization Test Org")

s.orgID = org.ID.String()
}
Expand Down
293 changes: 293 additions & 0 deletions web_src/src/pages/auth/OwnerSetup.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import OwnerSetup from "./OwnerSetup";

vi.mock("@/posthog", () => ({
isPostHogEnabled: false,
posthog: { getActiveMatchingSurveys: vi.fn() },
}));

const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);

const mockLocationAssign = vi.fn();
Object.defineProperty(window, "location", {
value: { set href(url: string) { mockLocationAssign(url); } },
writable: true,
});

type User = ReturnType<typeof userEvent.setup>;

async function fillOwnerForm(
user: User,
overrides: { organizationName?: string; email?: string } = {},
) {
const orgInput = screen.getByPlaceholderText("Acme Inc.");
await user.clear(orgInput);
await user.type(orgInput, overrides.organizationName ?? "Acme Corp");
await user.type(screen.getByPlaceholderText("First name"), "Jane");
await user.type(screen.getByPlaceholderText("Last name"), "Doe");
await user.type(screen.getByPlaceholderText("you@example.com"), overrides.email ?? "jane@example.com");
await user.type(screen.getByPlaceholderText("Password"), "Password1");
await user.type(screen.getByPlaceholderText("Confirm password"), "Password1");
}

async function advanceToSmtpPrompt(user: User) {
await fillOwnerForm(user);
await user.click(screen.getByRole("button", { name: "Next" }));
await screen.findByText("Private network access");
await user.click(screen.getByRole("button", { name: "Next" }));
await screen.findByText("Set up email delivery?");
}

async function advanceToSmtpConfig(user: User) {
await advanceToSmtpPrompt(user);
await user.click(screen.getByRole("button", { name: "Set up SMTP" }));
await screen.findByText("SMTP configuration");
}

describe("OwnerSetup", () => {
beforeEach(() => {
mockFetch.mockReset();
mockLocationAssign.mockReset();
});

describe("owner step", () => {
it("renders with Organization Name defaulting to Demo", () => {
render(<OwnerSetup />);
expect(screen.getByPlaceholderText("Acme Inc.")).toHaveValue("Demo");
});

it("shows validation errors when required fields are empty", async () => {
const user = userEvent.setup();
render(<OwnerSetup />);
await user.click(screen.getByRole("button", { name: "Next" }));
expect(await screen.findByText("Email is required.")).toBeInTheDocument();
expect(screen.getByText("First name is required.")).toBeInTheDocument();
expect(screen.getByText("Last name is required.")).toBeInTheDocument();
expect(screen.getByText("Password is required.")).toBeInTheDocument();
});

it("shows invalid email error", async () => {
const user = userEvent.setup();
render(<OwnerSetup />);
await user.type(screen.getByPlaceholderText("you@example.com"), "not-an-email");
await user.click(screen.getByRole("button", { name: "Next" }));
expect(await screen.findByText("Please enter a valid email address.")).toBeInTheDocument();
});

it("shows password strength error", async () => {
const user = userEvent.setup();
render(<OwnerSetup />);
await user.type(screen.getByPlaceholderText("Password"), "weak");
await user.click(screen.getByRole("button", { name: "Next" }));
expect(
await screen.findByText("Password must be 8+ characters with at least 1 number and 1 capital letter."),
).toBeInTheDocument();
});

it("shows error when passwords do not match", async () => {
const user = userEvent.setup();
render(<OwnerSetup />);
await user.type(screen.getByPlaceholderText("Password"), "Password1");
await user.type(screen.getByPlaceholderText("Confirm password"), "Different1");
await user.click(screen.getByRole("button", { name: "Next" }));
expect(await screen.findByText("Passwords do not match.")).toBeInTheDocument();
});

it("advances to private network step when form is valid", async () => {
const user = userEvent.setup();
render(<OwnerSetup />);
await fillOwnerForm(user);
await user.click(screen.getByRole("button", { name: "Next" }));
expect(await screen.findByText("Private network access")).toBeInTheDocument();
});
});

describe("private network step", () => {
it("goes back to owner step", async () => {
const user = userEvent.setup();
render(<OwnerSetup />);
await fillOwnerForm(user);
await user.click(screen.getByRole("button", { name: "Next" }));
await screen.findByText("Private network access");
await user.click(screen.getByRole("button", { name: "Back" }));
expect(await screen.findByText("Set up owner account")).toBeInTheDocument();
});

it("advances to SMTP prompt step", async () => {
const user = userEvent.setup();
render(<OwnerSetup />);
await fillOwnerForm(user);
await user.click(screen.getByRole("button", { name: "Next" }));
await screen.findByText("Private network access");
await user.click(screen.getByRole("button", { name: "Next" }));
expect(await screen.findByText("Set up email delivery?")).toBeInTheDocument();
});
});

describe("SMTP prompt step", () => {
it("submits without SMTP when skipped and redirects", async () => {
mockFetch.mockResolvedValue({ ok: true, json: async () => ({ organization_id: "org-123" }) });
const user = userEvent.setup();
render(<OwnerSetup />);
await advanceToSmtpPrompt(user);
await user.click(screen.getByRole("button", { name: "Do this later" }));
await waitFor(() => expect(mockLocationAssign).toHaveBeenCalledWith("/org-123"));
});

it("advances to SMTP config when Set up SMTP is clicked", async () => {
const user = userEvent.setup();
render(<OwnerSetup />);
await advanceToSmtpPrompt(user);
await user.click(screen.getByRole("button", { name: "Set up SMTP" }));
expect(await screen.findByText("SMTP configuration")).toBeInTheDocument();
});

it("goes back to private network step", async () => {
const user = userEvent.setup();
render(<OwnerSetup />);
await advanceToSmtpPrompt(user);
await user.click(screen.getByRole("button", { name: "Back" }));
expect(await screen.findByText("Private network access")).toBeInTheDocument();
});
});

describe("SMTP config step", () => {
it("shows validation errors for missing required SMTP fields", async () => {
const user = userEvent.setup();
render(<OwnerSetup />);
await advanceToSmtpConfig(user);
await user.click(screen.getByRole("button", { name: "Finish setup" }));
expect(await screen.findByText("SMTP host is required.")).toBeInTheDocument();
expect(screen.getByText("SMTP port is required.")).toBeInTheDocument();
expect(screen.getByText("SMTP from email is required.")).toBeInTheDocument();
});

it("shows error when SMTP port is not a number", async () => {
const user = userEvent.setup();
render(<OwnerSetup />);
await advanceToSmtpConfig(user);
await user.type(screen.getByPlaceholderText("smtp.example.com"), "smtp.example.com");
await user.type(screen.getByPlaceholderText("587"), "abc");
await user.type(screen.getByPlaceholderText("noreply@example.com"), "noreply@example.com");
await user.click(screen.getByRole("button", { name: "Finish setup" }));
expect(await screen.findByText("SMTP port must be a number.")).toBeInTheDocument();
});

it("shows error when username provided without password", async () => {
const user = userEvent.setup();
render(<OwnerSetup />);
await advanceToSmtpConfig(user);
await user.type(screen.getByPlaceholderText("smtp.example.com"), "smtp.example.com");
await user.type(screen.getByPlaceholderText("587"), "587");
await user.type(screen.getByPlaceholderText("smtp-user"), "user");
await user.type(screen.getByPlaceholderText("noreply@example.com"), "noreply@example.com");
await user.click(screen.getByRole("button", { name: "Finish setup" }));
expect(
await screen.findByText("SMTP password is required when username is provided."),
).toBeInTheDocument();
});

it("submits with SMTP config and redirects", async () => {
mockFetch.mockResolvedValue({ ok: true, json: async () => ({ organization_id: "org-456" }) });
const user = userEvent.setup();
render(<OwnerSetup />);
await advanceToSmtpConfig(user);
await user.type(screen.getByPlaceholderText("smtp.example.com"), "smtp.example.com");
await user.type(screen.getByPlaceholderText("587"), "587");
await user.type(screen.getByPlaceholderText("noreply@example.com"), "noreply@example.com");
await user.click(screen.getByRole("button", { name: "Finish setup" }));
await waitFor(() => expect(mockLocationAssign).toHaveBeenCalledWith("/org-456"));
});

it("can skip SMTP from the config step", async () => {
mockFetch.mockResolvedValue({ ok: true, json: async () => ({ organization_id: "org-789" }) });
const user = userEvent.setup();
render(<OwnerSetup />);
await advanceToSmtpConfig(user);
await user.click(screen.getByRole("button", { name: "Do this later" }));
await waitFor(() => expect(mockLocationAssign).toHaveBeenCalledWith("/org-789"));
});

it("goes back to SMTP prompt", async () => {
const user = userEvent.setup();
render(<OwnerSetup />);
await advanceToSmtpConfig(user);
await user.click(screen.getByRole("button", { name: "Back" }));
expect(await screen.findByText("Set up email delivery?")).toBeInTheDocument();
});
});

describe("API submission", () => {
it("sends organization_name in the request body", async () => {
mockFetch.mockResolvedValue({ ok: true, json: async () => ({ organization_id: "org-123" }) });
const user = userEvent.setup();
render(<OwnerSetup />);
await fillOwnerForm(user, { organizationName: "My Company" });
await user.click(screen.getByRole("button", { name: "Next" }));
await screen.findByText("Private network access");
await user.click(screen.getByRole("button", { name: "Next" }));
await screen.findByText("Set up email delivery?");
await user.click(screen.getByRole("button", { name: "Do this later" }));
await waitFor(() => expect(mockFetch).toHaveBeenCalled());
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.organization_name).toBe("My Company");
});

it("sends Demo as organization_name when left at default", async () => {
mockFetch.mockResolvedValue({ ok: true, json: async () => ({ organization_id: "org-123" }) });
const user = userEvent.setup();
render(<OwnerSetup />);
await user.type(screen.getByPlaceholderText("you@example.com"), "jane@example.com");
await user.type(screen.getByPlaceholderText("First name"), "Jane");
await user.type(screen.getByPlaceholderText("Last name"), "Doe");
await user.type(screen.getByPlaceholderText("Password"), "Password1");
await user.type(screen.getByPlaceholderText("Confirm password"), "Password1");
await user.click(screen.getByRole("button", { name: "Next" }));
await screen.findByText("Private network access");
await user.click(screen.getByRole("button", { name: "Next" }));
await screen.findByText("Set up email delivery?");
await user.click(screen.getByRole("button", { name: "Do this later" }));
await waitFor(() => expect(mockFetch).toHaveBeenCalled());
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.organization_name).toBe("Demo");
});

it("shows error banner when API returns an error message", async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ message: "Server exploded" }),
});
const user = userEvent.setup();
render(<OwnerSetup />);
await advanceToSmtpPrompt(user);
await user.click(screen.getByRole("button", { name: "Do this later" }));
expect(await screen.findByText("Server exploded")).toBeInTheDocument();
});

it("shows conflict message when instance is already initialized", async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 409,
json: async () => { throw new Error("no json"); },
});
const user = userEvent.setup();
render(<OwnerSetup />);
await advanceToSmtpPrompt(user);
await user.click(screen.getByRole("button", { name: "Do this later" }));
expect(await screen.findByText("This instance is already initialized.")).toBeInTheDocument();
});

it("shows network error message on fetch failure", async () => {
mockFetch.mockRejectedValue(new Error("Network down"));
const user = userEvent.setup();
render(<OwnerSetup />);
await advanceToSmtpPrompt(user);
await user.click(screen.getByRole("button", { name: "Do this later" }));
expect(await screen.findByText("Network error occurred")).toBeInTheDocument();
});
});
});
4 changes: 4 additions & 0 deletions web_src/src/pages/auth/OwnerSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const OWNER_SETUP_SURVEY_NAME = "Owner Setup Survey";

const OwnerSetup: React.FC = () => {
const [email, setEmail] = useState("");
const [organizationName, setOrganizationName] = useState("Demo");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [password, setPassword] = useState("");
Expand Down Expand Up @@ -115,6 +116,7 @@ const OwnerSetup: React.FC = () => {
credentials: "include",
body: JSON.stringify({
email: email.trim(),
organization_name: organizationName.trim(),
first_name: firstName.trim(),
last_name: lastName.trim(),
password,
Expand Down Expand Up @@ -233,6 +235,7 @@ const OwnerSetup: React.FC = () => {
{step === "owner" && (
<OwnerStep
email={email}
organizationName={organizationName}
firstName={firstName}
lastName={lastName}
password={password}
Expand All @@ -241,6 +244,7 @@ const OwnerSetup: React.FC = () => {
error={error}
fieldErrors={fieldErrors}
onEmailChange={setEmail}
onOrganizationNameChange={setOrganizationName}
onFirstNameChange={setFirstName}
onLastNameChange={setLastName}
onPasswordChange={setPassword}
Expand Down
Loading