Make auth toggle a settings option instead of env var
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Issue Manager / issue-manager (push) Has been cancelled

- Auth enabled state now stored in database setting (auth_enabled)
- Add POST /auth/toggle endpoint to enable/disable auth from UI
- Add auth toggle section to Settings > General page
- Enabling auth without users prompts to create first admin
- Disabling auth requires admin password confirmation
- Auth setting cached in memory and loaded on startup

Removes need for SPOOLMAN_AUTH_ENABLED env var

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 01:01:14 -06:00
parent e782b89929
commit f63f70ce86
7 changed files with 316 additions and 7 deletions

View File

@@ -1,11 +1,12 @@
import { DownloadOutlined, UploadOutlined } from "@ant-design/icons";
import { DownloadOutlined, LockOutlined, UploadOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import { Button, Checkbox, Divider, Form, Input, message, Popconfirm, Space, Upload } from "antd";
import { Button, Checkbox, Divider, Form, Input, message, Modal, Popconfirm, Space, Switch, Upload } from "antd";
import type { UploadFile } from "antd";
import { useContext, useEffect, useState } from "react";
import { getAPIURL } from "../../utils/url";
import { useGetSettings, useSetSetting } from "../../utils/querySettings";
import { ColorModeContext, THEME_COLORS, ThemeColorKey } from "../../contexts/color-mode";
import { useAuth } from "../../contexts/auth";
export function GeneralSettings() {
const settings = useGetSettings();
@@ -137,6 +138,10 @@ export function GeneralSettings() {
<Divider />
<AuthSettings messageApi={messageApi} t={t} />
<Divider />
<BackupRestore messageApi={messageApi} t={t} />
{contextHolder}
@@ -144,6 +149,176 @@ export function GeneralSettings() {
);
}
interface AuthSettingsProps {
messageApi: ReturnType<typeof message.useMessage>[0];
t: ReturnType<typeof useTranslate>;
}
function AuthSettings({ messageApi, t }: AuthSettingsProps) {
const { authStatus, refreshAuthStatus, setup } = useAuth();
const [toggling, setToggling] = useState(false);
const [setupModalOpen, setSetupModalOpen] = useState(false);
const [disableModalOpen, setDisableModalOpen] = useState(false);
const [setupForm] = Form.useForm();
const [disableForm] = Form.useForm();
const handleToggle = async (enabled: boolean) => {
if (enabled) {
// If enabling and no users exist, show setup modal
if (!authStatus?.has_users) {
setSetupModalOpen(true);
return;
}
// If users exist, just enable
await toggleAuth(true);
} else {
// Disabling - show password confirmation modal
setDisableModalOpen(true);
}
};
const toggleAuth = async (enabled: boolean, password?: string) => {
setToggling(true);
try {
const res = await fetch(`${getAPIURL()}/auth/toggle`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled, password }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to toggle authentication");
}
await refreshAuthStatus();
messageApi.success(
enabled
? t("settings.auth.enabled_success")
: t("settings.auth.disabled_success")
);
if (enabled) {
// Redirect to login after enabling
window.location.href = "/login";
}
} catch (err) {
messageApi.error(err instanceof Error ? err.message : "Failed to toggle authentication");
} finally {
setToggling(false);
}
};
const handleSetup = async (values: { username: string; password: string }) => {
setToggling(true);
try {
// Create the first admin user
await setup(values.username, values.password);
// Enable auth
await toggleAuth(true);
setSetupModalOpen(false);
setupForm.resetFields();
} catch (err) {
messageApi.error(err instanceof Error ? err.message : "Setup failed");
} finally {
setToggling(false);
}
};
const handleDisable = async (values: { password: string }) => {
await toggleAuth(false, values.password);
setDisableModalOpen(false);
disableForm.resetFields();
};
return (
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
<h3>
<LockOutlined style={{ marginRight: 8 }} />
{t("settings.auth.title")}
</h3>
<p style={{ marginBottom: "1em", color: "#888" }}>
{t("settings.auth.description")}
</p>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<Switch
checked={authStatus?.enabled ?? false}
onChange={handleToggle}
loading={toggling}
/>
<span>
{authStatus?.enabled
? t("settings.auth.status_enabled")
: t("settings.auth.status_disabled")}
</span>
</div>
{/* Setup Modal - Create first admin user */}
<Modal
title={t("settings.auth.setup_title")}
open={setupModalOpen}
onCancel={() => {
setSetupModalOpen(false);
setupForm.resetFields();
}}
onOk={() => setupForm.submit()}
okText={t("settings.auth.enable_button")}
confirmLoading={toggling}
>
<p style={{ marginBottom: 16 }}>{t("settings.auth.setup_description")}</p>
<Form form={setupForm} layout="vertical" onFinish={handleSetup}>
<Form.Item
name="username"
label={t("settings.users.username")}
rules={[
{ required: true, message: t("settings.users.username_required") },
{ min: 3, message: t("settings.users.username_min") },
]}
>
<Input />
</Form.Item>
<Form.Item
name="password"
label={t("settings.users.password")}
rules={[
{ required: true, message: t("settings.users.password_required") },
{ min: 6, message: t("settings.users.password_min") },
]}
>
<Input.Password />
</Form.Item>
</Form>
</Modal>
{/* Disable Modal - Require password confirmation */}
<Modal
title={t("settings.auth.disable_title")}
open={disableModalOpen}
onCancel={() => {
setDisableModalOpen(false);
disableForm.resetFields();
}}
onOk={() => disableForm.submit()}
okText={t("settings.auth.disable_button")}
okButtonProps={{ danger: true }}
confirmLoading={toggling}
>
<p style={{ marginBottom: 16 }}>{t("settings.auth.disable_description")}</p>
<Form form={disableForm} layout="vertical" onFinish={handleDisable}>
<Form.Item
name="password"
label={t("settings.auth.admin_password")}
rules={[{ required: true, message: t("settings.auth.password_required") }]}
>
<Input.Password />
</Form.Item>
</Form>
</Modal>
</div>
);
}
interface BackupRestoreProps {
messageApi: ReturnType<typeof message.useMessage>[0];
t: ReturnType<typeof useTranslate>;