Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (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
Backend: - Add User model with username, hashed password, role, is_active - Add passlib[bcrypt] and python-jose[cryptography] dependencies - Create auth.py with JWT utilities, password hashing, role checking - Add /auth endpoints: status, setup, login, me, users CRUD - Add role-based permission system (viewer/operator/editor/admin) - Environment: SPOOLMAN_AUTH_ENABLED, SPOOLMAN_AUTH_SECRET_KEY - Migration for user table Frontend: - Add AuthProvider context with login/logout/setup - Add LoginPage component with setup mode for first user - Add ProtectedRoute wrapper for auth-required pages - Add axios interceptor to attach JWT token to requests - Add User Management tab in Settings (admin only) - Add translation keys for auth UI When SPOOLMAN_AUTH_ENABLED=true: - Users must login to access the app - First visit shows setup page to create admin account - Role-based access control for future endpoint protection Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
286 lines
9.3 KiB
TypeScript
286 lines
9.3 KiB
TypeScript
import { DeleteOutlined, EditOutlined, PlusOutlined, UserOutlined } from "@ant-design/icons";
|
|
import { useTranslate } from "@refinedev/core";
|
|
import { Button, Form, Input, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from "antd";
|
|
import React, { useCallback, useEffect, useState } from "react";
|
|
import { hasRole, useAuth, User } from "../../contexts/auth";
|
|
import { getAPIURL } from "../../utils/url";
|
|
|
|
const { Title, Text } = Typography;
|
|
|
|
interface UserFormValues {
|
|
username: string;
|
|
password?: string;
|
|
role: "admin" | "editor" | "operator" | "viewer";
|
|
}
|
|
|
|
export const UserSettings: React.FC = () => {
|
|
const t = useTranslate();
|
|
const { user: currentUser, token, authStatus, logout } = useAuth();
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
|
const [form] = Form.useForm<UserFormValues>();
|
|
|
|
const apiUrl = getAPIURL();
|
|
|
|
const fetchUsers = useCallback(async () => {
|
|
if (!token) return;
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(`${apiUrl}/auth/users`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (res.ok) {
|
|
setUsers(await res.json());
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to fetch users:", err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [apiUrl, token]);
|
|
|
|
useEffect(() => {
|
|
if (authStatus?.enabled && hasRole(currentUser, "admin")) {
|
|
fetchUsers();
|
|
}
|
|
}, [authStatus, currentUser, fetchUsers]);
|
|
|
|
const handleCreate = async (values: UserFormValues) => {
|
|
try {
|
|
const res = await fetch(`${apiUrl}/auth/users`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(values),
|
|
});
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
throw new Error(error.detail || "Failed to create user");
|
|
}
|
|
message.success(t("settings.users.created"));
|
|
setModalOpen(false);
|
|
form.resetFields();
|
|
fetchUsers();
|
|
} catch (err) {
|
|
message.error(err instanceof Error ? err.message : "Failed to create user");
|
|
}
|
|
};
|
|
|
|
const handleUpdate = async (values: UserFormValues) => {
|
|
if (!editingUser) return;
|
|
try {
|
|
const body: Partial<UserFormValues> = { role: values.role };
|
|
if (values.password) {
|
|
body.password = values.password;
|
|
}
|
|
const res = await fetch(`${apiUrl}/auth/users/${editingUser.id}`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
throw new Error(error.detail || "Failed to update user");
|
|
}
|
|
message.success(t("settings.users.updated"));
|
|
setModalOpen(false);
|
|
setEditingUser(null);
|
|
form.resetFields();
|
|
fetchUsers();
|
|
} catch (err) {
|
|
message.error(err instanceof Error ? err.message : "Failed to update user");
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (userId: number) => {
|
|
try {
|
|
const res = await fetch(`${apiUrl}/auth/users/${userId}`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (!res.ok) {
|
|
const error = await res.json();
|
|
throw new Error(error.detail || "Failed to delete user");
|
|
}
|
|
message.success(t("settings.users.deleted"));
|
|
fetchUsers();
|
|
} catch (err) {
|
|
message.error(err instanceof Error ? err.message : "Failed to delete user");
|
|
}
|
|
};
|
|
|
|
const openCreateModal = () => {
|
|
setEditingUser(null);
|
|
form.resetFields();
|
|
form.setFieldsValue({ role: "viewer" });
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const openEditModal = (user: User) => {
|
|
setEditingUser(user);
|
|
form.setFieldsValue({ username: user.username, role: user.role, password: undefined });
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const roleColors: Record<string, string> = {
|
|
admin: "red",
|
|
editor: "blue",
|
|
operator: "green",
|
|
viewer: "default",
|
|
};
|
|
|
|
// If auth is not enabled, show message
|
|
if (!authStatus?.enabled) {
|
|
return (
|
|
<div style={{ textAlign: "center", padding: 40 }}>
|
|
<UserOutlined style={{ fontSize: 48, color: "#999", marginBottom: 16 }} />
|
|
<Title level={4}>{t("settings.users.auth_disabled")}</Title>
|
|
<Text type="secondary">{t("settings.users.auth_disabled_hint")}</Text>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// If user is not admin, show access denied
|
|
if (!hasRole(currentUser, "admin")) {
|
|
return (
|
|
<div style={{ textAlign: "center", padding: 40 }}>
|
|
<UserOutlined style={{ fontSize: 48, color: "#999", marginBottom: 16 }} />
|
|
<Title level={4}>{t("settings.users.admin_required")}</Title>
|
|
<Text type="secondary">{t("settings.users.admin_required_hint")}</Text>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
|
<Title level={4} style={{ margin: 0 }}>
|
|
{t("settings.users.title")}
|
|
</Title>
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
|
{t("settings.users.add")}
|
|
</Button>
|
|
</div>
|
|
|
|
<Table<User>
|
|
dataSource={users}
|
|
rowKey="id"
|
|
loading={loading}
|
|
pagination={false}
|
|
columns={[
|
|
{
|
|
title: t("settings.users.username"),
|
|
dataIndex: "username",
|
|
key: "username",
|
|
render: (username: string, record: User) => (
|
|
<Space>
|
|
<UserOutlined />
|
|
{username}
|
|
{record.id === currentUser?.id && <Tag>{t("settings.users.you")}</Tag>}
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
title: t("settings.users.role"),
|
|
dataIndex: "role",
|
|
key: "role",
|
|
render: (role: string) => <Tag color={roleColors[role]}>{role.toUpperCase()}</Tag>,
|
|
},
|
|
{
|
|
title: t("settings.users.status"),
|
|
dataIndex: "is_active",
|
|
key: "is_active",
|
|
render: (isActive: boolean) => (
|
|
<Tag color={isActive ? "green" : "red"}>{isActive ? t("settings.users.active") : t("settings.users.inactive")}</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: t("settings.users.actions"),
|
|
key: "actions",
|
|
width: 120,
|
|
render: (_: unknown, record: User) => (
|
|
<Space>
|
|
<Button size="small" icon={<EditOutlined />} onClick={() => openEditModal(record)} />
|
|
<Popconfirm
|
|
title={t("settings.users.delete_confirm")}
|
|
onConfirm={() => handleDelete(record.id)}
|
|
disabled={record.id === currentUser?.id}
|
|
>
|
|
<Button size="small" danger icon={<DeleteOutlined />} disabled={record.id === currentUser?.id} />
|
|
</Popconfirm>
|
|
</Space>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<Modal
|
|
title={editingUser ? t("settings.users.edit") : t("settings.users.add")}
|
|
open={modalOpen}
|
|
onCancel={() => {
|
|
setModalOpen(false);
|
|
setEditingUser(null);
|
|
form.resetFields();
|
|
}}
|
|
onOk={() => form.submit()}
|
|
okText={editingUser ? t("buttons.save") : t("buttons.create")}
|
|
>
|
|
<Form<UserFormValues>
|
|
form={form}
|
|
layout="vertical"
|
|
onFinish={editingUser ? handleUpdate : handleCreate}
|
|
>
|
|
<Form.Item
|
|
name="username"
|
|
label={t("settings.users.username")}
|
|
rules={[
|
|
{ required: !editingUser, message: t("settings.users.username_required") },
|
|
{ min: 3, message: t("settings.users.username_min") },
|
|
]}
|
|
>
|
|
<Input disabled={!!editingUser} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="password"
|
|
label={t("settings.users.password")}
|
|
rules={[
|
|
{ required: !editingUser, message: t("settings.users.password_required") },
|
|
{ min: 6, message: t("settings.users.password_min") },
|
|
]}
|
|
help={editingUser ? t("settings.users.password_change_hint") : undefined}
|
|
>
|
|
<Input.Password placeholder={editingUser ? t("settings.users.leave_blank") : undefined} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="role"
|
|
label={t("settings.users.role")}
|
|
rules={[{ required: true }]}
|
|
>
|
|
<Select>
|
|
<Select.Option value="viewer">{t("settings.users.role_viewer")}</Select.Option>
|
|
<Select.Option value="operator">{t("settings.users.role_operator")}</Select.Option>
|
|
<Select.Option value="editor">{t("settings.users.role_editor")}</Select.Option>
|
|
<Select.Option value="admin">{t("settings.users.role_admin")}</Select.Option>
|
|
</Select>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{currentUser && (
|
|
<div style={{ marginTop: 24, textAlign: "center" }}>
|
|
<Button onClick={logout}>{t("settings.users.logout")}</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default UserSettings;
|