Add user management page (only creates admin users, currently)

This commit is contained in:
Kazi 2025-08-11 04:10:01 +06:00
parent 396eb4973e
commit 05e4a9c249
18 changed files with 740 additions and 24 deletions

View File

@ -21,6 +21,12 @@ export default tseslint.config(
],
rules: {
"@typescript-eslint/no-floating-promises": "error",
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ ignoreRestSiblings: true },
],
},
},
plugins: {
lingui,

View File

@ -191,6 +191,15 @@ export const router = createHashRouter([
return { Component: SettingsServer };
},
},
{
path: "users",
lazy: async () => {
const { SettingsUsers } = await import(
"./features/settings/SettingsUsers/SettingsUsers"
);
return { Component: SettingsUsers };
},
},
],
},
],

View File

@ -1,5 +1,6 @@
import { PBKDF2, MD5, algo } from "crypto-js";
import testoutputProgress from "./TestoutputProgress.json";
import { formatUserRights } from "../utils/formatUserRights";
interface SaltResult {
salt: string;
@ -1105,17 +1106,7 @@ class UrBackupServer {
const salt=randomString();
const password_md5=MD5(salt+password).toString();
const params: Record<string, string> = { sa: "useradd", name: name, pwmd5: password_md5, salt: salt };
let i = 0;
let idx = "";
for (const right of rights) {
params[i+"_domain"] = right.domain;
params[i+"_right"] = right.right;
i++;
if(idx.length>0)
idx += ",";
idx += "" + i;
}
params["idx"] = idx;
params['rights'] = formatUserRights(rights)
const resp = await this.fetchData(params, "settings");
if (typeof resp.add_ok != "undefined" && resp.add_ok) {
return;

View File

@ -1,9 +1,22 @@
import { isRouteErrorResponse, useRouteError } from "react-router-dom";
import {
isRouteErrorResponse,
Navigate,
useLocation,
useRouteError,
} from "react-router-dom";
import { BackupsAccessDeniedError } from "../api/urbackupserver";
import {
BackupsAccessDeniedError,
SessionNotFoundError,
} from "../api/urbackupserver";
export function ErrorPage({ returnToLink }: { returnToLink: React.ReactNode }) {
const error = useRouteError();
const { pathname } = useLocation();
if (error instanceof SessionNotFoundError) {
return <Navigate to="/login" replace state={{ pathname }} />;
}
if (error instanceof BackupsAccessDeniedError) {
return (

View File

@ -8,7 +8,7 @@ import NavSidebar from "./NavSidebar";
import { SettingsNavSidebar } from "../features/settings/SettingsNavSidebar";
export function Layout() {
const match = useMatch("settings");
const match = useMatch("/settings/*");
return (
<div className={styles.layout}>

View File

@ -1,3 +1,11 @@
export function TableWrapper({ children }: { children: React.ReactNode }) {
return <section className="flow table-wrapper">{children}</section>;
export function TableWrapper({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<section className={`flow table-wrapper ${className}`}>{children}</section>
);
}

View File

@ -102,6 +102,11 @@
align-items: var(--repel-vertical-alignment, center);
gap: var(--gutter);
}
.centered {
max-width: 1200px;
margin: 0 auto;
}
}
@layer blocks {

View File

@ -0,0 +1,12 @@
import { z } from "zod/v4-mini";
export const VALIDATION_MESSAGES = {
required: (label: string) =>
`Please enter a value for ${label.toLowerCase()}`,
numeric: (label: string) =>
`Please enter a numeric value for ${label.toLowerCase()}`,
regex: (label: string) => `The format for ${label.toLowerCase()} is invalid`,
} as const;
export const requiredStringValidation = (message?: string) =>
z.string().check(z.minLength(1, message));

View File

@ -2,6 +2,7 @@ import { z, type ZodMiniType } from "zod/v4-mini";
import { InputProps } from "@fluentui/react-components";
import { NewTabLink } from "../../../components/NewTabLink";
import { VALIDATION_MESSAGES } from "../Fields/validation";
const CLEANUP_WINDOW_REGEX =
/^(([mon|mo|tu|tue|tues|di|wed|mi|th|thu|thur|thurs|do|fri|fr|sat|sa|sun|so|1-7]\-?[mon|mo|tu|tue|tues|di|wed|mi|th|thu|thur|thurs|do|fri|fr|sat|sa|sun|so|1-7]?\s*[,]?\s*)+\/([0-9][0-9]?:?[0-9]?[0-9]?\-[0-9][0-9]?:?[0-9]?[0-9]?\s*[,]?\s*)+\s*[;]?\s*)*$/i;
@ -45,14 +46,6 @@ function getLabelFromName(name: string, labels: Record<string, string>) {
return labels[name as keyof typeof labels];
}
const VALIDATION_MESSAGES = {
required: (label: string) =>
`Please enter a value for ${label.toLowerCase()}`,
numeric: (label: string) =>
`Please enter a numeric value for ${label.toLowerCase()}`,
regex: (label: string) => `The format for ${label.toLowerCase()} is invalid`,
} as const;
const FORM_MESSAGES = {
backupfolder: VALIDATION_MESSAGES.required(SERVER_LABELS["backupfolder"]),
tmpdir: VALIDATION_MESSAGES.required(SERVER_LABELS["tmpdir"]),

View File

@ -0,0 +1,7 @@
.dialog {
max-width: 50ch;
}
.actions {
margin-block-start: var(--spacingL);
}

View File

@ -0,0 +1,247 @@
import { useState } from "react";
import { z } from "zod/v4-mini";
import type { $ZodIssue } from "zod/v4/core";
import {
Dialog,
DialogTrigger,
DialogSurface,
DialogTitle,
DialogContent,
DialogBody,
DialogActions,
Button,
Input,
Field,
Select,
} from "@fluentui/react-components";
import styles from "./CreateUser.module.css";
import {
requiredStringValidation,
VALIDATION_MESSAGES,
} from "../Fields/validation";
import { DismissRegular } from "@fluentui/react-icons";
import { UserAlreadyExistsError, UserRight } from "../../../api/urbackupserver";
import { addMessage, clearMessages } from "./messageStore";
import { useUsers, type UserInput } from "./useUsers";
const ADMIN_RIGHTS: UserRight[] = [
{
domain: "all",
right: "all",
},
];
const ADMINISTRATOR = "Administrator";
const userSchema = z
.object({
username: requiredStringValidation(
VALIDATION_MESSAGES.required("username"),
),
password: requiredStringValidation(
VALIDATION_MESSAGES.required("password"),
),
repeatPassword: requiredStringValidation(
VALIDATION_MESSAGES.required("repeat password"),
),
rights: z.string(),
})
.check(
z.refine((data) => data.password === data.repeatPassword, {
message: "Passwords do not match",
path: ["repeatPassword"],
// @ts-expect-error: update package and check if ZodMini type error is fixed
when(payload) {
return payload.issues.every((iss: $ZodIssue) => {
const firstPathEl = iss.path?.[0];
return firstPathEl !== "password" && firstPathEl !== "repeatPassword";
});
},
}),
);
interface ValidationMessages {
username: string;
password: string;
repeatPassword: string;
rights: string;
}
const initialValidationMessages: ValidationMessages = {
username: "",
password: "",
repeatPassword: "",
rights: ADMINISTRATOR,
};
export const CreateUser = () => {
const [validationMessages, setValidationMessages] = useState(
initialValidationMessages,
);
const { createUser } = useUsers();
const handleSuccess = () => {
clearMessages();
addMessage("success", `New user successfully added.`);
setOpen(false);
};
const handleFailure = () => {
clearMessages();
addMessage("error", `Failed to create new user.`);
setOpen(false);
};
const resetValidationMessages = () => {
setValidationMessages(initialValidationMessages);
};
const submitUser = (user: UserInput) => {
createUser(user, {
onError: (e) => {
if (e instanceof UserAlreadyExistsError) {
setValidationMessages({
...initialValidationMessages,
username: "User with this name already exists",
});
return;
}
handleFailure();
},
onSuccess: handleSuccess,
});
};
const handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
const formData = new FormData(ev.currentTarget);
const parsed = userSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
const newValidationMessages = formatErrorMessages(parsed.error.issues);
setValidationMessages(newValidationMessages);
return;
}
resetValidationMessages();
submitUser(transformParsedData(parsed.data));
};
const [open, setOpen] = useState(false);
return (
<Dialog
open={open}
onOpenChange={(event, data) => {
if (data.open) {
resetValidationMessages();
}
setOpen(data.open);
}}
>
<DialogTrigger disableButtonEnhancement>
<Button appearance="primary">Create user</Button>
</DialogTrigger>
<DialogSurface className={`${styles.dialog}`}>
<form onSubmit={handleSubmit}>
<DialogBody>
<DialogTitle
action={
<DialogTrigger action="close">
<Button
appearance="subtle"
aria-label="close"
icon={<DismissRegular />}
/>
</DialogTrigger>
}
>
Create user
</DialogTitle>
<DialogContent className={`${styles.form} flow `}>
<Field
label="Username"
validationMessage={validationMessages["username"]}
>
<Input name="username" />
</Field>
<Field
label="Password"
validationMessage={validationMessages["password"]}
>
<Input name="password" type="password" />
</Field>
<Field
label="Repeat password"
validationMessage={validationMessages["repeatPassword"]}
>
<Input name="repeatPassword" type="password" />
</Field>
<Field label="Rights">
<Select name="rights">
<option>Administrator</option>
</Select>
</Field>
</DialogContent>
<DialogActions className={`${styles.actions}`}>
<DialogTrigger disableButtonEnhancement>
<Button>Cancel</Button>
</DialogTrigger>
<Button type="submit" appearance="primary">
Submit
</Button>
</DialogActions>
</DialogBody>
</form>
</DialogSurface>
</Dialog>
);
};
function transformParsedData(parsedData: ValidationMessages): UserInput {
const updated = updateRights(parsedData);
return removeRepeatPassword(updated);
}
function updateRights(parsedData: ValidationMessages) {
const { rights } = parsedData;
if (rights !== ADMINISTRATOR) {
return {
...parsedData,
rights: [],
};
}
return {
...parsedData,
rights: ADMIN_RIGHTS,
};
}
function removeRepeatPassword(
parsedData: UserInput & {
repeatPassword: string;
},
): UserInput {
const { repeatPassword, ...rest } = parsedData;
return rest;
}
function formatErrorMessages(issues: $ZodIssue[]) {
return issues.reduce(
(all, i) => ({ ...all, [i.path[0]]: i.message }),
{} as ValidationMessages,
);
}

View File

@ -0,0 +1,66 @@
import { useEffect } from "react";
import { useSettings } from "../useSettings";
import { UsersTable } from "./UsersTable";
import { TableWrapper } from "../../../components/TableWrapper";
import {
Button,
MessageBar,
MessageBarActions,
MessageBarBody,
} from "@fluentui/react-components";
import { CreateUser } from "./CreateUser";
import { useSnapshot } from "valtio";
import { clearMessages, dismissMessage, messageStore } from "./messageStore";
import { DismissRegular } from "@fluentui/react-icons";
export function SettingsUsers() {
const { settings } = useSettings();
if (!settings) {
return null;
}
return (
<div className="flow centered">
<div className="repel">
<h1>Users</h1>
<CreateUser />
</div>
<Banner />
<TableWrapper>
<UsersTable />
</TableWrapper>
</div>
);
}
function Banner() {
const snap = useSnapshot(messageStore);
useEffect(() => {
return clearMessages();
}, []);
if (!snap.messages.length) {
return null;
}
return snap.messages.map((m) => (
<MessageBar key={m.id} intent={m.intent}>
<MessageBarBody>{m.message}</MessageBarBody>
<MessageBarActions
containerAction={
<Button
aria-label="dismiss"
appearance="transparent"
icon={<DismissRegular />}
onClick={() => dismissMessage(m.id)}
/>
}
/>
</MessageBar>
));
}

View File

@ -0,0 +1,78 @@
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
DialogTrigger,
} from "@fluentui/react-components";
import { UserListItem } from "../../../api/urbackupserver";
import { useUsers } from "./useUsers";
import { addMessage, clearMessages } from "./messageStore";
import { useState } from "react";
export function UserTableActions(user: UserListItem) {
const { removeUser, isRemovable } = useUsers();
const handleSuccess = () => {
clearMessages();
addMessage("success", "Successfully removed user.");
};
const handleFailure = () => {
clearMessages();
addMessage("error", `Failed to remove user.`);
};
const handleRemove = () => {
removeUser(user.id, {
onError: handleFailure,
onSuccess: handleSuccess,
});
};
const [open, setOpen] = useState(false);
return (
<div className="cluster gutter-xs">
<Button>Change rights</Button>
<Button>Change password</Button>
{isRemovable(user) && (
<Dialog
open={open}
onOpenChange={(event, data) => {
setOpen(data.open);
}}
>
<DialogTrigger disableButtonEnhancement>
<Button appearance="subtle">Remove</Button>
</DialogTrigger>
<DialogSurface
style={{
maxWidth: "50ch",
}}
>
<DialogBody className="flow">
<DialogTitle>Remove user</DialogTitle>
<DialogContent>
Are you sure you want to remove this user?
</DialogContent>
<DialogActions>
<DialogTrigger disableButtonEnhancement>
<Button appearance="secondary">Cancel</Button>
</DialogTrigger>
<Button appearance="primary" onClick={handleRemove}>
Delete
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
)}
</div>
);
}

View File

@ -0,0 +1,112 @@
import {
DataGrid,
DataGridHeader,
DataGridRow,
DataGridHeaderCell,
DataGridBody,
DataGridCell,
TableCellLayout,
TableColumnDefinition,
createTableColumn,
TableColumnId,
Body2,
} from "@fluentui/react-components";
import { UserListItem, UserRight } from "../../../api/urbackupserver";
import { getCellFocusMode } from "../../../utils/table";
import { UserTableActions } from "./UserTableActions";
import { useUsers, isUserAdmin } from "./useUsers";
export const columns: TableColumnDefinition<UserListItem>[] = [
createTableColumn<UserListItem>({
columnId: "username",
renderHeaderCell: () => {
return "Username";
},
renderCell: (item) => {
return <TableCellLayout>{item.name}</TableCellLayout>;
},
}),
createTableColumn<UserListItem>({
columnId: "rights",
renderHeaderCell: () => {
return "Rights";
},
renderCell: (item) => {
return <TableCellLayout>{getDisplayRights(item.rights)}</TableCellLayout>;
},
}),
createTableColumn<UserListItem>({
columnId: "actions",
renderHeaderCell: () => {
return "Actions";
},
renderCell: UserTableActions,
}),
];
export function UsersTable() {
const { users } = useUsers();
if (users.length === 0) {
return (
<div>
<Body2>No users</Body2>
</div>
);
}
return (
<DataGrid items={users} getRowId={(item) => item.id} columns={columns}>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell, columnId }) => (
<DataGridHeaderCell style={getNarrowColumnStyles(columnId)}>
{renderHeaderCell()}
</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<UserListItem>>
{({ item }) => (
<DataGridRow<UserListItem> key={item.id}>
{({ renderCell, columnId }) => (
<DataGridCell
focusMode={getCellFocusMode(columnId)}
style={getNarrowColumnStyles(columnId)}
>
{renderCell(item)}
</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
);
}
const BASE_COL_WIDTH = {
actions: "48ch",
} as const;
/**
* Style some columns to take up less space.
*/
function getNarrowColumnStyles(columnId: TableColumnId) {
const stringId = columnId.toString();
if (!Object.keys(BASE_COL_WIDTH).includes(stringId)) {
return;
}
return {
flexGrow: "0",
flexBasis: BASE_COL_WIDTH[stringId as keyof typeof BASE_COL_WIDTH],
};
}
function getDisplayRights(rights: UserRight[]) {
const isAdmin = isUserAdmin(rights);
return isAdmin ? "Administrator" : "User";
}

View File

@ -0,0 +1,34 @@
import { MessageBarIntent } from "@fluentui/react-components";
import { proxy } from "valtio";
interface Message {
id: string;
intent: MessageBarIntent;
message: string;
}
export const messageStore = proxy<{ messages: Message[] }>({
messages: [],
});
export function addMessage(
intent: Message["intent"],
message: Message["message"],
) {
messageStore.messages.push({
id: crypto.randomUUID(),
intent,
message,
});
}
export function dismissMessage(id: Message["id"]) {
const index = messageStore.messages.findIndex((m) => m.id === id);
if (index >= 0) {
messageStore.messages.splice(index, 1);
}
}
export function clearMessages() {
messageStore.messages = [];
}

View File

@ -0,0 +1,94 @@
import {
useSuspenseQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { urbackupServer } from "../../../App";
import type { UserListItem, UserRight } from "../../../api/urbackupserver";
export interface UserInput {
username: string;
password: string;
rights: UserRight[];
}
export function useUsers() {
const queryClient = useQueryClient();
const invalidateQueries = () => {
return queryClient.invalidateQueries({
queryKey: ["users"],
});
};
const { data } = useSuspenseQuery({
queryKey: ["users"],
queryFn: urbackupServer.getUserList,
});
const isRemovable = (user: UserListItem) => {
if (!isUserAdmin(user.rights)) {
return true;
}
// Prevent deleting the last admin before others users are removed
const admins = data.users.filter((u) => isUserAdmin(u.rights));
const hasSingleAdmin = data.users.length > 1 && admins.length === 1;
if (hasSingleAdmin) {
return false;
}
return true;
};
const createUserMutation = useMutation({
mutationFn: ({
username,
password,
rights,
}: {
username: string;
password: string;
rights: UserRight[];
}) => urbackupServer.createUser(username, password, rights),
onSuccess: invalidateQueries,
});
const createUser = (
user: UserInput,
mutationOptions: {
onError: (e: Error) => void;
onSuccess: () => void;
},
) => {
createUserMutation.mutate(user, mutationOptions);
};
const removeUserMutation = useMutation({
mutationFn: urbackupServer.removeUser,
onSuccess: invalidateQueries,
});
const removeUser = (
userId: string,
mutationOptions: {
onError: (e?: Error) => void;
onSuccess: () => void;
},
) => {
removeUserMutation.mutate(userId, mutationOptions);
};
return {
users: data.users,
createUser,
isRemovable,
removeUser,
};
}
export function isUserAdmin(rights: UserRight[]) {
return rights.some((r) => r.domain === "all" && r.right === "all");
}

View File

@ -0,0 +1,24 @@
import { describe, expect, test } from "vitest";
import { formatUserRights } from "./formatUserRights";
import type { UserRight } from "../api/urbackupserver";
const baseRight: UserRight = {
domain: "test",
right: "test",
};
describe("action from backup", () => {
test("should format single right", () => {
const result = formatUserRights([baseRight]);
expect(result).toBe("0_domain=test&0_right=test&idx=0");
});
test("should format for multiple rights", () => {
const result = formatUserRights(Array.from({ length: 3 }, () => baseRight));
expect(result).toBe(
"0_domain=test&0_right=test,1_domain=test&1_right=test,2_domain=test&2_right=test&idx=0,1,2",
);
});
});

View File

@ -0,0 +1,17 @@
import type { UserRight } from "../api/urbackupserver";
export function formatUserRights(rights: UserRight[]) {
const transformed = rights
.map((r, i) =>
Object.entries(r).map(([key, value]) => addIdxToEntry(i, key, value)),
)
.map((e) => new URLSearchParams(e).toLocaleString());
const formattedIdx = `idx=${Array.from(rights, (_, i) => i).join(",")}`;
return [transformed, formattedIdx].join("&");
}
function addIdxToEntry(i: number, key: string, value: string) {
return [`${i}_${key}`, value];
}