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

Large diffs are not rendered by default.

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion public/index.backoffice.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/index.frontoffice.html

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions web/src/screens/admin-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,30 @@ describe("admin workflows", () => {
expect(adminApi.login).toHaveBeenCalledWith("new password");
expect(replace).toHaveBeenCalledWith("/admin/");
});

it("renders the real error message when setup fails", async () => {
adminApi.session.mockResolvedValue(false);
adminApi.getConfig.mockRejectedValueOnce(new Error("setup config unreachable"));
const view = renderAdmin("/admin/setup");
const inputs = view.container.querySelectorAll('input[type="password"]');
const token = "t".repeat(32);
await userEvent.type(inputs[0], token);
await userEvent.type(inputs[1], "new password");
await userEvent.type(inputs[2], "new password");
await userEvent.click(screen.getByRole("button", { name: "Create admin" }));
expect(await screen.findByText("setup config unreachable")).toBeInTheDocument();
});

it("renders a fallback message for a non-Error setup rejection", async () => {
adminApi.session.mockResolvedValue(false);
adminApi.getConfig.mockRejectedValueOnce("boom");
const view = renderAdmin("/admin/setup");
const inputs = view.container.querySelectorAll('input[type="password"]');
const token = "t".repeat(32);
await userEvent.type(inputs[0], token);
await userEvent.type(inputs[1], "new password");
await userEvent.type(inputs[2], "new password");
await userEvent.click(screen.getByRole("button", { name: "Create admin" }));
expect(await screen.findByText("The admin setup request failed.")).toBeInTheDocument();
});
});
44 changes: 44 additions & 0 deletions web/src/screens/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,51 @@ describe("admin config serialization", () => {
expect(result).toEqual({ general: { port: 8334, name: "Files" }, connections: [{ label: "Local", type: "local" }] });
});

it("uses effective defaults for unset fields", () => {
const result = serializeConfig({
general: {
port: { label: "port", type: "number", value: null, default: 45673 },
force_ssl: { label: "force_ssl", type: "boolean", value: null, default: true },
display_hidden: { label: "display_hidden", type: "boolean", value: null, default: false },
name: { label: "name", type: "text", value: null, default: "Filestash" },
host: { label: "host", type: "text", value: null, default: null },
},
});

expect(result).toEqual({ general: { port: 45673, force_ssl: true, display_hidden: false, name: "Filestash" } });
});

it("rejects invalid numeric values before saving", () => {
expect(() => serializeConfig({ port: { label: "Port", type: "number", value: "nope" } })).toThrow("Port must be a valid number");
});

it("omits number fields with no value and no default instead of inventing 0", () => {
const result = serializeConfig({ general: { port: { label: "port", type: "number", value: null } } });
expect(result).toEqual({ general: {} });
});

it("preserves a falsy-but-set boolean value over a truthy default", () => {
const result = serializeConfig({ general: { force_ssl: { label: "force_ssl", type: "boolean", value: false, default: true } } });
expect(result).toEqual({ general: { force_ssl: false } });
});

it("preserves a zero numeric value over a non-zero default", () => {
const result = serializeConfig({ general: { retries: { label: "retries", type: "number", value: 0, default: 99 } } });
expect(result).toEqual({ general: { retries: 0 } });
});

it("uses the default for an unset enable field", () => {
const result = serializeConfig({ general: { feature: { label: "feature", type: "enable", value: null, default: true } } });
expect(result).toEqual({ general: { feature: true } });
});

it("omits text/boolean fields with no value and no default", () => {
const result = serializeConfig({
general: {
host: { label: "host", type: "text", value: null, default: null },
force_ssl: { label: "force_ssl", type: "boolean", value: null, default: null },
},
});
expect(result).toEqual({ general: {} });
});
});
23 changes: 17 additions & 6 deletions web/src/screens/admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,26 @@ export function serializeConfig(node: unknown): unknown {
const o = node as Record<string, unknown>;
if (typeof o.type === "string" && "value" in o) {
if (o.type === "number") {
if (o.value === "" || o.value == null) return null;
if (o.value === "" || o.value == null) {
if (o.default == null) return undefined;
const number = typeof o.default === "number" ? o.default : Number(o.default);
if (!Number.isFinite(number)) throw new Error(`${String(o.label ?? "Number")} must be a valid number`);
return number;
}
const number = typeof o.value === "number" ? o.value : Number(o.value);
if (!Number.isFinite(number)) throw new Error(`${String(o.label ?? "Number")} must be a valid number`);
return number;
}
return o.value;
const effectiveValue = o.value ?? o.default;
if (effectiveValue == null) return undefined;
return effectiveValue;
}
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(o)) out[k] = serializeConfig(v);
for (const [k, v] of Object.entries(o)) {
const serialized = serializeConfig(v);
if (serialized === undefined) continue;
out[k] = serialized;
}
return out;
}
return node;
Expand All @@ -61,7 +72,7 @@ export function AdminScreen({ pathname }: { pathname: string }) {
}
if (session.isLoading) return <Centered label="Loading admin session" />;
if (session.isError) {
return <Centered><Callout title="Could not check the admin session" variant="error"><div className="grid gap-3"><span>{(session.error as Error).message}</span><Button size="sm" variant="neutral" onClick={() => void session.refetch()}>Retry</Button></div></Callout></Centered>;
return <Centered><Callout title="Could not check the admin session" variant="error"><div className="grid gap-3"><span>{session.error instanceof Error ? session.error.message : "The admin session endpoint returned an error."}</span><Button size="sm" variant="neutral" onClick={() => void session.refetch()}>Retry</Button></div></Callout></Centered>;
}
if (!session.data) {
return <AdminLogin onSuccess={() => queryClient.invalidateQueries({ queryKey: ["admin-session"] })} />;
Expand Down Expand Up @@ -119,7 +130,7 @@ function AdminSetup({ onDone }: { onDone: () => void }) {
<Field label="Confirm password" htmlFor="admin-setup-confirm" error={confirm && confirm !== password ? "Passwords don't match" : undefined}>
<Input id="admin-setup-confirm" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} />
</Field>
{setup.isError ? <Callout title="Setup failed" variant="error">Try again.</Callout> : null}
{setup.isError ? <Callout title="Setup failed" variant="error">{setup.error instanceof Error ? setup.error.message : "The admin setup request failed."}</Callout> : null}
<Button type="submit" variant="aurora" disabled={!canSubmit || setup.isPending} loading={setup.isPending}>
{setup.isPending ? "Setting up…" : "Create admin"}
</Button>
Expand Down Expand Up @@ -259,7 +270,7 @@ function SettingsPanel() {
{save.isSuccess ? "Saved" : "Save"}
</Button>
</div>
{save.isError ? <Callout title="Settings were not saved" variant="error"><div className="grid gap-3"><span>{(save.error as Error).message}</span><Button size="sm" variant="neutral" onClick={() => save.mutate()}>Retry</Button></div></Callout> : null}
{save.isError ? <Callout title="Settings were not saved" variant="error"><div className="grid gap-3"><span>{save.error instanceof Error ? save.error.message : "The admin save endpoint returned an error."}</span><Button size="sm" variant="neutral" onClick={() => save.mutate()}>Retry</Button></div></Callout> : null}
{save.isSuccess ? <Callout title="Settings saved" variant="success">The current configuration was reloaded from the server.</Callout> : null}
{Object.entries(tree).map(([category, node]) => (
<Card key={category} elevated className="overflow-hidden">
Expand Down
Loading