Skip to content
This repository was archived by the owner on Nov 7, 2025. It is now read-only.

Commit 5545b27

Browse files
committed
chore: allow configuring domains from ui
1 parent 8f0d0ea commit 5545b27

File tree

2 files changed

+291
-5
lines changed

2 files changed

+291
-5
lines changed

src/pages/admin/System.spec.tsx

Lines changed: 161 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,29 @@ describe("~/pages/admin/System.tsx", () => {
4949
.reply(200, { version: "1.0.0" });
5050
};
5151

52+
const fetchDomainsScope = () => {
53+
return nock(process.env.API_DOMAIN || "")
54+
.get("/admin/domains")
55+
.reply(200, {
56+
domains: {
57+
dev: "https://dev.example.org",
58+
api: "https://api.example.org",
59+
app: "https://app.example.org",
60+
},
61+
});
62+
};
63+
5264
beforeEach(async () => {
5365
const scope = fetchRuntimesScope(["node@24", "python@3"]);
5466
const scopeVersion = fetchMiseVersionScope();
67+
const scopeDomains = fetchDomainsScope();
5568

5669
createWrapper();
5770

5871
await waitFor(() => {
5972
expect(scopeVersion.isDone()).toBe(true);
6073
expect(scope.isDone()).toBe(true);
74+
expect(scopeDomains.isDone()).toBe(true);
6175
});
6276
});
6377

@@ -69,9 +83,13 @@ describe("~/pages/admin/System.tsx", () => {
6983
expect(wrapper.getByText("Installed runtimes")).toBeTruthy();
7084
expect(
7185
wrapper.getByText(
72-
"Manage runtimes that are installed on your Stormkit instance."
86+
"Manage runtimes that are installed on your Stormkit instance"
7387
)
7488
).toBeTruthy();
89+
expect(wrapper.getByText("Domains")).toBeTruthy();
90+
expect(
91+
wrapper.getByText("Configure custom domains for your Stormkit instance")
92+
).toBeTruthy();
7593
});
7694

7795
it("should submit the form", async () => {
@@ -89,7 +107,9 @@ describe("~/pages/admin/System.tsx", () => {
89107
// Turn off auto-install
90108
await fireEvent.click(wrapper.getByLabelText("Auto install"));
91109

92-
await fireEvent.click(wrapper.getByText("Save"));
110+
// Find the save button in the runtimes section specifically
111+
const saveButtons = wrapper.getAllByText("Save");
112+
await fireEvent.click(saveButtons[0]); // First save button is for runtimes
93113

94114
await waitFor(() => {
95115
expect(scope.isDone()).toBe(true);
@@ -101,7 +121,7 @@ describe("~/pages/admin/System.tsx", () => {
101121
expect(wrapper.getByText("Mise"));
102122
expect(
103123
wrapper.getByText(
104-
"Stormkit relies on open-source mise for runtime management."
124+
"Stormkit relies on open-source mise for runtime management"
105125
)
106126
);
107127
expect(wrapper.getByText("Upgrade to latest"));
@@ -329,4 +349,142 @@ describe("~/pages/admin/System.tsx", () => {
329349
});
330350
});
331351
});
352+
353+
describe("Domains", () => {
354+
it("should render domain configuration form", async () => {
355+
await waitFor(() => {
356+
expect(wrapper.getByText("Domains")).toBeTruthy();
357+
expect(
358+
wrapper.getByText(
359+
"Configure custom domains for your Stormkit instance"
360+
)
361+
).toBeTruthy();
362+
expect(wrapper.getByLabelText("API Domain")).toBeTruthy();
363+
expect(wrapper.getByLabelText("App Domain")).toBeTruthy();
364+
expect(wrapper.getByLabelText("Dev Domain")).toBeTruthy();
365+
});
366+
});
367+
368+
it("should display fetched domain values", async () => {
369+
await waitFor(() => {
370+
const apiField = wrapper.getByDisplayValue("https://api.example.org");
371+
const appField = wrapper.getByDisplayValue("https://app.example.org");
372+
const devField = wrapper.getByDisplayValue("https://dev.example.org");
373+
374+
expect(apiField).toBeTruthy();
375+
expect(appField).toBeTruthy();
376+
expect(devField).toBeTruthy();
377+
});
378+
});
379+
380+
it("should display helper texts for domain fields", async () => {
381+
await waitFor(() => {
382+
expect(
383+
wrapper.getByText("API requests will be served from this domain")
384+
).toBeTruthy();
385+
expect(
386+
wrapper.getByText(
387+
"This domain will be used to access your Stormkit dashboard"
388+
)
389+
).toBeTruthy();
390+
expect(
391+
wrapper.getByText(
392+
"Deployment previews will be displayed using subdomains of this domain"
393+
)
394+
).toBeTruthy();
395+
});
396+
});
397+
398+
it("should handle domain fetch error", async () => {
399+
// Create a new wrapper with error response
400+
const errorScope = nock(process.env.API_DOMAIN || "")
401+
.get("/admin/system/runtimes")
402+
.reply(200, {
403+
runtimes: [],
404+
autoInstall: true,
405+
installed: {},
406+
status: "ok",
407+
});
408+
409+
const errorMiseScope = nock(process.env.API_DOMAIN || "")
410+
.get("/admin/system/mise")
411+
.reply(200, { version: "1.0.0" });
412+
413+
const errorDomainsScope = nock(process.env.API_DOMAIN || "")
414+
.get("/admin/domains")
415+
.reply(500, { error: "Internal server error" });
416+
417+
const errorWrapper = render(<AdminSystem />);
418+
419+
await waitFor(() => {
420+
expect(errorScope.isDone()).toBe(true);
421+
expect(errorMiseScope.isDone()).toBe(true);
422+
expect(errorDomainsScope.isDone()).toBe(true);
423+
});
424+
425+
await waitFor(() => {
426+
expect(
427+
errorWrapper.getByText("Something went wrong while fetching domains")
428+
).toBeTruthy();
429+
});
430+
});
431+
432+
it("should submit domain configuration successfully", async () => {
433+
const postScope = nock(process.env.API_DOMAIN || "")
434+
.post("/admin/domains", {
435+
api: "https://new-api.example.org",
436+
app: "https://new-app.example.org",
437+
dev: "https://new-dev.example.org",
438+
})
439+
.reply(200, { ok: true });
440+
441+
await waitFor(() => {
442+
const apiField = wrapper.getByDisplayValue("https://api.example.org");
443+
const appField = wrapper.getByDisplayValue("https://app.example.org");
444+
const devField = wrapper.getByDisplayValue("https://dev.example.org");
445+
446+
fireEvent.change(apiField, {
447+
target: { value: "https://new-api.example.org" },
448+
});
449+
fireEvent.change(appField, {
450+
target: { value: "https://new-app.example.org" },
451+
});
452+
fireEvent.change(devField, {
453+
target: { value: "https://new-dev.example.org" },
454+
});
455+
});
456+
457+
// Find the save button in the domains section specifically (second save button)
458+
const saveButtons = wrapper.getAllByText("Save");
459+
fireEvent.click(saveButtons[1]); // Second save button is for domains
460+
461+
await waitFor(() => {
462+
expect(postScope.isDone()).toBe(true);
463+
});
464+
});
465+
466+
it("should handle domain update error", async () => {
467+
const postScope = nock(process.env.API_DOMAIN || "")
468+
.post("/admin/domains")
469+
.reply(400, { error: "Invalid domain" });
470+
471+
await waitFor(() => {
472+
const apiField = wrapper.getByDisplayValue("https://api.example.org");
473+
fireEvent.change(apiField, { target: { value: "invalid-domain" } });
474+
});
475+
476+
// Find the save button in the domains section specifically (second save button)
477+
const saveButtons = wrapper.getAllByText("Save");
478+
fireEvent.click(saveButtons[1]); // Second save button is for domains
479+
480+
await waitFor(() => {
481+
expect(postScope.isDone()).toBe(true);
482+
expect(
483+
wrapper.getByText(
484+
"An error occurred while updating domains. Make sure specified domains are valid."
485+
)
486+
).toBeTruthy();
487+
});
488+
});
489+
});
332490
});

src/pages/admin/System.tsx

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import CircularProgress from "@mui/material/CircularProgress";
66
import CheckIcon from "@mui/icons-material/Check";
77
import TimesIcon from "@mui/icons-material/Close";
88
import Switch from "@mui/material/Switch";
9+
import TextField from "@mui/material/TextField";
910
import Typography from "@mui/material/Typography";
1011
import Link from "@mui/material/Link";
1112
import Api from "~/utils/api/Api";
@@ -172,7 +173,7 @@ function Runtimes() {
172173
>
173174
<CardHeader
174175
title="Installed runtimes"
175-
subtitle="Manage runtimes that are installed on your Stormkit instance."
176+
subtitle="Manage runtimes that are installed on your Stormkit instance"
176177
/>
177178
<Box sx={{ px: 4 }}>
178179
<KeyValue
@@ -308,7 +309,7 @@ function Mise() {
308309
>
309310
<CardHeader
310311
title="Mise"
311-
subtitle="Stormkit relies on open-source mise for runtime management."
312+
subtitle="Stormkit relies on open-source mise for runtime management"
312313
actions={
313314
<Button
314315
variant="contained"
@@ -352,11 +353,138 @@ function Mise() {
352353
);
353354
}
354355

356+
interface DomainsConfig {
357+
dev: string;
358+
api: string;
359+
app: string;
360+
}
361+
362+
const useFetchDomains = () => {
363+
const [loading, setLoading] = useState(true);
364+
const [error, setError] = useState<string>();
365+
const [domains, setDomains] = useState<DomainsConfig>({
366+
dev: "",
367+
api: "",
368+
app: "",
369+
});
370+
371+
useEffect(() => {
372+
Api.fetch<{ domains: DomainsConfig }>("/admin/domains")
373+
.then(({ domains }) => {
374+
setDomains(domains);
375+
})
376+
.catch(() => {
377+
setError("Something went wrong while fetching domains");
378+
})
379+
.finally(() => {
380+
setLoading(false);
381+
});
382+
}, []);
383+
384+
return { loading, error, domains };
385+
};
386+
387+
function Domains() {
388+
const { error, loading, domains } = useFetchDomains();
389+
const [updateError, setUpdateError] = useState<string>();
390+
const [updateLoading, setUpdateLoading] = useState(false);
391+
392+
return (
393+
<Card
394+
error={error || updateError}
395+
loading={loading}
396+
sx={{ mt: 4, backgroundColor: "container.transparent" }}
397+
contentPadding={false}
398+
component="form"
399+
>
400+
<CardHeader
401+
title="Domains"
402+
subtitle="Configure custom domains for your Stormkit instance"
403+
/>
404+
<CardRow>
405+
<Typography sx={{ display: "flex", alignItems: "center" }}>
406+
<TextField
407+
label="API Domain"
408+
variant="filled"
409+
name="api"
410+
defaultValue={domains.api}
411+
slotProps={{ inputLabel: { shrink: true } }}
412+
helperText="API requests will be served from this domain"
413+
fullWidth
414+
/>
415+
</Typography>
416+
</CardRow>
417+
<CardRow>
418+
<Typography sx={{ display: "flex", alignItems: "center" }}>
419+
<TextField
420+
label="App Domain"
421+
name="app"
422+
defaultValue={domains.app}
423+
variant="filled"
424+
slotProps={{ inputLabel: { shrink: true } }}
425+
helperText="This domain will be used to access your Stormkit dashboard"
426+
fullWidth
427+
/>
428+
</Typography>
429+
</CardRow>
430+
<CardRow>
431+
<Typography sx={{ display: "flex", alignItems: "center" }}>
432+
<TextField
433+
label="Dev Domain"
434+
name="dev"
435+
defaultValue={domains.dev}
436+
variant="filled"
437+
slotProps={{ inputLabel: { shrink: true } }}
438+
helperText="Deployment previews will be displayed using subdomains of this domain"
439+
fullWidth
440+
/>
441+
</Typography>
442+
</CardRow>
443+
<CardFooter>
444+
<Button
445+
variant="contained"
446+
color="secondary"
447+
type="submit"
448+
loading={updateLoading}
449+
onClick={e => {
450+
e.preventDefault();
451+
setUpdateLoading(true);
452+
453+
const form = e.currentTarget.form as HTMLFormElement;
454+
const formData = new FormData(form);
455+
const payload = {
456+
api: formData.get("api") as string,
457+
app: formData.get("app") as string,
458+
dev: formData.get("dev") as string,
459+
};
460+
461+
Api.post<DomainsConfig>("/admin/domains", payload)
462+
.then(() => {
463+
window.location.reload();
464+
})
465+
.catch(() => {
466+
setUpdateError(
467+
"An error occurred while updating domains. Make sure specified domains are valid."
468+
);
469+
})
470+
.finally(() => {
471+
setUpdateLoading(false);
472+
});
473+
}}
474+
>
475+
Save
476+
</Button>
477+
</CardFooter>
478+
</Card>
479+
);
480+
}
481+
355482
export default function System() {
356483
return (
357484
<Box>
358485
<Runtimes />
359486
<Mise />
487+
<Domains />
360488
</Box>
361489
);
362490
}

0 commit comments

Comments
 (0)