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
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:
@@ -390,6 +390,22 @@
|
||||
"replace_warning_title": "Replace all data?",
|
||||
"replace_warning": "This will DELETE all existing data and replace it with the backup. This cannot be undone!"
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentication",
|
||||
"description": "Require users to log in to access Spoolman. Once enabled, you'll be redirected to the login page.",
|
||||
"status_enabled": "Authentication is enabled",
|
||||
"status_disabled": "Authentication is disabled",
|
||||
"setup_title": "Enable Authentication",
|
||||
"setup_description": "Create an admin account to enable authentication. This user will have full access to manage Spoolman.",
|
||||
"enable_button": "Create Admin & Enable",
|
||||
"disable_title": "Disable Authentication",
|
||||
"disable_description": "Enter an admin password to confirm disabling authentication. Anyone will be able to access Spoolman without logging in.",
|
||||
"disable_button": "Disable Authentication",
|
||||
"admin_password": "Admin Password",
|
||||
"password_required": "Password is required",
|
||||
"enabled_success": "Authentication enabled",
|
||||
"disabled_success": "Authentication disabled"
|
||||
},
|
||||
"extra_fields": {
|
||||
"tab": "Extra Fields",
|
||||
"description": "<p>Here you can add extra custom fields to your entities.</p><p>Once a field is added, you can not change its key or type, and for choice type fields you can not remove choices or change the multi choice state. If you remove a field, the associated data for all entities will be deleted.</p><p>The key is what other programs read/write the data as, so if your custom field is supposed to integrate with a third-party program, make sure to set it correctly. Default value is only applied to new items.</p><p>Extra fields can not be sorted or filtered in the table views.</p>",
|
||||
@@ -449,7 +465,7 @@
|
||||
"role_editor": "Editor - Can view, use, create, and edit",
|
||||
"role_admin": "Admin - Full access including user management",
|
||||
"auth_disabled": "Authentication is not enabled",
|
||||
"auth_disabled_hint": "Set SPOOLMAN_AUTH_ENABLED=true to enable user management",
|
||||
"auth_disabled_hint": "Enable authentication in Settings > General to manage users",
|
||||
"admin_required": "Admin Access Required",
|
||||
"admin_required_hint": "Only administrators can manage users",
|
||||
"logout": "Log Out"
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user